test(e2e): rule-variant vertical slice

Phase F.4 of the rule-variants epic.

Three end-to-end scenarios covering the full lobby -> game flow:

  1. knightmate: selecting the knightmate layout surfaces a
     'Suggested rules' chip for knightmate-rules in LayoutPicker;
     tapping activates the preset; Play Solo loads the game with
     no console errors; a pawn push resolves normally.

  2. double-move: in-game rules drawer shows a 'Multi-move'
     category section (Phase F.3 grouping); toggling double-move
     ON makes white play two consecutive half-moves before the
     turn flips to black, verified by successful drags of two
     white pieces followed by a black piece drag.

  3. suicide-chess: after establishing a capture opportunity
     (1. e4 d5), toggling suicide-chess on via the in-game rules
     drawer. White's next move: a non-capture (a2-a3) is
     rejected by filterLegalMoves; the compulsory capture
     (e4xd5) succeeds.

Scenarios 2 and 3 toggle the preset via the IN-GAME drawer
(lives in GameView.tsx) rather than the lobby — Lobby.tsx has
no rules drawer; only the layout-picker chip path is available
pre-game. Scenario 1 exercises the chip path. Deferred:
lobby-side drawer is a follow-up if richer pre-game preset
activation UX is desired.

Tests: 1651 unit + 3 new e2e = 4-test file added to e2e suite.
All 3 pass.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 09:49:55 -06:00
commit cd7baeae0d
No known key found for this signature in database

View file

@ -0,0 +1,267 @@
/**
* Rule-variants epic vertical slice e2e.
*
* Three representative scenarios covering the full lobby game flow
* for the 2026 rule-variants epic. These are INTEGRATION smoke tests:
* they verify the UI wiring (layout picker, suggested-rules chips,
* rules drawer category grouping, per-preset toggle) correctly
* plumbs preset activations into the solo engine. They are NOT
* chess-logic tests the unit suites in
* packages/chess/src/presets/*.test.ts already exhaustively cover
* preset behaviour.
*
* All three scenarios are solo-play; no WS server required.
*
* 1. Knightmate: layout-picker chip toggles `knightmate-rules`
* which loads on Play Solo without errors.
* 2. Double-move: drawer toggle of `double-move`; white plays two
* consecutive moves and the turn indicator lands on black only
* after the second.
* 3. Suicide-chess: drawer toggle of `suicide-chess`; a non-capture
* move is NOT offered when a capture is available (compulsory
* capture via `filterLegalMoves`).
*
* Patterns borrowed from:
* - solo-smoke.spec.ts (localStorage reset, drag helper, console
* error collection).
* - modifier-profiles.spec.ts (rules drawer open + preset toggle).
*/
import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
/**
* Reset localStorage so a previous test's preset activations or
* profile selections don't leak into this one. Mirrors the
* solo-smoke pattern.
*/
async function resetStorage(page: Page): Promise<void> {
await page.goto('/');
await page.evaluate(() => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
}
});
}
/**
* Drag a piece from `from` to `to` (algebraic squares).
*/
async function drag(page: Page, from: string, to: string): Promise<void> {
await page
.locator(`[data-square="${from}"] [data-piece]`)
.dragTo(page.locator(`[data-square="${to}"]`));
// small settle for animation + state commit
await page.waitForTimeout(120);
}
/**
* Toggle a preset via the rules drawer inside a GAME VIEW.
*
* The rules drawer is part of `GameView`, not the lobby opening
* it requires an active game. These scenarios use the pattern:
*
* 1. Play Solo (get into the game with no presets).
* 2. Open the drawer.
* 3. Toggle the preset ON (applies immediately to the running
* engine).
* 4. Close the drawer.
* 5. Drive moves with the preset active.
*/
async function togglePresetInGameDrawer(
page: Page,
presetId: string,
): Promise<void> {
const drawer = page.locator('[data-testid="rules-drawer"]');
const isOpen = await drawer.isVisible().catch(() => false);
if (!isOpen) {
await page.locator('[data-action="open-rules-drawer"]').click();
await expect(drawer).toBeVisible();
}
const toggle = page.locator(
`[data-preset="${presetId}"] [data-role="toggle"]`,
);
await expect(toggle).toBeVisible();
await toggle.click();
// Give the engine a frame to re-activate with the new preset set.
await page.waitForTimeout(120);
// Close the drawer so the board is interactable.
await page.locator('[data-action="close-rules-drawer"]').click();
await expect(drawer).not.toBeVisible();
}
test.describe('Rule variants — lobby vertical slice', () => {
test.beforeEach(async ({ page }) => {
await resetStorage(page);
});
// ────────────────────────────────────────────────────────────────
// Scenario 1: Knightmate
// ────────────────────────────────────────────────────────────────
test('knightmate: layout chip toggles rules, solo game loads', async ({
page,
}) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
// Pick the knightmate layout.
await page
.locator('[data-testid="layout-picker"]')
.selectOption('knightmate');
// The layout's suggestedPresets declares "knightmate-rules".
// LayoutPicker renders a chip for it.
const chip = page.locator(
'[data-testid="layout-suggested-preset-knightmate-rules"]',
);
await expect(chip).toBeVisible();
// Activate the preset via the chip.
await chip.click();
// Start the solo game.
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game', { timeout: 5000 });
// Board renders; back rank is the knightmate shape (3 knights
// per side, no queens). We don't assert piece identity across
// every square — just sanity that the board loaded and no
// console/page errors fired through the activation path.
await expect(page.locator('[data-square="e1"]')).toBeVisible({
timeout: 3000,
});
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
// Make a simple move — pawn e2→e4.
await drag(page, 'e2', 'e4');
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
timeout: 3000,
});
if (errors.length > 0) throw new Error(errors.join('; '));
});
// ────────────────────────────────────────────────────────────────
// Scenario 2: Double-move
// ────────────────────────────────────────────────────────────────
test('double-move: white plays two half-moves before the flip', async ({
page,
}) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
// Start a solo game first (the drawer lives in GameView).
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
// Open the drawer and verify the multi-move category header
// rendered (Phase F.3 grouping).
await page.locator('[data-action="open-rules-drawer"]').click();
await expect(
page.locator('[data-testid="rules-category-multi-move"]'),
).toBeVisible();
// Toggle double-move ON. Engine re-activates the preset set
// immediately — applies to the NEXT move.
const toggle = page.locator('[data-preset="double-move"] [data-role="toggle"]');
await expect(toggle).toBeVisible();
await toggle.click();
await page.waitForTimeout(120);
await page.locator('[data-action="close-rules-drawer"]').click();
await expect(
page.locator('[data-testid="rules-drawer"]'),
).not.toBeVisible();
// White's first half-move: e2 → e4.
await drag(page, 'e2', 'e4');
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
timeout: 3000,
});
// At this point HalfMovesThisTurn=1 and the engine's
// shouldAdvanceTurn hook vetoed the flip → still white's turn.
// White plays a second half-move. If it's still white's turn,
// a white-owned piece like b1 knight should be movable.
await drag(page, 'g1', 'f3');
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible({
timeout: 3000,
});
// Now it should be black's turn. Black plays first half-move:
// e7 → e5. If the flip didn't happen, this move would fail
// because e7 belongs to black and the engine would reject a
// white-mover attempting it (or the drag would be ignored by
// the UI gating).
await drag(page, 'e7', 'e5');
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible({
timeout: 3000,
});
if (errors.length > 0) throw new Error(errors.join('; '));
});
// ────────────────────────────────────────────────────────────────
// Scenario 3: Suicide-chess (compulsory capture)
// ────────────────────────────────────────────────────────────────
test('suicide-chess: compulsory capture hides non-capture moves', async ({
page,
}) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
// Play Solo FIRST (drawer lives in GameView). We'll play
// without the preset briefly to establish a capture position,
// THEN toggle suicide-chess on, THEN verify the compulsion.
//
// Approach rationale: suicide-chess' `filterLegalMoves`
// applies to BOTH sides. Driving a fresh-FIDE game forward
// with the preset active-from-start requires both sides to
// resolve captures as they arise, which is brittle to script
// through the UI. Instead: set up a known capture opportunity
// OFF-preset, then flip the preset ON and verify white's
// next move is constrained.
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
// Build a position with one clear capture: 1. e4 d5.
// Now white's e4 attacks d5 → e4xd5 is the only capture
// available to white on ply 3. Other white moves (pawn
// pushes, knight developments) are non-captures.
await drag(page, 'e2', 'e4');
await drag(page, 'd7', 'd5');
// Now flip suicide-chess ON. With the preset active,
// `filterLegalMoves` will restrict white's next move to
// captures only — just e4xd5.
await togglePresetInGameDrawer(page, 'suicide-chess');
// Try a non-capture: a2 → a3. Should be rejected (board
// unchanged).
await drag(page, 'a2', 'a3');
await expect(page.locator('[data-square="a2"] [data-piece]')).toBeVisible({
timeout: 2000,
});
await expect(
page.locator('[data-square="a3"] [data-piece]'),
).toHaveCount(0);
// Play the compulsory capture. e4xd5 should succeed — the
// pawn moves to d5, captures the black pawn.
await drag(page, 'e4', 'd5');
await expect(page.locator('[data-square="d5"] [data-piece]')).toBeVisible({
timeout: 3000,
});
// e4 should now be empty.
await expect(
page.locator('[data-square="e4"] [data-piece]'),
).toHaveCount(0);
if (errors.length > 0) throw new Error(errors.join('; '));
});
});