545 lines
20 KiB
JavaScript
545 lines
20 KiB
JavaScript
/**
|
|
* 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 Scrying Pool
|
|
await page.locator('button:has-text("Scrying Pool")').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("Scrying Pool")').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("Scrying Pool")').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();
|
|
});
|
|
});
|
|
});
|