test(e2e): modifier profiles full vertical slice

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 23:33:42 -06:00
commit c292695309
No known key found for this signature in database

View file

@ -396,3 +396,306 @@ test.describe('Modifier Profiles — Lobby integration (T26)', () => {
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<string, unknown> {
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 <option>.
// The ordering inside the picker isn't part of this assertion (that
// would overlap with T18's starred-first test); we only claim that
// BOTH seeded profiles are selectable.
test('profile picker lists every saved profile', async ({ page }) => {
const entries = [
buildEntry({
id: 't27-picker-a',
name: 'Alpha Profile',
updatedAt: 1000,
}),
buildEntry({
id: 't27-picker-b',
name: 'Beta Profile',
updatedAt: 2000,
}),
];
await page.evaluate(
({ key, es }) => localStorage.setItem(key, JSON.stringify(es)),
{ key: LIBRARY_KEY, es: entries },
);
await page.reload();
const picker = page.getByTestId('profile-picker');
await expect(picker).toBeVisible();
// Select each and verify the picker took the value — round-trip
// proves the option exists and is wired up to onChange.
await picker.selectOption('t27-picker-a');
await expect(picker).toHaveValue('t27-picker-a');
await picker.selectOption('t27-picker-b');
await expect(picker).toHaveValue('t27-picker-b');
});
// T16 — All six modifier kinds are reachable via the per-type form's
// kind-select dropdown. This protects against regressions in the
// descriptor-registry-driven option generation: adding a new modifier
// without re-running the barrel import would silently drop it from
// the UI; losing one from the barrel would likewise disappear it.
//
// We drive through `kind-select.selectOption(id)`, which Playwright
// validates the option exists before setting it — a missing option
// throws here, which is precisely the failure mode we want.
test('editor supports all six modifier kinds in the dropdown', async ({
page,
}) => {
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();
await page.click('[data-testid="add-type-modifier"]');
const kindSelect = page.getByTestId('kind-select');
await expect(kindSelect).toBeVisible();
// Exact six descriptors in id-order — if a new kind is added,
// this list should grow alongside the descriptor and the test
// will surface the oversight when it wasn't added here.
const allKinds = [
'hp-bonus',
'range-bonus',
'direction-additions',
'capture-flags',
'promotion-override',
'damage-resistance',
] as const;
for (const k of allKinds) {
await kindSelect.selectOption(k);
await expect(kindSelect).toHaveValue(k);
}
});
// T17 — Library eviction at capacity. Seed exactly 20 entries (one
// starred at index 0 so it's protected from FIFO eviction), save a
// 21st via the editor's Save button, then assert:
// - total entries stayed at 20 (bounded),
// - the starred entry is still present,
// - the oldest non-starred entry was evicted.
//
// Exercises `saveToLibrary`'s capacity branch end-to-end through the
// UI rather than the unit-tested helper — proves the button wiring
// surfaces the result, too.
test('library eviction at capacity: 21st save removes oldest non-starred', async ({
page,
}) => {
const STARRED_ID = 't27-starred';
const OLDEST_NONSTAR_ID = 't27-nonstar-000';
// updatedAt spread across 20 distinct timestamps so "oldest" is
// unambiguous. The starred entry gets timestamp 999 so FIFO would
// target it first if we weren't exempting starred entries.
const twenty: Record<string, unknown>[] = [];
for (let i = 0; i < 19; i++) {
twenty.push(
buildEntry({
id: `t27-nonstar-${String(i).padStart(3, '0')}`,
name: `Non-Starred ${String(i)}`,
updatedAt: 1000 + i,
}),
);
}
// Starred entry — placed in the middle (chronologically old) so
// the test only passes if eviction actually honours the starred
// flag rather than blindly dropping index 0.
twenty.push(
buildEntry({
id: STARRED_ID,
name: 'Starred Fixture',
starred: true,
updatedAt: 999,
}),
);
await page.evaluate(
({ key, es }) => localStorage.setItem(key, JSON.stringify(es)),
{ key: LIBRARY_KEY, es: twenty },
);
// Sanity: 20 in storage before we Save the 21st.
const before = await page.evaluate(
(key) => (JSON.parse(localStorage.getItem(key) ?? '[]') as unknown[]).length,
LIBRARY_KEY,
);
expect(before).toBe(20);
// Open editor and Save the current (blank) profile as a new entry.
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();
// Give it a distinct name so we can verify it landed.
await page.fill('[data-testid="profile-name"]', 'Twenty-First');
await page.click('[data-testid="save-profile"]');
// Library still at 20 (capacity-bounded). Starred survived; oldest
// non-starred was evicted; new "Twenty-First" entry is present.
const lib = (await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) ?? '[]') as unknown[],
LIBRARY_KEY,
)) as Array<{ id: string; name: string; starred: boolean }>;
expect(lib).toHaveLength(20);
expect(lib.some((e) => e.id === STARRED_ID && e.starred)).toBe(true);
expect(lib.some((e) => e.id === OLDEST_NONSTAR_ID)).toBe(false);
expect(lib.some((e) => e.name === 'Twenty-First')).toBe(true);
});
// T18 — Starred entries sort before unstarred in the library panel.
// We seed two profiles — an older starred one and a newer unstarred
// one — and assert the starred one appears FIRST in the rendered
// list. If we didn't honour the starred-first rule, the newer
// unstarred profile would win on "most recently updated" order and
// appear on top.
test('starred profile appears first in the library list', async ({ page }) => {
const entries = [
buildEntry({
id: 't27-old-starred',
name: 'Starred Older',
starred: true,
updatedAt: 1000,
}),
buildEntry({
id: 't27-new-unstarred',
name: 'Unstarred Newer',
starred: false,
updatedAt: 9999,
}),
];
await page.evaluate(
({ key, es }) => localStorage.setItem(key, JSON.stringify(es)),
{ key: LIBRARY_KEY, es: entries },
);
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();
// Two entries visible; first one (top of list) must be the
// starred one despite being the older updatedAt.
const rows = page.getByTestId('profile-library-entry');
await expect(rows).toHaveCount(2);
await expect(rows.first()).toContainText('Starred Older');
await expect(rows.nth(1)).toContainText('Unstarred Newer');
});
});