feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+148
View File
@@ -0,0 +1,148 @@
import { useState } from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AidMemoire } from "../../src/components/AidMemoire";
import {
configureAidMemoire,
createAidMemoire,
type AidMemoireState,
} from "../../src/state/aidMemoire";
import type { EntryMode } from "../../src/state/session";
function Harness({
initial,
readOnly = false,
}: {
initial?: AidMemoireState;
readOnly?: boolean;
}) {
const [state, setState] = useState(
initial ?? createAidMemoire(9, { enabled: true }),
);
const [selected, setSelected] = useState(0);
const [active, setActive] = useState(false);
const [mode, setMode] = useState<EntryMode>("value");
return (
<AidMemoire
size={9}
state={state}
selectedCell={selected}
active={active}
readOnly={readOnly}
mode={mode}
onStateChange={setState}
onSelect={(cell) => {
setSelected(cell);
setActive(true);
}}
onMode={setMode}
/>
);
}
describe("aid-mémoire component", () => {
it("renders normal-like cells with keyboard entry and full descriptions", async () => {
const user = userEvent.setup();
render(<Harness />);
const grid = screen.getByRole("grid", {
name: "9 aid-mémoire scratch cells",
});
expect(within(grid).getAllByRole("row")).toHaveLength(1);
const first = within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, empty",
});
expect(first).toHaveAttribute("aria-colindex", "1");
await user.click(first);
await user.keyboard("4");
expect(
within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, value 4",
}),
).toHaveAttribute("aria-selected", "true");
const second = within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 2, label 2, empty",
});
second.focus();
await user.keyboard("c7");
expect(
within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 2, label 2, empty, centre marks 7",
}),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Centre" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("uses row-aware WAI grid navigation without horizontal wrapping", () => {
const initial = configureAidMemoire(
createAidMemoire(9, { enabled: true }),
9,
5,
2,
);
render(<Harness initial={initial} />);
const cells = screen.getAllByRole("gridcell");
const rows = screen.getAllByRole("row");
expect(rows[0]).toHaveAttribute("aria-rowindex", "1");
expect(rows[2]).toHaveAttribute("aria-rowindex", "3");
expect(cells[2]).toHaveAttribute("aria-colindex", "1");
cells[1]!.focus();
fireEvent.keyDown(cells[1]!, { key: "ArrowRight" });
expect(document.activeElement).toBe(cells[1]);
fireEvent.keyDown(cells[1]!, { key: "Home" });
expect(document.activeElement).toBe(cells[0]);
fireEvent.keyDown(cells[0]!, { key: "End", ctrlKey: true });
expect(document.activeElement).toBe(cells[4]);
fireEvent.keyDown(cells[4]!, { key: "PageUp" });
expect(document.activeElement).toBe(cells[0]);
fireEvent.keyDown(cells[0]!, { key: "PageDown" });
expect(document.activeElement).toBe(cells[4]);
});
it("keeps read-only replay cells navigable without allowing edits", () => {
const initial = configureAidMemoire(
createAidMemoire(9, { enabled: true }),
9,
4,
2,
);
render(<Harness initial={initial} readOnly />);
const cells = screen.getAllByRole("gridcell");
cells[0]!.focus();
fireEvent.keyDown(cells[0]!, { key: "ArrowDown" });
expect(document.activeElement).toBe(cells[2]);
fireEvent.keyDown(cells[2]!, { key: "5" });
expect(cells[2]).toHaveAccessibleName("Aid-mémoire cell 3, label 3, empty");
});
it("edits labels, reflows, clears entries and resets with confirmation", async () => {
const user = userEvent.setup();
vi.spyOn(window, "confirm").mockReturnValue(true);
render(<Harness />);
const label = screen.getByLabelText("Selected cell label");
await user.clear(label);
await user.type(label, "Pseudo A");
expect(
screen.getByRole("gridcell", {
name: "Aid-mémoire cell 1, label Pseudo A, empty",
}),
).toBeInTheDocument();
await user.click(screen.getByText("Configure scratch layout"));
await user.click(screen.getByRole("button", { name: "Compact grid" }));
expect(screen.getByLabelText("Columns")).toHaveValue(3);
await user.click(screen.getByRole("button", { name: "Clear entries" }));
await user.click(screen.getByRole("button", { name: "Reset all" }));
expect(window.confirm).toHaveBeenCalledTimes(2);
expect(screen.getByLabelText("Columns")).toHaveValue(9);
expect(screen.getByLabelText("Selected cell label")).toHaveValue("1");
});
});
@@ -0,0 +1,139 @@
import { 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";
class WorkerStub {
addEventListener() {}
postMessage() {}
terminate() {}
}
beforeAll(() => {
vi.stubGlobal("Worker", WorkerStub);
});
function mainDigit(name: string) {
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
"button",
{ name },
);
}
describe("aid-mémoire Workbench integration", () => {
it("routes the shared keypad, undo and redo to a selected scratch cell", async () => {
const user = userEvent.setup();
render(<Workbench />);
const toggle = screen.getByLabelText("Show aid-mémoire scratch cells");
await user.click(toggle);
const grid = screen.getByRole("grid", {
name: "9 aid-mémoire scratch cells",
});
await user.click(
within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, empty",
}),
);
await user.click(mainDigit("5"));
expect(
within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, value 5",
}),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Undo" }));
expect(
within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, empty",
}),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Redo" }));
expect(
within(grid).getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, value 5",
}),
).toBeInTheDocument();
await user.click(toggle);
expect(
screen.queryByRole("grid", { name: "9 aid-mémoire scratch cells" }),
).not.toBeInTheDocument();
await user.click(toggle);
expect(
screen.getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, value 5",
}),
).toBeInTheDocument();
});
it("restores scratch labels and entries from the local Library", async () => {
const user = userEvent.setup();
render(<Workbench />);
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
await user.clear(screen.getByLabelText("Selected cell label"));
await user.type(screen.getByLabelText("Selected cell label"), "Pseudo A");
await user.click(mainDigit("6"));
await user.click(screen.getByRole("button", { name: "Library" }));
await user.click(screen.getByRole("button", { name: "Save current" }));
expect(
await screen.findByText("Current puzzle and progress saved locally."),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(mainDigit("7"));
expect(
screen.getByRole("gridcell", {
name: "Aid-mémoire cell 1, label Pseudo A, value 7",
}),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Library" }));
await user.click(
await screen.findByRole("button", { name: /A first classic/u }),
);
expect(
screen.getByRole("gridcell", {
name: "Aid-mémoire cell 1, label Pseudo A, value 6",
}),
).toBeInTheDocument();
});
it("restores scratch state with a savepoint and reverses that restore", async () => {
const user = userEvent.setup();
render(<Workbench />);
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
await user.click(mainDigit("5"));
await user.click(
screen.getByRole("button", { name: "History & branches" }),
);
await user.type(screen.getByLabelText("Savepoint name"), "Scratch five");
await user.click(screen.getByRole("button", { name: "Save current grid" }));
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(mainDigit("6"));
expect(
screen.getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, value 6",
}),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "History & branches" }),
);
await user.click(screen.getByRole("button", { name: "Restore" }));
expect(
screen.getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, value 5",
}),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Undo" }));
expect(
screen.getByRole("gridcell", {
name: "Aid-mémoire cell 1, label 1, value 6",
}),
).toBeInTheDocument();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { CandidateLab } from "../../src/components/CandidateLab";
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
import type { CandidateOverlay } from "../../src/helpers";
function mask(...values: number[]): number {
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
}
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
describe("candidate lab", () => {
it("inspects selected cells, filters digits and focuses a link overlay", async () => {
const user = userEvent.setup();
const overlays: (CandidateOverlay | undefined)[] = [];
const candidateMasks = new Array<number>(16).fill(0);
candidateMasks[0] = mask(1, 2);
candidateMasks[1] = mask(1, 2);
render(
<CandidateLab
puzzle={puzzle}
values={puzzle.givens}
candidateMasks={candidateMasks}
selectedCells={[0]}
onOverlayChange={(overlay) => overlays.push(overlay)}
/>,
);
const selected = screen.getByRole("heading", {
name: "Selected cells",
}).parentElement!;
expect(within(selected).getByText("r1c1")).toBeInTheDocument();
expect(within(selected).getByText("1 2")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Filter candidate 1" }),
);
await waitFor(() => {
expect(overlays.at(-1)?.activeValues).toEqual([1]);
expect(overlays.at(-1)?.candidateCells).toEqual([0, 1]);
});
const link = screen.getByRole("button", { name: /r1c1\(1\).*r1c2\(1\)/u });
await user.click(link);
await waitFor(() => expect(overlays.at(-1)?.links).toHaveLength(1));
expect(link).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "Show all" }));
expect(link).toHaveAttribute("aria-pressed", "false");
});
it("reveals weak links on demand within a chosen house", async () => {
const user = userEvent.setup();
const candidateMasks = new Array<number>(16).fill(0);
candidateMasks[0] = mask(3);
candidateMasks[1] = mask(3);
candidateMasks[2] = mask(3);
render(
<CandidateLab
puzzle={puzzle}
values={puzzle.givens}
candidateMasks={candidateMasks}
selectedCells={[0]}
/>,
);
await user.selectOptions(
screen.getByLabelText("Candidate house scope"),
"unit:0",
);
await user.click(
screen.getByRole("button", { name: "Filter candidate 3" }),
);
expect(screen.getByText(/No links match this scope/u)).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
expect(
screen.getAllByRole("button", { name: /weak · Row 1/u }),
).toHaveLength(3);
});
it("clears the board overlay when it unmounts", async () => {
const onOverlayChange = vi.fn();
const { unmount } = render(
<CandidateLab
puzzle={puzzle}
values={puzzle.givens}
candidateMasks={new Array<number>(16).fill(0)}
selectedCells={[0]}
onOverlayChange={onOverlayChange}
/>,
);
await waitFor(() => expect(onOverlayChange).toHaveBeenCalled());
unmount();
expect(onOverlayChange).toHaveBeenLastCalledWith(undefined);
});
});
@@ -0,0 +1,104 @@
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";
class WorkerStub {
addEventListener() {}
postMessage() {}
terminate() {}
}
beforeAll(() => {
vi.stubGlobal("Worker", WorkerStub);
});
function digitButton(name: string) {
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
"button",
{ name },
);
}
describe("gameplay history integration", () => {
it("restores savepoints and preserves discarded hypotheses for replay", async () => {
const user = userEvent.setup();
render(<Workbench />);
fireEvent.pointerDown(
screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
{ buttons: 1 },
);
await user.click(digitButton("2"));
await user.click(
screen.getByRole("button", { name: "History & branches" }),
);
expect(
screen.getByRole("heading", {
name: "Branches, savepoints and replay",
}),
).toBeInTheDocument();
await user.type(screen.getByLabelText("Savepoint name"), "After two");
await user.click(screen.getByRole("button", { name: "Save current grid" }));
expect(screen.getByText("After two")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(digitButton("3"));
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "History & branches" }),
);
await user.click(screen.getByRole("button", { name: "Restore" }));
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "History & branches" }),
);
await user.type(screen.getByLabelText("Hypothesis name"), "Try three");
await user.click(screen.getByRole("button", { name: "Start hypothesis" }));
expect(
screen.getByRole("button", { name: "Hypothesis: Try three" }),
).toBeInTheDocument();
await user.click(digitButton("3"));
await user.click(
screen.getByRole("button", { name: "Hypothesis: Try three" }),
);
await user.click(
screen.getByRole("button", { name: "Discard and return" }),
);
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "History & branches" }),
);
expect(screen.getByText("discarded")).toBeInTheDocument();
await user.click(
within(screen.getByRole("list", { name: "Solve history" })).getAllByRole(
"button",
{ name: /Set r1c3 to 3/u },
)[0]!,
);
expect(
screen.getByRole("heading", { name: "Set r1c3 to 3" }),
).toBeInTheDocument();
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Return to live grid" }),
);
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeInTheDocument();
}, 15_000);
});
@@ -59,4 +59,42 @@ describe("Sudoku generator workspace", () => {
);
expect(onRate).toHaveBeenCalledOnce();
});
it("submits a bounded evidence-backed practice target", async () => {
const user = userEvent.setup();
const onGenerate = vi.fn();
render(
<GeneratorWorkspace
busy={false}
onGenerate={onGenerate}
onRate={vi.fn()}
/>,
);
await user.selectOptions(
screen.getByLabelText("Practice technique"),
"x-wing",
);
const attempts = screen.getByLabelText("Mining attempts");
await user.clear(attempts);
await user.type(attempts, "7");
await user.clear(screen.getByLabelText("Seed"));
await user.type(screen.getByLabelText("Seed"), "x-wing-training");
await user.click(screen.getByRole("button", { name: "Generate Classic" }));
expect(onGenerate).toHaveBeenCalledWith(
expect.objectContaining({
requiredTechnique: "x-wing",
maxTechniqueAttempts: 7,
seed: "x-wing-training",
}),
);
await user.selectOptions(screen.getByLabelText("Variant"), "killer");
expect(
within(screen.getByLabelText("Practice technique")).getByRole("option", {
name: "Killer Cage",
}),
).toBeInTheDocument();
});
});
+117
View File
@@ -0,0 +1,117 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { HelpersWorkspace } from "../../src/components/HelpersWorkspace";
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
function mask(...digits: number[]): number {
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
}
describe("Sum Lab helper", () => {
const puzzle = normalizePuzzle(createEmptyPuzzle(9));
it("eliminates and restores combinations without changing the inputs", async () => {
const user = userEvent.setup();
render(
<HelpersWorkspace
size={9}
puzzle={puzzle}
values={puzzle.givens}
selectedCells={[]}
candidateMasks={Array<number>(81).fill(0)}
/>,
);
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
expect(
screen.getByRole("heading", { name: "Generalized Sum Lab" }),
).toBeInTheDocument();
expect(screen.getByText("4", { selector: "strong" })).toBeInTheDocument();
const combination = screen.getByRole("button", {
name: "1 + 9; eliminate",
});
await user.click(combination);
expect(
screen.getByRole("button", { name: "1 + 9; restore" }),
).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByText("3", { selector: ".metric-row strong" }).parentElement,
).toHaveTextContent("3 active of 4");
expect(screen.getByRole("button", { name: "Restore all" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Restore all" }));
expect(
screen.getByRole("button", { name: "1 + 9; eliminate" }),
).toHaveAttribute("aria-pressed", "false");
});
it("filters through selected board-cell candidate masks", async () => {
const user = userEvent.setup();
const candidateMasks = Array<number>(81).fill(0);
candidateMasks[0] = mask(1, 2);
candidateMasks[1] = mask(8, 9);
render(
<HelpersWorkspace
size={9}
puzzle={puzzle}
values={puzzle.givens}
selectedCells={[0, 1]}
candidateMasks={candidateMasks}
/>,
);
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
await user.click(
screen.getByRole("checkbox", {
name: "Use 2 board cells and candidates",
}),
);
const positions = screen.getByRole("list");
expect(within(positions).getByText("r1c1: 1 2")).toBeInTheDocument();
expect(within(positions).getByText("r1c2: 8 9")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "3 + 7; eliminate" }),
).not.toBeInTheDocument();
});
it("resets digit bounds when the puzzle size changes", async () => {
const user = userEvent.setup();
const { rerender } = render(
<HelpersWorkspace
key={9}
size={9}
puzzle={puzzle}
values={puzzle.givens}
selectedCells={[]}
candidateMasks={Array<number>(81).fill(0)}
/>,
);
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
expect(
screen.getByRole("spinbutton", { name: "Maximum digit" }),
).toHaveValue(9);
const smallerPuzzle = normalizePuzzle(createEmptyPuzzle(4));
rerender(
<HelpersWorkspace
key={4}
size={4}
puzzle={smallerPuzzle}
values={smallerPuzzle.givens}
selectedCells={[]}
candidateMasks={Array<number>(16).fill(0)}
/>,
);
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
expect(
screen.getByRole("spinbutton", { name: "Maximum digit" }),
).toHaveValue(4);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,186 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ImportExportDialog } from "../../src/components/ImportExportDialog";
import { createEmptyPuzzle } from "../../src/domain";
import type { PuzzleDefinition } from "../../src/domain/types";
import { fromDomainPuzzle } from "../../src/formats";
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
import { createSession } from "../../src/state/session";
const TEST_AID_MEMOIRE: PortableAidMemoire = {
version: 1,
enabled: true,
columns: 1,
cells: [
{
label: "A",
value: 2,
cornerMarks: [],
centerMarks: [],
color: 3,
},
],
};
function localScl(overrides: Record<string, unknown> = {}) {
const cells = Array.from({ length: 4 }, () =>
Array.from({ length: 4 }, () => ({})),
);
cells[0]![0] = { value: 1 };
return JSON.stringify({
id: "synthetic-local-test",
cells,
metadata: { title: "Synthetic local puzzle" },
...overrides,
});
}
function renderDialog(
options: {
readonly puzzle?: PuzzleDefinition;
readonly aidMemoire?: PortableAidMemoire;
} = {},
) {
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
const onClose = vi.fn();
const onImport = vi.fn();
render(
<ImportExportDialog
open
puzzle={puzzle}
session={createSession(puzzle.givens)}
aidMemoire={options.aidMemoire}
onClose={onClose}
onImport={onImport}
/>,
);
return { onClose, onImport };
}
describe("ImportExportDialog interoperability", () => {
it("reports a detected compatible format before importing it", async () => {
const user = userEvent.setup();
const { onClose, onImport } = renderDialog();
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
target: { value: localScl() },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(
await screen.findByText(
/SudokuPad\/CTC JSON: 4×4, 1 givens and 0 constraints/u,
),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Import locally" }));
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
expect.any(Object),
);
expect(onClose).toHaveBeenCalledOnce();
});
it("reports unsupported constructs and never fetches short IDs", async () => {
const user = userEvent.setup();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
renderDialog();
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
fireEvent.change(input, {
target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
fireEvent.change(input, {
target: { value: "https://sudokupad.app/serverOnly42" },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(
await screen.findByText(/server-hosted SudokuPad puzzle ID/u),
).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("downloads the current puzzle as a self-contained SVG", async () => {
const user = userEvent.setup();
let downloaded: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
downloaded = value as Blob;
return "blob:visual-export-test";
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
renderDialog();
await user.click(screen.getByRole("button", { name: "Download SVG" }));
expect(
await screen.findByText("SVG rendered and downloaded locally."),
).toBeInTheDocument();
expect(downloaded?.type).toContain("image/svg+xml");
expect(await downloaded?.text()).toContain("<svg");
});
it("round-trips aid-mémoire progress through project import and export", async () => {
const user = userEvent.setup();
const puzzle = createEmptyPuzzle(4);
const { onImport } = renderDialog({
puzzle,
aidMemoire: TEST_AID_MEMOIRE,
});
let downloaded: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
downloaded = value as Blob;
return "blob:project-export-test";
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await user.click(
screen.getByRole("button", { name: "Download project JSON" }),
);
expect(
await screen.findByText("Project JSON downloaded locally."),
).toBeInTheDocument();
expect(JSON.parse((await downloaded?.text()) ?? "{}").aidMemoire).toEqual(
TEST_AID_MEMOIRE,
);
const importedDocument = {
...fromDomainPuzzle(puzzle),
aidMemoire: TEST_AID_MEMOIRE,
};
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
target: { value: JSON.stringify(importedDocument) },
});
await user.click(screen.getByRole("button", { name: "Import locally" }));
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4 }),
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
);
});
it("reports an f-puzzles export incompatibility without throwing", async () => {
const user = userEvent.setup();
renderDialog({
puzzle: {
...createEmptyPuzzle(4),
constraints: [{ type: "xv", a: 0, b: 1, total: 5, negated: true }],
},
});
await user.click(
screen.getByRole("button", { name: "Download f-puzzles JSON" }),
);
expect(
await screen.findByText(/cannot preserve an individually false clue/u),
).toBeInTheDocument();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SolveWorkspace } from "../../src/components/SolveWorkspace";
import type { ExactSolveResult } from "../../src/solver";
const limited: ExactSolveResult = {
solutions: [],
count: 0,
truncated: true,
limitReason: "timeout",
nodes: 100,
elapsedMs: 10_000,
};
describe("solve result reporting", () => {
it("does not report zero solutions at a safety limit as unsatisfiable", () => {
const props = {
size: 9,
busy: false,
onLogical: vi.fn(),
onExact: vi.fn(),
onApplyValues: vi.fn(),
onFocusCells: vi.fn(),
};
const { rerender } = render(<SolveWorkspace {...props} exact={limited} />);
expect(
screen.getByText(/solvability is not established/u),
).toBeInTheDocument();
expect(
screen.queryByText("No completion satisfies every supported rule."),
).not.toBeInTheDocument();
rerender(
<SolveWorkspace
{...props}
exact={{ ...limited, truncated: false, limitReason: undefined }}
/>,
);
expect(
screen.getByText("No completion satisfies every supported rule."),
).toBeInTheDocument();
});
});
+175 -1
View File
@@ -1,4 +1,4 @@
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { normalizePuzzle } from "../../src/domain";
import { SudokuBoard } from "../../src/components/SudokuBoard";
@@ -41,4 +41,178 @@ describe("Sudoku board constraint visuals", () => {
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 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("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");
});
});
+144
View File
@@ -63,6 +63,34 @@ describe("Sudoku workbench", () => {
expect(undo).toBeDisabled();
});
it("supports non-wrapping WAI-ARIA grid navigation", () => {
render(<Workbench />);
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("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
const user = userEvent.setup();
render(<Workbench />);
@@ -124,6 +152,30 @@ describe("Sudoku workbench", () => {
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 () => {
@@ -157,6 +209,66 @@ describe("Sudoku workbench", () => {
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(<Workbench />);
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("Clue"));
await user.type(screen.getByLabelText("Clue"), "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 () => {
@@ -206,6 +318,38 @@ describe("Sudoku workbench", () => {
).toBeInTheDocument();
});
it("projects candidate filters and links onto the board only while inspecting", async () => {
const user = userEvent.setup();
const { container } = render(<Workbench />);
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();