feat: launch local-first Sudoku workbench

This commit is contained in:
2026-08-30 14:14:11 +02:00
commit 659640b231
97 changed files with 19111 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
test("loads standalone and keeps the core play workflow 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/");
await expect(
page.getByRole("heading", { name: "A first classic" }),
).toBeVisible();
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
await expect(grid).toBeVisible();
await expect(grid.getByRole("gridcell")).toHaveCount(81);
await grid.getByRole("gridcell", { name: "Row 1, column 3, 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: "Undo" }).click();
await expect(
grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
).toBeVisible();
await page.getByRole("button", { name: "Helpers" }).click();
await expect(
page.getByRole("heading", { name: "Sudoku helpers" }),
).toBeVisible();
await expect(
page.getByRole("tab", { name: "Killer combinations" }),
).toHaveAttribute("aria-selected", "true");
await expect(page.locator(".helper-result")).toContainText("4 combinations");
await page.getByRole("button", { name: "Import / export" }).click();
await expect(
page.getByRole("heading", { name: "Import and export" }),
).toBeVisible();
await expect(
page.getByText(/Server-only short IDs are never fetched\./u),
).toBeVisible();
await expect(
page.getByText(/Share links are self-contained\./u),
).toBeVisible();
expect(runtimeErrors).toEqual([]);
});
+130
View File
@@ -0,0 +1,130 @@
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);
});
describe("Sudoku workbench", () => {
it("opens with the local classic example rendered as an accessible grid", () => {
render(<Workbench />);
expect(
screen.getByRole("heading", { name: "A first classic" }),
).toBeInTheDocument();
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
expect(within(grid).getAllByRole("gridcell")).toHaveLength(81);
expect(
within(grid).getByRole("gridcell", {
name: "Row 1, column 1, 4",
}),
).toHaveClass("is-given");
expect(
within(grid).getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).toBeInTheDocument();
});
it("enters a digit in an empty cell and restores it with undo", async () => {
const user = userEvent.setup();
render(<Workbench />);
const emptyCell = screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
});
fireEvent.pointerDown(emptyCell, { buttons: 1 });
await user.click(
within(screen.getByRole("group", { name: "Digits" })).getByRole(
"button",
{ name: "2" },
),
);
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeInTheDocument();
const undo = screen.getByRole("button", { name: "Undo" });
expect(undo).toBeEnabled();
await user.click(undo);
expect(
screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).toBeInTheDocument();
expect(undo).toBeDisabled();
});
it("switches between setting, solving, playing and helper workspaces", async () => {
const user = userEvent.setup();
render(<Workbench />);
await user.click(screen.getByRole("button", { name: "Set" }));
expect(
screen.getByRole("heading", { name: "Set a puzzle" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Enter givens" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Solve" }));
expect(
screen.getByRole("heading", { name: "Solve and verify" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Helpers" }));
expect(
screen.getByRole("heading", { name: "Sudoku helpers" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Killer combinations" }),
).toHaveAttribute("aria-selected", "true");
expect(document.querySelector(".helper-result")).toHaveTextContent(
"combinations",
);
await user.click(screen.getByRole("tab", { name: "45-rule residual" }));
expect(
screen.getByLabelText("Accounted values or cage sums"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Play" }));
expect(
screen.getByRole("heading", { name: "Enter your solve" }),
).toBeInTheDocument();
});
it("makes the import boundary and self-contained local sharing explicit", async () => {
const user = userEvent.setup();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
render(<Workbench />);
await user.click(screen.getByRole("button", { name: "Import / export" }));
expect(
screen.getByRole("heading", { name: "Import and export" }),
).toBeInTheDocument();
expect(
screen.getByText(/Server-only short IDs are never fetched\./u),
).toBeInTheDocument();
expect(
screen.getByText(/Share links are self-contained\./u),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Copy local share URL" }),
);
expect(
await screen.findByText("Local share URL copied."),
).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { SAMPLE_PUZZLES } from "../../src/data/samples";
import { solveExact, solveLogically } 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);
});
it("solves the generated classic with the supported logical techniques", () => {
expect(solveLogically(SAMPLE_PUZZLES[0]).status).toBe("solved");
});
});
+177
View File
@@ -0,0 +1,177 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
allCandidates,
candidatesForCell,
classicRegions,
createEmptyPuzzle,
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(overrides: Partial<PuzzleDefinition> = {}): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
...overrides,
};
}
describe("puzzle domain", () => {
it("creates valid rectangular regions for every supported size", () => {
fc.assert(
fc.property(fc.integer({ min: 4, max: 16 }), (size) => {
const regions = classicRegions(size);
expect(regions).toHaveLength(size * size);
for (let region = 0; region < size; region += 1) {
expect(regions.filter((entry) => entry === region)).toHaveLength(
size,
);
}
expect(validatePuzzle(createEmptyPuzzle(size)).valid).toBe(true);
}),
);
});
it("strictly rejects unknown, oversized, malformed, and contradictory data", () => {
expect(validatePuzzle({ ...puzzle4(), surprise: true }).valid).toBe(false);
expect(validatePuzzle({ ...puzzle4(), title: "x".repeat(257) }).valid).toBe(
false,
);
expect(
validatePuzzle({
...puzzle4(),
givens: [1, 1, ...new Array<number>(14).fill(0)],
}).valid,
).toBe(false);
expect(
validatePuzzle({ ...puzzle4(), regions: new Array<number>(16).fill(0) })
.valid,
).toBe(false);
expect(
validatePuzzle({
...puzzle4(),
constraints: [{ type: "thermo", cells: [0, 0], extra: true }],
}).valid,
).toBe(false);
});
it("accepts all supported constraint shapes and preserves bounded metadata", () => {
const constraints: VariantConstraint[] = [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
{ type: "anti-knight" },
{ type: "anti-king" },
{ type: "non-consecutive" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [0, 1, 2] },
{ type: "arrow", bulb: [0], line: [1, 2] },
{ type: "kropki", a: 0, b: 1, kind: "white" },
{ type: "kropki", a: 0, b: 4, kind: "black" },
{ type: "xv", a: 0, b: 1, total: 5 },
{ type: "inequality", lesser: 0, greater: 1 },
{ type: "renban", cells: [0, 1, 2] },
{ type: "palindrome", cells: [0, 5] },
];
const definition = puzzle4({
id: "demo",
title: "Variant",
rules: "Local rules",
constraints,
});
const result = validatePuzzle(definition);
expect(result).toEqual({ valid: true, issues: [] });
expect(normalizePuzzle(definition)).toMatchObject({
id: "demo",
rules: "Local rules",
});
});
it("validates an optional solution against givens and constraints", () => {
const constraints: VariantConstraint[] = [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [0, 1, 2, 3] },
{ type: "arrow", bulb: [3], line: [0, 2] },
{ type: "kropki", a: 0, b: 1, kind: "white" },
{ type: "kropki", a: 0, b: 1, kind: "black" },
{ type: "xv", a: 0, b: 3, total: 5 },
{ type: "inequality", lesser: 0, greater: 1 },
{ type: "renban", cells: [0, 1, 2, 3] },
{ type: "palindrome", cells: [0, 6] },
];
expect(
validatePuzzle(
puzzle4({
givens: [1, ...new Array<number>(15).fill(0)],
solution: solved4,
constraints,
}),
).valid,
).toBe(true);
const wrong = [...solved4];
wrong[0] = 2;
expect(
validatePuzzle(
puzzle4({
givens: [1, ...new Array<number>(15).fill(0)],
solution: wrong,
}),
).valid,
).toBe(false);
});
it("computes classic candidates and reports exact conflict cells", () => {
const values = [1, 2, 3, 0, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3];
const puzzle = puzzle4({ givens: new Array<number>(16).fill(0) });
expect(candidatesForCell(puzzle, values, 3)).toEqual([4]);
expect(allCandidates(puzzle, values)[3]).toEqual([4]);
const conflict = [...values];
conflict[3] = 1;
expect(
findConflicts(puzzle, conflict).some(
({ cells }) => cells.includes(0) && cells.includes(3),
),
).toBe(true);
});
it("enforces anti, adjacency, line, sum, ratio and equality constraints in candidates", () => {
const scenarios: Array<
readonly [VariantConstraint, number, number, boolean]
> = [
[{ type: "anti-knight" }, 6, 1, false],
[{ type: "anti-king" }, 5, 1, false],
[{ type: "non-consecutive" }, 1, 2, false],
[{ type: "killer-cage", cells: [0, 1], sum: 3 }, 1, 4, false],
[{ type: "thermo", cells: [0, 1, 2, 3] }, 1, 1, false],
[{ type: "arrow", bulb: [0], line: [1, 2] }, 2, 4, false],
[{ type: "kropki", a: 0, b: 1, kind: "black" }, 1, 3, false],
[{ type: "xv", a: 0, b: 1, total: 5 }, 1, 3, false],
[{ type: "inequality", lesser: 0, greater: 1 }, 1, 1, false],
[{ type: "renban", cells: [0, 1, 2] }, 2, 4, false],
[{ type: "palindrome", cells: [0, 5] }, 5, 2, false],
];
for (const [constraint, cell, candidate, expected] of scenarios) {
const values = new Array<number>(16).fill(0);
values[0] = 1;
if (constraint.type === "arrow") values[1] = 1;
if (constraint.type === "renban") values[1] = 2;
expect(
candidatesForCell(
puzzle4({ constraints: [constraint] }),
values,
cell,
).includes(candidate),
).toBe(expected);
}
});
});
+104
View File
@@ -0,0 +1,104 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
MAX_DOCUMENT_BYTES,
SUDOKU_DOCUMENT_SCHEMA,
SudokuFormatError,
decodePuzzleHash,
encodePuzzleHash,
normalizeSudokuDocument,
parsePlainGrid,
parseSudokuDocument,
serializePlainGrid,
serializeSudokuDocument,
type SudokuDocument,
} from "../../src/formats";
function document(givens: readonly number[], size = 9): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size,
givens,
constraints: [],
};
}
describe("Sudoku Tools document format", () => {
it("normalizes and round-trips a versioned document", () => {
const source: SudokuDocument = {
...document(Array<number>(81).fill(0)),
title: "Local puzzle",
solution: Array.from({ length: 81 }, (_, index) => (index % 9) + 1),
candidates: Array.from({ length: 81 }, () => [3, 1, 3]),
cornerMarks: Array.from({ length: 81 }, () => [8, 2, 8]),
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 }],
};
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
expect(parsed).toEqual({
...source,
candidates: Array.from({ length: 81 }, () => [1, 3]),
cornerMarks: Array.from({ length: 81 }, () => [2, 8]),
centerMarks: Array.from({ length: 81 }, () => [4, 7]),
});
});
it("rejects unsupported versions and oversized input", () => {
expect(() =>
normalizeSudokuDocument({ ...document(Array(81).fill(0)), version: 2 }),
).toThrow(/Unsupported puzzle document version/u);
expect(() =>
parseSudokuDocument(" ".repeat(MAX_DOCUMENT_BYTES + 1)),
).toThrowError(SudokuFormatError);
expect(() =>
normalizeSudokuDocument({
...document([1, ...Array<number>(15).fill(0)], 4),
values: [2, ...Array<number>(15).fill(0)],
}),
).toThrow(/preserve its given digit/u);
});
it("round-trips compact share hashes", () => {
const source = {
...document(Array<number>(81).fill(0)),
title: "Hash # & Unicode 🧩",
constraints: [{ type: "diagonal", direction: "main" } as const],
};
const hash = encodePuzzleHash(source);
expect(hash).toMatch(/^#sudoku=v1\./u);
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual(
source,
);
});
it("round-trips grids for every supported size and symbol", () => {
fc.assert(
fc.property(
fc.integer({ min: 4, max: 16 }).chain((size) =>
fc
.array(fc.integer({ min: 0, max: size }), {
minLength: size * size,
maxLength: size * size,
})
.map((givens) => ({ size, givens })),
),
({ size, givens }) => {
const text = serializePlainGrid(document(givens, size));
expect(parsePlainGrid(text)).toMatchObject({ size, givens });
},
),
{ numRuns: 80 },
);
});
it("accepts human grid separators and diagnoses bad lengths", () => {
const row = "1 . . 4";
const parsed = parsePlainGrid(`${row}\n${row}\n${row}\n${row}`);
expect(parsed.size).toBe(4);
expect(parsed.givens.slice(0, 4)).toEqual([1, 0, 0, 4]);
expect(() => parsePlainGrid("1234")).toThrow(/side length/u);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { normalizePuzzle } from "../../src/domain";
import {
NetworkPuzzleIdError,
SUDOKU_DOCUMENT_SCHEMA,
addressFromCellIndex,
cellIndexFromAddress,
exportFpuzzles,
exportFpuzzlesUrl,
importFpuzzles,
parseFpuzzles,
toDomainPuzzle,
type SudokuDocument,
} from "../../src/formats";
const emptyGrid = () =>
Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => ({})));
describe("fpuzzles interoperability", () => {
it("imports common givens, regions and variant constraints", () => {
const grid = emptyGrid();
for (let row = 0; row < 9; row += 1) {
for (let column = 0; column < 9; column += 1) {
grid[row]![column] = {
region: Math.floor(row / 3) * 3 + Math.floor(column / 3),
};
}
}
grid[0]![0] = { value: 5, given: true, region: 0 };
grid[0]![1] = {
value: 3,
region: 0,
centerPencilMarks: [7, 2],
cornerPencilMarks: [9, 1],
};
const puzzle = parseFpuzzles({
size: 9,
title: "Variants",
author: "Setter",
ruleset: "Normal Sudoku rules apply.",
grid,
"diagonal+": true,
antiknight: true,
antikingsmove: true,
nonconsecutive: true,
killercage: [{ cells: ["R1C1", "R1C2"], value: "8" }],
thermometer: [{ lines: [["R2C1", "R2C2", "R2C3"]] }],
arrow: [{ cells: ["R3C1"], lines: [["R3C1", "R3C2"]] }],
difference: [{ cells: ["R4C1", "R4C2"], value: "1" }],
ratio: [{ cells: ["R4C2", "R4C3"], value: "2" }],
xv: [{ cells: ["R5C1", "R5C2"], value: "V" }],
inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }],
renban: [{ lines: [["R7C1", "R7C2"]] }],
palindrome: [{ lines: [["R8C1", "R8C2"]] }],
});
expect(puzzle.givens[0]).toBe(5);
expect(puzzle.values?.[1]).toBe(3);
expect(puzzle.regions?.slice(0, 2)).toEqual([0, 0]);
expect(puzzle.centerMarks?.[1]).toEqual([2, 7]);
expect(puzzle.cornerMarks?.[1]).toEqual([1, 9]);
expect(puzzle.constraints).toEqual(
expect.arrayContaining([
{ type: "killer-cage", cells: [0, 1], sum: 8, noRepeat: true },
{ type: "kropki", a: 27, b: 28, kind: "white" },
{ type: "kropki", a: 28, b: 29, kind: "black" },
{ type: "inequality", lesser: 46, greater: 45 },
]),
);
expect(() => normalizePuzzle(toDomainPuzzle(puzzle))).not.toThrow();
});
it("exports and imports a self-contained SudokuPad fpuzzles URL", () => {
const source: SudokuDocument = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 9,
givens: [5, ...Array<number>(80).fill(0)],
constraints: [
{ type: "diagonal", direction: "anti" },
{ type: "killer-cage", cells: [0, 1], sum: 8 },
{ type: "thermo", cells: [9, 10, 11] },
{ type: "arrow", bulb: [18], line: [19] },
{ type: "kropki", a: 27, b: 28, kind: "white" },
{ type: "xv", a: 36, b: 37, total: 10 },
{ type: "inequality", lesser: 45, greater: 46 },
{ type: "renban", cells: [54, 55] },
{ type: "palindrome", cells: [63, 64] },
],
title: "Round trip",
};
const exported = exportFpuzzles(source);
expect(exported.grid).toBeInstanceOf(Array);
const imported = importFpuzzles(exportFpuzzlesUrl(source));
expect(imported.givens).toEqual(source.givens);
const expected = source.constraints.map((constraint) =>
constraint.type === "killer-cage"
? { ...constraint, noRepeat: true }
: constraint,
);
expect(imported.constraints).toHaveLength(expected.length);
expect(imported.constraints).toEqual(expect.arrayContaining(expected));
});
it("recognizes server-only short puzzle IDs without making a request", () => {
expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError(
NetworkPuzzleIdError,
);
expect(() =>
importFpuzzles("https://sudokupad.app/?puzzleid=abc123"),
).toThrow(/local-only app cannot fetch/u);
});
it("rejects unsupported generalized dots instead of weakening them", () => {
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
difference: [{ cells: ["R1C1", "R1C2"], value: "2" }],
}),
).toThrow(/Difference-2 dots are not supported/u);
});
it("rejects unsupported or unknown rules instead of silently weakening them", () => {
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
odd: [{ cell: "R1C1" }],
}),
).toThrow(/odd cells.*silently weakening/u);
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
futureConstraint: [{ cells: ["R1C1"] }],
}),
).toThrow(/Unknown fpuzzles field/u);
});
it("round-trips canonical fpuzzles cell addresses", () => {
for (let cell = 0; cell < 256; cell += 1) {
expect(cellIndexFromAddress(addressFromCellIndex(cell, 16), 16)).toBe(
cell,
);
}
});
});
+96
View File
@@ -0,0 +1,96 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
analyzeKillerCage,
calculateKillerCombinations,
} from "../../src/helpers";
describe("killer cage combinations", () => {
it("enumerates the familiar two-cell sum 10 combinations", () => {
expect(calculateKillerCombinations({ cellCount: 2, sum: 10 })).toEqual([
[1, 9],
[2, 8],
[3, 7],
[4, 6],
]);
});
it("supports allowed, required, excluded and repeated digits", () => {
expect(
calculateKillerCombinations({
cellCount: 3,
sum: 10,
allowedDigits: [1, 2, 3, 4, 5],
excludedDigits: [1],
requiredDigits: [5],
}),
).toEqual([[2, 3, 5]]);
expect(
calculateKillerCombinations({
cellCount: 2,
sum: 10,
allowRepeats: true,
}),
).toContainEqual([5, 5]);
});
it("filters assignments through per-cell candidates", () => {
const result = analyzeKillerCage({
cellCount: 2,
sum: 10,
candidates: [
[1, 2],
[8, 9],
],
});
expect(result.combinations).toEqual([
[1, 9],
[2, 8],
]);
expect(result.assignments).toEqual([
[1, 9],
[2, 8],
]);
expect(result.possibleByCell).toEqual([
[1, 2],
[8, 9],
]);
expect(result.necessaryDigits).toEqual([]);
});
it("reports digits necessary in every surviving combination", () => {
const result = analyzeKillerCage({ cellCount: 3, sum: 6 });
expect(result.combinations).toEqual([[1, 2, 3]]);
expect(result.necessaryDigits).toEqual([1, 2, 3]);
});
it("satisfies combination invariants for generated inputs", () => {
fc.assert(
fc.property(
fc.integer({ min: 4, max: 12 }),
fc.integer({ min: 1, max: 5 }),
fc.integer({ min: 1, max: 60 }),
(size, count, sum) => {
if (count > size || sum > size * count) return;
const result = calculateKillerCombinations({
size,
cellCount: count,
sum,
});
const keys = new Set<string>();
for (const combination of result) {
expect(combination).toHaveLength(count);
expect(combination.reduce((total, digit) => total + digit, 0)).toBe(
sum,
);
expect(combination).toEqual([...combination].sort((a, b) => a - b));
expect(new Set(combination).size).toBe(count);
keys.add(combination.join(","));
}
expect(keys.size).toBe(result.length);
},
),
{ numRuns: 100 },
);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
calculateResidual,
fortyFiveRuleResidual,
inequalityPairs,
kropkiPairs,
relationPairs,
xvPairs,
} from "../../src/helpers";
describe("relation pair tables", () => {
it("builds white and black Kropki tables", () => {
expect(kropkiPairs("white", 4)).toEqual([
[1, 2],
[2, 1],
[2, 3],
[3, 2],
[3, 4],
[4, 3],
]);
expect(kropkiPairs("black", 4)).toEqual([
[1, 2],
[2, 1],
[2, 4],
[4, 2],
]);
});
it("builds XV and directed inequality tables with candidate filtering", () => {
expect(xvPairs(5, 4)).toEqual([
[1, 4],
[2, 3],
[3, 2],
[4, 1],
]);
expect(inequalityPairs("<", 4, [2, 4], [1, 3])).toEqual([[2, 3]]);
expect(
relationPairs({ type: "difference", difference: 2 }, 4),
).toContainEqual([1, 3]);
});
});
describe("45-rule helpers", () => {
it("calculates a residual and its combinations", () => {
const result = calculateResidual({ knownSums: [10, 20], unknownCount: 2 });
expect(result.residual).toBe(15);
expect(result.analysis?.combinations).toEqual([
[6, 9],
[7, 8],
]);
});
it("subtracts complete cages and values but reports crossing cages", () => {
const result = fortyFiveRuleResidual({
unitCells: [0, 1, 2, 3, 4, 5, 6, 7, 8],
cages: [
{ cells: [0, 1], sum: 3 },
{ cells: [8, 17], sum: 10 },
],
knownValues: { 2: 4 },
});
expect(result.accounted).toBe(7);
expect(result.residual).toBe(38);
expect(result.residualCells).toEqual([3, 4, 5, 6, 7, 8]);
expect(result.crossingCages).toEqual([{ cells: [8, 17], sum: 10 }]);
});
});
+126
View File
@@ -0,0 +1,126 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
classicRegions,
isSolved,
type PuzzleDefinition,
} from "../../src/domain";
import {
countSolutions,
generateClassic,
minimizePuzzle,
solveExact,
} from "../../src/solver";
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const puzzle4: PuzzleDefinition = {
version: 1,
size: 4,
givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3],
regions: classicRegions(4),
constraints: [],
};
describe("exact solver", () => {
it("solves a classic puzzle with MRV without mutating its input", () => {
const original = [...puzzle4.givens];
const result = solveExact(puzzle4);
expect(result.count).toBe(1);
expect(result.solutions[0]).toEqual(solution4);
expect(result.truncated).toBe(false);
expect(puzzle4.givens).toEqual(original);
expect(isSolved(puzzle4, result.solutions[0] ?? [])).toBe(true);
});
it("caps solution counting and reports that unexplored work remains", () => {
const empty: PuzzleDefinition = {
...puzzle4,
givens: new Array<number>(16).fill(0),
};
const result = solveExact(empty, { maxSolutions: 2 });
expect(result.count).toBe(2);
expect(result.truncated).toBe(true);
expect(result.limitReason).toBe("solution-cap");
expect(countSolutions(empty)).toBe(2);
});
it("solves a puzzle combining supported local constraints", () => {
const variant: PuzzleDefinition = {
...puzzle4,
givens: solution4.map((value, cell) => (cell % 3 === 0 ? 0 : value)),
constraints: [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [0, 1, 2, 3] },
{ type: "arrow", bulb: [3], line: [0, 2] },
{ type: "kropki", a: 0, b: 1, kind: "black" },
{ type: "xv", a: 0, b: 3, total: 5 },
{ type: "inequality", lesser: 0, greater: 1 },
{ type: "renban", cells: [0, 1, 2, 3] },
{ type: "palindrome", cells: [0, 6] },
],
};
expect(solveExact(variant).solutions[0]).toEqual(solution4);
});
it("generates deterministic, unique 4x4 puzzles for arbitrary seeds", () => {
fc.assert(
fc.property(fc.integer(), (seed) => {
const generated = generateClassic({
size: 4,
seed,
targetClues: 8,
symmetry: "rotational",
});
const again = generateClassic({
size: 4,
seed,
targetClues: 8,
symmetry: "rotational",
});
expect(generated.givens).toEqual(again.givens);
const checked = solveExact(generated, { maxSolutions: 2 });
expect(checked.count).toBe(1);
expect(checked.truncated).toBe(false);
expect(checked.solutions[0]).toEqual(generated.solution);
}),
{ numRuns: 12 },
);
});
it("minimizes only when uniqueness is retained", () => {
const full: PuzzleDefinition = {
version: 1,
size: 4,
givens: solution4,
solution: solution4,
regions: classicRegions(4),
constraints: [],
};
const minimized = minimizePuzzle(full, {
seed: "minimal",
targetClues: 6,
symmetry: "none",
});
expect(minimized.givens.filter(Boolean).length).toBeLessThan(16);
expect(solveExact(minimized).count).toBe(1);
});
it("generates a practical uniquely solvable 9x9 puzzle", () => {
const generated = generateClassic({
size: 9,
seed: "nine-by-nine",
targetClues: 35,
symmetry: "rotational",
maxChecks: 60,
solveTimeoutMs: 2_000,
});
expect(generated.givens.filter(Boolean).length).toBeGreaterThanOrEqual(35);
const result = solveExact(generated, { timeoutMs: 5_000 });
expect(result.count).toBe(1);
expect(result.truncated).toBe(false);
expect(result.solutions[0]).toEqual(generated.solution);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import { killerDigitCombinations, solveLogically } from "../../src/solver";
const easy: PuzzleDefinition = {
version: 1,
size: 9,
givens: [
5, 3, 0, 0, 7, 0, 0, 0, 0, 6, 0, 0, 1, 9, 5, 0, 0, 0, 0, 9, 8, 0, 0, 0, 0,
6, 0, 8, 0, 0, 0, 6, 0, 0, 0, 3, 4, 0, 0, 8, 0, 3, 0, 0, 1, 7, 0, 0, 0, 2,
0, 0, 0, 6, 0, 6, 0, 0, 0, 0, 2, 8, 0, 0, 0, 0, 4, 1, 9, 0, 0, 5, 0, 0, 0,
0, 8, 0, 0, 7, 9,
],
regions: classicRegions(9),
constraints: [],
};
describe("logical solver", () => {
it("solves a standard puzzle and emits inspectable steps", () => {
const result = solveLogically(easy);
expect(result.status).toBe("solved");
expect(result.values.every((value) => value > 0)).toBe(true);
expect(result.steps.length).toBeGreaterThan(0);
expect(result.steps.every((step) => step.explanation.length > 0)).toBe(
true,
);
expect(
result.steps.some(
({ technique }) =>
technique === "naked-single" || technique === "hidden-single",
),
).toBe(true);
});
it("stops safely at the configured step bound", () => {
const result = solveLogically(easy, { maxSteps: 1 });
expect(result.status).toBe("step-limit");
expect(result.steps).toHaveLength(1);
});
it("returns bounded killer combinations", () => {
expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
).toEqual([
[1, 9],
[2, 8],
[3, 7],
[4, 6],
]);
expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10, noRepeat: false })
.combinations,
).toContainEqual([5, 5]);
expect(
killerDigitCombinations({ size: 16, count: 8, sum: 68, maxResults: 1 })
.truncated,
).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
createSession,
enterSelection,
eraseSelection,
maskValues,
valueForKey,
} from "../../src/state/session";
describe("play session", () => {
it("protects givens and toggles values and notes", () => {
const givens = [5, 0, 0, 0];
let state = createSession(givens);
state = enterSelection(state, new Set([0, 1]), "value", 3, givens);
expect(state.values).toEqual([5, 3, 0, 0]);
state = enterSelection(state, new Set([2]), "center", 2, givens);
state = enterSelection(state, new Set([2]), "center", 4, givens);
expect(maskValues(state.centerMarks[2]!, 4)).toEqual([2, 4]);
state = eraseSelection(state, new Set([0, 1]), "value", givens);
expect(state.values).toEqual([5, 0, 0, 0]);
});
it("maps keyboard symbols through hexadecimal-sized grids", () => {
expect(valueForKey("9", 9)).toBe(9);
expect(valueForKey("A", 16)).toBe(10);
expect(valueForKey("G", 16)).toBe(16);
expect(valueForKey("H", 16)).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
import {
PROJECT_RECORD_SCHEMA,
ProjectLibrary,
createProjectRecord,
normalizeProjectRecord,
} from "../../src/storage";
const puzzle = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1 as const,
size: 9,
givens: Array<number>(81).fill(0),
constraints: [],
};
describe("local project library", () => {
it("stores independent bounded clones in the memory fallback", async () => {
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(puzzle, {
id: "one",
title: "First",
now: 100,
progress: {
version: 1,
values: Array<number>(81).fill(0),
cornerMarks: Array.from({ length: 81 }, () => [1, 2]),
centerMarks: Array.from({ length: 81 }, () => [3]),
colors: Array<number>(81).fill(2),
elapsedMs: 1234,
},
});
await library.put(record);
const loaded = await library.get("one");
expect(loaded).toEqual(record);
(loaded?.puzzle.givens as number[])[0] = 9;
expect((await library.get("one"))?.puzzle.givens[0]).toBe(0);
expect(library.mode).toBe("memory");
});
it("lists newest first, deletes and clears", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, { id: "old", title: "Old", now: 1 }),
);
await library.put(
createProjectRecord(puzzle, { id: "new", title: "New", now: 2 }),
);
expect((await library.list()).map(({ id }) => id)).toEqual(["new", "old"]);
expect(await library.delete("old")).toBe(true);
expect(await library.delete("missing")).toBe(false);
await library.clear();
expect(await library.list()).toEqual([]);
});
it("exports and imports an explicitly versioned library", async () => {
const source = new ProjectLibrary({ indexedDB: null });
await source.put(createProjectRecord(puzzle, { id: "one", now: 10 }));
const exported = await source.exportAll();
const target = new ProjectLibrary({ indexedDB: null });
expect(await target.importAll(exported)).toBe(1);
expect(await target.get("one")).toEqual(await source.get("one"));
});
it("falls back when IndexedDB cannot be opened", async () => {
const broken = {
open: () => {
throw new Error("blocked");
},
} as unknown as IDBFactory;
const library = new ProjectLibrary({ indexedDB: broken });
expect(await library.ready()).toBe("memory");
await library.put(createProjectRecord(puzzle, { id: "fallback", now: 1 }));
expect(await library.get("fallback")).toBeDefined();
});
it("rejects malformed record versions and progress", () => {
expect(() =>
normalizeProjectRecord({
schema: PROJECT_RECORD_SCHEMA,
version: 2,
}),
).toThrow(/Unsupported project record/u);
const record = createProjectRecord(puzzle, { id: "bad", now: 1 });
expect(() =>
normalizeProjectRecord({
...record,
progress: { version: 1, values: [0] },
}),
).toThrow(/must contain 81/u);
});
});