import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { Workbench } from "../../src/components/Workbench"; import { encodePuzzleHash, fromDomainPuzzle } from "../../src/formats"; import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats"; class WorkerStub { addEventListener() {} postMessage() {} terminate() {} } beforeAll(() => { vi.stubGlobal("Worker", WorkerStub); }); describe("Sudoku workbench", () => { it("opens with the local classic example rendered as an accessible grid", () => { render(); expect( screen.getByRole("heading", { name: "A first classic" }), ).toBeInTheDocument(); const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); expect(within(grid).getAllByRole("gridcell")).toHaveLength(81); expect( within(grid).getByRole("gridcell", { name: "Row 1, column 1, 4", }), ).toHaveClass("is-given"); expect( within(grid).getByRole("gridcell", { name: "Row 1, column 3, empty", }), ).toBeInTheDocument(); }); it("enters a digit in an empty cell and restores it with undo", async () => { const user = userEvent.setup(); render(); const emptyCell = screen.getByRole("gridcell", { name: "Row 1, column 3, empty", }); fireEvent.pointerDown(emptyCell, { buttons: 1 }); await user.click( within(screen.getByRole("group", { name: "Digits" })).getByRole( "button", { name: "2" }, ), ); expect( screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }), ).toBeInTheDocument(); const undo = screen.getByRole("button", { name: "Undo" }); expect(undo).toBeEnabled(); await user.click(undo); expect( screen.getByRole("gridcell", { name: "Row 1, column 3, empty", }), ).toBeInTheDocument(); expect(undo).toBeDisabled(); }); it("fills a tracked candidate grid as one undoable maintenance action", async () => { const user = userEvent.setup(); render(); expect( screen.getByText("Candidate tracking is currently inactive."), ).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Fill legal candidates" }), ); expect( screen.getByText("The guided candidate grid is active."), ).toBeInTheDocument(); expect( screen.getByRole("gridcell", { name: "Row 1, column 3, empty", }), ).toHaveAccessibleDescription(expect.stringContaining("centre notes")); await user.click(screen.getByRole("button", { name: "Undo" })); expect( screen.getByText("Candidate tracking is currently inactive."), ).toBeInTheDocument(); expect( screen.getByRole("gridcell", { name: "Row 1, column 3, empty", }), ).not.toHaveAccessibleDescription(expect.stringContaining("centre notes")); }); it("supports non-wrapping WAI-ARIA grid navigation", () => { render(); const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); const cell = (row: number, column: number) => within(grid).getByRole("gridcell", { name: new RegExp(`^Row ${String(row)}, column ${String(column)},`, "u"), }); cell(1, 1).focus(); fireEvent.keyDown(cell(1, 1), { key: "ArrowLeft" }); expect(cell(1, 1)).toHaveAttribute("tabindex", "0"); fireEvent.keyDown(cell(1, 1), { key: "End" }); expect(cell(1, 9)).toHaveAttribute("tabindex", "0"); fireEvent.keyDown(cell(1, 9), { key: "ArrowRight" }); expect(cell(1, 9)).toHaveAttribute("tabindex", "0"); fireEvent.keyDown(cell(1, 9), { key: "ArrowDown" }); expect(cell(2, 9)).toHaveAttribute("tabindex", "0"); fireEvent.keyDown(cell(2, 9), { key: "Home" }); expect(cell(2, 1)).toHaveAttribute("tabindex", "0"); fireEvent.keyDown(cell(2, 1), { key: "PageDown" }); expect(cell(9, 1)).toHaveAttribute("tabindex", "0"); fireEvent.keyDown(cell(9, 1), { key: "Home", ctrlKey: true }); expect(cell(1, 1)).toHaveAttribute("tabindex", "0"); }); it("offers tap-by-tap multi-selection without drag selection", async () => { const user = userEvent.setup(); render(); const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); const cell = (column: number) => within(grid).getByRole("gridcell", { name: new RegExp(`^Row 1, column ${String(column)},`, "u"), }); fireEvent.pointerDown(cell(3), { buttons: 1 }); const toggleMode = screen.getByRole("button", { name: "Tap multi-select" }); expect(toggleMode).toHaveAttribute("aria-pressed", "false"); await user.click(toggleMode); expect(toggleMode).toHaveAttribute("aria-pressed", "true"); fireEvent.pointerDown(cell(4), { buttons: 1 }); expect(cell(3)).toHaveAttribute("aria-selected", "true"); expect(cell(4)).toHaveAttribute("aria-selected", "true"); fireEvent.pointerEnter(cell(5), { buttons: 1 }); expect(cell(5)).toHaveAttribute("aria-selected", "false"); fireEvent.pointerDown(cell(3), { buttons: 1 }); expect(cell(3)).toHaveAttribute("aria-selected", "false"); expect(cell(4)).toHaveAttribute("aria-selected", "true"); }); it("renders one sticky entry pad in a narrow viewport", () => { const original = Object.getOwnPropertyDescriptor(window, "matchMedia"); Object.defineProperty(window, "matchMedia", { configurable: true, value: (query: string) => ({ matches: query === "(max-width: 48rem)", media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), }), }); const { container, unmount } = render(); expect(container.querySelectorAll(".mobile-number-pad")).toHaveLength(1); expect(screen.getAllByRole("group", { name: "Entry mode" })).toHaveLength( 1, ); unmount(); if (original === undefined) Reflect.deleteProperty(window, "matchMedia"); else Object.defineProperty(window, "matchMedia", original); }); it("keeps fogged selections out of toolbar and helper output", async () => { const user = userEvent.setup(); const previousHash = window.location.hash; window.location.hash = encodePuzzleHash( fromDomainPuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), solution: [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1], constraints: [{ type: "fog", lights: [15], revealRadius: 0 }], }), ); const { unmount } = render(); expect( screen.getByRole("gridcell", { name: "Row 1, column 1, obscured by fog", }), ).toBeDisabled(); expect(screen.getByText("r4c4")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Helpers" })); await user.click(screen.getByRole("tab", { name: "Sum Lab" })); await user.clear(screen.getByLabelText("Target sum")); await user.type(screen.getByLabelText("Target sum"), "4"); await user.click( screen.getByRole("checkbox", { name: "Use 1 board cell and candidates", }), ); expect(screen.getByText(/^r4c4:/u)).toBeInTheDocument(); unmount(); window.location.hash = previousHash; }); it("shows digit progress and toggles matching-digit highlights separately from selection", async () => { const user = userEvent.setup(); render(); const completion = screen.getByRole("group", { name: "Highlight matching digits", }); expect(within(completion).getAllByRole("button")).toHaveLength(9); const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); const four = within(grid).getByRole("gridcell", { name: "Row 1, column 1, 4", }); fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true }); const highlighted = grid.querySelectorAll(".is-digit-highlighted"); expect(highlighted.length).toBeGreaterThan(1); expect(four).toHaveAttribute("aria-selected", "true"); fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true }); expect(grid.querySelectorAll(".is-digit-highlighted")).toHaveLength(0); await user.click(screen.getByLabelText("Show digit completion bar")); expect( screen.queryByRole("group", { name: "Highlight matching digits" }), ).not.toBeInTheDocument(); }); it("replaces and removes setter cages through the selected cells", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "New blank" })); const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); fireEvent.pointerDown( within(grid).getByRole("gridcell", { name: "Row 1, column 2, empty", }), { buttons: 1, shiftKey: true }, ); const cageSum = screen.getByRole("spinbutton", { name: "Cage sum" }); await user.clear(cageSum); await user.type(cageSum, "3"); await user.click( screen.getByRole("button", { name: "Add / replace cage" }), ); expect(screen.getByText("3 cage · 2 cells")).toBeInTheDocument(); await user.clear(cageSum); await user.type(cageSum, "4"); await user.click( screen.getByRole("button", { name: "Add / replace cage" }), ); expect(screen.queryByText("3 cage · 2 cells")).not.toBeInTheDocument(); expect(screen.getByText("4 cage · 2 cells")).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Remove selected cage" }), ); expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument(); await user.click(screen.getByLabelText("Cage digits may repeat")); await user.click( screen.getByLabelText("Newly added clues must be false (Wrogn mode)"), ); await user.clear(cageSum); await user.type(cageSum, "2"); await user.click( screen.getByRole("button", { name: "Add / replace cage" }), ); expect( screen.getByText("false · 2 cage · 2 cells · repeats allowed"), ).toBeInTheDocument(); await user.click(screen.getByLabelText("Cage digits may repeat")); await user.clear(cageSum); await user.type(cageSum, "3"); await user.click( screen.getByRole("button", { name: "Add / replace cage" }), ); expect( screen.queryByText("false · 2 cage · 2 cells · repeats allowed"), ).not.toBeInTheDocument(); expect(screen.getByText("false · 3 cage · 2 cells")).toBeInTheDocument(); }); it("opens the expanded built-in variant examples", async () => { const user = userEvent.setup(); const { container } = render(); await user.selectOptions( screen.getByLabelText("Open built-in puzzle"), "thermo", ); expect( screen.getByRole("heading", { name: "Warm fronts" }), ).toBeInTheDocument(); expect( screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }), ).toBeInTheDocument(); expect( container.querySelectorAll(".constraint-thermo").length, ).toBeGreaterThan(0); await user.selectOptions( screen.getByLabelText("Open built-in puzzle"), "xv", ); expect( screen.getByRole("heading", { name: "Five or ten" }), ).toBeInTheDocument(); expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent( "5", ); expect( container.querySelector(".constraint-xv--10 text"), ).toHaveTextContent("10"); await user.selectOptions( screen.getByLabelText("Open built-in puzzle"), "truth-and-lies", ); expect( screen.getByRole("heading", { name: "Truth and lies" }), ).toBeInTheDocument(); expect(container.querySelectorAll(".constraint-outside")).toHaveLength(4); expect(container.querySelectorAll(".constraint-quadruple")).toHaveLength(2); expect(container.querySelectorAll(".constraint-maximum")).toHaveLength(2); }); it("sets and batch-toggles false local and outside clues", async () => { const user = userEvent.setup(); const { container } = render(); await user.click(screen.getByRole("button", { name: "New blank" })); expect( screen.getByRole("button", { name: "Add quadruple" }), ).toBeDisabled(); await user.clear(screen.getByLabelText("Quadruple digits")); await user.type(screen.getByLabelText("Quadruple digits"), "1"); expect( screen.getByRole("button", { name: "Add quadruple" }), ).toBeDisabled(); await user.click( screen.getByLabelText("Newly added clues must be false (Wrogn mode)"), ); await user.click(screen.getByRole("button", { name: "Maximum cell" })); await user.click( screen.getByRole("button", { name: "Add / replace outside clue" }), ); expect(screen.getByText("false · maximum · r1c1")).toBeInTheDocument(); expect(screen.getByText("false · Σ3 · top 1")).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Require true for false · maximum · r1c1", }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Remove false · Σ3 · top 1", }), ).toBeInTheDocument(); expect(container.querySelectorAll(".is-negated")).toHaveLength(2); await user.clear(screen.getByLabelText("Sum")); await user.type(screen.getByLabelText("Sum"), "6562"); expect( screen.getByRole("button", { name: "Add / replace outside clue" }), ).toBeDisabled(); await user.click( screen.getByRole("button", { name: "Make all clues true" }), ); expect(screen.getByText("maximum · r1c1")).toBeInTheDocument(); expect(screen.getByText("Σ3 · top 1")).toBeInTheDocument(); expect(container.querySelectorAll(".is-negated")).toHaveLength(0); }); it("switches between setting, solving, playing and helper workspaces", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Set" })); expect( screen.getByRole("heading", { name: "Set a puzzle" }), ).toBeInTheDocument(); expect( screen.getByRole("heading", { name: "Enter givens" }), ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Generate" })); expect( screen.getByRole("heading", { name: "Sudoku generator" }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Rate current puzzle" }), ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Solve" })); expect( screen.getByRole("heading", { name: "Solve and verify" }), ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Helpers" })); expect( screen.getByRole("heading", { name: "Sudoku helpers" }), ).toBeInTheDocument(); expect( screen.getByRole("tab", { name: "Killer combinations" }), ).toHaveAttribute("aria-selected", "true"); expect(document.querySelector(".helper-result")).toHaveTextContent( "combinations", ); await user.click(screen.getByRole("tab", { name: "45-rule residual" })); expect( screen.getByLabelText("Accounted values or cage sums"), ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Play" })); expect( screen.getByRole("heading", { name: "Enter your solve" }), ).toBeInTheDocument(); }); it("projects candidate filters and links onto the board only while inspecting", async () => { const user = userEvent.setup(); const { container } = render(); await user.click(screen.getByRole("button", { name: "Helpers" })); await user.click(screen.getByRole("tab", { name: "Candidate links" })); expect( screen.getByRole("heading", { name: "Links and houses" }), ).toBeInTheDocument(); await user.click(screen.getByRole("checkbox", { name: "Weak links" })); expect( container.querySelector(".candidate-link-layer"), ).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Filter candidate 1" }), ); expect( container.querySelectorAll(".sudoku-cell.is-candidate-highlighted") .length, ).toBeGreaterThan(0); await user.click(screen.getByRole("button", { name: "Play" })); expect( container.querySelector(".candidate-link-layer"), ).not.toBeInTheDocument(); expect( container.querySelectorAll(".sudoku-cell.is-candidate-highlighted"), ).toHaveLength(0); }); it("makes the import boundary and self-contained local sharing explicit", async () => { const user = userEvent.setup(); const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); render(); await user.click(screen.getByRole("button", { name: "Import / export" })); expect( screen.getByRole("heading", { name: "Import and export" }), ).toBeInTheDocument(); expect( screen.getByText(/Server-only short IDs are never fetched\./u), ).toBeInTheDocument(); expect( screen.getByText(/Share links are self-contained\./u), ).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Copy local share URL" }), ); expect( await screen.findByText("Local share URL copied."), ).toBeInTheDocument(); expect(fetchSpy).not.toHaveBeenCalled(); }); it("keeps imported visuals, provenance and metadata after a board edit", async () => { const user = userEvent.setup(); let downloaded: Blob | undefined; vi.mocked(URL.createObjectURL).mockImplementation((value) => { downloaded = value as Blob; return "blob:preserved-workbench-export"; }); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); render(); const imported = { schema: SUDOKU_DOCUMENT_SCHEMA, version: 1, size: 4, givens: Array(16).fill(0), constraints: [], title: "Preservation regression", visuals: [ { type: "text", layer: "overlay", position: { kind: "cell", cell: 0 }, text: "source label", style: { fill: "#123456" }, }, ], source: { format: "sudokupad", id: "original-source" }, metadata: { edition: "kept" }, }; await user.click(screen.getByRole("button", { name: "Import / export" })); fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), { target: { value: JSON.stringify(imported) }, }); await user.click(screen.getByRole("button", { name: "Import locally" })); expect(document.querySelector(".source-visual--text")).toHaveTextContent( "source label", ); fireEvent.pointerDown( screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), { buttons: 1 }, ); await user.click( within(screen.getByRole("group", { name: "Digits" })).getByRole( "button", { name: "1" }, ), ); await user.click(screen.getByRole("button", { name: "Import / export" })); await user.click( screen.getByRole("button", { name: "Download project JSON" }), ); const exported = JSON.parse((await downloaded?.text()) ?? "{}") as { values?: number[]; visuals?: unknown[]; source?: unknown; metadata?: unknown; }; expect(exported.values?.[0]).toBe(1); expect(exported.visuals).toEqual(imported.visuals); expect(exported.source).toEqual(imported.source); expect(exported.metadata).toEqual(imported.metadata); }); });