feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+596 -1
View File
@@ -1,9 +1,77 @@
import { render, screen } from "@testing-library/react";
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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
visuals={[
{
type: "circle",
layer: "underlay",
center: { kind: "cell", cell: 0 },
radius: 0.3,
style: { fill: "#abcdef" },
},
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 15 },
text: "overlay",
style: { fill: "#123456" },
},
]}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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,
@@ -112,6 +180,412 @@ describe("Sudoku board constraint visuals", () => {
);
});
it("renders Pack 1 cell, global and outside clues with distinct semantics", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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<number>(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<number>(16).fill(0);
wrongValues[5] = 2;
wrongValues[10] = 3;
const { container, rerender } = render(
<SudokuBoard
puzzle={puzzle}
values={wrongValues}
candidates={new Array<number>(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<number>(16).fill(0);
correctValues[5] = 4;
rerender(
<SudokuBoard
puzzle={puzzle}
values={correctValues}
selected={new Set()}
activeCell={1}
onCellPointerDown={pointerDown}
onCellPointerEnter={vi.fn()}
onKeyDown={keyDown}
/>,
);
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<number>(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,
@@ -164,6 +638,51 @@ describe("Sudoku board constraint visuals", () => {
);
});
it("keeps guided effects distinct from focus cells and exposes the preview", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
centerMarks={[0, 15, 15, ...new Array<number>(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,
@@ -215,4 +734,80 @@ describe("Sudoku board constraint visuals", () => {
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<number>(16).fill(0),
});
const baseProps = {
puzzle,
values: puzzle.givens,
cornerMarks: [3, ...new Array<number>(15).fill(0)],
centerMarks: [12, ...new Array<number>(15).fill(0)],
selected: new Set([0]),
activeCell: 0,
onCellPointerDown: vi.fn(),
onCellPointerEnter: vi.fn(),
onKeyDown: vi.fn(),
} as const;
const { rerender } = render(
<SudokuBoard {...baseProps} candidateVerbosity="detailed" />,
);
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(<SudokuBoard {...baseProps} candidateVerbosity="concise" />);
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(<SudokuBoard {...baseProps} candidateVerbosity="off" />);
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<number>(16).fill(0),
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
colors={[1, ...new Array<number>(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"),
);
});
});