Story 4.1: Task 1 Complete - PlayerPrivacyManager Core Logic

- Created src/contracts/privacy-settings.js with:
  - PrivacySettings typedef
  - PRIVACY_SETTINGS_DEFAULT (both flags false)
  - PRIVACY_SETTING_KEYS and FEATURE_NAME_MAP constants
  - createPrivacySettings() factory
  - isValidPrivacySettings() validator
  - validateSettingKey(), validateSettingValue(), validateFeatureName() helpers
- Created src/core/PlayerPrivacyManager.js with:
  - Constructor with FoundryAdapter DI validation
  - getSettings(userId) - retrieves settings from user flags
  - setSetting(userId, key, value) - async, validates, persists via user.setFlag
  - isOptedIn(userId, feature) - convenience method for feature checks
  - getAllSettings() - aggregates all users' settings (GM view)
  - onChange(callback) - subscription pattern for change events
  - teardown() - cleanup
- Created tests/unit/contracts/privacy-settings.test.js - 44 tests
- Created tests/unit/core/PlayerPrivacyManager.test.js - 35 tests
- All tests passing, lint clean
- Updated sprint-status.yaml: 4-1 from ready-for-dev to in-progress
- Updated story file: Task 1 subtasks 1.1-1.8 marked complete

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-23 21:11:55 +02:00
parent e81c05a3db
commit 61f362004e
13 changed files with 1971 additions and 29 deletions
@@ -0,0 +1,379 @@
/**
* Epic 3 Scene Presets - Integration Tests
* Tests the deployed video-view-manager module on localhost:31000
* Run with: npx playwright test _bmad-output/tests/integration/epic-3-scene-presets.spec.js
*
* Note: Chrome DevTools must be running on port 9222 with FoundryVTT
* Browser WSEndpoint: ws://localhost:9222/devtools/browser/1aeaf428-412f-4e20-9f2d-c13533d031ae
*/
import { chromium, expect, test } from '@playwright/test';
// Configuration
const CHROME_DEVTOOLS_URL = 'ws://localhost:9222/devtools/browser/1aeaf428-412f-4e20-9f2d-c13533d031ae';
const FOUNDRY_URL = 'https://localhost:31000/game';
// ============================================================================
// Helper Functions
// ============================================================================
async function waitForModule(page) {
await page.waitForFunction(() => {
const module = game?.modules?.get('video-view-manager');
return module?.active === true;
}, { timeout: 30000 });
}
async function waitForDirectorsBoard(page) {
await page.waitForFunction(() => {
const board = game?.modules?.get('video-view-manager')?._directorsBoard;
return board?.rendered === true;
}, { timeout: 10000 });
}
async function clickByDataAction(page, action) {
await page.evaluate((action) => {
const element = document.querySelector(`[data-action="${action}"]`);
if (element) element.click();
}, action);
}
async function getPresetCount(page) {
return await page.evaluate(() => {
const scene = game.scenes.current;
const presets = scene?.getFlag('video-view-manager', 'presets') || {};
return Object.keys(presets.presets || {}).length;
});
}
async function verifyNotification(page, text) {
await page.waitForFunction((text) => {
const notifications = ui.notifications?.notifications || [];
return notifications.some(n => n.message?.includes(text));
}, { text, timeout: 5000 });
}
async function openDirectorsBoard(page) {
await page.keyboard.down('Control');
await page.keyboard.press('Shift+V');
await page.keyboard.up('Control');
await waitForDirectorsBoard(page);
}
// ============================================================================
// Test Suite: ScenePresetManager Core Functionality
// ============================================================================
test.describe('Epic 3 - ScenePresetManager Core', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('Module is active and ScenePresetManager exists', async () => {
const isActive = await page.evaluate(() => {
const module = game.modules.get('video-view-manager');
return module?.active && module?.scenePresetManager;
});
expect(isActive).toBe(true);
});
test('Save and load preset via DirectorsBoard', async () => {
await openDirectorsBoard(page);
const initialCount = await getPresetCount(page);
await clickByDataAction(page, 'save-preset');
await page.waitForSelector('.dialog [name="presetName"]', { timeout: 5000 });
await page.fill('.dialog [name="presetName"]', 'Epic 3 Test Preset');
await page.click('.dialog button:has-text("Save")');
await page.waitForTimeout(500);
const newCount = await getPresetCount(page);
expect(newCount).toBe(initialCount + 1);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await verifyNotification(page, 'applied preset: Epic 3 Test Preset');
});
test('Duplicate preset name shows confirmation dialog', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'save-preset');
await page.waitForSelector('.dialog [name="presetName"]', { timeout: 5000 });
await page.fill('.dialog [name="presetName"]', 'Epic 3 Test Preset');
await page.waitForSelector('.dialog:has-text("already exists")', { timeout: 3000 });
});
});
// ============================================================================
// Test Suite: ConfirmationBar & Auto-Apply
// ============================================================================
test.describe('Epic 3 - ConfirmationBar & Auto-Apply', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('ConfirmationBar appears after preset load', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForSelector('.sp-confirmation-bar', { timeout: 5000 });
const barText = await page.$eval('.sp-confirmation-bar', el => el.textContent);
expect(barText).toContain('Preset applied');
});
test('ConfirmationBar undo reverts matrix', async () => {
const initialMatrix = await page.evaluate(() => {
const controller = game.modules.get('video-view-manager')?._scryingPoolController;
return JSON.stringify(controller?.visibilityMatrix);
});
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForSelector('.sp-confirmation-bar', { timeout: 5000 });
await page.click('.sp-confirmation-bar [data-action="undo"]');
await page.waitForTimeout(500);
const revertedMatrix = await page.evaluate(() => {
const controller = game.modules.get('video-view-manager')?._scryingPoolController;
return JSON.stringify(controller?.visibilityMatrix);
});
expect(revertedMatrix).toBe(initialMatrix);
});
test('ConfirmationBar auto-dismisses after 8 seconds', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForSelector('.sp-confirmation-bar', { timeout: 5000 });
// Note: This test may be flaky depending on timing
// The bar should auto-dismiss after 8 seconds
await page.waitForTimeout(8500);
const isHidden = await page.evaluate(() => {
const bar = document.querySelector('.sp-confirmation-bar');
return !bar || bar.offsetParent === null;
});
expect(isHidden).toBe(true);
});
});
// ============================================================================
// Test Suite: Import/Export
// ============================================================================
test.describe('Epic 3 - Preset Import/Export', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('Export presets downloads JSON file', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'export-presets');
const download = await page.waitForEvent('download');
const path = await download.path();
const { readFileSync } = await import('fs');
const contents = readFileSync(path, 'utf8');
const data = JSON.parse(contents);
expect(data._version).toBe(1);
expect(data.presets).toBeDefined();
expect(Object.keys(data.presets).length).toBeGreaterThan(0);
});
test('Import with merge adds new presets', async () => {
// First export
await openDirectorsBoard(page);
await clickByDataAction(page, 'export-presets');
const download = await page.waitForEvent('download');
const exportPath = await download.path();
// Delete all presets
await page.evaluate(() => {
const scene = game.scenes.current;
scene.unsetFlag('video-view-manager', 'presets');
});
// Import with merge
await openDirectorsBoard(page);
await clickByDataAction(page, 'import-presets');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(exportPath);
await page.click('text="Merge"');
await page.click('button:has-text("Import")');
await page.waitForTimeout(1000);
const finalCount = await getPresetCount(page);
expect(finalCount).toBeGreaterThan(0);
await verifyNotification(page, 'Imported');
});
test('Import with invalid JSON shows error', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'import-presets');
const { writeFileSync } = await import('fs');
const invalidJsonPath = '/tmp/invalid-presets.json';
writeFileSync(invalidJsonPath, '{invalid json}');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(invalidJsonPath);
await verifyNotification(page, 'Invalid JSON');
});
});
// ============================================================================
// Test Suite: Edge Cases
// ============================================================================
test.describe('Epic 3 - Edge Cases', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('Load preset with no presets shows empty message', async () => {
await page.evaluate(() => {
const scene = game.scenes.current;
scene.unsetFlag('video-view-manager', 'presets');
});
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog:has-text("No presets saved yet")', { timeout: 5000 });
});
test('Auto-apply respects per-scene disable', async () => {
const hasSetting = await page.evaluate(() => {
const scene = game.scenes.current;
const autoApply = scene.getFlag('video-view-manager', 'autoApply') || {};
return autoApply.enabled !== undefined;
});
expect(hasSetting).toBe(true);
});
test('Socket events fire correctly', async () => {
await page.evaluate(() => {
window.testEvents = [];
Hooks.off('scrying-pool.preset.apply');
Hooks.off('scrying-pool.preset.applied');
});
await page.evaluate(() => {
Hooks.on('scrying-pool.preset.apply', () => window.testEvents.push('apply'));
Hooks.on('scrying-pool.preset.applied', () => window.testEvents.push('applied'));
});
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForTimeout(1000);
const events = await page.evaluate(() => window.testEvents);
expect(events).toContain('apply');
expect(events).toContain('applied');
});
});