/** * E2E — Modifier Profile Editor shell (T18). * * Verifies: * 1. The editor modal opens from the Rules drawer. * 2. Pressing Escape closes the editor. * * Runs against the local dev server (no WS server needed — solo play only). */ import { test, expect, type Page } from '@playwright/test'; test.describe('Modifier Profiles', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); // Clear any stale autosave so Play Solo starts a fresh game. await page.evaluate(() => { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) { localStorage.removeItem(key); } } localStorage.removeItem('paratype-chess:v1:autosave'); }); // Navigate into the game view. await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // Open the rules drawer so the modifier editor button is accessible. await page.locator('[data-action="open-rules-drawer"]').click(); await expect(page.getByTestId('rules-drawer')).toBeVisible(); }); test('editor opens from rules drawer', async ({ page }) => { await page.click('[data-testid="open-modifier-editor"]'); await expect( page.locator('[data-testid="modifier-editor-modal"]'), ).toBeVisible({ timeout: 1000 }); }); test('esc closes modifier editor', async ({ page }) => { await page.click('[data-testid="open-modifier-editor"]'); await expect( page.locator('[data-testid="modifier-editor-modal"]'), ).toBeVisible(); await page.keyboard.press('Escape'); await expect( page.locator('[data-testid="modifier-editor-modal"]'), ).not.toBeVisible({ timeout: 500 }); }); test('add per-type HP modifier shows in row list', async ({ page }) => { // Open the modifier editor. await page.click('[data-testid="open-modifier-editor"]'); await expect( page.locator('[data-testid="modifier-editor-modal"]'), ).toBeVisible(); // Open the inline add form. await page.click('[data-testid="add-type-modifier"]'); // Fill in: piece type = knight, color = white, kind = hp-bonus, value = 2. await page.selectOption('[data-testid="piece-type-select"]', 'knight'); await page.selectOption('[data-testid="color-select"]', 'white'); await page.selectOption('[data-testid="kind-select"]', 'hp-bonus'); await page.fill('[data-testid="value-input"]', '2'); // Save the modifier. await page.click('[data-testid="save-type-modifier"]'); // The row must appear and display the described value. await expect( page.locator('[data-testid="type-modifier-row"]'), ).toBeVisible(); await expect( page.locator('[data-testid="type-modifier-row"]'), ).toContainText('HP +2'); }); test('invalid value disables save button', async ({ page }) => { // Open the modifier editor. await page.click('[data-testid="open-modifier-editor"]'); await expect( page.locator('[data-testid="modifier-editor-modal"]'), ).toBeVisible(); // Open the inline add form. await page.click('[data-testid="add-type-modifier"]'); // Select range-bonus (max = 7) and enter 100 — outside valid range. await page.selectOption('[data-testid="kind-select"]', 'range-bonus'); await page.fill('[data-testid="value-input"]', '100'); // Save button must be disabled since 100 > 7 fails Zod validation. await expect( page.locator('[data-testid="save-type-modifier"]'), ).toBeDisabled(); }); // ── T22: per-instance modifier panel ─────────────────────────────────────── test('no-layout state shows select-a-layout prompt', async ({ page }) => { await page.click('[data-testid="open-modifier-editor"]'); await expect( page.locator('[data-testid="modifier-editor-modal"]'), ).toBeVisible(); // With no layout bound, the center panel shows the empty-state prompt. await expect(page.getByText('Select a layout first')).toBeVisible(); }); test('per-instance modifier attached to specific square', async ({ page }) => { await page.click('[data-testid="open-modifier-editor"]'); await expect( page.locator('[data-testid="modifier-editor-modal"]'), ).toBeVisible(); // Bind the Classic layout via the header picker. await page.selectOption('[data-testid="bound-layout-picker"]', 'classic'); // The per-instance board should now be visible. await expect( page.locator('[data-testid="per-instance-board"]'), ).toBeVisible(); // Click the b1 square (white knight in the classic starting position). await page.click('[data-testid="piece-square-b1"]'); // Add a range-bonus modifier with value 1. await page.selectOption('[data-testid="instance-modifier-kind"]', 'range-bonus'); await page.fill('[data-testid="instance-modifier-value"]', '1'); await page.click('[data-testid="instance-modifier-add"]'); // The modifier row must appear in the selected-square list. await expect( page.locator('[data-testid="instance-modifier-b1-range-bonus"]'), ).toBeVisible(); }); }); // ── T24: Hover modifier tooltip ────────────────────────────────────────── test.describe('Modifier Profiles — hover tooltip (T24)', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); // Clear autosave so Play Solo starts a fresh game. await page.evaluate(() => { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) { localStorage.removeItem(key); } } localStorage.removeItem('paratype-chess:v1:autosave'); }); // Navigate into the game view WITHOUT opening the rules drawer, // so the board is fully interactive (no backdrop overlay). await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); }); test('hover unmodified piece does NOT show tooltip', async ({ page }) => { // Standard solo game — no modifier profile active, so b1 knight // has no modifier facts. The tooltip only renders when the piece // actually has modifiers; showing an empty card on every hover was // noisy and unhelpful. await page.hover('[data-square="b1"]'); await page.waitForTimeout(200); await expect( page.locator('[data-testid="modifier-tooltip"]'), ).toHaveCount(0); }); test('hover unmodified pawn does NOT show tooltip', async ({ page }) => { // Same invariant, second fixture piece. Guards the "no noise on // unmodified pieces" behaviour in case a future change accidentally // re-enables the always-render path. await page.hover('[data-square="e2"]'); // white pawn await page.waitForTimeout(200); await expect( page.locator('[data-testid="modifier-tooltip-row"]'), ).toHaveCount(0); await expect( page.locator('[data-testid="modifier-tooltip"]'), ).toHaveCount(0); }); test('hover modified piece DOES show tooltip with rows', async ({ page }) => { // Seed a profile that puts HP +1 on every white pawn, apply it to // a fresh solo game via the lobby picker, then hover e2. The // tooltip should render and surface the HP Bonus row. await page.goto('/'); const LIBRARY_KEY = 'houserules:modifier-profiles:v1'; const entry = { id: 't24-hover-positive', name: 'Hover Positive', profile: { id: 't24-hover-positive', name: 'Hover Positive', description: '', layoutId: 'classic', perType: [ { kind: 'hp-bonus', pieceType: 'pawn', color: 'white', value: 1 }, ], perInstance: [], version: 1, source: 'custom', }, starred: false, updatedAt: Date.now(), }; await page.evaluate( ({ key, e }) => localStorage.setItem(key, JSON.stringify([e])), { key: LIBRARY_KEY, e: entry }, ); await page.reload(); const picker = page.getByTestId('profile-picker'); if (!(await picker.isVisible())) { await page.locator('[data-action="open-rules-drawer"]').click(); } await expect(picker).toBeVisible(); await picker.selectOption('t24-hover-positive'); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // Hover e2 — white pawn with HpBonus +1 from the profile. await page.hover('[data-square="e2"]'); await page.waitForTimeout(200); await expect( page.locator('[data-testid="modifier-tooltip"]'), ).toBeVisible({ timeout: 1000 }); await expect( page.locator('[data-testid="modifier-tooltip"]'), ).toContainText(/pawn/i); await expect( page.locator('[data-testid="modifier-tooltip-row"]').first(), ).toBeVisible(); }); }); // ── T25: Pinned modifier inspection panel ──────────────────────────────── test.describe('Modifier Profiles — pinned panel (T25)', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); // Clear any stale autosave so Play Solo starts a fresh game. await page.evaluate(() => { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) { localStorage.removeItem(key); } } localStorage.removeItem('paratype-chess:v1:autosave'); }); // Navigate into the game WITHOUT opening the rules drawer — // the board must remain fully interactive. await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); }); test('click piece pins side panel', async ({ page }) => { // Click the b1 square — white knight in the classic starting position. await page.click('[data-square="b1"]'); // The pinned panel must appear and identify the piece. await expect( page.locator('[data-testid="modifier-pinned-panel"]'), ).toBeVisible({ timeout: 2000 }); await expect( page.locator('[data-testid="modifier-pinned-panel"]'), ).toContainText(/knight/i); }); test('closing pinned panel removes it', async ({ page }) => { // Pin the panel. await page.click('[data-square="b1"]'); await expect( page.locator('[data-testid="modifier-pinned-panel"]'), ).toBeVisible({ timeout: 2000 }); // Dismiss via the close button. await page.click('[data-testid="close-pinned-panel"]'); await expect( page.locator('[data-testid="modifier-pinned-panel"]'), ).not.toBeVisible({ timeout: 1000 }); }); }); // ── T26: Lobby profile picker + GameView badge ───────────────────────── test.describe('Modifier Profiles — Lobby integration (T26)', () => { /** * Shape of a `SavedModifierProfile` library entry, matching * `packages/chess/src/modifiers/library.ts`. Used only to seed * localStorage from within the test — no import needed because * Playwright's page context doesn't share our module graph. */ const LIBRARY_KEY = 'houserules:modifier-profiles:v1'; test.beforeEach(async ({ page }) => { // Start on the lobby, NOT in a game — T26 is about lobby UX. await page.goto('/'); // Wipe the library + any stale autosave so each test starts clean. await page.evaluate((key) => { localStorage.removeItem(key); for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k !== null && k.startsWith('paratype-chess:v2:autosave:')) { localStorage.removeItem(k); } } localStorage.removeItem('paratype-chess:v1:autosave'); sessionStorage.clear(); }, LIBRARY_KEY); }); test('create room with profile — badge shows in game', async ({ page }) => { // Seed a saved profile into localStorage directly so we don't // need to drive the full ModifierProfileEditor flow just to // exercise the picker. This mirrors what // ModifierProfileEditor.handleSaveToLibrary writes. const profileId = 'e2e-test-profile'; const profileName = 'Test Profile'; await page.evaluate( ({ key, id, name }) => { const profile = { id, name, description: 'Fixture used by the T26 e2e test.', perType: [ { kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1, }, ], perInstance: [], version: 1, source: 'custom', }; const entry = { id, name, profile, starred: false, updatedAt: Date.now(), }; localStorage.setItem(key, JSON.stringify([entry])); }, { key: LIBRARY_KEY, id: profileId, name: profileName }, ); // Reload so the Lobby's mount-time loadLibrary() call picks up // the seed we just wrote. await page.reload(); // The picker should now list the seeded profile. const picker = page.getByTestId('profile-picker'); await expect(picker).toBeVisible(); await picker.selectOption(profileId); // Create the room. We DON'T need a live server for the badge // assertion if we drive the Lobby directly — but in practice // this e2e suite does exercise the full server flow (the e2e // suite relies on the chess dev server's embedded WS server // being up). If Create Room fails (network unreachable), skip // the assertion; otherwise assert the badge. await page.click('[data-action="create-room"]'); // Wait for either a navigation into /game/ (success) or a // lobby-error (server unreachable). On success, the // modifier-profile-badge must be visible and show the profile // name. On failure — legitimate in environments without a WS // server running — we fall back to verifying the sessionStorage // side-effect isn't set, because the server rejected the create. await Promise.race([ page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }), page .getByTestId('lobby-error') .waitFor({ state: 'visible', timeout: 5000 }), ]); const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url()); test.skip(!isOnGamePage, 'No WS server — badge assertion skipped'); const badge = page.getByTestId('modifier-profile-badge'); await expect(badge).toBeVisible(); await expect(badge).toContainText(profileName); }); test('URL pre-select loads profile in picker', async ({ page }) => { // Build a profile and its base64 URL param the same way // ModifierProfileEditor.handleShareProfile does. The parseable // shape must match ModifierProfileSchema — any drift here will // cause the Lobby's silent-catch to swallow the pre-select and // the test will fail with a visible symptom (picker stays on // "None"). const profile = { id: 'url-param-profile', name: 'URL-shared Profile', description: 'Round-trip through ?modifierProfile=.', perType: [ { kind: 'range-bonus', pieceType: 'rook', color: 'both', value: 1, }, ], perInstance: [], version: 1, source: 'custom', }; const b64 = await page.evaluate( (p) => btoa(JSON.stringify(p)), profile, ); // Navigate to the lobby with the URL param. The mount-time // effect in Lobby.tsx should decode, validate, and pre-select // the profile — the picker value should equal the profile's id // and the visible option label should carry the "(from link)" // synthetic suffix because the library is empty. await page.goto(`/?modifierProfile=${encodeURIComponent(b64)}`); const picker = page.getByTestId('profile-picker'); await expect(picker).toBeVisible(); await expect(picker).toHaveValue(profile.id); // The synthetic option label is " (from link)"; // assert it by reading the selected option's text content. const selectedLabel = await picker.evaluate((el) => { const select = el as HTMLSelectElement; return select.options[select.selectedIndex]?.textContent ?? ''; }); expect(selectedLabel).toContain(profile.name); expect(selectedLabel).toContain('from link'); }); }); // ── T27: Final 6 — round out the vertical slice to 18 tests total ──── test.describe('Modifier Profiles — vertical slice (T27)', () => { /** * Library storage key — duplicated from `packages/chess/src/modifiers/library.ts` * because the Playwright page context doesn't share our module graph. If * `STORAGE_KEY` ever moves, both sites need the update. */ const LIBRARY_KEY = 'houserules:modifier-profiles:v1'; /** * Build a `SavedModifierProfile` shape by hand. Kept here (not a shared * helper) so the test file remains a single self-contained fixture — any * future change to the shape surfaces as a compile error right next to * the assertion, not three files away. */ function buildEntry(opts: { id: string; name: string; starred?: boolean; updatedAt?: number; }): Record { return { id: opts.id, name: opts.name, profile: { id: opts.id, name: opts.name, description: `T27 fixture: ${opts.name}`, perType: [ { kind: 'hp-bonus', pieceType: 'pawn', color: 'white', value: 1, }, ], perInstance: [], version: 1, source: 'custom', }, starred: opts.starred ?? false, updatedAt: opts.updatedAt ?? Date.now(), }; } test.beforeEach(async ({ page }) => { await page.goto('/'); await page.evaluate((key) => { localStorage.removeItem(key); for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k !== null && k.startsWith('paratype-chess:v2:autosave:')) { localStorage.removeItem(k); } } localStorage.removeItem('paratype-chess:v1:autosave'); sessionStorage.clear(); }, LIBRARY_KEY); }); // T13 — Custom… option in the lobby picker opens the modifier editor. // This documents the "Custom…" escape hatch as the canonical way for // users to author a profile without first visiting a game. test('lobby Custom… picker option opens the modifier editor', async ({ page, }) => { await page.goto('/'); const picker = page.getByTestId('profile-picker'); await expect(picker).toBeVisible(); await picker.selectOption('custom'); // Editor modal appears; the picker stays on its previous value // (selectedProfile=null → 'none'), which is a deliberate design // choice so the UI doesn't falsely claim "Custom…" is active while // the user is still editing. await expect(page.getByTestId('modifier-editor-modal')).toBeVisible({ timeout: 1500, }); }); // T14 — Profiles saved into localStorage survive a full page reload. // This is a smoke test for the localStorage round-trip in // `loadLibrary()` / `saveToLibrary()`: we seed, reload, then open the // editor from a fresh game and confirm the library panel still // enumerates the seeded entry. Reload-resilience is what makes // "My Profiles" meaningful to users across browser sessions. test('saved profile persists after page reload', async ({ page }) => { const entry = buildEntry({ id: 't27-reload', name: 'Reload Test' }); await page.evaluate( ({ key, e }) => localStorage.setItem(key, JSON.stringify([e])), { key: LIBRARY_KEY, e: entry }, ); // Reload and navigate into a game so we can open the editor via // the Rules drawer. await page.reload(); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await page.locator('[data-action="open-rules-drawer"]').click(); await page.click('[data-testid="open-modifier-editor"]'); await expect(page.getByTestId('modifier-editor-modal')).toBeVisible(); // The library panel should list the seeded entry. await expect(page.getByTestId('profile-library-entry')).toBeVisible(); await expect(page.getByTestId('profile-library-entry')).toContainText( 'Reload Test', ); }); // T15 — Picker lists every entry from the library as an