/** * E2E — Modifier profile ⇄ layout binding. * * Per-instance modifiers are square-bound to the layout they were * authored against. Rather than leaving this coupling implicit, the * profile descriptor now carries an optional `layoutId` that: * 1. Is persisted when the user sets a bound layout in the * ModifierProfileEditor, AND * 2. Drives the Lobby's selectedLayout when the profile is picked, * AND * 3. Surfaces a mismatch warning if the user manually overrides * the layout after picking the profile. * * This spec exercises the full save → pick → auto-switch → mismatch * loop. */ import { test, expect, type Page } from '@playwright/test'; const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1'; /** * Seed a saved profile with `layoutId` pre-bound so downstream tests * don't have to open the editor to set it up. Mirrors the shape the * ModifierProfileEditor would write to localStorage via saveToLibrary. */ async function seedBoundProfile( page: Page, id: string, name: string, layoutId: string, ): Promise { await page.evaluate( ({ key, id, name, layoutId }) => { const entry = { id, name, profile: { id, name, description: '', layoutId, perType: [], perInstance: [], version: 1, source: 'custom', }, starred: false, updatedAt: Date.now(), }; localStorage.setItem(key, JSON.stringify([entry])); }, { key: PROFILE_LIBRARY_KEY, id, name, layoutId }, ); } async function freshLobby(page: Page): Promise { await page.goto('/'); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); await page.reload(); await expect(page.getByTestId('page-home')).toBeVisible(); } test.describe('Modifier profile ⇄ layout binding', () => { test('picking a profile with layoutId snaps the lobby layout', async ({ page, }) => { await freshLobby(page); await seedBoundProfile( page, 'lib-horde-bound', 'Horde Profile', 'horde', ); await page.reload(); // Pre-condition: lobby starts on Classic. await expect(page.getByTestId('layout-picker')).toHaveValue('classic'); await page.getByTestId('profile-picker').selectOption('lib-horde-bound'); // Auto-switch: LayoutPicker snaps to the profile's authored layout. await expect(page.getByTestId('layout-picker')).toHaveValue('horde'); // No mismatch banner should appear because the snap matches. await expect( page.getByTestId('layout-profile-mismatch'), ).toHaveCount(0); }); test('mismatch banner appears when user overrides layout after picking bound profile', async ({ page, }) => { await freshLobby(page); await seedBoundProfile( page, 'lib-horde-bound', 'Horde Profile', 'horde', ); await page.reload(); await page.getByTestId('profile-picker').selectOption('lib-horde-bound'); await expect(page.getByTestId('layout-picker')).toHaveValue('horde'); // User overrides to a different layout — mismatch surfaces. await page.getByTestId('layout-picker').selectOption('classic'); const banner = page.getByTestId('layout-profile-mismatch'); await expect(banner).toBeVisible(); await expect(banner).toContainText('Horde Profile'); // Clicking "Switch to …" restores the profile's authored layout. await page.getByTestId('layout-profile-mismatch-switch').click(); await expect(page.getByTestId('layout-picker')).toHaveValue('horde'); await expect( page.getByTestId('layout-profile-mismatch'), ).toHaveCount(0); }); test('profile without layoutId leaves the layout unchanged', async ({ page, }) => { await freshLobby(page); // Profile without a layoutId. await page.evaluate( ({ key }) => { const entry = { id: 'lib-unbound', name: 'Unbound Profile', profile: { id: 'lib-unbound', name: 'Unbound Profile', description: '', perType: [], perInstance: [], version: 1, source: 'custom', }, starred: false, updatedAt: Date.now(), }; localStorage.setItem(key, JSON.stringify([entry])); }, { key: PROFILE_LIBRARY_KEY }, ); await page.reload(); // Pick some non-default layout first. await page.getByTestId('layout-picker').selectOption('horde'); await expect(page.getByTestId('layout-picker')).toHaveValue('horde'); // Picking an unbound profile should NOT steal the layout. await page.getByTestId('profile-picker').selectOption('lib-unbound'); await expect(page.getByTestId('layout-picker')).toHaveValue('horde'); await expect( page.getByTestId('layout-profile-mismatch'), ).toHaveCount(0); }); test('profile with bundled presetActivations auto-activates them in the Lobby', async ({ page, }) => { await freshLobby(page); // Seed a profile bound to the pawns-only layout (which suggests // first-promotion-wins) AND bundling that same preset. This lets // us verify the chip flips to active purely from the profile // selection — no user-tap on the suggested-preset chip involved. await page.evaluate( ({ key }) => { const entry = { id: 'lib-with-preset', name: 'FPW Profile', profile: { id: 'lib-with-preset', name: 'FPW Profile', description: '', layoutId: 'pawns-only', perType: [], perInstance: [], presetActivations: [ { id: 'first-promotion-wins', scope: 'both', turnsRemaining: null, }, ], version: 1, source: 'custom', }, starred: false, updatedAt: Date.now(), }; localStorage.setItem(key, JSON.stringify([entry])); }, { key: PROFILE_LIBRARY_KEY }, ); await page.reload(); await page.getByTestId('profile-picker').selectOption('lib-with-preset'); // Layout auto-snaps to pawns-only (layoutId binding, already // covered elsewhere but confirmed here for precondition). await expect(page.getByTestId('layout-picker')).toHaveValue('pawns-only'); // The layout's suggested-preset chip for first-promotion-wins // should now be pressed because the profile's // presetActivations entry populated the Lobby's `presets` state. const chip = page.getByTestId( 'layout-suggested-preset-first-promotion-wins', ); await expect(chip).toBeVisible(); await expect(chip).toHaveAttribute('aria-pressed', 'true'); }); test('editor bundles a preset on save, which auto-activates on reselect', async ({ page, }) => { await freshLobby(page); // Open the editor via the picker's "Custom…" option. await page.getByTestId('profile-picker').selectOption('custom'); await expect(page.getByTestId('modifier-editor-modal')).toBeVisible({ timeout: 3000, }); // Set name, bind the pawns-only layout, switch to the Presets tab. await page.getByTestId('profile-name').fill('Bundled Presets Test'); await page.getByTestId('bound-layout-picker').selectOption('pawns-only'); await page.getByTestId('right-tab-presets').click(); await expect(page.getByTestId('preset-panel')).toBeVisible(); // Toggle the first-promotion-wins preset on. await page .getByTestId('preset-panel-toggle-first-promotion-wins') .check(); // Scope defaults to "both" (the toggle button exposes aria-checked). await expect( page.getByTestId('preset-panel-scope-first-promotion-wins-both'), ).toHaveAttribute('aria-checked', 'true'); // Save + close. await page.getByTestId('save-profile').click(); await page.keyboard.press('Escape'); // Back in the lobby: the suggested-preset chip for the bundled // rule shows as pressed because the editor-close handler applied // the profile's bundled presets to the Lobby's active set. const chip = page.getByTestId( 'layout-suggested-preset-first-promotion-wins', ); await expect(chip).toBeVisible(); await expect(chip).toHaveAttribute('aria-pressed', 'true'); }); test('editor persists the bound layout on save', async ({ page }) => { await freshLobby(page); // Open the editor via the picker's "Custom…" option. await page.getByTestId('profile-picker').selectOption('custom'); await expect( page.getByTestId('modifier-editor-modal'), ).toBeVisible({ timeout: 3000 }); // Set a name and bind the layout. await page.getByTestId('profile-name').fill('My Bound Test'); await page.getByTestId('bound-layout-picker').selectOption('horde'); // Save + close the editor. await page.getByTestId('save-profile').click(); await page.keyboard.press('Escape'); // Confirm the library entry carries layoutId. const stored = await page.evaluate( ({ key }) => localStorage.getItem(key), { key: PROFILE_LIBRARY_KEY }, ); expect(stored).not.toBeNull(); const entries = JSON.parse(stored as string) as { profile: { layoutId?: string; name: string }; }[]; const mine = entries.find((e) => e.profile.name === 'My Bound Test'); expect(mine?.profile.layoutId).toBe('horde'); // And the auto-selected newest entry already picks the bound layout. await expect(page.getByTestId('layout-picker')).toHaveValue('horde'); }); });