feat(modifiers): persist bound layout on profile + drive Lobby layout from profile
Previously the ModifierProfileEditor's layout picker was editor-only UI state — it drove per-instance board preview and live validation, but was dropped on save. This meant per-instance modifiers (square- bound to their authoring layout) silently became orphans in the Lobby if the user picked a different layout at apply time, with no signal about the mismatch. Changes: - ModifierProfileEditor now writes the bound layout through to profile.layoutId (schema already supported the optional field) and rehydrates boundLayout from profile.layoutId on open / load / undo / redo. - Lobby rearranged so the Modifier Profile picker sits ABOVE the Layout Picker. Picking a profile with a layoutId binding snaps selectedLayout to match. Same behavior on URL ?modifierProfile deep-links and after the editor closes with a new save. - Mismatch banner (amber) surfaces when the user overrides the layout after picking a bound profile, with a one-click "Switch to <LayoutName>" restore. Unit test added for library round-trip of layoutId; new profile-layout-binding.spec.ts covers the four scenarios: snap, manual override + mismatch banner, unbound profile leaves layout alone, and editor-save persists layoutId.
This commit is contained in:
parent
8605a22530
commit
4b08b0c71c
4 changed files with 387 additions and 75 deletions
193
packages/chess/e2e/profile-layout-binding.spec.ts
Normal file
193
packages/chess/e2e/profile-layout-binding.spec.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* 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('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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue