import { expect, test } from "@playwright/test"; test("loads standalone and keeps the core play workflow local", async ({ page, }) => { const runtimeErrors: string[] = []; page.on("pageerror", (error) => runtimeErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") runtimeErrors.push(message.text()); }); await page.goto("/deep/nested/sudoku/"); await expect( page.getByRole("heading", { name: "A first classic" }), ).toBeVisible(); const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" }); await expect(grid).toBeVisible(); await expect(grid.getByRole("gridcell")).toHaveCount(81); await grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }).click(); await page .getByRole("group", { name: "Digits" }) .getByRole("button", { name: "2", exact: true }) .click(); await expect( grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }), ).toBeVisible(); await page.getByRole("button", { name: "Undo" }).click(); await expect( grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }), ).toBeVisible(); const placedFour = grid.getByRole("gridcell", { name: "Row 1, column 1, 4", }); await placedFour.click({ modifiers: ["Control"] }); expect(await grid.locator(".is-digit-highlighted").count()).toBeGreaterThan( 1, ); await placedFour.click({ modifiers: ["Control"] }); await expect(grid.locator(".is-digit-highlighted")).toHaveCount(0); await page.getByRole("button", { name: "Helpers" }).click(); await expect( page.getByRole("heading", { name: "Sudoku helpers" }), ).toBeVisible(); await expect( page.getByRole("tab", { name: "Killer combinations" }), ).toHaveAttribute("aria-selected", "true"); await expect(page.locator(".helper-result")).toContainText("4 combinations"); await page.getByRole("button", { name: "Import / export" }).click(); await expect( page.getByRole("heading", { name: "Import and export" }), ).toBeVisible(); await expect( page.getByText(/Server-only short IDs are never fetched\./u), ).toBeVisible(); await expect( page.getByText(/Share links are self-contained\./u), ).toBeVisible(); expect(runtimeErrors).toEqual([]); }); test("keeps the board, keypad and solver controls within their layouts", async ({ page, }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto("/deep/nested/sudoku/"); const frame = page.locator(".sudoku-board-frame"); const sidebar = page.locator(".side-panel"); const boardColumn = page.locator(".board-column"); const scroller = page.locator(".board-viewport__scroller"); await expect(frame).toBeVisible(); const fittedWidth = (await frame.boundingBox())?.width ?? 0; expect(fittedWidth).toBeGreaterThan(0); expect( await scroller.evaluate( (element) => element.scrollHeight <= element.clientHeight + 1, ), ).toBe(true); const digits = page.getByRole("group", { name: "Digits" }); const digitBoxes = await Promise.all( ["1", "2", "3", "4", "7", "9"].map(async (digit) => digits.getByRole("button", { name: digit, exact: true }).boundingBox(), ), ); const [one, two, three, four, seven, nine] = digitBoxes; const erase = await digits .getByRole("button", { name: "Erase" }) .boundingBox(); for (const box of [...digitBoxes, erase]) expect(box).not.toBeNull(); expect(Math.abs(one!.y - two!.y)).toBeLessThan(1); expect(Math.abs(two!.y - three!.y)).toBeLessThan(1); expect(Math.abs(one!.x - four!.x)).toBeLessThan(1); expect(Math.abs(four!.x - seven!.x)).toBeLessThan(1); expect(four!.y).toBeGreaterThan(one!.y + one!.height); expect(seven!.y).toBeGreaterThan(four!.y + four!.height); expect(Math.abs(erase!.x - one!.x)).toBeLessThan(1); expect( Math.abs(erase!.x + erase!.width - (three!.x + three!.width)), ).toBeLessThan(1); expect(erase!.y).toBeGreaterThan(nine!.y + nine!.height); await page.getByRole("button", { name: "Zoom board out" }).click(); await expect(page.getByLabel("Board zoom level")).toHaveText("75%"); const reducedWidth = (await frame.boundingBox())?.width ?? 0; expect(reducedWidth / fittedWidth).toBeCloseTo(0.75, 2); await page.getByRole("button", { name: "Fit board" }).click(); const baselineSidebarWidth = (await sidebar.boundingBox())?.width ?? 0; const baselineBoardWidth = (await boardColumn.boundingBox())?.width ?? 0; for (const workspace of ["Set", "Generate", "Solve", "Helpers"]) { await page.getByRole("button", { name: workspace, exact: true }).click(); expect((await sidebar.boundingBox())?.width ?? 0).toBeCloseTo( baselineSidebarWidth, 1, ); expect((await boardColumn.boundingBox())?.width ?? 0).toBeCloseTo( baselineBoardWidth, 1, ); } await page.getByRole("tab", { name: "Candidate links" }).click(); await expect( page.getByRole("heading", { name: "Links and houses" }), ).toBeVisible(); expect( await sidebar.evaluate( (element) => element.scrollWidth <= element.clientWidth + 1, ), ).toBe(true); await page.getByRole("button", { name: "Solve", exact: true }).click(); await page.getByRole("button", { name: "Build logical solve path" }).click(); const solveSteps = page.locator(".solve-step"); await expect(solveSteps.first()).toBeVisible({ timeout: 60_000 }); expect(await solveSteps.count()).toBeGreaterThan(0); expect( await solveSteps.evaluateAll((steps) => steps.every( (step) => step.scrollHeight <= step.clientHeight + 1 && step.scrollWidth <= step.clientWidth + 1, ), ), ).toBe(true); }); test("replaces setter cages and generates a rated variant", async ({ page, }) => { const runtimeErrors: string[] = []; page.on("pageerror", (error) => runtimeErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") runtimeErrors.push(message.text()); }); await page.goto("/deep/nested/sudoku/"); await page.getByRole("button", { name: "New blank" }).click(); const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" }); await grid.getByRole("gridcell", { name: "Row 1, column 1, empty" }).click(); await grid .getByRole("gridcell", { name: "Row 1, column 2, empty" }) .click({ modifiers: ["Shift"] }); await page.getByRole("spinbutton", { name: "Cage sum" }).fill("3"); await page.getByRole("button", { name: "Add / replace cage" }).click(); await expect(page.getByText("3 cage · 2 cells")).toBeVisible(); await page.getByRole("spinbutton", { name: "Cage sum" }).fill("4"); await page.getByRole("button", { name: "Add / replace cage" }).click(); await expect(page.getByText("3 cage · 2 cells")).toHaveCount(0); await expect(page.getByText("4 cage · 2 cells")).toBeVisible(); await page.getByRole("button", { name: "Remove selected cage" }).click(); await expect(page.getByText("4 cage · 2 cells")).toHaveCount(0); await page.getByRole("button", { name: "Generate", exact: true }).click(); await page.getByLabel("Variant").selectOption("thermo"); await page .getByRole("combobox", { name: "Grid", exact: true }) .selectOption("4"); await page.getByLabel("Requested profile").selectOption("beginner"); await page.getByLabel("Seed").fill("browser-thermo"); await page.getByRole("button", { name: "Generate Thermo" }).click(); await expect( page.getByRole("heading", { name: "Thermo · browser-thermo" }), ).toBeVisible({ timeout: 60_000 }); await expect(page.getByText("Difficulty assessment")).toBeVisible(); await expect(page.locator(".difficulty-card .status-pill")).toHaveText( "unique", ); await expect(page.locator(".constraint-thermo")).not.toHaveCount(0); expect(runtimeErrors).toEqual([]); }); test("uses distinct numbered sum badges and directional inequalities", async ({ page, }) => { const runtimeErrors: string[] = []; page.on("pageerror", (error) => runtimeErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") runtimeErrors.push(message.text()); }); await page.goto("/deep/nested/sudoku/"); const examples = page.getByLabel("Open built-in puzzle"); await examples.selectOption("xv"); await expect( page.getByRole("heading", { name: "Five or ten" }), ).toBeVisible(); await expect(page.locator(".constraint-xv--5 text").first()).toHaveText("5"); await expect(page.locator(".constraint-xv--10 text").first()).toHaveText( "10", ); await examples.selectOption("inequality"); await expect( page.getByRole("heading", { name: "Lesser and greater" }), ).toBeVisible(); await expect(page.locator(".constraint-inequality")).not.toHaveCount(0); await expect( page.locator(".constraint-inequality-tip").first(), ).toBeVisible(); await examples.selectOption("truth-and-lies"); await expect( page.getByRole("heading", { name: "Truth and lies" }), ).toBeVisible(); await expect(page.locator(".constraint-outside")).toHaveCount(4); await expect(page.locator(".constraint-quadruple")).toHaveCount(2); await expect(page.locator(".constraint-maximum")).toHaveCount(2); await expect(page.locator(".constraint-outside.is-negated")).toHaveCount(2); await page.setViewportSize({ width: 390, height: 844 }); const outsideBox = await page .locator(".constraint-outside") .first() .boundingBox(); const maximumBox = await page .locator(".constraint-maximum") .first() .boundingBox(); expect(outsideBox?.width ?? 0).toBeGreaterThan(18); expect(outsideBox?.height ?? 0).toBeGreaterThan(12); expect(maximumBox?.width ?? 0).toBeGreaterThan(12); expect(maximumBox?.height ?? 0).toBeGreaterThan(12); await expect( page.locator(".constraint-thermo .constraint-false-mark").first(), ).toBeVisible(); expect(runtimeErrors).toEqual([]); }); test("keeps navigation, scratch work, branches and analysis tools local", async ({ page, }) => { const runtimeErrors: string[] = []; page.on("pageerror", (error) => runtimeErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") runtimeErrors.push(message.text()); }); await page.goto("/deep/nested/sudoku/"); const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" }); const first = grid.getByRole("gridcell", { name: "Row 1, column 1, 4", }); await first.focus(); await first.press("End"); await expect( grid.getByRole("gridcell", { name: /Row 1, column 9,/u }), ).toBeFocused(); await page.keyboard.press("PageDown"); await expect( grid.getByRole("gridcell", { name: /Row 9, column 9,/u }), ).toBeFocused(); await page.keyboard.press("Control+Home"); await expect(first).toBeFocused(); await page.getByLabel("Show aid-mémoire scratch cells").check(); const scratch = page.getByRole("grid", { name: "9 aid-mémoire scratch cells", }); const scratchCell = scratch.getByRole("gridcell").first(); await scratchCell.click(); await page .getByRole("group", { name: "Digits" }) .getByRole("button", { name: "2", exact: true }) .click(); await expect(scratch.getByRole("gridcell").first()).toHaveAccessibleName( /value 2/u, ); await page.getByRole("button", { name: "History & branches" }).click(); await page.getByLabel("Savepoint name").fill("Before test branch"); await page.getByRole("button", { name: "Save current grid" }).click(); await expect( page.getByText("Before test branch", { exact: true }), ).toBeVisible(); await page.getByLabel("Hypothesis name").fill("Try a two"); await page.getByRole("button", { name: "Start hypothesis" }).click(); const empty = grid.getByRole("gridcell", { name: "Row 1, column 3, empty", }); await empty.click(); await page .getByRole("group", { name: "Digits" }) .getByRole("button", { name: "2", exact: true }) .click(); await expect( grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }), ).toBeVisible(); await page.getByRole("button", { name: "Hypothesis: Try a two" }).click(); await page.getByRole("button", { name: "Discard and return" }).click(); await expect(empty).toBeVisible(); await page.getByRole("button", { name: "Helpers" }).click(); await page.getByRole("tab", { name: "Sum Lab" }).click(); await expect( page.getByRole("heading", { name: "Generalized Sum Lab" }), ).toBeVisible(); await page.getByRole("tab", { name: "Candidate links" }).click(); await expect( page.getByRole("heading", { name: "Links and houses" }), ).toBeVisible(); expect(runtimeErrors).toEqual([]); }); test("migrates the local database and recovers autosaved history", async ({ page, }) => { await page.goto("/deep/nested/sudoku/favicon.svg"); await page.evaluate(async () => { await new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase("sudoku-tools"); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); await new Promise((resolve, reject) => { const request = indexedDB.open("sudoku-tools", 1); request.onupgradeneeded = () => { const store = request.result.createObjectStore("projects", { keyPath: "id", }); store.createIndex("updatedAt", "updatedAt"); }; request.onsuccess = () => { request.result.close(); resolve(); }; request.onerror = () => reject(request.error); }); }); await page.goto("/deep/nested/sudoku/"); const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" }); await grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }).click(); await page .getByRole("group", { name: "Digits" }) .getByRole("button", { name: "2", exact: true }) .click(); await expect .poll( async () => await page.evaluate( async () => await new Promise((resolve, reject) => { const open = indexedDB.open("sudoku-tools"); open.onerror = () => reject(open.error); open.onsuccess = () => { const database = open.result; const request = database .transaction("autosaves", "readonly") .objectStore("autosaves") .get("current"); request.onerror = () => reject(request.error); request.onsuccess = () => { const value = request.result as | { record?: { progress?: { values?: number[] } } } | undefined; database.close(); resolve(value?.record?.progress?.values?.[2]); }; }; }), ), { timeout: 10_000 }, ) .toBe(2); await page.reload(); await expect( page.getByRole("heading", { name: "A first classic" }), ).toBeVisible(); await expect(page.getByText("Recover unsaved local work?")).toBeVisible(); await page.getByRole("button", { name: "Restore" }).click(); await expect( page .getByRole("grid", { name: "9 by 9 Sudoku grid" }) .getByRole("gridcell", { name: "Row 1, column 3, 2" }), ).toBeVisible(); await page.getByRole("button", { name: "History & branches" }).click(); await expect( page .getByRole("list", { name: "Solve history" }) .getByRole("button", { name: /Set r1c3 to 2/u }), ).toBeVisible(); }); test("installs a subpath-safe application shell that reopens offline", async ({ context, page, }) => { await page.goto("/deep/nested/sudoku/"); await page.waitForFunction(async () => { await navigator.serviceWorker.ready; return navigator.serviceWorker.controller !== null; }); const cachedUrls = await page.evaluate(async () => { const names = await caches.keys(); return ( await Promise.all( names .filter((name) => name.startsWith("sudoku-tools-shell-")) .map(async (name) => (await caches.open(name)) .keys() .then((requests) => requests.map((request) => request.url)), ), ) ).flat(); }); expect( cachedUrls.some((url) => /\/solver\.worker-[^/]+\.js$/u.test(url)), ).toBe(true); await page.reload(); await expect( page.getByRole("heading", { name: "A first classic" }), ).toBeVisible(); await context.setOffline(true); try { await page.reload(); await expect( page.getByRole("heading", { name: "A first classic" }), ).toBeVisible(); await expect( page.getByRole("grid", { name: "9 by 9 Sudoku grid" }), ).toBeVisible(); } finally { await context.setOffline(false); } });