Files
scrying-pool/tests/e2e/specs/epic-3-presets.spec.js
T
uberwald 20d13fc678 Story 4.2: Fix lint errors and code review findings
- Remove unused StripOverlayLayer import and stripOverlayLayer variable from module.js
- Add comprehensive JSDoc annotations to FoundryAdapter.js methods (settings, socket, users, scenes, notifications, hooks)
- Add /* global Dialog */ comment to PlayerPrivacyPanel.js for ESLint
- Remove unused _force parameter from GMPlayerPrivacySelector.js render() method
- Fix PlayerPrivacyPanelMenu.js: add constructor() to fallback class and call super()

All 862 unit tests passing. All Story 4.2 acceptance criteria met.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-24 01:25:30 +02:00

644 lines
22 KiB
JavaScript

/**
* 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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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('video-view-manager');
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();
});
});