/** * Epic 3: Scene-Aware Camera Automation (Scene Presets) - Tests E2E * * FR-15: GM can save named Scene Preset from current Visibility Matrix * FR-16: GM can load Scene Preset at any time * FR-17: Scene Preset auto-applies on Scene activation * FR-18: Disable auto-apply per-scene or globally * FR-19: Preset import/export as JSON */ import { test, expect } from '@playwright/test'; import { waitForFoundryReady, waitForVVMModule, openDirectorsBoard, clickFoundryButton, saveScenePreset, loadScenePreset, waitForNotification, } from '../utils/foundry-helpers'; const TEST_PRESET_NAME = 'TestPreset'; const TEST_PRESET_NAME_2 = 'TestPreset2'; test.describe('Epic 3: Scene-Aware Camera Automation', () => { test.setTimeout(120000); test.beforeEach(async ({ page }) => { await page.goto('https://localhost:31000', { waitUntil: 'domcontentloaded', timeout: 30000, }); await waitForFoundryReady(page); await waitForVVMModule(page); const isGM = await page.evaluate(() => game.user?.isGM || false); if (!isGM) { test.skip(true, 'Test requires GM user'); } // Nettoyer les presets de test await cleanupTestPresets(page); }); test.afterEach(async ({ page }) => { // Nettoyer après chaque test await cleanupTestPresets(page); }); }); /** * Nettoie les presets de test */ async function cleanupTestPresets(page) { await openDirectorsBoard(page); // Liste des presets à supprimer const presetsToDelete = [TEST_PRESET_NAME, TEST_PRESET_NAME_2]; for (const presetName of presetsToDelete) { try { // Ouvrir la liste des presets await clickFoundryButton(page, 'button:has-text("Load Preset")'); await page.waitForSelector('.sp-preset-list', { state: 'visible', timeout: 3000 }); // Trouver et supprimer le preset const presetItem = page.locator(`.sp-preset-item:has-text("${presetName}")`); if (await presetItem.count() > 0) { await presetItem.hover(); await clickFoundryButton(page, '.sp-preset-delete-button'); await clickFoundryButton(page, 'button:has-text("Confirm")'); await page.waitForTimeout(500); } } catch (error) { console.warn(`Could not delete preset ${presetName}:`, error.message); } } } // ============================================================================ // FR-15: Save named Scene Preset // ============================================================================ test.describe('FR-15: Save Scene Preset', () => { test('GM can save current Visibility Matrix as named preset', async ({ page }) => { await openDirectorsBoard(page); // Sauvegarder le preset await saveScenePreset(page, TEST_PRESET_NAME); // Vérifier la notification await waitForNotification(page, `Saved preset: ${TEST_PRESET_NAME}`, 5000); }); test('Preset name must be unique per world', async ({ page }) => { await openDirectorsBoard(page); // Sauvegarder le premier preset await saveScenePreset(page, TEST_PRESET_NAME); // Essayer de sauvegarder avec le même nom await page.locator('button:has-text("Save Preset")').click(); await page.waitForSelector('.sp-save-preset-dialog', { state: 'visible' }); await page.locator('.sp-save-preset-dialog input[name="presetName"]').fill(TEST_PRESET_NAME); // Devrait montrer une erreur ou ne pas permettre le doublon // Selon l'implémentation, soit: // 1. Le bouton Save est désactivé // 2. Une erreur apparaît const saveButton = page.locator('.sp-save-preset-dialog button:has-text("Save"):disabled'); if (await saveButton.count() > 0) { await expect(saveButton).toBeVisible({ timeout: 3000 }); } else { // Sinon, vérifier qu'une erreur apparaît const error = page.locator('.sp-error-message'); await expect(error).toBeVisible({ timeout: 3000 }); } }); test('Preset captures full current Visibility Matrix', async ({ page }) => { await openDirectorsBoard(page); // Configurer un état spécifique : cacher Player1, montrer Player2 await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); await page.waitForTimeout(500); // Sauvegarder await saveScenePreset(page, TEST_PRESET_NAME); // Vérifier que le preset contient la matrice const presetData = await page.evaluate((presetName) => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { const preset = module.api.scenePresetManager.getPreset(presetName); return preset?.matrix; } return null; }, TEST_PRESET_NAME); expect(presetData).toBeTruthy(); expect(presetData).toHaveProperty('_version'); expect(presetData).toHaveProperty('matrix'); }); test('Preset names are editable', async ({ page }) => { await openDirectorsBoard(page); await clickFoundryButton(page, 'button:has-text("Save Preset")'); await page.waitForSelector('.sp-save-preset-dialog', { state: 'visible' }); const input = page.locator('.sp-save-preset-dialog input[name="presetName"]'); await input.fill(TEST_PRESET_NAME); await expect(input).toHaveValue(TEST_PRESET_NAME); // Modifier le nom await input.fill(TEST_PRESET_NAME_2); await expect(input).toHaveValue(TEST_PRESET_NAME_2); }); test('Up to 50 presets per world can be saved', async ({ page }) => { await openDirectorsBoard(page); // Sauvegarder plusieurs presets const presetCount = 10; for (let i = 0; i < presetCount; i++) { const name = `Preset${i}`; await page.locator('button:has-text("Save Preset")').click(); await page.waitForSelector('.sp-save-preset-dialog', { state: 'visible' }); await page.locator('.sp-save-preset-dialog input[name="presetName"]').fill(name); await clickFoundryButton(page, '.sp-save-preset-dialog button:has-text("Save")'); await waitForNotification(page, `Saved preset: ${name}`, 3000); } // Vérifier que tous les presets sont dans la liste await clickFoundryButton(page, 'button:has-text("Load Preset")'); await page.waitForSelector('.sp-preset-list', { state: 'visible' }); const presetItems = page.locator('.sp-preset-item'); expect(await presetItems.count()).toBeGreaterThanOrEqual(presetCount); // Nettoyer await cleanupTestPresets(page); }); }); // ============================================================================ // FR-16: Load Scene Preset // ============================================================================ test.describe('FR-16: Load Scene Preset', () => { test('GM can load a preset at any time', async ({ page }) => { await openDirectorsBoard(page); // Sauvegarder un preset await saveScenePreset(page, TEST_PRESET_NAME); // Changer manuellement l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); await page.waitForTimeout(500); // Charger le preset await loadScenePreset(page, TEST_PRESET_NAME); // Vérifier que l'état est revenu à celui du preset const card = page.locator('.sp-participant-card:has-text("Player1")'); const state = await card.locator('.sp-state-badge').textContent(); // Le preset sauvé avait Player1 visible (par défaut) expect(state).toContain('Active'); }); test('Loading preset overrides current Visibility Matrix', async ({ page }) => { await openDirectorsBoard(page); // Cacher tous les participants await clickFoundryButton(page, 'button:has-text("Hide All")'); // Sauvegarder cet état await saveScenePreset(page, TEST_PRESET_NAME); // Montrer tous await clickFoundryButton(page, 'button:has-text("Show All")'); // Charger le preset "tous cachés" await loadScenePreset(page, TEST_PRESET_NAME); // Vérifier que tous sont cachés const cards = page.locator('.sp-participant-card'); const count = await cards.count(); for (let i = 0; i < count; i++) { const card = cards.nth(i); const state = await card.locator('.sp-state-badge').textContent(); expect(state).toContain('Hidden'); } }); test('All clients receive state within 500ms', async ({ page }) => { // Sauvegarder un preset await openDirectorsBoard(page); await saveScenePreset(page, TEST_PRESET_NAME); // Changer l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); await page.waitForTimeout(500); const startTime = Date.now(); // Charger le preset await loadScenePreset(page, TEST_PRESET_NAME); const endTime = Date.now(); const elapsed = endTime - startTime; // Vérifier que le changement s'est propagé rapidement const card = page.locator('.sp-participant-card:has-text("Player1")'); const state = await card.locator('.sp-state-badge').textContent(); expect(state).toContain('Active'); // Note: En mode single-player test, on ne peut pas vérifier multi-clients // Mais le temps devrait être < 500ms expect(elapsed).toBeLessThan(500); }); test('Loading generates notification "GM applied preset: [Preset Name]"', async ({ page }) => { await openDirectorsBoard(page); await saveScenePreset(page, TEST_PRESET_NAME); // Changer l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); // Charger le preset await loadScenePreset(page, TEST_PRESET_NAME); // Vérifier la notification await waitForNotification(page, `GM applied preset: ${TEST_PRESET_NAME}`, 5000); }); }); // ============================================================================ // FR-17: Scene Preset Auto-Apply // ============================================================================ test.describe('FR-17: Scene Auto-Apply', () => { test('Scene Preset auto-applies on Scene activation', async ({ page }) => { await openDirectorsBoard(page); // Sauvegarder un preset await saveScenePreset(page, TEST_PRESET_NAME); // Associer le preset à la scène actuelle await page.evaluate((presetName) => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { const currentScene = game.scenes?.active; if (currentScene) { module.api.scenePresetManager.setScenePreset( currentScene.id, presetName ); } } }, TEST_PRESET_NAME); // Changer manuellement l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); await page.waitForTimeout(500); // Recharger la scène (simuler le changement de scène) await page.evaluate(() => { const currentScene = game.scenes?.active; if (currentScene) { currentScene.activate(); } }); // Attendre l'auto-apply await page.waitForTimeout(1000); // Vérifier que l'état est revenu à celui du preset const card = page.locator('.sp-participant-card:has-text("Player1")'); const state = await card.locator('.sp-state-badge').textContent(); expect(state).toContain('Active'); }); test('Auto-apply has configurable pre-delay', async ({ page }) => { // Ce test vérifie que le délai est configurable // La valeur par défaut est 0ms const delay = await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { return module.api.scenePresetManager.autoApplyDelay; } return 0; }); // Devrait être un nombre (ms) expect(typeof delay).toBe('number'); expect(delay).toBeGreaterThanOrEqual(0); expect(delay).toBeLessThanOrEqual(5000); }); test('All clients receive "Scene changed: camera layout updated" notification', async ({ page }) => { // Sauvegarder et associer un preset await openDirectorsBoard(page); await saveScenePreset(page, TEST_PRESET_NAME); await page.evaluate((presetName) => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { const currentScene = game.scenes?.active; if (currentScene) { module.api.scenePresetManager.setScenePreset( currentScene.id, presetName ); } } }, TEST_PRESET_NAME); // Changer manuellement l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); // Recharger la scène await page.evaluate(() => { const currentScene = game.scenes?.active; if (currentScene) { currentScene.activate(); } }); // Attendre la notification // Note: En mode single-player, la notification pourrait ne pas apparaître // car c'est le même utilisateur try { await waitForNotification(page, 'Scene changed: camera layout updated', 5000); } catch (error) { console.warn('Notification may not appear in single-player mode:', error.message); } }); }); // ============================================================================ // FR-18: Disable Auto-Apply // ============================================================================ test.describe('FR-18: Disable Auto-Apply', () => { test('Auto-apply can be disabled per-scene', async ({ page }) => { await openDirectorsBoard(page); await saveScenePreset(page, TEST_PRESET_NAME); // Désactiver l'auto-apply pour la scène actuelle await page.evaluate((presetName) => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { const currentScene = game.scenes?.active; if (currentScene) { module.api.scenePresetManager.setScenePreset( currentScene.id, presetName, { autoApply: false } ); } } }, TEST_PRESET_NAME); // Changer manuellement l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); await page.waitForTimeout(500); // Recharger la scène await page.evaluate(() => { const currentScene = game.scenes?.active; if (currentScene) { currentScene.activate(); } }); // Attendre et vérifier que l'état N'A PAS changé await page.waitForTimeout(1500); const card = page.locator('.sp-participant-card:has-text("Player1")'); const state = await card.locator('.sp-state-badge').textContent(); // Devrait être toujours caché (car auto-apply est désactivé) expect(state).toContain('Hidden'); }); test('Auto-apply can be disabled globally', async ({ page }) => { // Désactiver l'auto-apply globalement await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { module.api.scenePresetManager.setAutoApplyEnabled(false); } }); await openDirectorsBoard(page); await saveScenePreset(page, TEST_PRESET_NAME); await page.evaluate((presetName) => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { const currentScene = game.scenes?.active; if (currentScene) { module.api.scenePresetManager.setScenePreset( currentScene.id, presetName, { autoApply: true } // Même si activé par scène, le global prend le dessus ); } } }, TEST_PRESET_NAME); // Changer l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); await page.waitForTimeout(500); // Recharger la scène await page.evaluate(() => { const currentScene = game.scenes?.active; if (currentScene) { currentScene.activate(); } }); await page.waitForTimeout(1500); const card = page.locator('.sp-participant-card:has-text("Player1")'); const state = await card.locator('.sp-state-badge').textContent(); // Devrait être toujours caché (car auto-apply global est désactivé) expect(state).toContain('Hidden'); // Réactiver l'auto-apply global await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { module.api.scenePresetManager.setAutoApplyEnabled(true); } }); }); test('Director\'s Board always provides manual override', async ({ page }) => { // Désactiver l'auto-apply await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { module.api.scenePresetManager.setAutoApplyEnabled(false); } }); await openDirectorsBoard(page); // Sauvegarder un preset await saveScenePreset(page, TEST_PRESET_NAME); // Changer manuellement l'état await clickFoundryButton(page, '.sp-participant-card:has-text("Player1")'); // Le bouton Load Preset devrait toujours être disponible const loadButton = page.locator('button:has-text("Load Preset")'); await expect(loadButton).toBeEnabled({ timeout: 5000 }); // Charger manuellement await loadScenePreset(page, TEST_PRESET_NAME); // Devrait fonctionner même avec auto-apply désactivé const card = page.locator('.sp-participant-card:has-text("Player1")'); const state = await card.locator('.sp-state-badge').textContent(); expect(state).toContain('Active'); // Réactiver await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { module.api.scenePresetManager.setAutoApplyEnabled(true); } }); }); }); // ============================================================================ // FR-19: Preset Import/Export // ============================================================================ test.describe('FR-19: Preset Import/Export', () => { test('Preset export downloads all presets as JSON', async ({ page }) => { await openDirectorsBoard(page); // Sauvegarder quelques presets await saveScenePreset(page, 'ExportTest1'); await saveScenePreset(page, 'ExportTest2'); // Ouvrir le menu d'export await clickFoundryButton(page, 'button:has-text("Export")'); await page.waitForSelector('.sp-export-dialog', { state: 'visible' }); // Cliquer sur Export const downloadPromise = page.waitForEvent('download'); await clickFoundryButton(page, '.sp-export-dialog button:has-text("Export")'); const download = await downloadPromise; // Vérifier que le fichier est JSON expect(download.url()).toMatch(/\.json$/i); // Vérifier que le nom contient les presets expect(download.url()).toContain('presets'); // Nettoyer await cleanupTestPresets(page); }); test('Exported JSON is human-readable', async ({ page }) => { await openDirectorsBoard(page); await saveScenePreset(page, 'ReadableTest'); await clickFoundryButton(page, 'button:has-text("Export")'); await page.waitForSelector('.sp-export-dialog', { state: 'visible' }); // Capturer le contenu JSON const jsonContent = await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { return JSON.stringify(module.api.scenePresetManager.getAllPresets(), null, 2); } return null; }); expect(jsonContent).toBeTruthy(); // Vérifier que c'est du JSON valide const parsed = JSON.parse(jsonContent); expect(parsed).toBeTruthy(); expect(parsed).toHaveProperty('ExportTest'); // Nettoyer await cleanupTestPresets(page); }); test('Preset import reads JSON and merges or replaces', async ({ page }) => { await openDirectorsBoard(page); // Sauvegarder un preset existant await saveScenePreset(page, 'ExistingPreset'); // Créer un JSON d'import const importData = { NewPreset1: { _version: 1, matrix: { player1: 'hidden', player2: 'hidden' } }, NewPreset2: { _version: 1, matrix: { player1: 'active', player2: 'active' } } }; // Importer via l'API await page.evaluate((data) => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { module.api.scenePresetManager.importPresets(data, { merge: true }); } }, importData); // Vérifier que les nouveaux presets existent const presetNames = await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { return Object.keys(module.api.scenePresetManager.getAllPresets()); } return []; }); expect(presetNames).toContain('ExistingPreset'); expect(presetNames).toContain('NewPreset1'); expect(presetNames).toContain('NewPreset2'); // Nettoyer await cleanupTestPresets(page); }); test('Invalid JSON shows error', async ({ page }) => { await openDirectorsBoard(page); // Essayer d'importer du JSON invalide const result = await page.evaluate(() => { const module = game.modules.get('scrying-pool'); if (module && module.api?.scenePresetManager) { try { module.api.scenePresetManager.importPresets('invalid json', { merge: true }); return { success: true }; } catch (e) { return { success: false, error: e.message }; } } return { success: false, error: 'Module not found' }; }); // Devrait échouer expect(result.success).toBe(false); expect(result.error).toBeTruthy(); }); });