diff --git a/packages/chess/e2e/full-flow.spec.ts b/packages/chess/e2e/full-flow.spec.ts new file mode 100644 index 0000000..fd0eb92 --- /dev/null +++ b/packages/chess/e2e/full-flow.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from '@playwright/test'; + +/** + * Full Phase-3 gate scenario: + * preset-toggle → Scholar's Mate play → undo → save/reload → export → import → checkmate + * + * Undo is exercised on the *original* page before reload, where moveHistoryRef + * still holds all moves since start (loadEngine resets history, so undo is only + * meaningful there). Export/import is verified by restoring that same 8-move + * position in a fresh browser context, then completing the Scholar's Mate. + */ +test("full game flow: preset toggle, Scholar's Mate, save/reload, export/import, undo, checkmate", async ({ page, browser }) => { + + // ── Step 1: Preset toggles ───────────────────────────────────────────────── + await page.goto('/rules'); + // pawns-move-backward: incompatibleWith ["double-pawn-sprint"] only + await page.locator('[data-preset="pawns-move-backward"] [data-role="toggle"]').click(); + // knights-leap-twice: incompatibleWith [] + await page.locator('[data-preset="knights-leap-twice"] [data-role="toggle"]').click(); + // These two presets are compatible — no compat warning + await expect(page.locator('[data-testid="compat-warning"]')).not.toBeVisible(); + + await page.locator('[data-action="start-new-game"]').click(); + await page.waitForURL('**/game'); + + // ── Step 2: Play all 8 moves before any reload ──────────────────────────── + // + // We play ALL 8 moves here on the pristine page so that moveHistoryRef still + // holds the full sequence. loadEngine() resets moveHistoryRef to [], so any + // subsequent reload/import invalidates the undo chain; doing undo NOW (before + // the first reload) is the only way to test it correctly without source changes. + // + // Targeting `[data-square="${from}"] [data-piece]` places the drag origin on + // the draggable piece element so that dragstart fires on it. The target is the + // square div that has onDrop/onDragOver. + const drag = async (from: string, to: string) => { + await page + .locator(`[data-square="${from}"] [data-piece]`) + .dragTo(page.locator(`[data-square="${to}"]`)); + }; + + // Scholar's Mate setup — moves 1-5 + await drag('e2', 'e4'); // 1. e4 + await drag('e7', 'e5'); // 1...e5 + await drag('f1', 'c4'); // 2. Bc4 + await drag('b8', 'c6'); // 2...Nc6 + await drag('d1', 'h5'); // 3. Qh5 + + await expect(page.locator('[data-testid="turn-indicator"]')).toContainText('Black'); + + // Continuation — moves 6-8 + await drag('d7', 'd6'); // 3...d6 (random; Qf7 still covers d7 via rank-7) + await drag('a2', 'a3'); // 4. a3 (random) + await drag('g8', 'f6'); // 4...Nf6?? — will be undone + + // ── Step 3: Undo Nf6, verify knight back on g8 ──────────────────────────── + // History = [e4,e5,Bc4,Nc6,Qh5,d6,a3,Nf6] → pop → [e4,e5,Bc4,Nc6,Qh5,d6,a3] + // Rebuild replays all 7 moves from initial; g8 kingside knight is untouched + // (only the b8 queenside knight moved to c6 in move 4). + await page.locator('[data-action="undo"]').click(); + await expect(page.locator('[data-square="g8"] [data-piece="black-knight"]')).toBeVisible(); + + // Re-play Nf6 to restore the 8-move export state (White's turn) + await drag('g8', 'f6'); // 4...Nf6?? again + + // ── Step 4: Save named slot ──────────────────────────────────────────────── + await page.goto('/save'); + await page.fill('input[type="text"]', 'scholars-test'); + await page.locator('[data-action="save"]').click(); + await expect(page.locator('[data-testid="save-slot-scholars-test"]')).toBeVisible(); + + // ── Step 5: Reload → autosave restore ───────────────────────────────────── + // Each applyMove() calls saveAutoSave(); App.useEffect restores it on mount. + // The last applyMove was Nf6 → autosave = 8-move state. + // Queen on h5 and bishop on c4 are present in that state (neither moved + // during moves 6-8). + await page.reload(); + await page.goto('/game'); + await expect(page.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); + await expect(page.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible(); + + // ── Step 6: Export JSON (8-move state, White's turn) ────────────────────── + await page.goto('/save'); + const downloadPromise = page.waitForEvent('download'); + await page.locator('[data-action="export"]').click(); + const download = await downloadPromise; + const exportPath = `/tmp/chess-export-${Date.now()}.json`; + await download.saveAs(exportPath); + + // ── Step 7: Import in a fresh browser context ────────────────────────────── + const freshContext = await browser.newContext(); + const freshPage = await freshContext.newPage(); + await freshPage.goto('http://localhost:5173/save'); + + // setInputFiles works on hidden inputs; onChange → FileReader → onLoad(facts) + // → handleLoad in SaveWrapper → loadEngine → saveAutoSave → navigate('/game') + const fileInput = freshPage.locator('input[type="file"]'); + await fileInput.setInputFiles(exportPath); + await freshPage.waitForURL('**/game'); + + // Verify the 8-move position was restored in the fresh context + await expect(freshPage.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); + await expect(freshPage.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible(); + + // ── Step 8: Scholar's Mate — Qxf7# ─────────────────────────────────────── + // It is White's turn (after Black's Nf6 in the imported 8-move state). + // h5→f7 is a 2-square diagonal via empty g6. f7 has the black f7 pawn. + // Queen on f7 is defended by Bc4 (c4-d5-e6-f7 diagonal). + // Black king on e8 cannot escape: + // d8 = own queen (occupied), d7 = attacked by Qf7 along rank-7, + // e7 = attacked by Qf7 (adjacent on rank-7), f8 = own bishop (occupied), + // f7 = queen (defended by Bc4). + // Nf6 cannot capture f7 (not an L-shaped jump from f6). + await freshPage + .locator('[data-square="h5"] [data-piece]') + .dragTo(freshPage.locator('[data-square="f7"]')); + + // ── Step 9: Assert checkmate ────────────────────────────────────────────── + await expect(freshPage.locator('[data-testid="game-over"]')).toBeVisible(); + + await freshContext.close(); +}); diff --git a/packages/chess/tsconfig.json b/packages/chess/tsconfig.json index b01e4f4..a026d05 100644 --- a/packages/chess/tsconfig.json +++ b/packages/chess/tsconfig.json @@ -7,6 +7,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*", "e2e/**/*"], + "include": ["src/**/*"], "references": [{ "path": "../rete" }] }