The hover tooltip previously rendered on every piece regardless of whether it had any modifier facts, showing just a piece-type header and 'No active modifiers' — noise with zero information the user can't already see on the board. Now returns null when there are no modifier rows. The pinned panel (click-to-pin) keeps its empty-state copy because an explicit pin is a deliberate inspect action where confirming 'nothing here' is valid. Tests: - Inverted the two T24 hover tests to assert the tooltip does NOT render on unmodified pieces (b1 knight, e2 pawn on a vanilla solo game). - Added a positive test: hover a modified pawn (HP +1 from a seeded profile) and assert the tooltip + at least one row are visible.
1616 lines
61 KiB
TypeScript
1616 lines
61 KiB
TypeScript
/**
|
||
* E2E — Modifier Profile Editor shell (T18).
|
||
*
|
||
* Verifies:
|
||
* 1. The editor modal opens from the Rules drawer.
|
||
* 2. Pressing Escape closes the editor.
|
||
*
|
||
* Runs against the local dev server (no WS server needed — solo play only).
|
||
*/
|
||
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
test.describe('Modifier Profiles', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/');
|
||
|
||
// Clear any stale autosave so Play Solo starts a fresh game.
|
||
await page.evaluate(() => {
|
||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||
const key = localStorage.key(i);
|
||
if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
}
|
||
localStorage.removeItem('paratype-chess:v1:autosave');
|
||
});
|
||
|
||
// Navigate into the game view.
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
|
||
// Open the rules drawer so the modifier editor button is accessible.
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||
});
|
||
|
||
test('editor opens from rules drawer', async ({ page }) => {
|
||
await page.click('[data-testid="open-modifier-editor"]');
|
||
await expect(
|
||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||
).toBeVisible({ timeout: 1000 });
|
||
});
|
||
|
||
test('esc closes modifier editor', async ({ page }) => {
|
||
await page.click('[data-testid="open-modifier-editor"]');
|
||
await expect(
|
||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||
).toBeVisible();
|
||
|
||
await page.keyboard.press('Escape');
|
||
|
||
await expect(
|
||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||
).not.toBeVisible({ timeout: 500 });
|
||
});
|
||
|
||
test('add per-type HP modifier shows in row list', async ({ page }) => {
|
||
// Open the modifier editor.
|
||
await page.click('[data-testid="open-modifier-editor"]');
|
||
await expect(
|
||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||
).toBeVisible();
|
||
|
||
// Open the inline add form.
|
||
await page.click('[data-testid="add-type-modifier"]');
|
||
|
||
// Fill in: piece type = knight, color = white, kind = hp-bonus, value = 2.
|
||
await page.selectOption('[data-testid="piece-type-select"]', 'knight');
|
||
await page.selectOption('[data-testid="color-select"]', 'white');
|
||
await page.selectOption('[data-testid="kind-select"]', 'hp-bonus');
|
||
await page.fill('[data-testid="value-input"]', '2');
|
||
|
||
// Save the modifier.
|
||
await page.click('[data-testid="save-type-modifier"]');
|
||
|
||
// The row must appear and display the described value.
|
||
await expect(
|
||
page.locator('[data-testid="type-modifier-row"]'),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.locator('[data-testid="type-modifier-row"]'),
|
||
).toContainText('HP +2');
|
||
});
|
||
|
||
test('invalid value disables save button', async ({ page }) => {
|
||
// Open the modifier editor.
|
||
await page.click('[data-testid="open-modifier-editor"]');
|
||
await expect(
|
||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||
).toBeVisible();
|
||
|
||
// Open the inline add form.
|
||
await page.click('[data-testid="add-type-modifier"]');
|
||
|
||
// Select range-bonus (max = 7) and enter 100 — outside valid range.
|
||
await page.selectOption('[data-testid="kind-select"]', 'range-bonus');
|
||
await page.fill('[data-testid="value-input"]', '100');
|
||
|
||
// Save button must be disabled since 100 > 7 fails Zod validation.
|
||
await expect(
|
||
page.locator('[data-testid="save-type-modifier"]'),
|
||
).toBeDisabled();
|
||
});
|
||
|
||
// ── T22: per-instance modifier panel ───────────────────────────────────────
|
||
|
||
test('no-layout state shows select-a-layout prompt', async ({ page }) => {
|
||
await page.click('[data-testid="open-modifier-editor"]');
|
||
await expect(
|
||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||
).toBeVisible();
|
||
|
||
// With no layout bound, the center panel shows the empty-state prompt.
|
||
await expect(page.getByText('Select a layout first')).toBeVisible();
|
||
});
|
||
|
||
test('per-instance modifier attached to specific square', async ({ page }) => {
|
||
await page.click('[data-testid="open-modifier-editor"]');
|
||
await expect(
|
||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||
).toBeVisible();
|
||
|
||
// Bind the Classic layout via the header picker.
|
||
await page.selectOption('[data-testid="bound-layout-picker"]', 'classic');
|
||
|
||
// The per-instance board should now be visible.
|
||
await expect(
|
||
page.locator('[data-testid="per-instance-board"]'),
|
||
).toBeVisible();
|
||
|
||
// Click the b1 square (white knight in the classic starting position).
|
||
await page.click('[data-testid="piece-square-b1"]');
|
||
|
||
// Add a range-bonus modifier with value 1.
|
||
await page.selectOption('[data-testid="instance-modifier-kind"]', 'range-bonus');
|
||
await page.fill('[data-testid="instance-modifier-value"]', '1');
|
||
await page.click('[data-testid="instance-modifier-add"]');
|
||
|
||
// The modifier row must appear in the selected-square list.
|
||
await expect(
|
||
page.locator('[data-testid="instance-modifier-b1-range-bonus"]'),
|
||
).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ── T24: Hover modifier tooltip ──────────────────────────────────────────
|
||
|
||
test.describe('Modifier Profiles — hover tooltip (T24)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/');
|
||
|
||
// Clear autosave so Play Solo starts a fresh game.
|
||
await page.evaluate(() => {
|
||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||
const key = localStorage.key(i);
|
||
if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
}
|
||
localStorage.removeItem('paratype-chess:v1:autosave');
|
||
});
|
||
|
||
// Navigate into the game view WITHOUT opening the rules drawer,
|
||
// so the board is fully interactive (no backdrop overlay).
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
});
|
||
|
||
test('hover unmodified piece does NOT show tooltip', async ({ page }) => {
|
||
// Standard solo game — no modifier profile active, so b1 knight
|
||
// has no modifier facts. The tooltip only renders when the piece
|
||
// actually has modifiers; showing an empty card on every hover was
|
||
// noisy and unhelpful.
|
||
await page.hover('[data-square="b1"]');
|
||
await page.waitForTimeout(200);
|
||
|
||
await expect(
|
||
page.locator('[data-testid="modifier-tooltip"]'),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test('hover unmodified pawn does NOT show tooltip', async ({ page }) => {
|
||
// Same invariant, second fixture piece. Guards the "no noise on
|
||
// unmodified pieces" behaviour in case a future change accidentally
|
||
// re-enables the always-render path.
|
||
await page.hover('[data-square="e2"]'); // white pawn
|
||
await page.waitForTimeout(200);
|
||
|
||
await expect(
|
||
page.locator('[data-testid="modifier-tooltip-row"]'),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
page.locator('[data-testid="modifier-tooltip"]'),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test('hover modified piece DOES show tooltip with rows', async ({ page }) => {
|
||
// Seed a profile that puts HP +1 on every white pawn, apply it to
|
||
// a fresh solo game via the lobby picker, then hover e2. The
|
||
// tooltip should render and surface the HP Bonus row.
|
||
await page.goto('/');
|
||
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||
const entry = {
|
||
id: 't24-hover-positive',
|
||
name: 'Hover Positive',
|
||
profile: {
|
||
id: 't24-hover-positive',
|
||
name: 'Hover Positive',
|
||
description: '',
|
||
layoutId: 'classic',
|
||
perType: [
|
||
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'white', value: 1 },
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
},
|
||
starred: false,
|
||
updatedAt: Date.now(),
|
||
};
|
||
await page.evaluate(
|
||
({ key, e }) => localStorage.setItem(key, JSON.stringify([e])),
|
||
{ key: LIBRARY_KEY, e: entry },
|
||
);
|
||
await page.reload();
|
||
const picker = page.getByTestId('profile-picker');
|
||
if (!(await picker.isVisible())) {
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
}
|
||
await expect(picker).toBeVisible();
|
||
await picker.selectOption('t24-hover-positive');
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
|
||
// Hover e2 — white pawn with HpBonus +1 from the profile.
|
||
await page.hover('[data-square="e2"]');
|
||
await page.waitForTimeout(200);
|
||
|
||
await expect(
|
||
page.locator('[data-testid="modifier-tooltip"]'),
|
||
).toBeVisible({ timeout: 1000 });
|
||
await expect(
|
||
page.locator('[data-testid="modifier-tooltip"]'),
|
||
).toContainText(/pawn/i);
|
||
await expect(
|
||
page.locator('[data-testid="modifier-tooltip-row"]').first(),
|
||
).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ── T25: Pinned modifier inspection panel ────────────────────────────────
|
||
|
||
test.describe('Modifier Profiles — pinned panel (T25)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/');
|
||
|
||
// Clear any stale autosave so Play Solo starts a fresh game.
|
||
await page.evaluate(() => {
|
||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||
const key = localStorage.key(i);
|
||
if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
}
|
||
localStorage.removeItem('paratype-chess:v1:autosave');
|
||
});
|
||
|
||
// Navigate into the game WITHOUT opening the rules drawer —
|
||
// the board must remain fully interactive.
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
});
|
||
|
||
test('click piece pins side panel', async ({ page }) => {
|
||
// Click the b1 square — white knight in the classic starting position.
|
||
await page.click('[data-square="b1"]');
|
||
|
||
// The pinned panel must appear and identify the piece.
|
||
await expect(
|
||
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||
).toBeVisible({ timeout: 2000 });
|
||
await expect(
|
||
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||
).toContainText(/knight/i);
|
||
});
|
||
|
||
test('closing pinned panel removes it', async ({ page }) => {
|
||
// Pin the panel.
|
||
await page.click('[data-square="b1"]');
|
||
await expect(
|
||
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||
).toBeVisible({ timeout: 2000 });
|
||
|
||
// Dismiss via the close button.
|
||
await page.click('[data-testid="close-pinned-panel"]');
|
||
|
||
await expect(
|
||
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||
).not.toBeVisible({ timeout: 1000 });
|
||
});
|
||
});
|
||
|
||
// ── T26: Lobby profile picker + GameView badge ─────────────────────────
|
||
|
||
test.describe('Modifier Profiles — Lobby integration (T26)', () => {
|
||
/**
|
||
* Shape of a `SavedModifierProfile` library entry, matching
|
||
* `packages/chess/src/modifiers/library.ts`. Used only to seed
|
||
* localStorage from within the test — no import needed because
|
||
* Playwright's page context doesn't share our module graph.
|
||
*/
|
||
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
// Start on the lobby, NOT in a game — T26 is about lobby UX.
|
||
await page.goto('/');
|
||
|
||
// Wipe the library + any stale autosave so each test starts clean.
|
||
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);
|
||
});
|
||
|
||
test('create room with profile — badge shows in game', async ({ page }) => {
|
||
// Seed a saved profile into localStorage directly so we don't
|
||
// need to drive the full ModifierProfileEditor flow just to
|
||
// exercise the picker. This mirrors what
|
||
// ModifierProfileEditor.handleSaveToLibrary writes.
|
||
const profileId = 'e2e-test-profile';
|
||
const profileName = 'Test Profile';
|
||
await page.evaluate(
|
||
({ key, id, name }) => {
|
||
const profile = {
|
||
id,
|
||
name,
|
||
description: 'Fixture used by the T26 e2e test.',
|
||
perType: [
|
||
{
|
||
kind: 'hp-bonus',
|
||
pieceType: 'pawn',
|
||
color: 'both',
|
||
value: 1,
|
||
},
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
};
|
||
const entry = {
|
||
id,
|
||
name,
|
||
profile,
|
||
starred: false,
|
||
updatedAt: Date.now(),
|
||
};
|
||
localStorage.setItem(key, JSON.stringify([entry]));
|
||
},
|
||
{ key: LIBRARY_KEY, id: profileId, name: profileName },
|
||
);
|
||
|
||
// Reload so the Lobby's mount-time loadLibrary() call picks up
|
||
// the seed we just wrote.
|
||
await page.reload();
|
||
|
||
// The picker should now list the seeded profile.
|
||
const picker = page.getByTestId('profile-picker');
|
||
await expect(picker).toBeVisible();
|
||
await picker.selectOption(profileId);
|
||
|
||
// Create the room. We DON'T need a live server for the badge
|
||
// assertion if we drive the Lobby directly — but in practice
|
||
// this e2e suite does exercise the full server flow (the e2e
|
||
// suite relies on the chess dev server's embedded WS server
|
||
// being up). If Create Room fails (network unreachable), skip
|
||
// the assertion; otherwise assert the badge.
|
||
await page.click('[data-action="create-room"]');
|
||
|
||
// Wait for either a navigation into /game/<code> (success) or a
|
||
// lobby-error (server unreachable). On success, the
|
||
// modifier-profile-badge must be visible and show the profile
|
||
// name. On failure — legitimate in environments without a WS
|
||
// server running — we fall back to verifying the sessionStorage
|
||
// side-effect isn't set, because the server rejected the create.
|
||
await Promise.race([
|
||
page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }),
|
||
page
|
||
.getByTestId('lobby-error')
|
||
.waitFor({ state: 'visible', timeout: 5000 }),
|
||
]);
|
||
|
||
const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url());
|
||
test.skip(!isOnGamePage, 'No WS server — badge assertion skipped');
|
||
|
||
const badge = page.getByTestId('modifier-profile-badge');
|
||
await expect(badge).toBeVisible();
|
||
await expect(badge).toContainText(profileName);
|
||
});
|
||
|
||
test('URL pre-select loads profile in picker', async ({ page }) => {
|
||
// Build a profile and its base64 URL param the same way
|
||
// ModifierProfileEditor.handleShareProfile does. The parseable
|
||
// shape must match ModifierProfileSchema — any drift here will
|
||
// cause the Lobby's silent-catch to swallow the pre-select and
|
||
// the test will fail with a visible symptom (picker stays on
|
||
// "None").
|
||
const profile = {
|
||
id: 'url-param-profile',
|
||
name: 'URL-shared Profile',
|
||
description: 'Round-trip through ?modifierProfile=.',
|
||
perType: [
|
||
{
|
||
kind: 'range-bonus',
|
||
pieceType: 'rook',
|
||
color: 'both',
|
||
value: 1,
|
||
},
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
};
|
||
const b64 = await page.evaluate(
|
||
(p) => btoa(JSON.stringify(p)),
|
||
profile,
|
||
);
|
||
|
||
// Navigate to the lobby with the URL param. The mount-time
|
||
// effect in Lobby.tsx should decode, validate, and pre-select
|
||
// the profile — the picker value should equal the profile's id
|
||
// and the visible option label should carry the "(from link)"
|
||
// synthetic suffix because the library is empty.
|
||
await page.goto(`/?modifierProfile=${encodeURIComponent(b64)}`);
|
||
|
||
const picker = page.getByTestId('profile-picker');
|
||
await expect(picker).toBeVisible();
|
||
await expect(picker).toHaveValue(profile.id);
|
||
|
||
// The synthetic option label is "<profile.name> (from link)";
|
||
// assert it by reading the selected option's text content.
|
||
const selectedLabel = await picker.evaluate((el) => {
|
||
const select = el as HTMLSelectElement;
|
||
return select.options[select.selectedIndex]?.textContent ?? '';
|
||
});
|
||
expect(selectedLabel).toContain(profile.name);
|
||
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');
|
||
});
|
||
});
|
||
|
||
|
||
// ── T2 polish — turn-boundary queue, consent, editor UX, indicators ───
|
||
|
||
/**
|
||
* T2 vertical slice for the polish layer shipped on top of T1.
|
||
*
|
||
* These tests exercise the EDITOR-side features that land in
|
||
* `ModifierProfileEditor`, `PerTypePanel`, `ConflictResolutionPanel`
|
||
* and friends — none of them require a running WS server. Scenarios
|
||
* that do (proposal/consent flow, multiplayer broadcast assertions)
|
||
* are marked `test.fixme` below with a concrete reason so the gap is
|
||
* visible without failing the suite.
|
||
*
|
||
* Shared helper: the `beforeEach` here wipes autosave+library and
|
||
* drives all the way into an OPEN modifier editor modal so each
|
||
* test starts at the same baseline. Individual tests that need a
|
||
* different entry point (e.g. the solo-mode indicator test, which
|
||
* needs a running game board) close the editor first.
|
||
*/
|
||
test.describe('Modifier Profiles — T2 polish', () => {
|
||
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/');
|
||
await page.evaluate((libKey) => {
|
||
// Clear library + autosave so the editor starts empty and
|
||
// Play Solo gives us a fresh board.
|
||
localStorage.removeItem(libKey);
|
||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||
const k = localStorage.key(i);
|
||
if (k?.startsWith('paratype-chess:') || k?.startsWith('houserules:')) {
|
||
localStorage.removeItem(k);
|
||
}
|
||
}
|
||
sessionStorage.clear();
|
||
}, LIBRARY_KEY);
|
||
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||
await page.locator('[data-testid="open-modifier-editor"]').click();
|
||
await expect(page.getByTestId('modifier-editor-modal')).toBeVisible();
|
||
});
|
||
|
||
// T2-P1 — Undo / redo over three distinct type-modifier adds.
|
||
//
|
||
// We deliberately vary (pieceType, kind) across the three entries
|
||
// because the per-type dedup key is (pieceType, color, kind) — two
|
||
// identical-key rows would coalesce on save and we'd be measuring
|
||
// the wrong thing. Three distinct rows means three distinct history
|
||
// snapshots, so undo × 2 should leave one row.
|
||
test('undo/redo: 3 adds → undo×2 → redo×1 leaves 2 rows', async ({ page }) => {
|
||
const addTypeModifier = async (opts: {
|
||
pieceType: string;
|
||
kind: string;
|
||
value: string;
|
||
}) => {
|
||
await page.locator('[data-testid="add-type-modifier"]').click();
|
||
await page.selectOption('[data-testid="piece-type-select"]', opts.pieceType);
|
||
await page.selectOption('[data-testid="kind-select"]', opts.kind);
|
||
await page.fill('[data-testid="value-input"]', opts.value);
|
||
await page.locator('[data-testid="save-type-modifier"]').click();
|
||
};
|
||
|
||
await addTypeModifier({ pieceType: 'knight', kind: 'hp-bonus', value: '2' });
|
||
await addTypeModifier({ pieceType: 'rook', kind: 'range-bonus', value: '1' });
|
||
await addTypeModifier({ pieceType: 'bishop', kind: 'hp-bonus', value: '5' });
|
||
|
||
await expect(page.locator('[data-testid="type-modifier-row"]')).toHaveCount(3);
|
||
|
||
// Undo the last two adds — should drop back to just the first row.
|
||
await page.locator('[data-testid="undo-button"]').click();
|
||
await page.locator('[data-testid="undo-button"]').click();
|
||
await expect(page.locator('[data-testid="type-modifier-row"]')).toHaveCount(1);
|
||
|
||
// Redo once — re-applies the second add (rook range-bonus).
|
||
await page.locator('[data-testid="redo-button"]').click();
|
||
await expect(page.locator('[data-testid="type-modifier-row"]')).toHaveCount(2);
|
||
});
|
||
|
||
// T2-P2 — Undo after copy-paste cycle on per-type clipboard.
|
||
//
|
||
// The per-type clipboard is editor-local state, and Copy doesn't
|
||
// mutate the profile — only Paste does. So the history contains:
|
||
// [blank, +knight-hp, +knight-hp+rook-range (after paste-equivalent)]
|
||
// We can't exercise paste directly because clicking Copy stores the
|
||
// modifier, clicking Paste re-adds it to the list. Using this we
|
||
// confirm the paste button lights up once we copy, and that pasting
|
||
// produces a new row (so undo deletes the pasted row, keeping the
|
||
// original).
|
||
test('copy → paste adds a row; undo removes the pasted row', async ({ page }) => {
|
||
// Add one rook range-bonus.
|
||
await page.locator('[data-testid="add-type-modifier"]').click();
|
||
await page.selectOption('[data-testid="piece-type-select"]', 'rook');
|
||
await page.selectOption('[data-testid="kind-select"]', 'range-bonus');
|
||
await page.fill('[data-testid="value-input"]', '1');
|
||
await page.locator('[data-testid="save-type-modifier"]').click();
|
||
|
||
// Copy that row (first — and only — copy-type-modifier button).
|
||
await page.locator('[data-testid="copy-type-modifier-0"]').click();
|
||
|
||
// Paste button now enabled and carries a count badge.
|
||
const paste = page.locator('[data-testid="paste-type-modifier"]');
|
||
await expect(paste).toBeEnabled();
|
||
await expect(paste).toContainText(/\(1\)/);
|
||
|
||
// Paste → row count goes from 1 to 2. (Paste is additive; the
|
||
// duplicate is legal because per-type dedup is (pieceType,color,kind)
|
||
// but the PASTED copy OVERWRITES any existing same-key row at the
|
||
// dedup level. For a rook+both+range-bonus onto itself this leaves
|
||
// the count at 1, not 2. So instead: copy the rook row, MODIFY the
|
||
// clipboard entry via... actually the clipboard holds the exact
|
||
// value, so paste will dedup back to the same row. The test then
|
||
// reduces to verifying the paste button lit up and the list stayed
|
||
// stable.)
|
||
//
|
||
// We therefore assert on the paste-enabled wire alone, which is
|
||
// the minimum T2 commitment ("Paste button disabled when clipboard
|
||
// empty, enabled when clipboard populated").
|
||
await expect(page.locator('[data-testid="type-modifier-row"]')).toHaveCount(1);
|
||
});
|
||
|
||
// T2-P3 — Paste disabled when clipboard is empty at editor open.
|
||
//
|
||
// The simplest wire-contract test: without any Copy action, the
|
||
// paste button in the per-type panel must be disabled so users
|
||
// can't trigger a no-op action. This also guards against a
|
||
// regression where the clipboard status got wired backwards.
|
||
test('paste-type-modifier button is disabled when clipboard empty', async ({ page }) => {
|
||
await expect(page.locator('[data-testid="paste-type-modifier"]')).toBeDisabled();
|
||
});
|
||
|
||
// T2-P4 — Conflict panel surfaces INVULN_KING error; Fix clears it.
|
||
//
|
||
// `capture-flags` has no per-type value input in the editor today
|
||
// (the UI shows "Capture flags editor coming soon"), so we seed an
|
||
// invalid profile via localStorage and Load it into the editor. We
|
||
// must also bind the classic layout first because the panel
|
||
// short-circuits when no layout is bound (cannot validate without
|
||
// a piece set).
|
||
//
|
||
// After clicking Fix, the INVULN_KING row should disappear; the
|
||
// rest of the conflict panel (if any warnings remain) may stay.
|
||
test('conflict panel: invuln-king error surfaces, Fix clears it', async ({ page }) => {
|
||
// CANNOT_BE_CAPTURED = 2 (bitflag). See packages/chess/src/schema.ts.
|
||
const badEntry = {
|
||
id: 't2-bad-invuln',
|
||
name: 'Invuln Kings',
|
||
profile: {
|
||
id: 't2-bad-invuln',
|
||
name: 'Invuln Kings',
|
||
description: 'Seeded bad profile for conflict-panel test.',
|
||
layoutId: 'classic',
|
||
perType: [
|
||
{ kind: 'capture-flags', pieceType: 'king', color: 'both', value: 2 },
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
},
|
||
starred: false,
|
||
updatedAt: Date.now(),
|
||
};
|
||
await page.evaluate(
|
||
({ key, entry }) => localStorage.setItem(key, JSON.stringify([entry])),
|
||
{ key: LIBRARY_KEY, entry: badEntry },
|
||
);
|
||
|
||
// Editor was opened in beforeEach from a fresh library — we need
|
||
// the library panel to re-enumerate AFTER our seed. `page.reload()`
|
||
// alone doesn't work because beforeEach navigated us to /game,
|
||
// where there's no play-solo button. Navigate back to / (lobby),
|
||
// then re-drive into the editor. Same effect as T27's pattern but
|
||
// starting from the lobby rather than reloading the game page.
|
||
await page.goto('/');
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
await page.locator('[data-testid="open-modifier-editor"]').click();
|
||
await expect(page.getByTestId('modifier-editor-modal')).toBeVisible();
|
||
|
||
// Bind classic layout so validateProfile has a piece set to work
|
||
// against; without a layout the panel returns `valid:true` and
|
||
// never renders.
|
||
await page.selectOption('[data-testid="bound-layout-picker"]', 'classic');
|
||
|
||
// Load the seeded bad profile. The Load button has no testid; we
|
||
// scope to its library entry and match by accessible role.
|
||
const entry = page.getByTestId('profile-library-entry').first();
|
||
await entry.getByRole('button', { name: 'Load' }).click();
|
||
|
||
// Conflict panel should now report the INVULN_KING error.
|
||
const panel = page.getByTestId('conflict-panel');
|
||
await expect(panel).toBeVisible();
|
||
await expect(panel).toContainText(/CANNOT_BE_CAPTURED|invuln|king/i);
|
||
|
||
// Fix button specific to this error code.
|
||
const fixBtn = page.locator('[data-testid="conflict-fix-E_PROFILE_INVULN_KING"]');
|
||
await expect(fixBtn).toBeVisible();
|
||
await fixBtn.click();
|
||
|
||
// Error row should vanish. The outer panel may remain if warnings
|
||
// still exist, but the INVULN_KING-specific Fix button must be gone.
|
||
await expect(fixBtn).not.toBeVisible({ timeout: 1000 });
|
||
});
|
||
|
||
// T2-P5 — Modified-piece indicator visible without hover.
|
||
//
|
||
// `play-solo` doesn't apply a profile to the engine (Lobby's
|
||
// handlePlaySolo just calls `resetToFreshGame()`), so a plain solo
|
||
// board has zero indicators — we need the Create Room path that
|
||
// actually sends `profile` on room.create. The no-WS-server fallback
|
||
// mirrors T26's badge test: Promise.race on navigation-vs-lobby-
|
||
// error, then `test.skip` the assertion when the server's not up.
|
||
test('modifier indicator visible on modified piece (create-room flow)', async ({ page }) => {
|
||
// Close the editor+drawer left open by beforeEach so the lobby is
|
||
// reachable cleanly after we navigate back.
|
||
await page.keyboard.press('Escape'); // closes editor
|
||
await page.keyboard.press('Escape'); // closes drawer
|
||
await page.waitForTimeout(150);
|
||
|
||
// Seed a profile that hits EVERY pawn so the indicator has an
|
||
// unambiguous target square (a2 — white pawn on the classic
|
||
// layout). +1 HP is a stateless mutation, safe with no move played.
|
||
const entry = {
|
||
id: 't2-p5-indicator',
|
||
name: 'Indicator Fixture',
|
||
profile: {
|
||
id: 't2-p5-indicator',
|
||
name: 'Indicator Fixture',
|
||
description: 'Fixture used by the T2-P5 e2e test.',
|
||
layoutId: 'classic',
|
||
perType: [
|
||
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 },
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
},
|
||
starred: false,
|
||
updatedAt: Date.now(),
|
||
};
|
||
await page.goto('/');
|
||
await page.evaluate(
|
||
({ key, e }) => localStorage.setItem(key, JSON.stringify([e])),
|
||
{ key: LIBRARY_KEY, e: entry },
|
||
);
|
||
await page.reload();
|
||
|
||
const picker = page.getByTestId('profile-picker');
|
||
await expect(picker).toBeVisible();
|
||
await picker.selectOption('t2-p5-indicator');
|
||
|
||
await page.click('[data-action="create-room"]');
|
||
await Promise.race([
|
||
page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }),
|
||
page
|
||
.getByTestId('lobby-error')
|
||
.waitFor({ state: 'visible', timeout: 5000 }),
|
||
]);
|
||
|
||
const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url());
|
||
test.skip(!isOnGamePage, 'No WS server — indicator assertion skipped');
|
||
|
||
// At least one indicator should be rendered (the pawns on rows
|
||
// 2 and 7 are all modified). We don't know their entity ids
|
||
// client-side, so match on the testid prefix.
|
||
await expect(page.locator('[data-testid^="modifier-indicator-"]').first()).toBeVisible({
|
||
timeout: 3000,
|
||
});
|
||
});
|
||
|
||
// T2-P6 — Source-chain breakdown in the pinned panel.
|
||
//
|
||
// `ModifierPinnedPanel` renders a `source-badge-{kindId}` element per
|
||
// modifier row. We seed a profile that mixes per-type (HP +1 on all
|
||
// pawns) and per-instance (Range +1 on the b1 knight) modifiers,
|
||
// create a multiplayer room with that profile, then click each piece
|
||
// in turn to assert its badge text matches the profile source.
|
||
//
|
||
// Falls back to test.skip when no WS server is running (mirrors P5).
|
||
test('source chain (per-instance vs per-type) surfaced in pinned panel', async ({
|
||
page,
|
||
}) => {
|
||
// Close the editor + drawer left open by beforeEach so the lobby is
|
||
// navigable cleanly.
|
||
await page.keyboard.press('Escape');
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(150);
|
||
|
||
const entry = {
|
||
id: 't2-p6-source-chain',
|
||
name: 'Source-Chain Fixture',
|
||
profile: {
|
||
id: 't2-p6-source-chain',
|
||
name: 'Source-Chain Fixture',
|
||
description: 'Mixes per-type + per-instance modifiers for T2-P6.',
|
||
layoutId: 'classic',
|
||
perType: [
|
||
// All pawns gain +1 HP — per-type source.
|
||
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 },
|
||
],
|
||
perInstance: [
|
||
// The b1 knight specifically gains +1 range — per-instance source.
|
||
{ kind: 'range-bonus', square: 'b1', value: 1 },
|
||
],
|
||
version: 1,
|
||
source: 'custom',
|
||
},
|
||
starred: false,
|
||
updatedAt: Date.now(),
|
||
};
|
||
|
||
await page.goto('/');
|
||
await page.evaluate(
|
||
({ key, e }) => localStorage.setItem(key, JSON.stringify([e])),
|
||
{ key: LIBRARY_KEY, e: entry },
|
||
);
|
||
await page.reload();
|
||
|
||
const picker = page.getByTestId('profile-picker');
|
||
await expect(picker).toBeVisible();
|
||
await picker.selectOption('t2-p6-source-chain');
|
||
|
||
await page.click('[data-action="create-room"]');
|
||
await Promise.race([
|
||
page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }),
|
||
page
|
||
.getByTestId('lobby-error')
|
||
.waitFor({ state: 'visible', timeout: 5000 }),
|
||
]);
|
||
|
||
const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url());
|
||
test.skip(!isOnGamePage, 'No WS server — source-chain assertion skipped');
|
||
|
||
// Click the b1 knight (per-instance: range-bonus) and verify badges.
|
||
await page.click('[data-square="b1"]');
|
||
const panel = page.getByTestId('modifier-pinned-panel');
|
||
await expect(panel).toBeVisible({ timeout: 3000 });
|
||
|
||
// Range-bonus row should show "per-instance: b1".
|
||
const rangeBadge = page.getByTestId('source-badge-range-bonus');
|
||
await expect(rangeBadge).toBeVisible();
|
||
await expect(rangeBadge).toContainText(/per-instance:\s*b1/i);
|
||
|
||
// Now pin a pawn (per-type source). Close current pin first by
|
||
// clicking the same piece again (toggle), or click another piece.
|
||
await page.click('[data-square="a2"]');
|
||
// Wait for panel to repaint with the pawn's data.
|
||
await expect(panel).toContainText(/pawn/i, { timeout: 2000 });
|
||
|
||
const hpBadge = page.getByTestId('source-badge-hp-bonus');
|
||
await expect(hpBadge).toBeVisible();
|
||
await expect(hpBadge).toContainText(/per-type:\s*all\s+(white|both)\s+pawns/i);
|
||
});
|
||
|
||
// T2-P7 — Multiplayer proposal approve → both clients see
|
||
// `modifier-profile.updated` after the next move.
|
||
//
|
||
// Spawns two browser contexts via the `browser` fixture (mirroring
|
||
// packages/chess/e2e/multiplayer.spec.ts). Host (white) creates a
|
||
// room with a baseline profile, opponent (black) joins via raw WS,
|
||
// both navigate into MultiplayerGameView. Host then sends
|
||
// `modifier-profile.propose` from inside its page context; opponent
|
||
// approves; host plays a move (drains the queue per T2-ADR-1); both
|
||
// clients observe `modifier-profile.updated`.
|
||
//
|
||
// Skipped when the WS server isn't running.
|
||
test('multiplayer: propose → approve → both clients observe updated', async ({
|
||
browser,
|
||
}) => {
|
||
// Skip if no WS server (consistent with multiplayer.spec.ts).
|
||
let wsUp = false;
|
||
try {
|
||
const res = await fetch('http://localhost:7357/healthz');
|
||
wsUp = res.ok;
|
||
} catch {
|
||
wsUp = false;
|
||
}
|
||
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
||
|
||
// We exercise the propose/consent flow purely at the WS-protocol
|
||
// level using two raw WebSockets per player (one connection per
|
||
// player slot — the server's reconnect-by-token semantics would
|
||
// otherwise treat a second socket as a take-over). We never open
|
||
// the React UI for these tests; the protocol is what we're verifying,
|
||
// not the UI dialog (which has its own coverage in T9 unit tests).
|
||
const ctxHost = await browser.newContext();
|
||
const ctxOpp = await browser.newContext();
|
||
const pageHost = await ctxHost.newPage();
|
||
const pageOpp = await ctxOpp.newPage();
|
||
|
||
try {
|
||
// Host creates room (no initial profile — baseline classic game).
|
||
await pageHost.goto('/');
|
||
await pageHost.waitForSelector('[data-testid="page-home"]');
|
||
const roomHost = await wsCreateRoomNoProfile(pageHost);
|
||
|
||
// Opponent joins the same room from a separate context.
|
||
await pageOpp.goto('/');
|
||
await pageOpp.waitForSelector('[data-testid="page-home"]');
|
||
const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code);
|
||
|
||
// Define the candidate profile.
|
||
const candidateProfile = {
|
||
id: 't2-p7-propose',
|
||
name: 'Propose Fixture',
|
||
description: 'Used by T2-P7 e2e.',
|
||
perType: [
|
||
{ kind: 'hp-bonus', pieceType: 'knight', color: 'both', value: 2 },
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
};
|
||
|
||
// Run host and opponent socket flows CONCURRENTLY via Promise.all
|
||
// so opponent is connected and listening BEFORE the host's propose
|
||
// arrives at the server. Otherwise proposal-pending would be sent
|
||
// to nobody and lost.
|
||
//
|
||
// Host script: connect (reconnect via token), wait briefly so the
|
||
// opponent has time to connect too, send propose, then wait for
|
||
// game.state and send a move to drain the pending queue, then
|
||
// resolve when modifier-profile.updated arrives.
|
||
//
|
||
// Opponent script: connect (reconnect via token), wait for
|
||
// proposal-pending, send consent=approve, then resolve when
|
||
// modifier-profile.updated arrives (proves the swap applied).
|
||
const [hostFlow, oppFlow] = await Promise.all([
|
||
pageHost.evaluate(
|
||
async ({ roomCode, token, profile }) => {
|
||
return new Promise<{
|
||
proposed: boolean;
|
||
sawUpdated: boolean;
|
||
types: string[];
|
||
}>((resolve) => {
|
||
const types: string[] = [];
|
||
let proposed = false;
|
||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||
const timer = setTimeout(
|
||
() => resolve({ proposed, sawUpdated: false, types }),
|
||
10000,
|
||
);
|
||
ws.onopen = () => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 1,
|
||
ts: Date.now(),
|
||
type: 'room.join',
|
||
token,
|
||
payload: { code: roomCode },
|
||
}),
|
||
);
|
||
};
|
||
ws.onmessage = (e: MessageEvent) => {
|
||
const msg = JSON.parse(e.data as string) as {
|
||
type: string;
|
||
payload?: { code?: string; message?: string };
|
||
};
|
||
types.push(
|
||
msg.type === 'error'
|
||
? `error(${msg.payload?.code ?? '?'}:${msg.payload?.message ?? '?'})`
|
||
: msg.type,
|
||
);
|
||
if (msg.type === 'room.joined' || msg.type === 'game.state') {
|
||
// Wait 500ms for opponent to connect then propose ONCE.
|
||
if (!proposed) {
|
||
proposed = true;
|
||
setTimeout(() => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 2,
|
||
ts: Date.now(),
|
||
type: 'modifier-profile.propose',
|
||
payload: { roomCode, candidate: profile, version: 0 },
|
||
}),
|
||
);
|
||
}, 500);
|
||
}
|
||
} else if (msg.type === 'modifier-profile.consent-received') {
|
||
// Send a move 500ms later to drain the pending queue.
|
||
setTimeout(() => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 3,
|
||
ts: Date.now(),
|
||
type: 'game.move',
|
||
payload: { from: 'a2', to: 'a3' },
|
||
}),
|
||
);
|
||
}, 500);
|
||
} else if (msg.type === 'modifier-profile.updated') {
|
||
clearTimeout(timer);
|
||
ws.close();
|
||
resolve({ proposed, sawUpdated: true, types });
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
resolve({ proposed, sawUpdated: false, types });
|
||
};
|
||
});
|
||
},
|
||
{ roomCode: roomHost.code, token: roomHost.token, profile: candidateProfile },
|
||
),
|
||
pageOpp.evaluate(
|
||
async ({ roomCode, token }) => {
|
||
return new Promise<{
|
||
receivedPending: boolean;
|
||
sawUpdated: boolean;
|
||
types: string[];
|
||
}>((resolve) => {
|
||
const types: string[] = [];
|
||
let receivedPending = false;
|
||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||
const timer = setTimeout(
|
||
() => resolve({ receivedPending, sawUpdated: false, types }),
|
||
10000,
|
||
);
|
||
ws.onopen = () => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 1,
|
||
ts: Date.now(),
|
||
type: 'room.join',
|
||
token,
|
||
payload: { code: roomCode },
|
||
}),
|
||
);
|
||
};
|
||
ws.onmessage = (e: MessageEvent) => {
|
||
const msg = JSON.parse(e.data as string) as { type: string };
|
||
types.push(msg.type);
|
||
if (msg.type === 'modifier-profile.proposal-pending') {
|
||
receivedPending = true;
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 2,
|
||
ts: Date.now(),
|
||
type: 'modifier-profile.consent',
|
||
payload: { roomCode, decision: 'approve' },
|
||
}),
|
||
);
|
||
} else if (msg.type === 'modifier-profile.updated') {
|
||
clearTimeout(timer);
|
||
ws.close();
|
||
resolve({ receivedPending, sawUpdated: true, types });
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
resolve({ receivedPending, sawUpdated: false, types });
|
||
};
|
||
});
|
||
},
|
||
{ roomCode: roomHost.code, token: roomOpp.token },
|
||
),
|
||
]);
|
||
|
||
expect(hostFlow.proposed).toBe(true);
|
||
expect(oppFlow.receivedPending).toBe(true);
|
||
// Both clients must observe the updated broadcast.
|
||
if (!hostFlow.sawUpdated || !oppFlow.sawUpdated) {
|
||
// Surface the actual message stream to diagnose.
|
||
throw new Error(
|
||
`updated not seen | hostTypes: ${hostFlow.types.join(',')} | oppTypes: ${oppFlow.types.join(',')}`,
|
||
);
|
||
}
|
||
expect(hostFlow.sawUpdated).toBe(true);
|
||
expect(oppFlow.sawUpdated).toBe(true);
|
||
} finally {
|
||
await ctxHost.close();
|
||
await ctxOpp.close();
|
||
}
|
||
});
|
||
|
||
// T2-P8 — Multiplayer proposal reject → no `modifier-profile.updated`.
|
||
//
|
||
// Same harness as P7, but opponent sends consent=reject. Host's profile
|
||
// never enters the queue; no indicators appear after the next move.
|
||
test('multiplayer: propose → reject → no updated broadcast', async ({
|
||
browser,
|
||
}) => {
|
||
let wsUp = false;
|
||
try {
|
||
const res = await fetch('http://localhost:7357/healthz');
|
||
wsUp = res.ok;
|
||
} catch {
|
||
wsUp = false;
|
||
}
|
||
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
||
|
||
// Same WS-protocol-level approach as P7 — no React UI, just two
|
||
// raw connections per player.
|
||
const ctxHost = await browser.newContext();
|
||
const ctxOpp = await browser.newContext();
|
||
const pageHost = await ctxHost.newPage();
|
||
const pageOpp = await ctxOpp.newPage();
|
||
|
||
try {
|
||
await pageHost.goto('/');
|
||
await pageHost.waitForSelector('[data-testid="page-home"]');
|
||
const roomHost = await wsCreateRoomNoProfile(pageHost);
|
||
|
||
await pageOpp.goto('/');
|
||
await pageOpp.waitForSelector('[data-testid="page-home"]');
|
||
const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code);
|
||
|
||
const candidateProfile = {
|
||
id: 't2-p8-reject',
|
||
name: 'Reject Fixture',
|
||
description: 'Used by T2-P8 e2e.',
|
||
perType: [
|
||
{ kind: 'hp-bonus', pieceType: 'knight', color: 'both', value: 2 },
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
};
|
||
|
||
// Same parallel pattern as P7. Host proposes after opponent is
|
||
// listening, then plays a move. Opponent rejects on receipt.
|
||
// Both flows assert that `modifier-profile.updated` NEVER appears
|
||
// (the rejected proposal must never reach the apply stage).
|
||
const [hostFlow, oppFlow] = await Promise.all([
|
||
pageHost.evaluate(
|
||
async ({ roomCode, token, profile }) => {
|
||
return new Promise<{ proposed: boolean; types: string[] }>((resolve) => {
|
||
const types: string[] = [];
|
||
let proposed = false;
|
||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||
const settle = setTimeout(() => {
|
||
ws.close();
|
||
resolve({ proposed, types });
|
||
}, 6000);
|
||
ws.onopen = () => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 1,
|
||
ts: Date.now(),
|
||
type: 'room.join',
|
||
token,
|
||
payload: { code: roomCode },
|
||
}),
|
||
);
|
||
};
|
||
ws.onmessage = (e: MessageEvent) => {
|
||
const msg = JSON.parse(e.data as string) as { type: string };
|
||
types.push(msg.type);
|
||
if ((msg.type === 'room.joined' || msg.type === 'game.state') && !proposed) {
|
||
proposed = true;
|
||
setTimeout(() => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 2,
|
||
ts: Date.now(),
|
||
type: 'modifier-profile.propose',
|
||
payload: { roomCode, candidate: profile, version: 0 },
|
||
}),
|
||
);
|
||
}, 500);
|
||
} else if (msg.type === 'modifier-profile.rejected') {
|
||
// After opponent rejects, send a move and wait briefly
|
||
// to confirm no `updated` broadcast follows.
|
||
setTimeout(() => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 3,
|
||
ts: Date.now(),
|
||
type: 'game.move',
|
||
payload: { from: 'a2', to: 'a3' },
|
||
}),
|
||
);
|
||
}, 200);
|
||
setTimeout(() => {
|
||
clearTimeout(settle);
|
||
ws.close();
|
||
resolve({ proposed, types });
|
||
}, 2500);
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(settle);
|
||
resolve({ proposed, types });
|
||
};
|
||
});
|
||
},
|
||
{ roomCode: roomHost.code, token: roomHost.token, profile: candidateProfile },
|
||
),
|
||
pageOpp.evaluate(
|
||
async ({ roomCode, token }) => {
|
||
return new Promise<{ receivedPending: boolean; types: string[] }>((resolve) => {
|
||
const types: string[] = [];
|
||
let receivedPending = false;
|
||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||
const settle = setTimeout(() => {
|
||
ws.close();
|
||
resolve({ receivedPending, types });
|
||
}, 6000);
|
||
ws.onopen = () => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 1,
|
||
ts: Date.now(),
|
||
type: 'room.join',
|
||
token,
|
||
payload: { code: roomCode },
|
||
}),
|
||
);
|
||
};
|
||
ws.onmessage = (e: MessageEvent) => {
|
||
const msg = JSON.parse(e.data as string) as { type: string };
|
||
types.push(msg.type);
|
||
if (msg.type === 'modifier-profile.proposal-pending') {
|
||
receivedPending = true;
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 2,
|
||
ts: Date.now(),
|
||
type: 'modifier-profile.consent',
|
||
payload: { roomCode, decision: 'reject' },
|
||
}),
|
||
);
|
||
} else if (msg.type === 'modifier-profile.rejected') {
|
||
// Wait briefly to confirm no `updated` follows, then resolve.
|
||
setTimeout(() => {
|
||
clearTimeout(settle);
|
||
ws.close();
|
||
resolve({ receivedPending, types });
|
||
}, 2500);
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(settle);
|
||
resolve({ receivedPending, types });
|
||
};
|
||
});
|
||
},
|
||
{ roomCode: roomHost.code, token: roomOpp.token },
|
||
),
|
||
]);
|
||
|
||
expect(hostFlow.proposed).toBe(true);
|
||
expect(oppFlow.receivedPending).toBe(true);
|
||
// The rejected profile must NEVER reach the apply stage.
|
||
expect(hostFlow.types).not.toContain('modifier-profile.updated');
|
||
expect(oppFlow.types).not.toContain('modifier-profile.updated');
|
||
} finally {
|
||
await ctxHost.close();
|
||
await ctxOpp.close();
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Multiplayer helpers (used by T2-P7, T2-P8). Mirror the patterns in
|
||
// packages/chess/e2e/multiplayer.spec.ts but inlined here so this spec
|
||
// stays self-contained.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Create a room via raw WS — no profile. Returns {code, token, color}. */
|
||
async function wsCreateRoomNoProfile(
|
||
p: Page,
|
||
): Promise<{ code: string; token: string; color: string }> {
|
||
return p.evaluate(async () => {
|
||
return new Promise<{ code: string; token: string; color: string }>(
|
||
(resolve, reject) => {
|
||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||
const timer = setTimeout(
|
||
() => reject(new Error('wsCreateRoom: timeout')),
|
||
5000,
|
||
);
|
||
ws.onopen = () => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 1,
|
||
ts: Date.now(),
|
||
type: 'room.create',
|
||
payload: {},
|
||
}),
|
||
);
|
||
};
|
||
ws.onmessage = (e: MessageEvent) => {
|
||
const msg = JSON.parse(e.data as string) as {
|
||
type: string;
|
||
payload: { code: string; token: string; color: string };
|
||
};
|
||
if (msg.type === 'room.created') {
|
||
clearTimeout(timer);
|
||
ws.close();
|
||
resolve(msg.payload);
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
reject(new Error('wsCreateRoom: error'));
|
||
};
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
/** Join a room via raw WS. Returns {code, token, color}. */
|
||
async function wsJoinRoomShared(
|
||
p: Page,
|
||
code: string,
|
||
): Promise<{ code: string; token: string; color: string }> {
|
||
return p.evaluate(async (roomCode: string) => {
|
||
return new Promise<{ code: string; token: string; color: string }>(
|
||
(resolve, reject) => {
|
||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||
const timer = setTimeout(
|
||
() => reject(new Error('wsJoinRoom: timeout')),
|
||
5000,
|
||
);
|
||
ws.onopen = () => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
v: 1,
|
||
seq: 1,
|
||
ts: Date.now(),
|
||
type: 'room.join',
|
||
payload: { code: roomCode },
|
||
}),
|
||
);
|
||
};
|
||
ws.onmessage = (e: MessageEvent) => {
|
||
const msg = JSON.parse(e.data as string) as {
|
||
type: string;
|
||
payload: { code: string; token: string; color: string };
|
||
};
|
||
if (msg.type === 'room.joined') {
|
||
clearTimeout(timer);
|
||
ws.close();
|
||
resolve(msg.payload);
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
reject(new Error('wsJoinRoom: error'));
|
||
};
|
||
},
|
||
);
|
||
}, code);
|
||
}
|