/** * Epic 2: Player Notifications & Director's Board - Tests E2E * * FR-9: GM opens Director's Board via sidebar button + keyboard shortcut * FR-10: Director's Board displays full Visibility Matrix in seating-chart layout * FR-11: Per-Participant visibility toggle from Director's Board * FR-12: Bulk Show All / Hide All with one-step Undo * FR-13: Spotlight action with pre-spotlight snapshot and Restore * FR-14: Full keyboard shortcuts for Director's Board actions * FR-20: Toast notification to all participants on GM visibility change * FR-21: Notification verbosity configuration per user */ import { test, expect } from '@playwright/test'; import { waitForFoundryReady, waitForVVMModule, openDirectorsBoard, clickFoundryButton, toggleParticipantInBoard, waitForNotification, hideParticipant, showParticipant, } from '../utils/foundry-helpers'; const TEST_PLAYERS = ['Player1', 'Player2', 'Player3']; test.describe('Epic 2: Player Notifications & Director\'s Board', () => { 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'); } }); // ============================================================================ // FR-9: GM opens Director's Board via sidebar button // ============================================================================ test.describe('FR-9: Director\'s Board Access', () => { test('GM can open Director\'s Board via sidebar button', async ({ page }) => { await openDirectorsBoard(page); // Vérifier que le board est visible await page.waitForSelector('.scrying-pool-directors-board', { state: 'visible', timeout: 10000, }); }); test('Director\'s Board is a resizable, draggable ApplicationV2 window', async ({ page }) => { await openDirectorsBoard(page); const board = page.locator('.scrying-pool-directors-board'); // Vérifier qu'il a la classe window-content (ApplicationV2) await expect(board).toHaveClass(/window-content/); // Vérifier qu'il est redimensionnable const resizeHandle = board.locator('.window-resizable-handle'); await expect(resizeHandle).toBeVisible(); }); test('Director\'s Board opens as floating window', async ({ page }) => { await openDirectorsBoard(page); const board = page.locator('.scrying-pool-directors-board'); // Vérifier la position (devrait être flottante, pas fixed) const position = await board.evaluate(el => { return window.getComputedStyle(el).position; }); expect(position).toBe('absolute'); }); }); // ============================================================================ // FR-10: Director's Board displays full Visibility Matrix // ============================================================================ test.describe('FR-10: Visibility Matrix Display', () => { test('Board displays all connected participants in seating-chart layout', async ({ page }) => { await openDirectorsBoard(page); // Attendre que les cartes des participants soient chargées await page.waitForSelector('.sp-participant-card', { state: 'visible', timeout: 10000, }); // Vérifier qu'il y a des cartes de participants const cards = page.locator('.sp-participant-card'); const count = await cards.count(); expect(count).toBeGreaterThan(0); }); test('Each participant card shows name, portrait, current state', async ({ page }) => { await openDirectorsBoard(page); const card = page.locator('.sp-participant-card').first(); // Vérifier le nom await expect(card.locator('.sp-participant-name')).toBeVisible(); // Vérifier le portrait await expect(card.locator('.sp-participant-avatar')).toBeVisible(); // Vérifier l'état await expect(card.locator('.sp-state-badge')).toBeVisible(); }); test('Visibility State updates appear within 500ms', async ({ page }) => { await openDirectorsBoard(page); const startTime = Date.now(); // Changer l'état via le context menu await hideParticipant(page, 'Player1'); // Attendre la mise à jour dans le board await page.waitForFunction((playerName) => { const card = document.querySelector('.sp-participant-card:has-text("Player1")'); if (!card) return false; const badge = card.querySelector('.sp-state-badge'); if (!badge) return false; return badge.textContent.includes('Hidden'); }, { timeout: 1000 }); const endTime = Date.now(); const elapsed = endTime - startTime; expect(elapsed).toBeLessThan(500); }); }); // ============================================================================ // FR-11: Per-Participant visibility toggle from Director's Board // ============================================================================ test.describe('FR-11: Per-Participant Toggle', () => { test('Clicking participant card toggles visibility', async ({ page }) => { await openDirectorsBoard(page); const participantName = 'Player1'; const card = page.locator(`.sp-participant-card:has-text("${participantName}")`); // Obtenir l'état initial const initialState = await card.locator('.sp-state-badge').textContent(); // Cliquer pour toggler await card.click(); await page.waitForTimeout(500); // Obtenir l'état après toggle const newState = await card.locator('.sp-state-badge').textContent(); // Les états devraient être différents expect(initialState).not.toBe(newState); }); test('Toggle via card works same as context menu', async ({ page }) => { const participantName = 'Player1'; // Cacher via le board await toggleParticipantInBoard(page, participantName); const card = page.locator(`.sp-participant-card:has-text("${participantName}")`); const boardState = await card.locator('.sp-state-badge').textContent(); // Vérifier via l'AV Tile const tile = page.locator(`.av-tile:has-text("${participantName}")`); const tileBadge = tile.locator('.sp-visibility-badge'); const tileState = await tileBadge.textContent(); // Les deux devraient montrer "Hidden" expect(boardState).toContain('Hidden'); expect(tileState).toContain('Hidden'); }); test('Pending state shows pulse animation (reduced motion: static)', async ({ page }) => { await openDirectorsBoard(page); // Toggling rapidement devrait montrer l'état pending const card = page.locator('.sp-participant-card').first(); await card.click(); await card.click(); // Vérifier l'animation de pulse const stateRing = card.locator('.sp-state-ring--pending'); // En mode normal, devrait avoir l'animation // En mode reduced motion, devrait être statique const prefersReduced = await page.evaluate(() => { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }); if (!prefersReduced) { await expect(stateRing).toBeVisible({ timeout: 2000 }); } }); }); // ============================================================================ // FR-12: Bulk Show All / Hide All with one-step Undo // ============================================================================ test.describe('FR-12: Bulk Actions', () => { test('Show All button shows all participants', async ({ page }) => { await openDirectorsBoard(page); // D'abord cacher tous les participants for (const player of TEST_PLAYERS) { await toggleParticipantInBoard(page, player); } // Cliquer sur Show All await clickFoundryButton(page, 'button:has-text("Show All")'); // Vérifier que tous sont visibles for (const player of TEST_PLAYERS) { const card = page.locator(`.sp-participant-card:has-text("${player}")`); const state = await card.locator('.sp-state-badge').textContent(); expect(state).toContain('Active'); } }); test('Hide All button hides all participants', async ({ page }) => { await openDirectorsBoard(page); // Cliquer sur Hide All await clickFoundryButton(page, 'button:has-text("Hide All")'); // 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('Undo restores Visibility Matrix to previous state', async ({ page }) => { await openDirectorsBoard(page); // État initial: tous visibles // Exécuter Hide All await clickFoundryButton(page, 'button:has-text("Hide All")'); // Exécuter Undo await clickFoundryButton(page, 'button:has-text("Undo")'); // Vérifier que tous sont revenus à l'état initial (visible) 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('Active'); } }); test('Second undo is unavailable after first (no-op)', async ({ page }) => { await openDirectorsBoard(page); // Exécuter Hide All await clickFoundryButton(page, 'button:has-text("Hide All")'); // Premier Undo await clickFoundryButton(page, 'button:has-text("Undo")'); // Vérifier que le bouton Undo est désactivé const undoButton = page.locator('button:has-text("Undo"):disabled'); await expect(undoButton).toBeVisible({ timeout: 5000 }); }); }); // ============================================================================ // FR-13: Spotlight action // ============================================================================ test.describe('FR-13: Spotlight Action', () => { test('Spotlight shows exactly one participant and hides all others', async ({ page }) => { await openDirectorsBoard(page); const spotlightTarget = 'Player1'; const card = page.locator(`.sp-participant-card:has-text("${spotlightTarget}")`); // Cliquer avec majuscule pour spotlight (ou utiliser le bouton dédié) await card.hover(); await page.keyboard.press('s'); // ou utiliser le bouton contextuel // Vérifier que seul Player1 est visible const cards = page.locator('.sp-participant-card'); const count = await cards.count(); for (let i = 0; i < count; i++) { const c = cards.nth(i); const name = await c.locator('.sp-participant-name').textContent(); const state = await c.locator('.sp-state-badge').textContent(); if (name.includes(spotlightTarget)) { expect(state).toContain('Active'); } else { expect(state).toContain('Hidden'); } } }); test('Restore action reverts to pre-spotlight snapshot', async ({ page }) => { await openDirectorsBoard(page); // État initial: Player1 caché, Player2 visible await toggleParticipantInBoard(page, 'Player1'); // Spotlight Player2 const card = page.locator(`.sp-participant-card:has-text("Player2")`); await card.hover(); await page.keyboard.press('s'); // Restore await clickFoundryButton(page, 'button:has-text("Restore")'); // Vérifier que Player1 est toujours caché, Player2 visible const card1 = page.locator(`.sp-participant-card:has-text("Player1")`); const card2 = page.locator(`.sp-participant-card:has-text("Player2")`); expect(await card1.locator('.sp-state-badge').textContent()).toContain('Hidden'); expect(await card2.locator('.sp-state-badge').textContent()).toContain('Active'); }); }); // ============================================================================ // FR-14: Keyboard Shortcuts // ============================================================================ test.describe('FR-14: Keyboard Accessibility', () => { test('Ctrl+Shift+V opens/closes Director\'s Board', async ({ page }) => { // Fermer si déjà ouvert const isOpen = await page.locator('.scrying-pool-directors-board').count(); if (isOpen > 0) { await page.keyboard.press('Escape'); await page.waitForTimeout(500); } // Ouvrir avec le raccourci await page.keyboard.press('Control+Shift+V'); await page.waitForSelector('.scrying-pool-directors-board', { state: 'visible', timeout: 5000, }); // Fermer avec Escape await page.keyboard.press('Escape'); await page.waitForSelector('.scrying-pool-directors-board', { state: 'hidden', timeout: 5000, }); }); test('Arrow keys move focus between participant cards', async ({ page }) => { await openDirectorsBoard(page); const firstCard = page.locator('.sp-participant-card').first(); await firstCard.focus(); // Appuyer sur la flèche droite await page.keyboard.press('ArrowRight'); // Le focus devrait être sur la carte suivante const activeElement = await page.evaluate(() => document.activeElement); expect(activeElement.classList.contains('sp-participant-card')).toBeTruthy(); }); test('Space/Enter toggles focused participant visibility', async ({ page }) => { await openDirectorsBoard(page); const firstCard = page.locator('.sp-participant-card').first(); await firstCard.focus(); const initialState = await firstCard.locator('.sp-state-badge').textContent(); // Appuyer sur Espace await page.keyboard.press('Space'); const newState = await firstCard.locator('.sp-state-badge').textContent(); expect(initialState).not.toBe(newState); }); test('Ctrl+Shift+S triggers Show All', async ({ page }) => { await openDirectorsBoard(page); // Cacher tous await clickFoundryButton(page, 'button:has-text("Hide All")'); // Appuyer sur Ctrl+Shift+S await page.keyboard.press('Control+Shift+S'); // Vérifier que tous sont visibles 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('Active'); } }); test('Ctrl+Shift+H triggers Hide All', async ({ page }) => { await openDirectorsBoard(page); // Appuyer sur Ctrl+Shift+H await page.keyboard.press('Control+Shift+H'); // 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'); } }); }); // ============================================================================ // FR-20: Toast Notifications // ============================================================================ test.describe('FR-20: Toast Notifications', () => { test('GM visibility change generates toast notification', async ({ page }) => { const participantName = 'Player1'; // Changer la visibilité await hideParticipant(page, participantName); // Attendre la notification await waitForNotification(page, `GM hid ${participantName}'s camera`, 5000); }); test('Show action generates toast notification', async ({ page }) => { const participantName = 'Player1'; // D'abord cacher await hideParticipant(page, participantName); // Puis montrer await showParticipant(page, participantName); // Attendre la notification await waitForNotification(page, `GM showed ${participantName}'s camera`, 5000); }); test('Notification uses participant display name', async ({ page }) => { const participantName = 'Player1'; await hideParticipant(page, participantName); // Capturer le texte exact de la notification const notification = page.locator('.notification'); const text = await notification.textContent(); expect(text).toContain(participantName); expect(text).toContain('hid'); }); test('Notification uses FoundryVTT native notification UI', async ({ page }) => { const participantName = 'Player1'; await hideParticipant(page, participantName); // Vérifier que la notification a la classe Foundry const notification = page.locator('.notification.toast'); await expect(notification).toBeVisible({ timeout: 5000 }); }); }); // ============================================================================ // FR-21: Notification Verbosity // ============================================================================ test.describe('FR-21: Notification Verbosity Configuration', () => { test('Notification verbosity can be set to All', async ({ page }) => { // Ouvrir les paramètres du module await page.goto('https://localhost:31000', { waitUntil: 'domcontentloaded' }); await waitForFoundryReady(page); // Naviguer vers les paramètres await page.locator('button[aria-label="Configure Settings"]').click(); await page.waitForSelector('.app-v2.settings', { state: 'visible' }); // Sélectionner Video View Manager await page.locator('button:has-text("Video View Manager")').click(); // Trouver le paramètre de verbosité await page.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 }); await page.locator('.sp-notification-verbosity-select').selectOption('All'); // Sauvegarder await page.locator('button:has-text("Save")').click(); }); test('Notification verbosity can be set to GM Only', async ({ page }) => { // Similaire au test précédent mais avec "GM Only" await page.goto('https://localhost:31000', { waitUntil: 'domcontentloaded' }); await waitForFoundryReady(page); await page.locator('button[aria-label="Configure Settings"]').click(); await page.waitForSelector('.app-v2.settings', { state: 'visible' }); await page.locator('button:has-text("Video View Manager")').click(); await page.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 }); await page.locator('.sp-notification-verbosity-select').selectOption('GM Only'); await page.locator('button:has-text("Save")').click(); }); test('Notification verbosity can be set to Silent', async ({ page }) => { await page.goto('https://localhost:31000', { waitUntil: 'domcontentloaded' }); await waitForFoundryReady(page); await page.locator('button[aria-label="Configure Settings"]').click(); await page.waitForSelector('.app-v2.settings', { state: 'visible' }); await page.locator('button:has-text("Video View Manager")').click(); await page.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 }); await page.locator('.sp-notification-verbosity-select').selectOption('Silent'); await page.locator('button:has-text("Save")').click(); }); }); });