feat(chess): custom-layout editor + saved library (Phase E)
Adds a full in-browser editor for authoring custom starting
layouts and a persistent library to save them across sessions.
persist/layout-library.ts:
- SavedLayout shape with id/name/pieces/starred/updatedAt.
- saveToLibrary / loadLibrary / deleteFromLibrary / setStarred /
duplicateEntry / makeId helpers.
- Capacity cap at 20 entries; oldest non-starred is evicted on
overflow; all-starred + full returns { ok: false, reason }
so the UI can surface an actionable message.
- Localstorage key is versioned (houserules:layouts:v1) for a
future migration path.
- 14 unit tests cover eviction, shape validation, star toggles,
duplicate flow, and the crypto.randomUUID fallback.
ui/LayoutEditor.tsx:
- Modal overlay with three panels — palette (white/black pieces +
Erase + Clear), interactive 8x8 board with click-to-place
brushes, and an actions panel.
- Live FEN textarea (toFen/fromFen round-trips) with a Load
button that replaces the board and a Reset-to-board sync.
- Live validation panel shows errors (block CTA) and warnings
(inform but don't block). The 'Use This Layout' CTA is disabled
until errors clear.
- Save to Library writes a SavedLayout; the integrated Library
drawer lists saved layouts sorted by starred-first + most-recent,
with Load / Star / Duplicate / Delete actions.
- Copy Share Link writes a \${origin}/?fen=...&name=... URL to the
clipboard — pastes straight into the lobby's existing query-param
pre-select flow from Phase D.
- Esc closes the modal.
ui/Lobby.tsx:
- Custom... entry in the layout picker opens the editor.
- onApply(layout) commits the custom layout as the lobby's
current selection so Create Room ships it to the server.
e2e/layouts.spec.ts:
- Picker renders every premade + Custom entry.
- Selecting Dunsany updates description; ?layoutId=dunsany and
malformed ?fen behave correctly.
- Editor opens on Custom, validates king count, erases pieces,
loads FEN, commits via 'Use This Layout', saves to library
with cross-reload persistence, closes on Esc.
1025 unit tests passing; bun run check clean. Playwright suite
requires dev server — run with \`bun run --filter @paratype/chess e2e\`
when needed.
This commit is contained in:
parent
4b6306fb57
commit
832ebe9cd6
5 changed files with 1295 additions and 0 deletions
180
packages/chess/e2e/layouts.spec.ts
Normal file
180
packages/chess/e2e/layouts.spec.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* E2E — starting layouts feature (picker + editor + shareable URLs).
|
||||
*
|
||||
* Covers Phase A-E user-visible flows:
|
||||
* 1. Lobby shows a LayoutPicker with every registered premade.
|
||||
* 2. Picking a premade changes the selection (description text
|
||||
* updates to match).
|
||||
* 3. Opening the "Custom…" entry opens the editor modal.
|
||||
* 4. Placing pieces in the editor via the palette + board clicks.
|
||||
* 5. Live validation flips the CTA's disabled state.
|
||||
* 6. Saving to library round-trips through localStorage.
|
||||
* 7. "Use This Layout" commits a custom layout back to the lobby.
|
||||
* 8. Query-string ?fen= pre-selects a custom layout on page load.
|
||||
*
|
||||
* Does NOT exercise the multiplayer flow (server round-trip) because
|
||||
* the broadcast-level contract is covered in server unit tests and
|
||||
* packages/chess/e2e/multiplayer.spec.ts. This spec runs against a
|
||||
* local dev server only — no WS server needed.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('LayoutPicker (lobby)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('page-home')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the picker with Classic selected by default', async ({ page }) => {
|
||||
const picker = page.getByTestId('layout-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker).toHaveValue('classic');
|
||||
});
|
||||
|
||||
test('picker includes every expected premade', async ({ page }) => {
|
||||
const picker = page.getByTestId('layout-picker');
|
||||
// All premades + Custom entry.
|
||||
for (const id of [
|
||||
'classic',
|
||||
'dunsany',
|
||||
'monster',
|
||||
'pawns-only',
|
||||
'horde',
|
||||
'knightmate',
|
||||
'chess960',
|
||||
'empty',
|
||||
]) {
|
||||
await expect(picker.locator(`option[value="${id}"]`)).toHaveCount(1);
|
||||
}
|
||||
await expect(picker.locator('option[value="__custom__"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('selecting Dunsany updates the description', async ({ page }) => {
|
||||
const picker = page.getByTestId('layout-picker');
|
||||
await picker.selectOption('dunsany');
|
||||
await expect(page.locator('text=/Dunsany/i').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('?layoutId=dunsany query param pre-selects the layout', async ({ page }) => {
|
||||
await page.goto('/?layoutId=dunsany');
|
||||
const picker = page.getByTestId('layout-picker');
|
||||
await expect(picker).toHaveValue('dunsany');
|
||||
});
|
||||
|
||||
test('malformed ?fen query silently falls back to Classic', async ({ page }) => {
|
||||
await page.goto('/?fen=not-a-valid-fen');
|
||||
const picker = page.getByTestId('layout-picker');
|
||||
// Custom layout from malformed FEN isn't applied; Classic stays
|
||||
// selected.
|
||||
await expect(picker).toHaveValue('classic');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('LayoutEditor modal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByTestId('layout-picker').selectOption('__custom__');
|
||||
await expect(page.getByTestId('layout-editor')).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens with an empty board — validation errors show', async ({ page }) => {
|
||||
// Empty board = no kings → validation errors.
|
||||
await expect(page.getByTestId('validation-errors')).toBeVisible();
|
||||
// Primary CTA disabled.
|
||||
await expect(page.locator('[data-action="apply-layout"]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('placing one king per side makes the layout valid', async ({ page }) => {
|
||||
// Select white king from palette, click e1.
|
||||
await page.getByTestId('palette-white-king').click();
|
||||
await page.getByTestId('editor-square-4').click(); // e1 = square 4
|
||||
|
||||
await page.getByTestId('palette-black-king').click();
|
||||
await page.getByTestId('editor-square-60').click(); // e8 = square 60
|
||||
|
||||
// Validation flips to "ok".
|
||||
await expect(page.getByTestId('validation-ok')).toBeVisible();
|
||||
await expect(page.locator('[data-action="apply-layout"]')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('erase brush removes a placed piece', async ({ page }) => {
|
||||
await page.getByTestId('palette-white-king').click();
|
||||
await page.getByTestId('editor-square-4').click();
|
||||
|
||||
// Piece rendered in the square.
|
||||
await expect(
|
||||
page.getByTestId('editor-square-4').locator('img'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('[data-action="brush-erase"]').click();
|
||||
await page.getByTestId('editor-square-4').click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('editor-square-4').locator('img'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('FEN Load replaces the board', async ({ page }) => {
|
||||
const fen = '8/8/8/8/8/8/8/4K2k'; // white king e1, black king h1 (not a realistic position but valid)
|
||||
await page.getByTestId('editor-fen').fill(fen);
|
||||
await page.locator('[data-action="load-fen"]').click();
|
||||
|
||||
// White king at square 4 (e1).
|
||||
await expect(
|
||||
page.getByTestId('editor-square-4').locator('img'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Use This Layout commits a custom layout to the lobby', async ({ page }) => {
|
||||
// Build minimal valid layout.
|
||||
await page.getByTestId('palette-white-king').click();
|
||||
await page.getByTestId('editor-square-4').click();
|
||||
await page.getByTestId('palette-black-king').click();
|
||||
await page.getByTestId('editor-square-60').click();
|
||||
|
||||
// Rename to something identifiable.
|
||||
await page.getByTestId('layout-name').fill('My Mate-in-0');
|
||||
|
||||
await page.locator('[data-action="apply-layout"]').click();
|
||||
|
||||
// Editor closes, picker now reflects the custom layout.
|
||||
await expect(page.getByTestId('layout-editor')).not.toBeVisible();
|
||||
const picker = page.getByTestId('layout-picker');
|
||||
await expect(picker).toHaveValue('__custom__');
|
||||
});
|
||||
|
||||
test('Save to Library persists across reloads', async ({ page, context }) => {
|
||||
await page.getByTestId('palette-white-king').click();
|
||||
await page.getByTestId('editor-square-4').click();
|
||||
await page.getByTestId('palette-black-king').click();
|
||||
await page.getByTestId('editor-square-60').click();
|
||||
|
||||
await page.getByTestId('layout-name').fill('Persisted');
|
||||
|
||||
await page.locator('[data-action="save-to-library"]').click();
|
||||
|
||||
// Open the library drawer.
|
||||
await page.locator('[data-action="library-toggle"]').click();
|
||||
await expect(page.getByTestId('library-list')).toBeVisible();
|
||||
await expect(page.locator('text=Persisted').first()).toBeVisible();
|
||||
|
||||
// Reload and re-open the editor; library should still have the entry.
|
||||
await page.reload();
|
||||
await page.getByTestId('layout-picker').selectOption('__custom__');
|
||||
await page.locator('[data-action="library-toggle"]').click();
|
||||
await expect(page.locator('text=Persisted').first()).toBeVisible();
|
||||
|
||||
// Cleanup — remove the entry so parallel test runs stay clean.
|
||||
// (Library is localStorage-scoped per browser context; this block
|
||||
// is belt-and-suspenders.)
|
||||
await context.clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('houserules:layouts:v1');
|
||||
});
|
||||
});
|
||||
|
||||
test('Esc closes the editor', async ({ page }) => {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('layout-editor')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue