814 lines
26 KiB
TypeScript
814 lines
26 KiB
TypeScript
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,
|
||
size: 4,
|
||
givens: new Array<number>(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(
|
||
<SudokuBoard
|
||
puzzle={puzzle}
|
||
values={puzzle.givens}
|
||
selected={new Set([0])}
|
||
activeCell={0}
|
||
onCellPointerDown={vi.fn()}
|
||
onCellPointerEnter={vi.fn()}
|
||
onKeyDown={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
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<number>(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(
|
||
<SudokuBoard
|
||
puzzle={puzzle}
|
||
values={puzzle.givens}
|
||
selected={new Set([0])}
|
||
activeCell={0}
|
||
onCellPointerDown={vi.fn()}
|
||
onCellPointerEnter={vi.fn()}
|
||
onKeyDown={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
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<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,
|
||
size: 4,
|
||
givens: new Array<number>(16).fill(0),
|
||
});
|
||
const { container } = render(
|
||
<SudokuBoard
|
||
puzzle={puzzle}
|
||
values={puzzle.givens}
|
||
selected={new Set([0])}
|
||
activeCell={0}
|
||
candidateOverlay={{
|
||
activeValues: [1],
|
||
candidateCells: [0, 1],
|
||
links: [
|
||
{
|
||
id: "strong",
|
||
kind: "strong",
|
||
a: { cell: 0, value: 1 },
|
||
b: { cell: 1, value: 1 },
|
||
contexts: [{ kind: "house", label: "Row 1" }],
|
||
},
|
||
{
|
||
id: "weak",
|
||
kind: "weak",
|
||
a: { cell: 0, value: 2 },
|
||
b: { cell: 0, value: 3 },
|
||
contexts: [{ kind: "cell", label: "Cell r1c1" }],
|
||
},
|
||
],
|
||
}}
|
||
onCellPointerDown={vi.fn()}
|
||
onCellPointerEnter={vi.fn()}
|
||
onKeyDown={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
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<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,
|
||
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(
|
||
<SudokuBoard
|
||
puzzle={puzzle}
|
||
values={[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||
cornerMarks={[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||
centerMarks={new Array<number>(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<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"),
|
||
);
|
||
});
|
||
});
|