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:
parent
ed2f0cc660
commit
cd7baeae0d
1 changed files with 267 additions and 0 deletions
267
packages/chess/e2e/rule-variants.spec.ts
Normal file
267
packages/chess/e2e/rule-variants.spec.ts
Normal 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('; '));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue