houserules/packages/chess/e2e/profile-layout-binding.spec.ts
Joey Yakimowich-Payne 699c288a98
feat(modifiers): bundle preset activations on profiles + editor Presets tab
Profiles can now carry a list of PresetActivation entries alongside
their per-type / per-instance modifiers. When such a profile is
picked in the Lobby, its bundled presets are unioned with any
layout suggestedPresets (profile config wins on id-collision).

Changes:

- ModifierProfile.presetActivations added (optional readonly array).
  Zod schema + server wire schema mirror the field; drift guard
  picks up forgotten updates on either side.
- ModifierProfileEditor grows a Presets tab in the right column
  sharing space with the Library tab. Each preset row carries a
  checkbox, scope radio (both/white/black), and a turns counter
  (blank = permanent). Inline diagnostics warn on redundant layout
  overlap and on loose-scope incompatibility; hard errors under
  overlapping scope disable Save.
- Lobby merges profile.presetActivations into its active preset
  set on profile select, on editor close, and on URL deep-link.
- LayoutPicker suggested-preset chips now expose aria-pressed so
  the active state is readable by assistive tech + e2e tests.

Tests: library round-trip preserves presetActivations (unit); two
new e2e scenarios (pre-seeded bundled profile auto-activates; full
editor-author loop persists + reflects in the lobby). 1752 unit +
105 playwright all green.
2026-04-21 14:03:52 -06:00

288 lines
9.5 KiB
TypeScript

/**
* 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<void> {
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<void> {
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');
});
});