import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { normalizePuzzle } from "../../src/domain"; import { SudokuBoard } from "../../src/components/SudokuBoard"; import { foggedCellsForPuzzle } from "../../src/components/fogVisibility"; describe("Sudoku board constraint visuals", () => { it("orders retained underlays before semantic clues and overlays afterward", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), solution: [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3], constraints: [ { type: "diagonal", direction: "main" }, { type: "fog", lights: [0], revealRadius: 0 }, ], }); const { container } = render( , ); const underlay = container.querySelector(".safe-visual-layer--underlay")!; const regions = container.querySelector(".region-boundaries")!; const semantic = container.querySelector(".constraint-diagonal")!; const overlay = container.querySelector(".safe-visual-layer--overlay")!; const fogMask = container.querySelector(".fog-constraint-mask")!; expect( underlay.compareDocumentPosition(regions) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( underlay.compareDocumentPosition(semantic) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( semantic.compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( overlay.compareDocumentPosition(fogMask) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect(fogMask.querySelectorAll("rect").length).toBeGreaterThan(0); expect(underlay.querySelector(".source-visual--circle")).toBeTruthy(); expect(overlay.querySelector(".source-visual--text")).toHaveTextContent( "overlay", ); }); it("distinguishes XV sums from directional inequalities", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), constraints: [ { type: "xv", a: 0, b: 1, total: 5 }, { type: "xv", a: 4, b: 5, total: 10 }, { type: "inequality", lesser: 8, greater: 9 }, ], }); const { container } = render( , ); expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent( "5", ); expect( container.querySelector(".constraint-xv--10 text"), ).toHaveTextContent("10"); expect( container.querySelector(".constraint-inequality path"), ).toHaveAttribute("d", "M0.12 -0.17L-0.12 0L0.12 0.17"); expect( container.querySelector(".constraint-inequality-tip"), ).toBeInTheDocument(); }); it("renders false local clues and combines dual-purpose outside labels", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), constraints: [ { type: "maximum", cell: 5, negated: true }, { type: "thermo", cells: [8, 9], negated: true }, { type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 3], negated: true, }, { type: "x-sum", side: "top", index: 0, sum: 42, negated: true, }, { type: "skyscraper", side: "top", index: 0, count: 42, negated: true, }, ], }); const { container } = render( , ); expect(container.querySelector(".sudoku-board-frame")).toHaveClass( "has-outside-clues", ); expect(container.querySelectorAll(".constraint-outside")).toHaveLength(1); expect(container.querySelector(".outside-clue-kinds")).toHaveTextContent( "Σ · ▥", ); expect(container.querySelector(".outside-clue-value")).toHaveTextContent( "≠42", ); expect(container.querySelectorAll(".is-negated")).toHaveLength(4); expect( container.querySelector(".constraint-thermo.is-negated polyline"), ).toBeInTheDocument(); expect( container.querySelector(".constraint-thermo .constraint-false-mark"), ).toHaveTextContent("≠"); const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }); expect(board).toHaveAccessibleDescription( expect.stringContaining("False clue: X-sum 42 from the top, column 1."), ); expect(board).toHaveAccessibleDescription( expect.stringContaining("False clue: maximum at row 2, column 2."), ); }); it("renders Pack 1 cell, global and outside clues with distinct semantics", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), constraints: [ { type: "maximum", cell: 4 }, { type: "minimum", cell: 5, negated: true }, { type: "odd", cell: 9 }, { type: "even", cell: 10 }, { type: "disjoint-groups" }, { type: "little-killer", side: "top", index: 1, direction: "down-right", sum: 7, }, { type: "sandwich", side: "left", index: 2, sum: 3 }, ], }); const { container } = render( , ); const maximumPath = container .querySelector(".constraint-maximum path") ?.getAttribute("d"); const minimumPath = container .querySelector(".constraint-minimum path") ?.getAttribute("d"); expect(maximumPath).toBeTruthy(); expect(minimumPath).toBeTruthy(); expect(minimumPath).not.toBe(maximumPath); expect(container.querySelector(".constraint-minimum")).toHaveClass( "is-negated", ); expect( container.querySelector(".constraint-odd circle"), ).toBeInTheDocument(); expect( container.querySelector(".constraint-even rect"), ).toBeInTheDocument(); expect( container.querySelectorAll(".constraint-disjoint-groups path"), ).toHaveLength(4); expect( container.querySelector( '.constraint-little-killer[data-direction="down-right"] .little-killer-arrow', ), ).toBeInTheDocument(); expect( container.querySelector(".constraint-little-killer text"), ).toHaveTextContent("7"); expect( container.querySelector(".constraint-sandwich .outside-clue-kinds"), ).toHaveTextContent("1⋯N"); expect( container.querySelector(".constraint-sandwich .outside-clue-value"), ).toHaveTextContent("3"); const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }); expect(board).toHaveAccessibleDescription( expect.stringContaining( "Disjoint groups rule: corresponding positions in every standard box contain each digit once.", ), ); expect(board).toHaveAccessibleDescription( expect.stringContaining( "little killer sum 7 from the top, column 2, travelling down right.", ), ); expect(board).toHaveAccessibleDescription( expect.stringContaining( "sandwich sum 3 from the left, row 3, between 1 and 4.", ), ); expect( screen.getByRole("gridcell", { name: "Row 3, column 2, empty" }), ).toHaveAccessibleDescription( expect.stringMatching(/odd digit.*sandwich sum 3/iu), ); }); it("renders Pack 2 lines and regions as distinct accessible clues", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), constraints: [ { type: "between-line", cells: [0, 1, 2], negated: true }, { type: "german-whisper", cells: [4, 5, 6], minimumDifference: 2, }, { type: "region-sum-line", cells: [8, 9, 10] }, { type: "clone", cells: [0, 4], cloneCells: [3, 7] }, { type: "extra-region", cells: [0, 3, 12, 15] }, ], }); const { container } = render( , ); expect(container.querySelector(".constraint-between-line")).toHaveClass( "is-negated", ); expect( container.querySelectorAll(".constraint-between-line > circle"), ).toHaveLength(2); expect( container.querySelector(".constraint-german-whisper polyline"), ).toBeInTheDocument(); expect( container.querySelector(".constraint-region-sum-line polyline"), ).toBeInTheDocument(); expect( container.querySelectorAll( ".constraint-region-sum-line .region-sum-divider", ), ).toHaveLength(1); expect( container.querySelectorAll(".constraint-clone .clone-cell-fill"), ).toHaveLength(4); expect( container.querySelectorAll(".constraint-clone .clone-boundary"), ).toHaveLength(2); expect( container.querySelector(".constraint-clone .clone-link"), ).toBeInTheDocument(); expect( container.querySelectorAll(".constraint-clone .clone-label"), ).toHaveLength(2); expect( container.querySelectorAll( ".constraint-extra-region .extra-region-cell-fill", ), ).toHaveLength(4); const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }); expect(board).toHaveAccessibleDescription( expect.stringContaining( "False clue: between line from row 1, column 1 through row 1, column 2 to row 1, column 3", ), ); expect(board).toHaveAccessibleDescription( expect.stringContaining( "German whisper through row 2, column 1; row 2, column 2; row 2, column 3; adjacent digits differ by at least 2.", ), ); expect(board).toHaveAccessibleDescription( expect.stringContaining( "clone regions pairing row 1, column 1; row 2, column 1 with row 1, column 4; row 2, column 4 in order.", ), ); expect(board).toHaveAccessibleDescription( expect.stringContaining( "Extra region through row 1, column 1; row 1, column 4; row 4, column 1; row 4, column 4; every digit appears exactly once.", ), ); expect( screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), ).toHaveAccessibleDescription( expect.stringMatching(/between line.*clone regions.*extra region/iu), ); }); it("renders Pack 3 pattern lines and indexers with distinct non-colour shapes", () => { const puzzle = normalizePuzzle({ version: 1, size: 6, givens: new Array(36).fill(0), constraints: [ { type: "modular-line", cells: [0, 1, 2], negated: true }, { type: "entropic-line", cells: [6, 7, 8] }, { type: "zipper-line", cells: [12, 13, 14, 15, 16] }, { type: "double-arrow", cells: [18, 19, 20] }, { type: "indexer", kind: "row", cell: 24 }, { type: "indexer", kind: "column", cell: 25 }, { type: "indexer", kind: "box", cell: 26 }, ], }); const { container } = render( , ); expect(container.querySelector(".constraint-modular-line")).toHaveClass( "is-negated", ); expect( container.querySelectorAll(".constraint-modular-line .modular-line-node"), ).toHaveLength(3); expect( container.querySelector( ".constraint-entropic-line .entropic-line-underlay", ), ).toBeInTheDocument(); expect( container.querySelector(".constraint-entropic-line .entropic-line-path"), ).toBeInTheDocument(); expect( container.querySelector(".constraint-zipper-line .zipper-line-centre"), ).toBeInTheDocument(); expect( container.querySelectorAll(".constraint-double-arrow > circle"), ).toHaveLength(2); expect( container.querySelectorAll( ".constraint-double-arrow .double-arrow-chevron", ), ).toHaveLength(2); expect( container.querySelector(".constraint-indexer--row circle"), ).toBeInTheDocument(); expect( container.querySelector(".constraint-indexer--column rect"), ).toBeInTheDocument(); expect( container.querySelector(".constraint-indexer--box rect"), ).toBeInTheDocument(); expect( container.querySelector(".constraint-indexer--row text"), ).toHaveTextContent("R"); expect( container.querySelector(".constraint-indexer--column text"), ).toHaveTextContent("C"); expect( container.querySelector(".constraint-indexer--box text"), ).toHaveTextContent("B"); const board = screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }); expect(board).toHaveAccessibleDescription( expect.stringContaining( "False clue: modular line through row 1, column 1; row 1, column 2; row 1, column 3", ), ); expect(board).toHaveAccessibleDescription( expect.stringContaining( "entropic line through row 2, column 1; row 2, column 2; row 2, column 3", ), ); expect(board).toHaveAccessibleDescription( expect.stringContaining( "row indexer at row 5, column 1; the marker digit selects a row in the same column", ), ); }); it("masks fogged content and clues until a correct entry reveals them", () => { const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1]; const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), solution, constraints: [ { type: "fog", lights: [0], revealRadius: 1 }, { type: "even", cell: 10 }, ], }); const pointerDown = vi.fn(); const keyDown = vi.fn(); const wrongValues = new Array(16).fill(0); wrongValues[5] = 2; wrongValues[10] = 3; const { container, rerender } = render( (16).fill(15)} selected={new Set([10])} highlighted={new Set([10])} conflicts={new Set([10])} activeCell={1} candidateOverlay={{ activeValues: [4], candidateCells: [0, 10], links: [ { id: "hidden-link", kind: "strong", a: { cell: 0, value: 4 }, b: { cell: 10, value: 4 }, contexts: [{ kind: "house", label: "Test" }], }, ], }} guidedHintOverlay={{ focusCells: [10], placements: [{ cell: 10, value: 4 }], }} onCellPointerDown={pointerDown} onCellPointerEnter={vi.fn()} onKeyDown={keyDown} />, ); const hidden = screen.getByRole("gridcell", { name: "Row 3, column 3, obscured by fog", }); expect(hidden).toBeDisabled(); expect(hidden).toHaveClass("is-fogged"); expect(hidden).not.toHaveClass( "is-selected", "has-conflict", "is-hint-focus", ); expect(hidden).toHaveAccessibleDescription( "Obscured by Fog of War. This cell cannot be selected until revealed.", ); expect(hidden).toBeEmptyDOMElement(); expect(container.querySelector(".constraint-even")).not.toBeInTheDocument(); expect( container.querySelectorAll(".fog-constraint-mask rect"), ).toHaveLength(12); expect( container.querySelector(".candidate-link-layer"), ).not.toBeInTheDocument(); expect( screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }), ).not.toHaveAccessibleDescription(expect.stringContaining("even digit")); fireEvent.pointerDown(hidden); expect(pointerDown).not.toHaveBeenCalled(); fireEvent.pointerDown( screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), ); expect(pointerDown).toHaveBeenCalledWith(0, expect.anything()); fireEvent.keyDown( screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }), { key: "ArrowRight" }, ); fireEvent.keyDown( screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }), { key: "a", ctrlKey: true }, ); expect(keyDown).not.toHaveBeenCalled(); const correctValues = new Array(16).fill(0); correctValues[5] = 4; rerender( , ); expect( screen.getByRole("gridcell", { name: "Row 3, column 3, empty" }), ).toHaveAccessibleDescription(expect.stringContaining("even digit")); expect(container.querySelector(".constraint-even")).toBeInTheDocument(); expect( container.querySelectorAll(".fog-constraint-mask rect"), ).toHaveLength(7); }); it("reveals given cells and their Chebyshev neighbourhood under fog", () => { const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1]; const givens = new Array(16).fill(0); givens[15] = 1; const puzzle = normalizePuzzle({ version: 1, size: 4, givens, solution, constraints: [{ type: "fog", lights: [0], revealRadius: 1 }], }); const fogged = foggedCellsForPuzzle(puzzle, givens); expect(fogged.has(10)).toBe(false); expect(fogged.has(15)).toBe(false); expect(fogged.has(9)).toBe(true); }); it("renders candidate filters and strong/weak graph overlays", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), }); const { container } = render( , ); expect( container.querySelectorAll(".is-candidate-highlighted"), ).toHaveLength(2); expect( container.querySelectorAll(".candidate-overlay-link--strong"), ).toHaveLength(1); expect( container.querySelectorAll(".candidate-overlay-link--weak"), ).toHaveLength(1); expect(container.querySelectorAll(".candidate-overlay-node")).toHaveLength( 4, ); }); it("keeps guided effects distinct from focus cells and exposes the preview", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), }); const { container } = render( (13).fill(0)]} selected={new Set()} activeCell={0} guidedHintOverlay={{ focusCells: [0, 1, 2], placements: [{ cell: 0, value: 4 }], eliminations: [{ cell: 1, values: [2, 3] }], }} onCellPointerDown={vi.fn()} onCellPointerEnter={vi.fn()} onKeyDown={vi.fn()} />, ); expect(container.querySelectorAll(".is-hint-focus")).toHaveLength(3); expect(container.querySelectorAll(".is-hint-placement")).toHaveLength(1); expect(container.querySelectorAll(".is-hint-elimination")).toHaveLength(1); expect( container.querySelector(".hint-placement-preview"), ).toHaveTextContent("4"); expect( container.querySelector(".hint-elimination-preview"), ).toHaveTextContent("−23"); expect( screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), ).toHaveAccessibleDescription( expect.stringContaining("hint preview: place 4"), ); expect( screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }), ).toHaveAccessibleDescription( expect.stringContaining("hint preview: remove 2, 3 from the candidates"), ); }); it("exposes real row semantics and detailed per-cell state", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }], }); render( (16).fill(0)} candidates={[0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]} colors={[0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]} selected={new Set([1])} conflicts={new Set([1])} activeCell={1} showCandidates onCellPointerDown={vi.fn()} onCellPointerEnter={vi.fn()} onKeyDown={vi.fn()} />, ); const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }); expect(board).toHaveAttribute("aria-rowcount", "4"); expect(board).toHaveAttribute("aria-colcount", "4"); expect(board).toHaveAccessibleDescription( expect.stringContaining("Home and End move to the first and last cell"), ); expect(screen.getAllByRole("row")).toHaveLength(4); const given = screen.getByRole("gridcell", { name: "Row 1, column 1, 1", }); expect(given).toHaveAttribute("aria-readonly", "true"); expect(given).toHaveAccessibleDescription( expect.stringMatching(/region 1.*given digit.*killer cage 3/i), ); const annotated = screen.getByRole("gridcell", { name: "Row 1, column 2, empty", }); expect(annotated).toHaveAccessibleDescription( expect.stringMatching( /region 1.*corner notes 1.*automatic candidates 1, 2.*colour 2.*conflict/i, ), ); expect(annotated).toHaveAttribute("aria-colindex", "2"); expect(annotated).toHaveAttribute("aria-invalid", "true"); expect(annotated).toHaveAttribute("aria-selected", "true"); }); it("offers off, concise and detailed screen-reader candidate output", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), }); const baseProps = { puzzle, values: puzzle.givens, cornerMarks: [3, ...new Array(15).fill(0)], centerMarks: [12, ...new Array(15).fill(0)], selected: new Set([0]), activeCell: 0, onCellPointerDown: vi.fn(), onCellPointerEnter: vi.fn(), onKeyDown: vi.fn(), } as const; const { rerender } = render( , ); const cell = () => screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }); expect(cell()).toHaveAccessibleDescription( expect.stringContaining("corner notes 1, 2"), ); expect(cell()).toHaveAccessibleDescription( expect.stringContaining("centre notes 3, 4"), ); rerender(); expect(cell()).toHaveAccessibleDescription( expect.stringContaining("2 corner notes"), ); expect(cell()).toHaveAccessibleDescription( expect.stringContaining("2 centre notes"), ); expect(cell()).not.toHaveAccessibleDescription( expect.stringContaining("corner notes 1, 2"), ); rerender(); expect(cell()).not.toHaveAccessibleDescription( expect.stringMatching(/candidate|corner note|centre note/iu), ); }); it("adds a non-colour pattern layer and names the pattern", () => { const puzzle = normalizePuzzle({ version: 1, size: 4, givens: new Array(16).fill(0), }); const { container } = render( (15).fill(0)]} selected={new Set()} activeCell={0} onCellPointerDown={vi.fn()} onCellPointerEnter={vi.fn()} onKeyDown={vi.fn()} />, ); expect( container.querySelector(".has-color-1 .cell-color-pattern"), ).toBeInTheDocument(); expect( screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), ).toHaveAccessibleDescription( expect.stringContaining("colour 1: red, diagonal stripes"), ); }); });