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.
288 lines
9.5 KiB
TypeScript
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');
|
|
});
|
|
});
|