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
+111
View File
@@ -116,6 +116,12 @@ test("replaces setter cages and generates a rated variant", async ({
test("uses distinct numbered sum badges and directional inequalities", async ({
page,
}) => {
const runtimeErrors: string[] = [];
page.on("pageerror", (error) => runtimeErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") runtimeErrors.push(message.text());
});
await page.goto("/deep/nested/sudoku/");
const examples = page.getByLabel("Open built-in puzzle");
@@ -136,4 +142,109 @@ test("uses distinct numbered sum badges and directional inequalities", async ({
await expect(
page.locator(".constraint-inequality-tip").first(),
).toBeVisible();
await examples.selectOption("truth-and-lies");
await expect(
page.getByRole("heading", { name: "Truth and lies" }),
).toBeVisible();
await expect(page.locator(".constraint-outside")).toHaveCount(4);
await expect(page.locator(".constraint-quadruple")).toHaveCount(2);
await expect(page.locator(".constraint-maximum")).toHaveCount(2);
await expect(page.locator(".constraint-outside.is-negated")).toHaveCount(2);
await page.setViewportSize({ width: 390, height: 844 });
const outsideBox = await page
.locator(".constraint-outside")
.first()
.boundingBox();
const maximumBox = await page
.locator(".constraint-maximum")
.first()
.boundingBox();
expect(outsideBox?.width ?? 0).toBeGreaterThan(18);
expect(outsideBox?.height ?? 0).toBeGreaterThan(12);
expect(maximumBox?.width ?? 0).toBeGreaterThan(12);
expect(maximumBox?.height ?? 0).toBeGreaterThan(12);
await expect(
page.locator(".constraint-thermo .constraint-false-mark").first(),
).toBeVisible();
expect(runtimeErrors).toEqual([]);
});
test("keeps navigation, scratch work, branches and analysis tools local", async ({
page,
}) => {
const runtimeErrors: string[] = [];
page.on("pageerror", (error) => runtimeErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") runtimeErrors.push(message.text());
});
await page.goto("/deep/nested/sudoku/");
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
const first = grid.getByRole("gridcell", {
name: "Row 1, column 1, 4",
});
await first.focus();
await first.press("End");
await expect(
grid.getByRole("gridcell", { name: /Row 1, column 9,/u }),
).toBeFocused();
await page.keyboard.press("PageDown");
await expect(
grid.getByRole("gridcell", { name: /Row 9, column 9,/u }),
).toBeFocused();
await page.keyboard.press("Control+Home");
await expect(first).toBeFocused();
await page.getByLabel("Show aid-mémoire scratch cells").check();
const scratch = page.getByRole("grid", {
name: "9 aid-mémoire scratch cells",
});
const scratchCell = scratch.getByRole("gridcell").first();
await scratchCell.click();
await page
.getByRole("group", { name: "Digits" })
.getByRole("button", { name: "2", exact: true })
.click();
await expect(scratch.getByRole("gridcell").first()).toHaveAccessibleName(
/value 2/u,
);
await page.getByRole("button", { name: "History & branches" }).click();
await page.getByLabel("Savepoint name").fill("Before test branch");
await page.getByRole("button", { name: "Save current grid" }).click();
await expect(
page.getByText("Before test branch", { exact: true }),
).toBeVisible();
await page.getByLabel("Hypothesis name").fill("Try a two");
await page.getByRole("button", { name: "Start hypothesis" }).click();
const empty = grid.getByRole("gridcell", {
name: "Row 1, column 3, empty",
});
await empty.click();
await page
.getByRole("group", { name: "Digits" })
.getByRole("button", { name: "2", exact: true })
.click();
await expect(
grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeVisible();
await page.getByRole("button", { name: "Hypothesis: Try a two" }).click();
await page.getByRole("button", { name: "Discard and return" }).click();
await expect(empty).toBeVisible();
await page.getByRole("button", { name: "Helpers" }).click();
await page.getByRole("tab", { name: "Sum Lab" }).click();
await expect(
page.getByRole("heading", { name: "Generalized Sum Lab" }),
).toBeVisible();
await page.getByRole("tab", { name: "Candidate links" }).click();
await expect(
page.getByRole("heading", { name: "Links and houses" }),
).toBeVisible();
expect(runtimeErrors).toEqual([]);
});
+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();
+14 -5
View File
@@ -11,11 +11,16 @@ import {
} from "../../src/solver";
describe("bundled original samples", () => {
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => {
const result = solveExact(puzzle, { maxSolutions: 2 });
expect(result.count).toBe(1);
expect(result.truncated).toBe(false);
});
const fastUniquenessSamples = SAMPLE_CATALOG.map(({ puzzle }) => puzzle);
it.each(fastUniquenessSamples)(
"ships $title as a unique puzzle",
(puzzle) => {
const result = solveExact(puzzle, { maxSolutions: 2 });
expect(result.count).toBe(1);
expect(result.truncated).toBe(false);
},
);
it("solves the generated classic with the supported logical techniques", () => {
expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved");
@@ -50,6 +55,10 @@ describe("bundled original samples", () => {
"inequality",
"renban",
"palindrome",
"x-sum",
"skyscraper",
"quadruple",
"maximum",
]),
);
});
+505
View File
@@ -0,0 +1,505 @@
import { describe, expect, it } from "vitest";
import {
candidatesForCell,
classicRegions,
compilePuzzle,
constraintIsFeasible,
findConflicts,
normalizePuzzle,
validatePuzzle,
type PuzzleDefinition,
type VariantConstraint,
} from "../../src/domain";
const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
function puzzle4(constraints: readonly VariantConstraint[]): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints,
};
}
describe("outside and local clue constraints", () => {
it("strictly validates and clones every new bounded shape", () => {
const constraints: VariantConstraint[] = [
{ type: "x-sum", side: "left", index: 0, sum: 6 },
{ type: "skyscraper", side: "bottom", index: 1, count: 2 },
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2, 2, 4] },
{ type: "maximum", cell: 5 },
];
const definition = puzzle4(constraints);
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
const normalized = normalizePuzzle(definition);
expect(normalized.constraints).toEqual(constraints);
expect(normalized.constraints).not.toBe(constraints);
expect(normalized.constraints[2]).not.toBe(constraints[2]);
const malformed = [
{ type: "x-sum", side: "near", index: 0, sum: 6 },
{ type: "x-sum", side: "left", index: 4, sum: 6 },
{ type: "x-sum", side: "left", index: 0, sum: 2 },
{ type: "skyscraper", side: "top", index: 0, count: 0 },
{ type: "quadruple", cells: [0, 1], digits: [1, 2, 3] },
{ type: "quadruple", cells: [0, 2, 4, 6], digits: [1, 2] },
{ type: "maximum", cell: 16 },
{
type: "maximum",
cell: 5,
negated: "yes",
},
];
for (const constraint of malformed) {
expect(validatePuzzle(puzzle4([constraint as never])).valid).toBe(false);
}
});
it("allows bounded impossible numbers only for false numeric clues", () => {
expect(
validatePuzzle(
puzzle4([
{
type: "killer-cage",
cells: [0, 1, 2],
sum: 42,
negated: true,
},
]),
).valid,
).toBe(true);
expect(
validatePuzzle(
puzzle4([{ type: "killer-cage", cells: [0, 1, 2], sum: 42 }]),
).valid,
).toBe(false);
for (const constraint of [
{ type: "x-sum", side: "left", index: 0, sum: 42 },
{ type: "skyscraper", side: "top", index: 0, count: 42 },
] as const) {
expect(
validatePuzzle(puzzle4([{ ...constraint, negated: true }])).valid,
).toBe(true);
expect(validatePuzzle(puzzle4([constraint])).valid).toBe(false);
}
expect(
validatePuzzle(
puzzle4([
{
type: "x-sum",
side: "left",
index: 0,
sum: 257,
negated: true,
},
]),
).valid,
).toBe(false);
});
it("attaches outside clues to their full lines and local clues to every affected cell", () => {
const compiled = compilePuzzle(
normalizePuzzle(
puzzle4([
{ type: "x-sum", side: "top", index: 1, sum: 6 },
{ type: "skyscraper", side: "right", index: 2, count: 2 },
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2] },
{ type: "maximum", cell: 5 },
]),
),
);
expect(compiled.constraintsByCell[13]).toContain(0);
expect(compiled.constraintsByCell[8]).toContain(1);
expect(compiled.constraintsByCell[6]).toContain(2);
for (const cell of [5, 1, 9, 4, 6]) {
expect(compiled.constraintsByCell[cell]).toContain(3);
}
});
it("uses the first digit and exact distinct subset sums to prune X-sums", () => {
const constraint = {
type: "x-sum",
side: "left",
index: 0,
sum: 6,
} as const;
const values = new Array<number>(16).fill(0);
values[1] = 4;
expect(candidatesForCell(puzzle4([constraint]), values, 0)).toEqual([2]);
const reusedOutsidePrefix = new Array<number>(16).fill(0);
reusedOutsidePrefix.splice(0, 4, 3, 1, 0, 2);
expect(constraintIsFeasible(constraint, reusedOutsidePrefix, 4)).toBe(
false,
);
expect(
constraintIsFeasible(
{ type: "x-sum", side: "left", index: 0, sum: 1 },
solved4,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "x-sum", side: "bottom", index: 0, sum: 6 },
solved4,
4,
),
).toBe(true);
const conflict = findConflicts(
puzzle4([{ type: "x-sum", side: "left", index: 0, sum: 6 }]),
solved4,
).find(({ kind }) => kind === "constraint");
expect(conflict?.cells).toEqual([0, 1, 2, 3]);
});
it("enforces exact skyscraper visibility and safely prunes partial lines", () => {
expect(
constraintIsFeasible(
{ type: "skyscraper", side: "left", index: 0, count: 4 },
solved4,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "skyscraper", side: "right", index: 0, count: 1 },
solved4,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "skyscraper", side: "left", index: 0, count: 3 },
solved4,
4,
),
).toBe(false);
expect(
candidatesForCell(
puzzle4([{ type: "skyscraper", side: "left", index: 0, count: 1 }]),
new Array<number>(16).fill(0),
0,
),
).toEqual([4]);
const impossiblePrefix = new Array<number>(16).fill(0);
impossiblePrefix[0] = 2;
expect(
constraintIsFeasible(
{ type: "skyscraper", side: "left", index: 0, count: 4 },
impossiblePrefix,
4,
),
).toBe(false);
expect(
constraintIsFeasible(
{ type: "skyscraper", side: "top", index: 0, count: 8 },
new Array<number>(16 * 16).fill(0),
16,
),
).toBe(true);
});
it("honours quadruple multiplicity in candidates and completed boards", () => {
const constraint = {
type: "quadruple",
cells: [1, 2, 5, 6],
digits: [2, 2, 4],
} as const;
const values = new Array<number>(16).fill(0);
values[1] = 2;
values[2] = 3;
values[5] = 4;
expect(candidatesForCell(puzzle4([constraint]), values, 6)).toEqual([2]);
const wrong = [...values];
wrong[6] = 1;
expect(constraintIsFeasible(constraint, wrong, 4)).toBe(false);
const right = [...values];
right[6] = 2;
expect(constraintIsFeasible(constraint, right, 4)).toBe(true);
});
it("prunes false clues as soon as their positive statement is fixed", () => {
const xSumValues = new Array<number>(16).fill(0);
xSumValues.splice(0, 3, 3, 1, 2);
expect(
constraintIsFeasible(
{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true },
xSumValues,
4,
),
).toBe(false);
expect(
constraintIsFeasible(
{ type: "x-sum", side: "left", index: 0, sum: 7, negated: true },
xSumValues,
4,
),
).toBe(true);
const quadrupleValues = new Array<number>(16).fill(0);
quadrupleValues[0] = 1;
quadrupleValues[1] = 2;
expect(
constraintIsFeasible(
{
type: "quadruple",
cells: [0, 1, 4, 5],
digits: [1, 2],
negated: true,
},
quadrupleValues,
4,
),
).toBe(false);
const maximumValues = new Array<number>(16).fill(0);
maximumValues[5] = 4;
expect(
constraintIsFeasible(
{ type: "maximum", cell: 5, negated: true },
maximumValues,
4,
),
).toBe(false);
});
it("requires a maximum cell to exceed every orthogonal neighbour", () => {
const values = new Array<number>(16).fill(0);
values[1] = 4;
expect(
candidatesForCell(puzzle4([{ type: "maximum", cell: 5 }]), values, 5),
).toEqual([]);
expect(constraintIsFeasible({ type: "maximum", cell: 5 }, solved4, 4)).toBe(
true,
);
expect(constraintIsFeasible({ type: "maximum", cell: 6 }, solved4, 4)).toBe(
false,
);
const conflict = findConflicts(
puzzle4([{ type: "maximum", cell: 6 }]),
solved4,
).find(({ kind }) => kind === "constraint");
expect(conflict?.cells).toEqual([6, 2, 10, 5, 7]);
});
});
describe("false-clue semantics", () => {
it("validates and clones polarity for every local clue type", () => {
const constraints: VariantConstraint[] = [
{
type: "killer-cage",
cells: [0, 6],
sum: 42,
negated: true,
},
{ type: "thermo", cells: [0, 1], negated: true },
{ type: "arrow", bulb: [0], line: [1], negated: true },
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
{ type: "xv", a: 0, b: 1, total: 5, negated: true },
{ type: "inequality", lesser: 0, greater: 1, negated: true },
{ type: "renban", cells: [0, 1], negated: true },
{ type: "palindrome", cells: [0, 1], negated: true },
{
type: "x-sum",
side: "left",
index: 0,
sum: 2,
negated: true,
},
{
type: "skyscraper",
side: "left",
index: 0,
count: 1,
negated: true,
},
{
type: "quadruple",
cells: [0, 1, 4, 5],
digits: [1, 1],
negated: true,
},
{ type: "maximum", cell: 5, negated: true },
];
const definition = puzzle4(constraints);
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
const normalized = normalizePuzzle(definition);
expect(normalized.constraints).toEqual(constraints);
expect(normalized.constraints).not.toBe(constraints);
expect(normalized.constraints[0]).not.toBe(constraints[0]);
});
it("does not turn a false cage's no-repeat rule into an unconditional peer", () => {
const falseCage = compilePuzzle(
normalizePuzzle(
puzzle4([
{
type: "killer-cage",
cells: [0, 6],
sum: 3,
negated: true,
},
]),
),
);
const ordinaryCage = compilePuzzle(
normalizePuzzle(
puzzle4([{ type: "killer-cage", cells: [0, 6], sum: 3 }]),
),
);
expect(falseCage.peers[0]?.has(6)).toBe(false);
expect(ordinaryCage.peers[0]?.has(6)).toBe(true);
const repeated = new Array<number>(16).fill(0);
repeated[0] = 1;
repeated[6] = 1;
expect(
constraintIsFeasible(
{
type: "killer-cage",
cells: [0, 6],
sum: 3,
negated: true,
},
repeated,
4,
),
).toBe(true);
});
it("inverts completed dots, XV, thermometers and cages", () => {
const cases: Array<
readonly [VariantConstraint, readonly number[], boolean]
> = [
[
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
[1, 2],
false,
],
[
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
[1, 3],
true,
],
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 4], false],
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 3], true],
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 2, 3], false],
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 3, 2], true],
[
{ type: "killer-cage", cells: [0, 1], sum: 3, negated: true },
[1, 2],
false,
],
[
{ type: "killer-cage", cells: [0, 1], sum: 4, negated: true },
[1, 2],
true,
],
];
for (const [constraint, relevant, expected] of cases) {
const values = new Array<number>(16).fill(0);
relevant.forEach((value, cell) => {
values[cell] = value;
});
expect(
constraintIsFeasible(constraint, values, 4),
JSON.stringify(constraint),
).toBe(expected);
}
});
it("inverts each completed outside and local clue", () => {
const cases: Array<readonly [VariantConstraint, boolean]> = [
[{ type: "x-sum", side: "left", index: 0, sum: 1, negated: true }, false],
[{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true }, true],
[
{
type: "skyscraper",
side: "left",
index: 0,
count: 4,
negated: true,
},
false,
],
[
{
type: "skyscraper",
side: "left",
index: 0,
count: 3,
negated: true,
},
true,
],
[
{
type: "quadruple",
cells: [1, 2, 5, 6],
digits: [1, 2, 3, 4],
negated: true,
},
false,
],
[
{
type: "quadruple",
cells: [1, 2, 5, 6],
digits: [2, 2, 4],
negated: true,
},
true,
],
[{ type: "maximum", cell: 5, negated: true }, false],
[{ type: "maximum", cell: 6, negated: true }, true],
];
for (const [constraint, expected] of cases) {
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(expected);
}
});
it("keeps partial false clues feasible and prunes a completed true pair", () => {
const falseDot = {
type: "kropki",
a: 0,
b: 1,
kind: "white",
negated: true,
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
expect(constraintIsFeasible(falseDot, partial, 4)).toBe(true);
expect(candidatesForCell(puzzle4([falseDot]), partial, 1)).toEqual([3, 4]);
const falseThermo = {
type: "thermo",
cells: [0, 1, 2],
negated: true,
} as const;
partial[1] = 2;
expect(constraintIsFeasible(falseThermo, partial, 4)).toBe(true);
});
it("reports a true false-clue as the contradiction", () => {
const conflict = findConflicts(
puzzle4([{ type: "xv", a: 0, b: 3, total: 5, negated: true }]),
solved4,
).find(({ kind }) => kind === "constraint");
expect(conflict?.message).toBe("xv clue is true but must be false.");
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
SUDOKU_DOCUMENT_SCHEMA,
cloneSudokuDocument,
normalizeSudokuDocument,
parseSudokuDocument,
serializeSudokuDocument,
type SudokuDocument,
} from "../../src/formats";
import {
aidMemoireToPortable,
createAidMemoire,
enterAidMemoireCell,
labelAidMemoireCell,
MAX_AID_MEMOIRE_CELLS,
} from "../../src/state/aidMemoire";
function baseDocument(): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 9,
givens: Array<number>(81).fill(0),
constraints: [],
};
}
describe("portable aid-mémoire document data", () => {
it("normalizes, serializes and deeply clones scratch cells", () => {
let state = createAidMemoire(9, {
enabled: true,
cellCount: 3,
columns: 2,
});
state = labelAidMemoireCell(state, 0, "Prime");
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
state = enterAidMemoireCell(state, 0, "center", 7, 9);
state = enterAidMemoireCell(state, 0, "color", 4, 9);
const source: SudokuDocument = {
...baseDocument(),
aidMemoire: aidMemoireToPortable(state, 9),
};
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
expect(parsed.aidMemoire).toEqual(source.aidMemoire);
const clone = cloneSudokuDocument(parsed);
(clone.aidMemoire!.cells[0]!.cornerMarks as number[])[0] = 9;
expect(parsed.aidMemoire?.cells[0]?.cornerMarks).toEqual([2]);
});
it("rejects unbounded layouts and out-of-range scratch symbols", () => {
expect(() =>
normalizeSudokuDocument({
...baseDocument(),
aidMemoire: {
version: 1,
enabled: true,
columns: 1,
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
},
}),
).toThrow(/aidMemoire.cells must contain 1 to 36/u);
expect(() =>
normalizeSudokuDocument({
...baseDocument(),
aidMemoire: {
version: 1,
enabled: true,
columns: 1,
cells: [{ label: "Bad", value: 10 }],
},
}),
).toThrow(/value must be an integer from 0 to 9/u);
});
});
+30 -1
View File
@@ -35,7 +35,30 @@ describe("Sudoku Tools document format", () => {
centerMarks: Array.from({ length: 81 }, () => [7, 4]),
colors: Array.from({ length: 81 }, (_, index) => index % 9),
elapsedMs: 12_345,
constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }],
constraints: [
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{
type: "x-sum",
side: "top",
index: 8,
sum: 1111,
negated: true,
},
{
type: "skyscraper",
side: "left",
index: 4,
count: 898,
negated: true,
},
{
type: "quadruple",
cells: [0, 1, 9, 10],
digits: [1, 3, 8],
negated: true,
},
{ type: "maximum", cell: 10, negated: true },
],
};
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
expect(parsed).toEqual({
@@ -59,6 +82,12 @@ describe("Sudoku Tools document format", () => {
values: [2, ...Array<number>(15).fill(0)],
}),
).toThrow(/preserve its given digit/u);
expect(() =>
normalizeSudokuDocument({
...document(Array(81).fill(0)),
constraints: [{ type: "quadruple", cells: [0], digits: [1, 2] }],
}),
).toThrow(/no more digits than clue cells/u);
});
it("round-trips compact share hashes", () => {
+95
View File
@@ -52,6 +52,12 @@ describe("fpuzzles interoperability", () => {
inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }],
renban: [{ lines: [["R7C1", "R7C2"]] }],
palindrome: [{ lines: [["R8C1", "R8C2"]] }],
xsum: [{ cell: "R0C1", value: "15" }],
skyscraper: [{ cell: "R2C10", value: "2" }],
quadruple: [
{ cells: ["R1C1", "R1C2", "R2C1", "R2C2"], values: [1, 3, 8] },
],
maximum: [{ cell: "R2C2" }],
});
expect(puzzle.givens[0]).toBe(5);
expect(puzzle.values?.[1]).toBe(3);
@@ -64,6 +70,10 @@ describe("fpuzzles interoperability", () => {
{ type: "kropki", a: 27, b: 28, kind: "white" },
{ type: "kropki", a: 28, b: 29, kind: "black" },
{ type: "inequality", lesser: 46, greater: 45 },
{ type: "x-sum", side: "top", index: 0, sum: 15 },
{ type: "skyscraper", side: "right", index: 1, count: 2 },
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
{ type: "maximum", cell: 10 },
]),
);
expect(() => normalizePuzzle(toDomainPuzzle(puzzle))).not.toThrow();
@@ -85,6 +95,10 @@ describe("fpuzzles interoperability", () => {
{ type: "inequality", lesser: 45, greater: 46 },
{ type: "renban", cells: [54, 55] },
{ type: "palindrome", cells: [63, 64] },
{ type: "x-sum", side: "left", index: 0, sum: 6 },
{ type: "skyscraper", side: "right", index: 1, count: 2 },
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
{ type: "maximum", cell: 10 },
],
title: "Round trip",
};
@@ -110,6 +124,53 @@ describe("fpuzzles interoperability", () => {
).toThrow(/local-only app cannot fetch/u);
});
it("refuses to weaken false clues during fpuzzles export", () => {
const source: SudokuDocument = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 9,
givens: Array<number>(81).fill(0),
constraints: [
{
type: "x-sum",
side: "top",
index: 0,
sum: 11,
negated: true,
},
],
};
expect(() => exportFpuzzles(source)).toThrow(/individually false clue/u);
});
it("rejects out-of-range outside clues during fpuzzles export", () => {
const source = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 9,
givens: Array<number>(81).fill(0),
} as const;
for (const constraint of [
{ type: "x-sum", side: "top", index: -1, sum: 15 },
{ type: "x-sum", side: "bottom", index: 9, sum: 15 },
{ type: "x-sum", side: "right", index: 0, sum: 0 },
{ type: "x-sum", side: "left", index: 0, sum: 46 },
{ type: "skyscraper", side: "right", index: -1, count: 2 },
{ type: "skyscraper", side: "left", index: 9, count: 2 },
{ type: "skyscraper", side: "bottom", index: 0, count: 0 },
{ type: "skyscraper", side: "top", index: 0, count: 10 },
] as const) {
expect(() =>
exportFpuzzles({
...source,
constraints: [constraint],
}),
).toThrow(/outside the supported range/u);
}
});
it("rejects unsupported generalized dots instead of weakening them", () => {
expect(() =>
parseFpuzzles({
@@ -120,6 +181,40 @@ describe("fpuzzles interoperability", () => {
).toThrow(/Difference-2 dots are not supported/u);
});
it("rejects quadruples spanning more than four cells", () => {
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
quadruple: [
{
cells: ["R1C1", "R1C2", "R2C1", "R2C2", "R3C3"],
values: [1, 2],
},
],
}),
).toThrow(/one to four cells and clue digits/u);
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
quadruple: [{ cells: ["R1C1"], values: [1, 2] }],
}),
).toThrow(/one to four cells and clue digits/u);
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
quadruple: [
{
cells: ["R1C1", "R1C3", "R2C1", "R2C3"],
values: [1, 2],
},
],
}),
).toThrow(/four cells surrounding one grid intersection/u);
});
it("rejects unsupported or unknown rules instead of silently weakening them", () => {
expect(() =>
parseFpuzzles({
+160
View File
@@ -0,0 +1,160 @@
import fc from "fast-check";
import { compressToBase64, compressToEncodedURIComponent } from "lz-string";
import { describe, expect, it } from "vitest";
import {
LzStringOutputLimitError,
decompressFromBase64Bounded,
decompressFromBase64OrUriComponentBounded,
decompressFromEncodedURIComponentBounded,
} from "../../src/formats/boundedLz";
import {
MAX_DOCUMENT_BYTES,
PUZZLE_HASH_PREFIX,
SudokuFormatError,
decodePuzzleHash,
importFpuzzles,
importPuzzle,
importSudokuPad,
parseFpuzzles,
} from "../../src/formats";
function emptyGrid(size = 4) {
return Array.from({ length: size }, () =>
Array.from({ length: size }, () => ({})),
);
}
function expectLimitExceeded(action: () => unknown): void {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(SudokuFormatError);
expect(error).toMatchObject({ code: "LIMIT_EXCEEDED" });
return;
}
throw new Error("Expected the import to reject an oversized payload.");
}
describe("bounded puzzle imports", () => {
it("keeps both LZ-String wire formats compatible for arbitrary text", () => {
fc.assert(
fc.property(fc.string({ maxLength: 512 }), (source) => {
const byteLength = new TextEncoder().encode(source).byteLength;
const limit = Math.max(1, byteLength);
expect(
decompressFromBase64Bounded(compressToBase64(source), limit),
).toBe(source);
expect(
decompressFromEncodedURIComponentBounded(
compressToEncodedURIComponent(source),
limit,
),
).toBe(source);
}),
{ numRuns: 100 },
);
});
it("counts UTF-8 bytes while expanding and stops before oversized output", () => {
const source = "🧩".repeat(1_024);
const compressed = compressToEncodedURIComponent(source);
const exactBytes = new TextEncoder().encode(source).byteLength;
expect(
decompressFromEncodedURIComponentBounded(compressed, exactBytes),
).toBe(source);
expect(() =>
decompressFromEncodedURIComponentBounded(compressed, exactBytes - 1),
).toThrow(LzStringOutputLimitError);
});
it("selects URI-safe payloads before an invalid Base64 interpretation", () => {
const title = "AE(a^'XS2pclC*+Q: ?8IJnG(Fe-nR";
const grid = emptyGrid();
const fpuzzlesJson = JSON.stringify({ size: 4, grid, title });
const fpuzzlesPayload = compressToEncodedURIComponent(fpuzzlesJson);
const sudokuPadJson = JSON.stringify({
id: "local-scl",
cells: grid,
metadata: {
title,
author: "Tester",
rules: "Normal rules apply.",
antiknight: true,
},
cages: [
{
cells: [
[0, 0],
[0, 1],
],
value: "3",
unique: true,
},
],
});
const sudokuPadPayload = compressToEncodedURIComponent(sudokuPadJson);
expect(fpuzzlesPayload).toMatch(/[-$]/u);
expect(sudokuPadPayload).toMatch(/[-$]/u);
expect(
decompressFromBase64Bounded(fpuzzlesPayload, MAX_DOCUMENT_BYTES),
).not.toBe(fpuzzlesJson);
expect(
decompressFromBase64Bounded(sudokuPadPayload, MAX_DOCUMENT_BYTES),
).not.toBe(sudokuPadJson);
expect(
decompressFromBase64OrUriComponentBounded(
fpuzzlesPayload,
MAX_DOCUMENT_BYTES,
),
).toBe(fpuzzlesJson);
expect(importFpuzzles(`fpuzzles${fpuzzlesPayload}`).title).toBe(title);
expect(importSudokuPad(`ctc${sudokuPadPayload}`).title).toBe(title);
});
it("rejects compressed bombs in every LZ-backed import format", () => {
const oversized = " ".repeat(MAX_DOCUMENT_BYTES + 1);
const base64 = compressToBase64(oversized);
const uri = compressToEncodedURIComponent(oversized);
expectLimitExceeded(() => importFpuzzles(`fpuzzles${base64}`));
expectLimitExceeded(() => importSudokuPad(`ctc${base64}`));
expectLimitExceeded(() => decodePuzzleHash(`${PUZZLE_HASH_PREFIX}${uri}`));
});
it("checks raw JSON character and byte limits before parsing", async () => {
const tooManyCharacters = `{${" ".repeat(MAX_DOCUMENT_BYTES)}}`;
await expect(importPuzzle(tooManyCharacters)).rejects.toMatchObject({
code: "LIMIT_EXCEEDED",
});
const tooManyUtf8Bytes = `{"future":"${"é".repeat(
Math.floor(MAX_DOCUMENT_BYTES / 2),
)}"}`;
expect(tooManyUtf8Bytes.length).toBeLessThan(MAX_DOCUMENT_BYTES);
await expect(importPuzzle(tooManyUtf8Bytes)).rejects.toMatchObject({
code: "LIMIT_EXCEEDED",
});
});
it("rejects unknown fpuzzles inequality markers", () => {
for (const value of [undefined, "≤", "left", 1]) {
expect(() =>
parseFpuzzles({
size: 4,
grid: emptyGrid(),
inequality: [{ cells: ["R1C1", "R1C2"], value }],
}),
).toThrow(/inequality\.value must be either/u);
}
expect(
parseFpuzzles({
size: 4,
grid: emptyGrid(),
inequality: [{ cells: ["R1C1", "R1C2"], value: "<" }],
}).constraints,
).toContainEqual({ type: "inequality", lesser: 0, greater: 1 });
});
});
+175
View File
@@ -0,0 +1,175 @@
import { compressToBase64 } from "lz-string";
import { describe, expect, it, vi } from "vitest";
import {
NetworkPuzzleIdError,
UnsupportedPuzzleConstructsError,
importPenpa,
importPuzzle,
importSudokuPad,
parsePenpaText,
parseSudokuPadPuzzle,
} from "../../src/formats";
const penpaText = [
"square,4,4,40,0,1,1,320,320,18,18,1,1,1,1,Title: Tiny,Author: Test,,Normal Sudoku rules apply.,ON,false",
"[0,0,0,0]",
"{}",
JSON.stringify({
number: { 18: [1, 1, "1"], 45: [4, 1, "1"] },
thermo: [[19, 20]],
arrows: [[26, 27]],
}),
JSON.stringify({ number: { 21: [2, 2, "1"] } }),
"[18,1,1,1,5,1,1,1,5,1,1,1,5,1,1,1]",
"[]",
].join("\n");
const compressedPenpa =
"bY/LCsIwEEX3+Yow64sksb6y8wd0YXchi4gRxbTRpEGk9N+lKoIgZ4bLncWBybfikkc1IiAgITFV4rVy+ZoP9bkLXvP63D6wLt0pJs1rnztgE1PjAt+VQ7wUnkrwmbvrNTwm2G5wdCF7Zka5gLCsH1hPbWn2PpHuSS5Jm9FPkiyompE21acOoO7kUxNJGyNXUMJakEsp3vN4UnOohbW/QiVJGwX1NgzMfH+Y/U/LjH0C";
function sclPuzzle() {
const cells = Array.from({ length: 4 }, () =>
Array.from({ length: 4 }, () => ({})),
);
cells[0]![0] = { value: "1" };
cells[0]![1] = {
value: 2,
given: false,
pencilMarks: [4, 3],
centremarks: [2],
};
return {
id: "local-scl",
cells,
metadata: {
title: "Local SCL",
author: "Tester",
rules: "Normal rules apply.",
antiknight: true,
},
cages: [
{
cells: [
[0, 0],
[0, 1],
],
value: "3",
unique: true,
},
],
};
}
describe("local puzzle interoperability", () => {
it("imports bounded SudokuPad/CTC data and retains semantic features", () => {
const parsed = parseSudokuPadPuzzle(sclPuzzle());
expect(parsed.size).toBe(4);
expect(parsed.givens.slice(0, 2)).toEqual([1, 0]);
expect(parsed.values?.slice(0, 2)).toEqual([1, 2]);
expect(parsed.cornerMarks?.[1]).toEqual([3, 4]);
expect(parsed.constraints).toEqual(
expect.arrayContaining([
{
type: "killer-cage",
cells: [0, 1],
sum: 3,
noRepeat: true,
},
{ type: "anti-knight" },
]),
);
});
it("imports a self-contained ctc payload locally", async () => {
const inline = `ctc${compressToBase64(JSON.stringify(sclPuzzle()))}`;
const direct = importSudokuPad(inline);
const detected = await importPuzzle(`https://sudokupad.app/${inline}`);
expect(direct.title).toBe("Local SCL");
expect(detected.format).toBe("sudokupad");
expect(detected.document.givens[0]).toBe(1);
});
it("rejects visual-only SCL constructs instead of weakening them", () => {
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [
{
wayPoints: [
[0.5, 0.5],
[1.5, 1.5],
],
},
],
overlays: [{ text: "?" }],
}),
).toThrow(UnsupportedPuzzleConstructsError);
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [{}],
}),
).toThrow(/visual lines/u);
});
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
const parsed = parsePenpaText(penpaText);
expect(parsed.size).toBe(4);
expect(parsed.title).toBe("Tiny");
expect(parsed.author).toBe("Test");
expect(parsed.givens[0]).toBe(1);
expect(parsed.givens[15]).toBe(4);
expect(parsed.values?.[3]).toBe(2);
expect(parsed.constraints).toEqual([
{ type: "thermo", cells: [1, 2] },
{ type: "arrow", bulb: [4], line: [5] },
]);
});
it("detects an already-decompressed Penpa+ text record", async () => {
const parsed = await importPuzzle(penpaText);
expect(parsed.format).toBe("penpa");
expect(parsed.label).toBe("Penpa+ text");
expect(parsed.document.constraints).toHaveLength(2);
});
it("decompresses a complete Penpa+ long URL without fetching", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const parsed = await importPenpa(
`https://swaroopg92.github.io/penpa-edit/#m=solve&p=${compressedPenpa}`,
);
expect(parsed.givens[0]).toBe(1);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("rejects unsupported Penpa+ drawings with a compatibility list", () => {
const rows = penpaText.split("\n");
rows[3] = JSON.stringify({
number: { 18: [1, 1, "1"] },
line: { "18,19": 3 },
symbol: { 20: [1, "circle_L", 1] },
});
expect(() => parsePenpaText(rows.join("\n"))).toThrow(
/problem line, problem symbol/u,
);
});
it("never resolves a server-only SudokuPad short ID", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
await expect(
importPuzzle("https://sudokupad.app/serverOnly42"),
).rejects.toBeInstanceOf(NetworkPuzzleIdError);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
buildJpegPdf,
renderPuzzleSvg,
type SudokuDocument,
} from "../../src/formats";
function visualDocument(): SudokuDocument {
const values = Array.from({ length: 16 }, () => 0);
values[0] = 1;
values[15] = 4;
const cornerMarks = Array.from({ length: 16 }, () => [] as number[]);
cornerMarks[1] = [2, 3];
const centerMarks = Array.from({ length: 16 }, () => [] as number[]);
centerMarks[2] = [1, 4];
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size: 4,
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
values,
cornerMarks,
centerMarks,
colors: [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
constraints: [
{ type: "diagonal", direction: "main" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [4, 5] },
{ type: "arrow", bulb: [8], line: [9, 10] },
{ type: "kropki", a: 2, b: 3, kind: "white" },
{ type: "xv", a: 6, b: 7, total: 5 },
{ type: "inequality", lesser: 10, greater: 11 },
{ type: "renban", cells: [12, 13] },
{ type: "palindrome", cells: [14, 15] },
{ type: "maximum", cell: 5 },
{ type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 2] },
{ type: "x-sum", side: "top", index: 0, sum: 1 },
{ type: "skyscraper", side: "left", index: 2, count: 2 },
{ type: "anti-knight" },
],
title: 'A < B & "C"',
author: "Synthetic test",
};
}
describe("visual puzzle export", () => {
it("renders a standalone SVG with clues, regions, givens and progress", () => {
const svg = renderPuzzleSvg(visualDocument());
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
expect(parsed.querySelector("parsererror")).toBeNull();
expect(parsed.documentElement.localName).toBe("svg");
expect(svg).toContain("A &lt; B &amp; &quot;C&quot;");
expect(svg).toContain('class="constraint diagonal" x1="58" y1="132"');
expect(svg).toContain('class="constraint cage"');
expect(svg).toContain('class="constraint thermo"');
expect(svg).toContain('class="constraint arrow"');
expect(svg).toContain('class="constraint outside x-sum"');
expect(svg).toContain('class="constraint xv total-5"');
expect(svg).toContain(">5</text>");
expect(svg).not.toContain(">V</text>");
expect(svg).toContain('class="inequality-tip"');
expect(svg).toContain('class="cell-value given"');
expect(svg).toContain('class="cell-value progress"');
expect(svg).toContain('class="corner-note" x=');
expect(svg).toContain('class="center-note" x=');
expect(svg).not.toMatch(/<(?:script|image)|(?:href|src)=/iu);
});
it("can omit all solving progress from the visual", () => {
const svg = renderPuzzleSvg(visualDocument(), {
includeProgress: false,
includeNotes: false,
});
expect(svg).toContain('class="cell-value given"');
expect(svg).not.toContain('class="cell-value progress"');
expect(svg).not.toContain('class="corner-note" x=');
expect(svg).not.toContain('class="center-note" x=');
expect(svg).not.toContain('class="cell-color"');
});
it("builds a bounded single-page PDF around local JPEG bytes", () => {
const pdf = buildJpegPdf(
new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
320,
240,
);
const text = new TextDecoder("latin1").decode(pdf);
expect(text.startsWith("%PDF-1.4")).toBe(true);
expect(text).toContain("/DCTDecode");
expect(text).toContain("xref\n0 6");
expect(text.endsWith("%%EOF\n")).toBe(true);
});
it("rejects invalid or unbounded PDF image input", () => {
expect(() => buildJpegPdf(new Uint8Array([0, 1, 2, 3]), 320, 240)).toThrow(
/JPEG/u,
);
expect(() =>
buildJpegPdf(new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), 99_999, 240),
).toThrow(/dimensions/u);
});
it("validates progress before embedding it in a visual", () => {
const document = visualDocument();
const invalidMarks = Array.from({ length: 16 }, () => [] as number[]);
invalidMarks[0] = [99];
expect(() =>
renderPuzzleSvg({ ...document, cornerMarks: invalidMarks }),
).toThrow(/cornerMarks/u);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
import {
candidateCellsForValues,
candidateUnitIndicesForCells,
candidateValuesFromMask,
deriveCandidateLinks,
inspectCandidateCells,
inspectCandidateHouse,
} 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 inspection", () => {
it("decodes masks and identifies the houses touching selected cells", () => {
expect(candidateValuesFromMask(mask(1, 3, 4), 4)).toEqual([1, 3, 4]);
expect(candidateUnitIndicesForCells(puzzle, [0])).toEqual([0, 1, 2]);
const cells = inspectCandidateCells(
puzzle,
[mask(1, 4), ...new Array<number>(15).fill(0)],
[0],
);
expect(cells).toEqual([
{
cell: 0,
values: [1, 4],
houseLabels: ["Row 1", "Column 1", "Region 1"],
},
]);
});
it("reports missing values, candidate positions and conjugate pairs in a house", () => {
const values = [4, 0, 0, 2, ...new Array<number>(12).fill(0)];
const masks = new Array<number>(16).fill(0);
masks[1] = mask(1, 3);
masks[2] = mask(1, 3);
expect(inspectCandidateHouse(puzzle, values, masks, 0)).toMatchObject({
label: "Row 1",
missingValues: [1, 3],
positions: [
{ value: 1, cells: [1, 2], linkKind: "strong" },
{ value: 3, cells: [1, 2], linkKind: "strong" },
],
});
});
});
describe("candidate link graph", () => {
it("derives strong house and bivalue links and merges shared contexts", () => {
const masks = new Array<number>(16).fill(0);
masks[0] = mask(1, 2);
masks[1] = mask(1, 3);
const links = deriveCandidateLinks(puzzle, masks);
const houseLink = links.find(
({ a, b }) =>
a.cell === 0 && a.value === 1 && b.cell === 1 && b.value === 1,
);
expect(houseLink).toMatchObject({ kind: "strong" });
expect(houseLink?.contexts.map(({ label }) => label)).toEqual([
"Row 1",
"Region 1",
]);
expect(links).toContainEqual(
expect.objectContaining({
kind: "strong",
a: { cell: 0, value: 1 },
b: { cell: 0, value: 2 },
}),
);
});
it("keeps larger candidate groups weak and supports digit and house scopes", () => {
const masks = new Array<number>(16).fill(0);
masks[0] = mask(2, 4);
masks[1] = mask(2);
masks[2] = mask(2);
masks[3] = mask(3, 4);
const links = deriveCandidateLinks(puzzle, masks, {
unitIndices: [0],
values: [2],
});
expect(links).toHaveLength(3);
expect(links.every(({ kind }) => kind === "weak")).toBe(true);
expect(links.every(({ a, b }) => a.value === 2 && b.value === 2)).toBe(
true,
);
expect(candidateCellsForValues(masks, [4])).toEqual([0, 3]);
});
});
+146
View File
@@ -0,0 +1,146 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import { analyzeSumLab, sumCombinationKey } from "../../src/helpers/sumLab";
function mask(...digits: number[]): number {
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
}
describe("generalized sum lab", () => {
it("enumerates deterministic, sorted combinations", () => {
const result = analyzeSumLab({ cellCount: 2, target: 10 });
expect(result.combinations.map(({ digits }) => digits)).toEqual([
[1, 9],
[2, 8],
[3, 7],
[4, 6],
]);
expect(result.truncated).toBe(false);
});
it("supports a digit range, repeats, required and excluded digits", () => {
const result = analyzeSumLab({
cellCount: 3,
target: 15,
minimumDigit: 3,
maximumDigit: 7,
allowRepeats: true,
requiredDigits: [3],
excludedDigits: [4],
});
expect(result.combinations.map(({ digits }) => digits)).toEqual([
[3, 5, 7],
[3, 6, 6],
]);
});
it("uses per-cell candidate masks and derives positional possibilities", () => {
const result = analyzeSumLab({
cellCount: 2,
target: 10,
candidateMasks: [mask(1, 2), mask(8, 9)],
});
expect(result.combinations.map(({ digits }) => digits)).toEqual([
[1, 9],
[2, 8],
]);
expect(result.assignments).toEqual([
[1, 9],
[2, 8],
]);
expect(result.possibleByCell).toEqual([
[1, 2],
[8, 9],
]);
});
it("removes toggled combinations from every live deduction", () => {
const eliminatedKeys = new Set([sumCombinationKey([1, 9])]);
const result = analyzeSumLab({
cellCount: 2,
target: 10,
eliminatedKeys,
});
expect(result.combinations).toHaveLength(4);
expect(result.eliminatedCombinations.map(({ digits }) => digits)).toEqual([
[1, 9],
]);
expect(result.activeCombinations.map(({ digits }) => digits)).toEqual([
[2, 8],
[3, 7],
[4, 6],
]);
expect(result.possibleDigits).not.toContain(1);
expect(result.possibleDigits).not.toContain(9);
});
it("reports which independent safety bound stopped an analysis", () => {
const result = analyzeSumLab({
cellCount: 8,
target: 36,
allowRepeats: true,
maxSearchNodes: 1,
});
expect(result.truncated).toBe(true);
expect(result.truncationReason).toBe("search");
expect(result.exploredNodes).toBe(2);
});
it("validates positional candidates and contradictory digit filters", () => {
expect(() =>
analyzeSumLab({ cellCount: 2, target: 3, candidateMasks: [mask(1)] }),
).toThrow(/exactly 2/u);
expect(
analyzeSumLab({
cellCount: 2,
target: 3,
requiredDigits: [1],
excludedDigits: [1],
}).combinations,
).toEqual([]);
});
it("preserves combination invariants over generated inputs", () => {
fc.assert(
fc.property(
fc.integer({ min: 3, max: 12 }),
fc.integer({ min: 1, max: 6 }),
fc.integer({ min: 0, max: 72 }),
fc.boolean(),
(maximumDigit, cellCount, target, allowRepeats) => {
if (target > maximumDigit * cellCount) return;
const result = analyzeSumLab({
cellCount,
target,
maximumDigit,
allowRepeats,
});
const keys = new Set<string>();
for (const combination of result.combinations) {
expect(combination.digits).toHaveLength(cellCount);
expect(
combination.digits.reduce((total, digit) => total + digit, 0),
).toBe(target);
expect(combination.digits).toEqual(
[...combination.digits].sort((left, right) => left - right),
);
if (!allowRepeats) {
expect(new Set(combination.digits)).toHaveProperty(
"size",
cellCount,
);
}
keys.add(combination.key);
}
expect(keys.size).toBe(result.combinations.length);
},
),
{ numRuns: 100 },
);
});
});
+22
View File
@@ -38,6 +38,28 @@ describe("logical solver", () => {
expect(result.steps).toHaveLength(1);
});
it("does not apply positive cage reductions to a false cage", () => {
const result = solveLogically({
version: 1,
size: 4,
givens: Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [
{
type: "killer-cage",
cells: [0, 1],
sum: 3,
noRepeat: false,
negated: true,
},
],
});
expect(result.status).toBe("stuck");
expect(result.steps).toEqual([]);
expect(result.candidates[0]).toEqual([1, 2, 3, 4]);
});
it("returns bounded killer combinations", () => {
expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
+29
View File
@@ -196,6 +196,35 @@ describe("variant generator", () => {
expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/i);
});
it("mines deterministically for a requested practice technique", () => {
const options = {
variant: "classic",
size: 9,
seed: "mine-practice",
targetDifficulty: "hard",
requiredTechnique: "naked-pair",
maxTechniqueAttempts: 6,
maxChecks: 72,
} as const;
const generated = generateVariant(options);
expect(generated.requestedTechnique).toBe("naked-pair");
expect(generated.generationAttempts).toBe(4);
expect(generated.difficulty.techniqueCounts["naked-pair"]).toBeGreaterThan(
0,
);
expect(generateVariant(options)).toEqual(generated);
});
it("keeps Killer-cage practice tied to the Killer generator", () => {
expect(() =>
generateVariant({
variant: "classic",
size: 4,
requiredTechnique: "killer-cage",
}),
).toThrow(/requires the Killer generator/i);
});
it("keeps the public variant ID type exhaustive", () => {
const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id);
expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length);
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import {
aidMemoireCellDescription,
aidMemoireFromPortable,
aidMemoireToPortable,
clearAidMemoireEntries,
compactAidMemoireColumns,
configureAidMemoire,
createAidMemoire,
enterAidMemoireCell,
eraseAidMemoireCell,
labelAidMemoireCell,
MAX_AID_MEMOIRE_CELLS,
normalizePortableAidMemoire,
resetAidMemoire,
} from "../../src/state/aidMemoire";
import { maskValues } from "../../src/state/session";
describe("aid-mémoire state", () => {
it("creates a bounded row and preserves existing cells while reflowing", () => {
let state = createAidMemoire(9, { enabled: true });
expect(state.cells).toHaveLength(9);
expect(state.columns).toBe(9);
expect(state.cells.map((cell) => cell.label)).toEqual([
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
]);
state = labelAidMemoireCell(state, 0, "Prime");
state = enterAidMemoireCell(state, 0, "value", 7, 9);
state = configureAidMemoire(state, 9, 12, 4);
expect(state.cells).toHaveLength(12);
expect(state.columns).toBe(4);
expect(state.cells[0]).toMatchObject({ label: "Prime", value: 7 });
expect(state.cells[11]?.label).toBe("12");
state = configureAidMemoire(state, 9, 1_000, 1_000);
expect(state.cells).toHaveLength(MAX_AID_MEMOIRE_CELLS);
expect(state.columns).toBe(MAX_AID_MEMOIRE_CELLS);
state = configureAidMemoire(state, 9, Number.NaN, Number.NaN);
expect(state.cells).toHaveLength(9);
expect(state.columns).toBe(1);
state = configureAidMemoire(state, 9, 3.9, 2.8);
expect(state.cells).toHaveLength(3);
expect(state.columns).toBe(2);
expect(compactAidMemoireColumns(9)).toBe(3);
expect(compactAidMemoireColumns(Number.NaN)).toBe(1);
});
it("edits values, both mark types and colours like normal cells", () => {
let state = createAidMemoire(9, { enabled: true, cellCount: 1 });
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
state = enterAidMemoireCell(state, 0, "corner", 8, 9);
state = enterAidMemoireCell(state, 0, "center", 4, 9);
state = enterAidMemoireCell(state, 0, "color", 6, 9);
expect(maskValues(state.cells[0]!.cornerMarks, 9)).toEqual([2, 8]);
expect(maskValues(state.cells[0]!.centerMarks, 9)).toEqual([4]);
expect(state.cells[0]?.color).toBe(6);
state = enterAidMemoireCell(state, 0, "value", 5, 9);
expect(state.cells[0]).toMatchObject({
value: 5,
cornerMarks: 0,
centerMarks: 0,
color: 6,
});
expect(enterAidMemoireCell(state, 0, "center", 3, 9)).toBe(state);
expect(enterAidMemoireCell(state, 0, "value", 2.5, 9)).toBe(state);
state = eraseAidMemoireCell(state, 0, "value");
state = enterAidMemoireCell(state, 0, "color", 6, 9);
expect(state.cells[0]).toMatchObject({ value: 0, color: 0 });
});
it("clears entries without losing layout or labels and can reset all", () => {
let state = createAidMemoire(6, {
enabled: true,
cellCount: 4,
columns: 2,
});
state = labelAidMemoireCell(state, 0, "Used");
state = enterAidMemoireCell(state, 0, "value", 3, 6);
const cleared = clearAidMemoireEntries(state);
expect(cleared).toMatchObject({ enabled: true, columns: 2 });
expect(cleared.cells[0]).toMatchObject({ label: "Used", value: 0 });
const reset = resetAidMemoire(cleared, 6);
expect(reset).toMatchObject({ enabled: true, columns: 6 });
expect(reset.cells).toHaveLength(6);
expect(reset.cells[0]?.label).toBe("1");
});
it("round-trips a stable portable representation", () => {
const portable = normalizePortableAidMemoire(
{
version: 1,
enabled: true,
columns: 2,
cells: [
{
label: "Primes",
value: 0,
cornerMarks: [7, 2, 2],
centerMarks: [5, 3],
color: 4,
},
{ label: "Used", value: 8, color: 0 },
],
},
9,
);
expect(portable.cells[0]).toMatchObject({
cornerMarks: [2, 7],
centerMarks: [3, 5],
});
expect(
aidMemoireToPortable(aidMemoireFromPortable(portable, 9), 9),
).toEqual(portable);
});
it("rejects unsafe portable layouts and provides complete cell narration", () => {
expect(() =>
normalizePortableAidMemoire(
{
version: 1,
enabled: true,
columns: 1,
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
},
9,
),
).toThrow(/1 to 36/u);
let state = createAidMemoire(9, { cellCount: 1 });
state = labelAidMemoireCell(state, 0, "Odd candidates");
state = enterAidMemoireCell(state, 0, "corner", 1, 9);
state = enterAidMemoireCell(state, 0, "center", 7, 9);
state = enterAidMemoireCell(state, 0, "color", 3, 9);
expect(aidMemoireCellDescription(state.cells[0]!, 0, 9)).toBe(
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3",
);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { navigateGridCell } from "../../src/state/gridNavigation";
describe("accessible grid navigation", () => {
it("moves with arrows without wrapping at edges", () => {
expect(navigateGridCell(4, 3, "ArrowRight")).toBe(5);
expect(navigateGridCell(5, 3, "ArrowRight")).toBe(5);
expect(navigateGridCell(3, 3, "ArrowLeft")).toBe(3);
expect(navigateGridCell(1, 3, "ArrowUp")).toBe(1);
expect(navigateGridCell(7, 3, "ArrowDown")).toBe(7);
});
it("supports row, grid and column-edge shortcuts", () => {
expect(navigateGridCell(4, 3, "Home")).toBe(3);
expect(navigateGridCell(4, 3, "End")).toBe(5);
expect(navigateGridCell(4, 3, "Home", true)).toBe(0);
expect(navigateGridCell(4, 3, "End", true)).toBe(8);
expect(navigateGridCell(4, 3, "PageUp")).toBe(1);
expect(navigateGridCell(4, 3, "PageDown")).toBe(7);
});
it("ignores non-navigation keys and rejects unsafe inputs", () => {
expect(navigateGridCell(0, 9, "Enter")).toBeNull();
expect(() => navigateGridCell(-1, 9, "Home")).toThrow(/outside/i);
expect(() => navigateGridCell(0, 0, "Home")).toThrow(/size/i);
});
});
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
activeHypothesis,
beginHypothesis,
createGameplayHistory,
createSavepoint,
deleteSavepoint,
describeGameplayChange,
finishHypothesis,
gameplayMoment,
recordGameplayMoment,
restoreSavepoint,
sessionFromGameplayState,
} from "../../src/state/playHistory";
import {
createAidMemoire,
enterAidMemoireCell,
} from "../../src/state/aidMemoire";
import { createSession } from "../../src/state/session";
describe("gameplay history", () => {
it("records immutable states with useful move labels", () => {
const initial = createSession([0, 0, 0, 0]);
const changed = { ...initial, values: [0, 3, 0, 0] };
const label = describeGameplayChange(initial, changed, 2);
const history = recordGameplayMoment(
createGameplayHistory(initial),
changed,
label,
);
expect(label).toBe("Set r1c2 to 3");
expect(history.moments.map((moment) => moment.label)).toEqual([
"Puzzle opened",
"Set r1c2 to 3",
]);
changed.values[1] = 4;
expect(history.moments[1]!.state.values[1]).toBe(3);
});
it("creates, restores and deletes full named savepoints", () => {
const session = createSession([0, 0, 0, 0]);
session.values[1] = 2;
session.centerMarks[2] = 5;
session.colors[3] = 4;
session.elapsedSeconds = 75;
let history = createSavepoint(
createGameplayHistory(session),
session,
"Before chain",
);
session.values[1] = 4;
session.centerMarks[2] = 0;
const transition = restoreSavepoint(history, history.savepoints[0]!.id);
const restored = sessionFromGameplayState(transition.state);
expect(restored.values[1]).toBe(2);
expect(restored.centerMarks[2]).toBe(5);
expect(restored.colors[3]).toBe(4);
expect(restored.elapsedSeconds).toBe(75);
expect(transition.history.moments.at(-1)?.label).toBe(
"Restored “Before chain”",
);
history = deleteSavepoint(
transition.history,
transition.history.savepoints[0]!.id,
);
expect(history.savepoints).toHaveLength(0);
});
it("captures aid-mémoire state in moments and savepoints", () => {
const session = createSession([0, 0, 0, 0]);
let aidMemoire = createAidMemoire(2, {
enabled: true,
cellCount: 2,
});
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 1, 2);
const history = createSavepoint(
createGameplayHistory(session, aidMemoire),
session,
"Scratch one",
aidMemoire,
);
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 2, 2);
const restored = restoreSavepoint(history, history.savepoints[0]!.id);
expect(restored.state.aidMemoire?.cells[0]?.value).toBe(1);
expect(history.moments[0]?.state.aidMemoire?.cells[0]?.value).toBe(1);
expect(aidMemoire.cells[0]?.value).toBe(2);
});
it("keeps discarded hypotheses available while restoring their exact base", () => {
const session = createSession([0, 0, 0, 0]);
session.values[0] = 1;
let transition = beginHypothesis(
createGameplayHistory(session),
session,
"Try a 2",
);
expect(activeHypothesis(transition.history)?.name).toBe("Try a 2");
const branchSession = sessionFromGameplayState(transition.state);
branchSession.values[1] = 2;
transition = {
history: recordGameplayMoment(
transition.history,
branchSession,
"Set r1c2 to 2",
),
state: transition.state,
};
const result = finishHypothesis(
transition.history,
branchSession,
"discard",
);
expect(result.state.values).toEqual([1, 0, 0, 0]);
expect(result.history.branches[0]?.status).toBe("discarded");
expect(
result.history.moments.some(
(moment) =>
moment.branchId === result.history.branches[0]!.id &&
moment.state.values[1] === 2,
),
).toBe(true);
expect(result.history.moments.at(-1)?.label).toBe(
"Discarded hypothesis “Try a 2”",
);
});
it("can branch from a replayed moment and merge accepted changes", () => {
const initial = createSession([0, 0, 0, 0]);
const first = { ...initial, values: [1, 0, 0, 0] };
const second = { ...initial, values: [1, 2, 0, 0] };
let history = recordGameplayMoment(
createGameplayHistory(initial),
first,
"First move",
);
const replayId = history.currentMomentId;
history = recordGameplayMoment(history, second, "Second move");
let transition = beginHypothesis(history, second, "Alternative", replayId);
expect(transition.state.values).toEqual([1, 0, 0, 0]);
const alternative = sessionFromGameplayState(transition.state);
alternative.values[2] = 3;
transition = {
history: recordGameplayMoment(
transition.history,
alternative,
"Alternative move",
),
state: transition.state,
};
const kept = finishHypothesis(transition.history, alternative, "keep");
expect(kept.state.values).toEqual([1, 0, 3, 0]);
expect(kept.history.branches.at(-1)?.status).toBe("kept");
expect(
gameplayMoment(kept.history, kept.history.currentMomentId)?.label,
).toBe("Kept hypothesis “Alternative”");
});
it("rejects blank checkpoint and hypothesis names", () => {
const session = createSession([0, 0, 0, 0]);
const history = createGameplayHistory(session);
expect(() => createSavepoint(history, session, " ")).toThrow(
"Savepoint name cannot be empty",
);
expect(() => beginHypothesis(history, session, " ")).toThrow(
"Hypothesis name cannot be empty",
);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
import {
ProjectLibrary,
createProjectRecord,
normalizeProjectRecord,
} from "../../src/storage";
import {
aidMemoireToPortable,
createAidMemoire,
enterAidMemoireCell,
} from "../../src/state/aidMemoire";
const puzzle = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1 as const,
size: 9,
givens: Array<number>(81).fill(0),
constraints: [],
};
describe("aid-mémoire Library progress", () => {
it("persists bounded scratch state in an independent local clone", async () => {
let aidMemoire = createAidMemoire(9, {
enabled: true,
cellCount: 4,
columns: 2,
});
aidMemoire = enterAidMemoireCell(aidMemoire, 2, "value", 6, 9);
const record = createProjectRecord(puzzle, {
id: "aid-progress",
now: 10,
progress: {
version: 1,
values: Array<number>(81).fill(0),
aidMemoire: aidMemoireToPortable(aidMemoire, 9),
},
});
const library = new ProjectLibrary({ indexedDB: null });
await library.put(record);
const loaded = await library.get("aid-progress");
expect(loaded?.progress?.aidMemoire).toEqual(record.progress?.aidMemoire);
(loaded!.progress!.aidMemoire!.cells[2]!.centerMarks as number[]).push(4);
expect(
(await library.get("aid-progress"))?.progress?.aidMemoire?.cells[2]
?.centerMarks,
).toEqual([]);
});
it("rejects malformed scratch progress at the storage boundary", () => {
const record = createProjectRecord(puzzle, {
id: "bad-aid-progress",
now: 1,
});
expect(() =>
normalizeProjectRecord({
...record,
progress: {
version: 1,
values: Array<number>(81).fill(0),
aidMemoire: {
version: 1,
enabled: true,
columns: 2,
cells: [{}],
},
},
}),
).toThrow(/columns must be an integer from 1 to 1/u);
});
});