feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+126
View File
@@ -248,3 +248,129 @@ test("keeps navigation, scratch work, branches and analysis tools local", async
expect(runtimeErrors).toEqual([]);
});
test("migrates the local database and recovers autosaved history", async ({
page,
}) => {
await page.goto("/deep/nested/sudoku/favicon.svg");
await page.evaluate(async () => {
await new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase("sudoku-tools");
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open("sudoku-tools", 1);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore("projects", {
keyPath: "id",
});
store.createIndex("updatedAt", "updatedAt");
};
request.onsuccess = () => {
request.result.close();
resolve();
};
request.onerror = () => reject(request.error);
});
});
await page.goto("/deep/nested/sudoku/");
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
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
.poll(
async () =>
await page.evaluate(
async () =>
await new Promise<number | undefined>((resolve, reject) => {
const open = indexedDB.open("sudoku-tools");
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const database = open.result;
const request = database
.transaction("autosaves", "readonly")
.objectStore("autosaves")
.get("current");
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const value = request.result as
| { record?: { progress?: { values?: number[] } } }
| undefined;
database.close();
resolve(value?.record?.progress?.values?.[2]);
};
};
}),
),
{ timeout: 10_000 },
)
.toBe(2);
await page.reload();
await expect(
page.getByRole("heading", { name: "A first classic" }),
).toBeVisible();
await expect(page.getByText("Recover unsaved local work?")).toBeVisible();
await page.getByRole("button", { name: "Restore" }).click();
await expect(
page
.getByRole("grid", { name: "9 by 9 Sudoku grid" })
.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeVisible();
await page.getByRole("button", { name: "History & branches" }).click();
await expect(
page
.getByRole("list", { name: "Solve history" })
.getByRole("button", { name: /Set r1c3 to 2/u }),
).toBeVisible();
});
test("installs a subpath-safe application shell that reopens offline", async ({
context,
page,
}) => {
await page.goto("/deep/nested/sudoku/");
await page.waitForFunction(async () => {
await navigator.serviceWorker.ready;
return navigator.serviceWorker.controller !== null;
});
const cachedUrls = await page.evaluate(async () => {
const names = await caches.keys();
return (
await Promise.all(
names
.filter((name) => name.startsWith("sudoku-tools-shell-"))
.map(async (name) =>
(await caches.open(name))
.keys()
.then((requests) => requests.map((request) => request.url)),
),
)
).flat();
});
expect(
cachedUrls.some((url) => /\/solver\.worker-[^/]+\.js$/u.test(url)),
).toBe(true);
await page.reload();
await expect(
page.getByRole("heading", { name: "A first classic" }),
).toBeVisible();
await context.setOffline(true);
try {
await page.reload();
await expect(
page.getByRole("heading", { name: "A first classic" }),
).toBeVisible();
await expect(
page.getByRole("grid", { name: "9 by 9 Sudoku grid" }),
).toBeVisible();
} finally {
await context.setOffline(false);
}
});
+72
View File
@@ -0,0 +1,72 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BoardViewport } from "../../src/components/BoardViewport";
import { BOARD_SCALE_STORAGE_KEY } from "../../src/state/uiPreferences";
describe("BoardViewport", () => {
beforeEach(() => localStorage.clear());
it("zooms, fits and restores the persisted scale", async () => {
const user = userEvent.setup();
const { unmount } = render(
<BoardViewport>
<div>Board</div>
</BoardViewport>,
);
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
await user.click(screen.getByRole("button", { name: "Zoom board in" }));
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
expect(localStorage.getItem(BOARD_SCALE_STORAGE_KEY)).toBe("1.25");
unmount();
render(
<BoardViewport>
<div>Board</div>
</BoardViewport>,
);
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
await user.click(screen.getByRole("button", { name: "Fit board" }));
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
});
it("offers explicit pan mode and board-scoped zoom shortcuts", async () => {
const user = userEvent.setup();
const onCellKeyDown = vi.fn();
render(
<BoardViewport>
<button type="button" onKeyDown={onCellKeyDown}>
Cell
</button>
</BoardViewport>,
);
const pan = screen.getByRole("button", { name: "Pan board" });
expect(pan).toHaveAttribute("aria-pressed", "false");
await user.click(pan);
expect(
screen.getByRole("button", { name: "Stop panning" }),
).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByRole("status", {
name: "",
}),
).toHaveTextContent(
"Pan mode: drag the board to move it. Cell taps are paused.",
);
expect(screen.getByLabelText(/pan mode is on/iu)).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("button", { name: "Cell" }), {
key: "+",
ctrlKey: true,
});
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
fireEvent.keyDown(screen.getByRole("button", { name: "Cell" }), {
key: "0",
ctrlKey: true,
});
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
expect(onCellKeyDown).not.toHaveBeenCalled();
});
});
+474
View File
@@ -0,0 +1,474 @@
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ConstraintEditor } from "../../src/components/ConstraintEditor";
import { createEmptyPuzzle, type PuzzleDefinition } from "../../src/domain";
function EditorHarness({
selection,
initial = createEmptyPuzzle(4),
}: {
readonly selection: readonly number[];
readonly initial?: PuzzleDefinition;
}) {
const [puzzle, setPuzzle] = useState(initial);
return (
<>
<ConstraintEditor
puzzle={puzzle}
selection={selection}
onChange={setPuzzle}
onNewGrid={vi.fn()}
onCheck={vi.fn()}
onGenerate={vi.fn()}
busy={false}
/>
<output data-testid="puzzle-state">{JSON.stringify(puzzle)}</output>
</>
);
}
function currentPuzzle(): PuzzleDefinition {
return JSON.parse(
screen.getByTestId("puzzle-state").textContent ?? "{}",
) as PuzzleDefinition;
}
describe("ConstraintEditor expansion controls", () => {
it("validates, replaces, polarizes and removes selected-cell markers", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[5]} />);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(screen.getByRole("button", { name: "Minimum cell" }));
expect(screen.getByText("false · minimum · r2c2")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
expect(screen.getByText("false · odd circle · r2c2")).toBeInTheDocument();
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
expect(screen.getByText("odd circle · r2c2")).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "odd"),
).toHaveLength(1);
await user.click(
screen.getByRole("button", { name: "Even cell (square)" }),
);
expect(screen.getByText("even square · r2c2")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Remove selected cell markers" }),
);
expect(currentPuzzle().constraints).toEqual([]);
});
it("requires exactly one selected cell for cell-marker creation", () => {
render(<EditorHarness selection={[0, 1]} />);
for (const name of [
"Maximum cell",
"Minimum cell",
"Odd cell (circle)",
"Even cell (square)",
]) {
expect(screen.getByRole("button", { name })).toBeDisabled();
}
expect(
screen.getByRole("button", { name: "Remove selected cell markers" }),
).toBeDisabled();
});
it("toggles disjoint groups and validates/replaces new outside clues", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[]} />);
const disjoint = screen.getByRole("button", { name: "Disjoint groups" });
expect(disjoint).toHaveAttribute("aria-pressed", "false");
await user.click(disjoint);
expect(disjoint).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByText(
"disjoint groups · matching box positions do not repeat",
),
).toBeInTheDocument();
await user.click(disjoint);
expect(disjoint).toHaveAttribute("aria-pressed", "false");
await user.selectOptions(screen.getByLabelText("Type"), "little-killer");
await user.selectOptions(screen.getByLabelText("Direction"), "down-left");
const add = screen.getByRole("button", {
name: "Add / replace outside clue",
});
// Top line 1 travelling down-left leaves the grid after one cell.
expect(add).toBeDisabled();
const line = screen.getByRole("spinbutton", { name: "Row / column" });
await user.clear(line);
await user.type(line, "2");
expect(add).toBeEnabled();
await user.click(add);
expect(
screen.getByText("little killer 3 · top 2 · down left"),
).toBeInTheDocument();
const sum = screen.getByRole("spinbutton", { name: "Sum" });
await user.clear(sum);
await user.type(sum, "4");
await user.click(add);
expect(
screen.queryByText("little killer 3 · top 2 · down left"),
).not.toBeInTheDocument();
expect(
screen.getByText("little killer 4 · top 2 · down left"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "little-killer",
),
).toHaveLength(1);
await user.selectOptions(screen.getByLabelText("Type"), "sandwich");
await user.clear(sum);
await user.type(sum, "1");
expect(add).toBeDisabled();
await user.clear(sum);
await user.type(sum, "3");
expect(add).toBeEnabled();
await user.click(add);
expect(screen.getByText("sandwich 3 · top 2")).toBeInTheDocument();
const constraintList = screen.getByRole("list");
await user.click(
within(constraintList).getByRole("button", {
name: "Remove little killer 4 · top 2 · down left",
}),
);
expect(
currentPuzzle().constraints?.some(({ type }) => type === "little-killer"),
).toBe(false);
});
it("creates ordered Pack 2 lines with bounded whisper settings", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[0, 1, 2]} />);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(screen.getByRole("button", { name: "Between line" }));
expect(
screen.getByText("false · between line · 3 ordered cells"),
).toBeInTheDocument();
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
const difference = screen.getByRole("spinbutton", {
name: "Whisper minimum difference",
});
await user.clear(difference);
await user.type(difference, "0");
expect(
screen.getByRole("button", { name: "German whisper" }),
).toBeDisabled();
await user.clear(difference);
await user.type(difference, "3");
await user.click(screen.getByRole("button", { name: "German whisper" }));
expect(
screen.getByText("German whisper ≥ 3 · 3 ordered cells"),
).toBeInTheDocument();
await user.clear(difference);
await user.type(difference, "2");
await user.click(screen.getByRole("button", { name: "German whisper" }));
expect(
screen.queryByText("German whisper ≥ 3 · 3 ordered cells"),
).not.toBeInTheDocument();
expect(
screen.getByText("German whisper ≥ 2 · 3 ordered cells"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "german-whisper",
),
).toHaveLength(1);
await user.click(screen.getByRole("button", { name: "Region-sum line" }));
expect(
screen.getByText("region-sum line · 3 ordered cells"),
).toBeInTheDocument();
expect(currentPuzzle().constraints).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "between-line",
cells: [0, 1, 2],
negated: true,
}),
expect.objectContaining({
type: "german-whisper",
cells: [0, 1, 2],
minimumDifference: 2,
}),
expect.objectContaining({
type: "region-sum-line",
cells: [0, 1, 2],
}),
]),
);
await user.click(
screen.getByRole("button", {
name: "Remove region-sum line · 3 ordered cells",
}),
);
expect(
currentPuzzle().constraints?.some(
({ type }) => type === "region-sum-line",
),
).toBe(false);
});
it("splits an ordered selection into clone pairs and builds an extra house", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[0, 1, 4, 5]} />);
const cloneButton = screen.getByRole("button", {
name: "Clone selection halves",
});
const extraButton = screen.getByRole("button", {
name: "Extra region (4 cells)",
});
expect(cloneButton).toBeEnabled();
expect(extraButton).toBeEnabled();
await user.click(cloneButton);
await user.click(cloneButton);
expect(
screen.getByText("clone regions · 2 + 2 paired cells"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "clone"),
).toHaveLength(1);
expect(currentPuzzle().constraints).toContainEqual({
type: "clone",
cells: [0, 1],
cloneCells: [4, 5],
});
await user.click(extraButton);
await user.click(extraButton);
const extraDescription = screen.getByText("extra region · 4 cells");
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "extra-region",
),
).toHaveLength(1);
const extraItem = extraDescription.closest("li");
expect(extraItem).not.toBeNull();
expect(
within(extraItem as HTMLElement).queryByRole("button", {
name: /require false/iu,
}),
).not.toBeInTheDocument();
expect(
within(
screen
.getByText("clone regions · 2 + 2 paired cells")
.closest("li") as HTMLElement,
).getByRole("button", { name: /require false/iu }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", {
name: "Remove clone regions · 2 + 2 paired cells",
}),
);
await user.click(
screen.getByRole("button", { name: "Remove extra region · 4 cells" }),
);
expect(currentPuzzle().constraints).toEqual([]);
});
it("rejects incomplete Pack 2 selections", () => {
render(<EditorHarness selection={[0, 1, 2]} />);
expect(
screen.getByRole("button", { name: "Clone selection halves" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Extra region (4 cells)" }),
).toBeDisabled();
});
it("creates, replaces, polarizes and removes ordered Pack 3 lines", async () => {
const user = userEvent.setup();
render(
<EditorHarness selection={[0, 1, 2]} initial={createEmptyPuzzle(6)} />,
);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
for (const name of [
"Modular line",
"Entropic line",
"Zipper line",
"Double arrow",
]) {
const button = screen.getByRole("button", { name });
expect(button).toBeEnabled();
await user.click(button);
}
expect(
screen.getByText("false · modular line · 3 ordered cells"),
).toBeInTheDocument();
expect(
screen.getByText("false · entropic line · 3 ordered cells"),
).toBeInTheDocument();
expect(
screen.getByText("false · zipper line · 3 ordered cells"),
).toBeInTheDocument();
expect(
screen.getByText("false · double arrow · 3 ordered cells"),
).toBeInTheDocument();
expect(currentPuzzle().constraints).toHaveLength(4);
await user.click(screen.getByRole("button", { name: "Modular line" }));
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "modular-line",
),
).toHaveLength(1);
await user.click(
screen.getByRole("button", {
name: "Remove false · zipper line · 3 ordered cells",
}),
);
expect(
currentPuzzle().constraints?.some(({ type }) => type === "zipper-line"),
).toBe(false);
});
it("rejects incompatible Pack 3 grid and line lengths", () => {
const { unmount } = render(<EditorHarness selection={[0, 1, 2]} />);
expect(
screen.getByRole("button", { name: "Entropic line" }),
).toBeDisabled();
unmount();
render(
<EditorHarness selection={[0, 1, 2, 3]} initial={createEmptyPuzzle(6)} />,
);
expect(screen.getByRole("button", { name: "Modular line" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Double arrow" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Entropic line" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Zipper line" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Add / replace indexer" }),
).toBeDisabled();
});
it("replaces indexer kinds at one marker and keeps polarity controls", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[5]} />);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(
screen.getByRole("button", { name: "Add / replace indexer" }),
);
expect(screen.getByText("false · row indexer · r2c2")).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Indexer kind"), "column");
await user.click(
screen.getByRole("button", { name: "Add / replace indexer" }),
);
expect(
screen.queryByText("false · row indexer · r2c2"),
).not.toBeInTheDocument();
expect(
screen.getByText("false · column indexer · r2c2"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "indexer"),
).toHaveLength(1);
await user.click(
screen.getByRole("button", {
name: "Remove false · column indexer · r2c2",
}),
);
expect(currentPuzzle().constraints).toEqual([]);
});
it("requires a trusted solution for fog and replaces its lights and radius", async () => {
const user = userEvent.setup();
const { unmount } = render(<EditorHarness selection={[0, 5]} />);
const addFog = screen.getByRole("button", {
name: "Add / replace Fog of War",
});
expect(addFog).toBeDisabled();
expect(
screen.getByText(
"Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights.",
),
).toBeInTheDocument();
unmount();
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
render(
<EditorHarness
selection={[0, 5]}
initial={{ ...createEmptyPuzzle(4), solution }}
/>,
);
const enabledFog = screen.getByRole("button", {
name: "Add / replace Fog of War",
});
expect(enabledFog).toBeEnabled();
await user.click(enabledFog);
expect(
screen.getByText("fog · 2 initial lights · radius 1"),
).toBeInTheDocument();
expect(currentPuzzle().constraints).toContainEqual({
type: "fog",
lights: [0, 5],
revealRadius: 1,
});
await user.selectOptions(screen.getByLabelText("Fog reveal radius"), "0");
await user.click(enabledFog);
expect(
screen.queryByText("fog · 2 initial lights · radius 1"),
).not.toBeInTheDocument();
const description = screen.getByText("fog · 2 initial lights · radius 0");
expect(description).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "fog"),
).toHaveLength(1);
const item = description.closest("li");
expect(item).not.toBeNull();
expect(
within(item as HTMLElement).queryByRole("button", {
name: /require false/iu,
}),
).not.toBeInTheDocument();
await user.click(
within(item as HTMLElement).getByRole("button", {
name: "Remove fog · 2 initial lights · radius 0",
}),
);
expect(currentPuzzle().constraints).toEqual([]);
});
});
@@ -2,6 +2,11 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
import { classicRegions } from "../../src/domain";
import type {
GeneratedVariantBatch,
GeneratedVariantPuzzle,
} from "../../src/solver";
describe("Sudoku generator workspace", () => {
it("offers only reliable sizes and submits an explicit local recipe", async () => {
@@ -97,4 +102,158 @@ describe("Sudoku generator workspace", () => {
}),
).toBeInTheDocument();
});
it("submits mixed families, density, minimality and a full profile as a batch", async () => {
const user = userEvent.setup();
const onGenerateBatch = vi.fn();
render(
<GeneratorWorkspace
busy={false}
onGenerate={vi.fn()}
onGenerateBatch={onGenerateBatch}
onRate={vi.fn()}
/>,
);
await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
await user.click(screen.getByRole("checkbox", { name: "Kropki dots" }));
await user.selectOptions(
screen.getByLabelText("Given symmetry"),
"horizontal",
);
await user.selectOptions(
screen.getByLabelText("Constraint density"),
"dense",
);
await user.click(
screen.getByRole("checkbox", { name: "Prove minimal givens" }),
);
await user.selectOptions(
screen.getByLabelText("Practice technique"),
"x-wing",
);
await user.clear(screen.getByLabelText("Minimum occurrences"));
await user.type(screen.getByLabelText("Minimum occurrences"), "2");
await user.type(screen.getByLabelText("Maximum occurrences"), "3");
await user.selectOptions(
screen.getByLabelText("Forbidden technique"),
"swordfish",
);
await user.selectOptions(
screen.getByLabelText("Hardest technique target"),
"x-wing",
);
await user.clear(screen.getByLabelText("Batch size"));
await user.type(screen.getByLabelText("Batch size"), "3");
await user.selectOptions(
screen.getByLabelText("Batch ranking"),
"fewest-givens",
);
await user.type(screen.getByLabelText("Seed"), "mixed-batch");
await user.click(
screen.getByRole("button", { name: "Generate 3-puzzle batch" }),
);
expect(onGenerateBatch).toHaveBeenCalledWith({
variant: "thermo",
variants: ["thermo", "kropki"],
size: 9,
targetDifficulty: "medium",
symmetry: "horizontal",
constraintCount: 8,
constraintDensity: "dense",
minimalGivens: true,
requiredTechnique: "x-wing",
techniqueProfile: {
forbidden: ["swordfish"],
counts: [{ technique: "x-wing", min: 2, max: 3 }],
hardestTechnique: "x-wing",
},
maxTechniqueAttempts: 10,
seed: "mixed-batch",
batchSize: 3,
ranking: "fewest-givens",
});
});
it("renders ranked evidence and lets the caller open a candidate", async () => {
const user = userEvent.setup();
const onSelectGenerated = vi.fn();
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3];
const generation: GeneratedVariantPuzzle = {
puzzle: {
version: 1,
size: 4,
givens: solution,
solution,
regions: classicRegions(4),
constraints: [],
},
difficulty: {
score: 12,
level: "beginner",
label: "Beginner",
uniqueness: "unique",
clueCount: 16,
emptyCount: 0,
logicalStatus: "solved",
logicalSteps: 0,
techniqueCounts: {},
exactNodes: 1,
exactTruncated: false,
summary: "fixture",
},
variant: "classic",
families: ["classic"],
seed: "ranked:1",
generatedConstraintCount: 0,
generationAttempts: 1,
constraintDensity: "balanced",
minimality: {
status: "not-requested",
checksPerformed: 0,
nodes: 0,
removedClues: 0,
criticalCells: [],
unknownCells: [],
limitReasons: [],
symmetryPreserved: true,
},
};
const batch: GeneratedVariantBatch = {
entries: [generation],
summaries: [
{
rank: 1,
seed: generation.seed,
families: generation.families,
clueCount: 16,
constraintCount: 0,
score: 12,
level: "beginner",
minimalityStatus: "not-requested",
},
],
failures: [],
requested: 1,
completed: 1,
truncated: false,
ranking: "difficulty",
baseSeed: "ranked",
};
render(
<GeneratorWorkspace
busy={false}
batch={batch}
onGenerate={vi.fn()}
onSelectGenerated={onSelectGenerated}
onRate={vi.fn()}
/>,
);
expect(screen.getByText("1 of 1 generated")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Use" }));
expect(onSelectGenerated).toHaveBeenCalledWith(generation);
});
});
+181
View File
@@ -0,0 +1,181 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
GuidedHint,
type GuidedHintProps,
} from "../../src/components/GuidedHint";
import {
deriveGuidedHintCellSets,
guidedHintEffectItems,
guidedHintFocusSummary,
guidedHintOverlay,
guidedHintStepIsVisible,
nextGuidedHintStage,
} from "../../src/components/guidedHint";
import type { LogicalStep } from "../../src/solver";
const step: LogicalStep = {
technique: "naked-pair",
focusCells: [0, 1, 0],
placements: [{ cell: 9, value: 4 }],
eliminations: [{ cell: 10, values: [2, 3] }],
explanation: "The private pair reasoning is now visible.",
};
function props(overrides: Partial<GuidedHintProps> = {}): GuidedHintProps {
return {
size: 9,
step,
stage: "focus",
autoMaintainPeerNotes: false,
onRequestHint: vi.fn(),
onRevealNext: vi.fn(),
onApply: vi.fn(),
onDismiss: vi.fn(),
onFillLegalCandidates: vi.fn(),
onRemoveInvalidNotes: vi.fn(),
onAutoMaintainPeerNotesChange: vi.fn(),
...overrides,
};
}
describe("guided hint presentation helpers", () => {
it("derives unique focus and effect cells without revealing effects early", () => {
expect(deriveGuidedHintCellSets(step)).toEqual({
focusCells: [0, 1],
placementCells: [9],
eliminationCells: [10],
affectedCells: [9, 10],
});
expect(guidedHintOverlay(step, "reasoning")).toEqual({
focusCells: [0, 1],
placementCells: [],
eliminationCells: [],
});
expect(guidedHintOverlay(step, "preview")).toEqual({
focusCells: [0, 1],
placementCells: [9],
eliminationCells: [10],
});
});
it("describes focus locations, effects and the finite reveal sequence", () => {
expect(guidedHintFocusSummary(step, 9)).toBe("Look across row 1.");
expect(guidedHintFocusSummary({ ...step, focusCells: [0, 9] }, 9)).toBe(
"Look down column 1.",
);
expect(guidedHintFocusSummary({ ...step, focusCells: [0, 10] }, 9)).toBe(
"Look within box 1.",
);
expect(guidedHintEffectItems(step, 9)).toEqual([
"Place 4 in r2c1.",
"Remove 2 and 3 from r2c2.",
]);
expect(nextGuidedHintStage("focus")).toBe("technique");
expect(nextGuidedHintStage("technique")).toBe("reasoning");
expect(nextGuidedHintStage("reasoning")).toBe("preview");
expect(nextGuidedHintStage("preview")).toBeUndefined();
});
it("rejects a hint when fog hides a premise, placement or elimination", () => {
expect(guidedHintStepIsVisible(step, new Set())).toBe(true);
expect(guidedHintStepIsVisible(step, new Set([0]))).toBe(false);
expect(guidedHintStepIsVisible(step, new Set([9]))).toBe(false);
expect(guidedHintStepIsVisible(step, new Set([10]))).toBe(false);
expect(guidedHintStepIsVisible(step, new Set([80]))).toBe(true);
});
});
describe("guided hint", () => {
it("keeps unrevealed answer content out of the document", () => {
const base = props();
const { rerender } = render(<GuidedHint {...base} />);
expect(screen.getByText("Look across row 1.")).toBeInTheDocument();
expect(screen.queryByText("Naked Pair")).not.toBeInTheDocument();
expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="technique" />);
expect(screen.getByText("Naked Pair")).toBeInTheDocument();
expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="reasoning" />);
expect(screen.getByText(step.explanation)).toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="preview" />);
expect(screen.getByText("Place 4 in r2c1.")).toBeInTheDocument();
expect(screen.getByText("Remove 2 and 3 from r2c2.")).toBeInTheDocument();
expect(
screen.getByText(/will start a complete legal centre-candidate grid/u),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Apply this step" }),
).toBeEnabled();
});
it("delegates reveal, apply, replacement and dismissal to its parent", async () => {
const user = userEvent.setup();
const callbacks = {
onRevealNext: vi.fn(),
onApply: vi.fn(),
onRequestHint: vi.fn(),
onDismiss: vi.fn(),
};
const { rerender } = render(
<GuidedHint {...props(callbacks)} stage="focus" />,
);
await user.click(screen.getByRole("button", { name: "Reveal technique" }));
expect(callbacks.onRevealNext).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "New hint" }));
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(callbacks.onRequestHint).toHaveBeenCalledOnce();
expect(callbacks.onDismiss).toHaveBeenCalledOnce();
rerender(<GuidedHint {...props(callbacks)} stage="preview" />);
await user.click(screen.getByRole("button", { name: "Apply this step" }));
expect(callbacks.onApply).toHaveBeenCalledOnce();
});
it("offers candidate maintenance without requiring an open hint", async () => {
const user = userEvent.setup();
const onRequestHint = vi.fn();
const onFillLegalCandidates = vi.fn();
const onRemoveInvalidNotes = vi.fn();
const onAutoMaintainPeerNotesChange = vi.fn();
render(
<GuidedHint
{...props({
step: undefined,
onRequestHint,
onFillLegalCandidates,
onRemoveInvalidNotes,
onAutoMaintainPeerNotesChange,
})}
/>,
);
await user.click(screen.getByRole("button", { name: "Get a guided hint" }));
await user.click(
screen.getByRole("button", { name: "Fill legal candidates" }),
);
await user.click(
screen.getByRole("button", { name: "Remove invalid notes" }),
);
await user.click(
screen.getByRole("checkbox", {
name: "Automatically remove peer notes after placing a digit",
}),
);
expect(onRequestHint).toHaveBeenCalledOnce();
expect(onFillLegalCandidates).toHaveBeenCalledOnce();
expect(onRemoveInvalidNotes).toHaveBeenCalledOnce();
expect(onAutoMaintainPeerNotesChange).toHaveBeenCalledWith(true);
});
});
+76 -4
View File
@@ -4,7 +4,10 @@ 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 {
fromDomainPuzzle,
type PreservedSudokuDocumentExtras,
} from "../../src/formats";
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
import { createSession } from "../../src/state/session";
@@ -40,6 +43,7 @@ function renderDialog(
options: {
readonly puzzle?: PuzzleDefinition;
readonly aidMemoire?: PortableAidMemoire;
readonly preservedExtras?: PreservedSudokuDocumentExtras;
} = {},
) {
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
@@ -51,6 +55,7 @@ function renderDialog(
puzzle={puzzle}
session={createSession(puzzle.givens)}
aidMemoire={options.aidMemoire}
preservedExtras={options.preservedExtras}
onClose={onClose}
onImport={onImport}
/>,
@@ -79,11 +84,14 @@ describe("ImportExportDialog interoperability", () => {
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
expect.any(Object),
expect.objectContaining({
source: { format: "sudokupad", id: "synthetic-local-test" },
}),
);
expect(onClose).toHaveBeenCalledOnce();
});
it("reports unsupported constructs and never fetches short IDs", async () => {
it("previews safe visuals and never fetches short IDs", async () => {
const user = userEvent.setup();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
@@ -91,12 +99,35 @@ describe("ImportExportDialog interoperability", () => {
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
fireEvent.change(input, {
target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
target: {
value: localScl({
overlays: [
{
center: [0.5, 0.5],
width: 1,
height: 1,
text: "visual only",
},
],
}),
},
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
expect(
await screen.findByRole("region", { name: "Import mapping preview" }),
).toBeInTheDocument();
expect(screen.getByText("Preserved visuals")).toBeInTheDocument();
expect(screen.getByText("Text overlay: 1")).toBeInTheDocument();
fireEvent.change(input, {
target: { value: localScl({ overlays: [{ text: "missing center" }] }) },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(await screen.findByText(/center.*point/u)).toBeInTheDocument();
fireEvent.change(input, {
target: { value: "https://sudokupad.app/serverOnly42" },
@@ -164,6 +195,47 @@ describe("ImportExportDialog interoperability", () => {
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4 }),
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
{ source: { format: "sudoku-tools" } },
);
});
it("includes preserved source extras in SudokuPad exports", async () => {
const user = userEvent.setup();
let downloaded: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
downloaded = value as Blob;
return "blob:scl-export-test";
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
renderDialog({
preservedExtras: {
source: { format: "sudokupad", id: "kept-source" },
metadata: { edition: "nightly" },
visuals: [
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 0 },
text: "kept visual",
style: { fill: "#123456" },
},
],
},
});
await user.click(
screen.getByRole("button", { name: "Download SudokuPad JSON" }),
);
const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
metadata?: Record<string, unknown>;
overlays?: unknown[];
};
expect(exported.metadata?.edition).toBe("nightly");
expect(exported.overlays).toEqual(
expect.arrayContaining([
expect.objectContaining({ text: "kept visual" }),
]),
);
});
+109
View File
@@ -0,0 +1,109 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LibraryDialog } from "../../src/components/LibraryDialog";
const summaries = [
{
id: "killer",
title: "Evening Killer",
createdAt: 1,
updatedAt: 2,
size: 4,
completed: false,
tags: ["killer", "hard"],
thumbnail: "1...............",
},
{
id: "classic",
title: "Morning Classic",
createdAt: 1,
updatedAt: 3,
size: 4,
completed: true,
tags: ["classic"],
thumbnail: "....2...........",
},
] as const;
function renderDialog(overrides: Record<string, unknown> = {}) {
const props = {
open: true,
summaries,
mode: "indexeddb" as const,
busy: false,
onClose: vi.fn(),
onSave: vi.fn(),
onOpen: vi.fn(),
onDelete: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
onExportSelected: vi.fn(),
onDuplicateSelected: vi.fn(),
onDeleteSelected: vi.fn(),
onUpdateTags: vi.fn(),
onImport: vi.fn(),
...overrides,
};
render(<LibraryDialog {...props} />);
return props;
}
describe("LibraryDialog", () => {
it("filters by text, tag and completion while showing safe previews", async () => {
const user = userEvent.setup();
renderDialog();
expect(
screen.getAllByRole("img", { name: /puzzle preview/u }),
).toHaveLength(2);
await user.type(screen.getByRole("searchbox"), "killer");
expect(screen.getByText("Evening Killer")).toBeInTheDocument();
expect(screen.queryByText("Morning Classic")).not.toBeInTheDocument();
await user.clear(screen.getByRole("searchbox"));
await user.selectOptions(
screen.getByLabelText("Completion filter"),
"complete",
);
expect(screen.getByText("Morning Classic")).toBeInTheDocument();
expect(screen.queryByText("Evening Killer")).not.toBeInTheDocument();
});
it("exports, duplicates and deletes an explicit selection", async () => {
const user = userEvent.setup();
const props = renderDialog();
await user.click(screen.getByLabelText("Select Evening Killer"));
const selection = screen.getByText(/1 selected/u).parentElement!;
await user.click(
within(selection).getByRole("button", { name: "Export selected" }),
);
await user.click(
within(selection).getByRole("button", { name: "Duplicate selected" }),
);
await user.click(
within(selection).getByRole("button", { name: "Delete selected" }),
);
expect(props.onExportSelected).toHaveBeenCalledWith(["killer"]);
expect(props.onDuplicateSelected).toHaveBeenCalledWith(["killer"]);
expect(props.onDeleteSelected).toHaveBeenCalledWith(["killer"]);
});
it("edits bounded comma-separated tags", async () => {
const user = userEvent.setup();
const props = renderDialog();
const item = screen.getByText("Evening Killer").closest("li")!;
await user.click(within(item).getByRole("button", { name: "Edit tags" }));
const input = within(item).getByLabelText("Tags for Evening Killer");
await user.clear(input);
await user.type(input, "killer, weekend");
await user.click(within(item).getByRole("button", { name: "Apply" }));
expect(props.onUpdateTags).toHaveBeenCalledWith("killer", [
"killer",
"weekend",
]);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { NumberPad } from "../../src/components/NumberPad";
describe("NumberPad colour accessibility", () => {
it("names each colour by both hue and pattern", async () => {
const user = userEvent.setup();
const onValue = vi.fn();
const { container } = render(
<NumberPad
size={9}
mode="color"
onMode={vi.fn()}
onValue={onValue}
onErase={vi.fn()}
/>,
);
const stripes = screen.getByRole("button", {
name: "Colour 1: red, diagonal stripes",
});
expect(stripes).toHaveClass("color-1");
expect(container.querySelector(".color-8")).toHaveAccessibleName(
"Colour 8: pink, rings",
);
await user.click(stripes);
expect(onValue).toHaveBeenCalledWith(1);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SafeVisualLayer } from "../../src/components/SafeVisualLayer";
import type { SafeVisualPrimitive } from "../../src/formats";
const visuals: readonly SafeVisualPrimitive[] = [
{
type: "line",
layer: "underlay",
start: { kind: "coordinate", x: 0.25, y: 0.75 },
end: { kind: "cell", cell: 5, offsetX: 0.1, offsetY: -0.1 },
style: { stroke: "#123456", strokeWidth: 0.05, opacity: 0.75 },
},
{
type: "polyline",
layer: "underlay",
points: [
{ kind: "cell", cell: 0 },
{ kind: "cell", cell: 1 },
{ kind: "cell", cell: 5 },
],
style: { stroke: "#abcdef", fill: "transparent" },
},
{
type: "rectangle",
layer: "overlay",
center: { kind: "cell", cell: 6 },
width: 0.8,
height: 0.6,
cornerRadius: 0.1,
style: { fill: "#ffeecc", stroke: "#112233" },
},
{
type: "ellipse",
layer: "overlay",
center: { kind: "coordinate", x: 2.5, y: 2.5 },
radiusX: 0.4,
radiusY: 0.2,
},
{
type: "circle",
layer: "overlay",
center: { kind: "cell", cell: 10 },
radius: 0.25,
},
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 15 },
text: "source label",
style: { fill: "#010203", fontSize: 0.4 },
},
];
describe("SafeVisualLayer", () => {
it("renders every canonical primitive with stable classes and grid geometry", () => {
const { container } = render(
<svg viewBox="0 0 4 4">
<SafeVisualLayer size={4} visuals={visuals} layer="underlay" />
<SafeVisualLayer size={4} visuals={visuals} layer="overlay" />
</svg>,
);
const line = container.querySelector(".source-visual--line");
expect(line).toHaveAttribute("x1", "0.25");
expect(line).toHaveAttribute("y1", "0.75");
expect(line).toHaveAttribute("x2", "1.6");
expect(line).toHaveAttribute("y2", "1.4");
expect(line).toHaveAttribute("stroke", "#123456");
expect(line).toHaveAttribute("stroke-width", "0.05");
expect(container.querySelector(".source-visual--polyline")).toHaveAttribute(
"points",
"0.5,0.5 1.5,0.5 1.5,1.5",
);
expect(
container.querySelector(".source-visual--rectangle"),
).toHaveAttribute("rx", "0.1");
expect(container.querySelector(".source-visual--ellipse")).toHaveAttribute(
"rx",
"0.4",
);
expect(container.querySelector(".source-visual--circle")).toHaveAttribute(
"r",
"0.25",
);
expect(container.querySelector(".source-visual--text")).toHaveTextContent(
"source label",
);
expect(
container.querySelectorAll(".safe-visual-layer--underlay > *"),
).toHaveLength(2);
expect(
container.querySelectorAll(".safe-visual-layer--overlay > *"),
).toHaveLength(4);
});
it("uses a React text node and never creates executable descendants", () => {
const malicious =
'</text><script onload="alert(1)">x</script><image href="x">';
const { container } = render(
<svg viewBox="0 0 4 4">
<SafeVisualLayer
size={4}
layer="overlay"
visuals={[
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 0 },
text: malicious,
style: { fill: "#000000" },
},
]}
/>
</svg>,
);
expect(container.querySelector(".source-visual--text")?.textContent).toBe(
malicious,
);
expect(container.querySelector("script, image")).toBeNull();
expect(
container.querySelector("[href], [src], [onclick], [onload]"),
).toBeNull();
});
});
+279
View File
@@ -0,0 +1,279 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
SetterQualityLab,
type SetterQualityLabProps,
} from "../../src/components/SetterQualityLab";
import type {
PuzzleQualityAnalysis,
QualityItemReference,
} from "../../src/solver/quality";
const given: QualityItemReference = { kind: "given", cell: 0, value: 1 };
const constraint: QualityItemReference = {
kind: "constraint",
index: 0,
constraintType: "diagonal",
};
const bounds = {
perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 },
aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 },
} as const;
const multiple: PuzzleQualityAnalysis = {
analysisDepth: "full",
solutionStatus: "multiple",
ambiguityWitness: {
firstSolution: [1, 2, 3, 4],
secondSolution: [2, 1, 3, 4],
differences: [
{ cell: 0, first: 1, second: 2 },
{ cell: 1, first: 2, second: 1 },
],
},
contradiction: {
status: "not-applicable",
core: [],
necessary: [],
removable: [],
unknown: [],
},
redundancy: {
givens: [
{
item: given,
classification: "critical",
checkIndex: 1,
solutionStatus: "multiple",
},
],
constraints: [
{
item: constraint,
classification: "redundant",
checkIndex: 2,
solutionStatus: "unique",
},
],
},
criticalityHeatmap: [
{
cell: 0,
score: 1,
criticalWeight: 1,
redundantWeight: 0,
unknownWeight: 0,
},
{
cell: 1,
score: null,
criticalWeight: 0,
redundantWeight: 0,
unknownWeight: 1,
},
],
minimality: {
status: "not-minimal",
redundant: [constraint],
unknown: [],
},
checks: [
{
index: 0,
purpose: "baseline",
solutionStatus: "multiple",
solutionsFound: 2,
conclusive: true,
truncated: true,
limitReason: "solution-cap",
nodes: 10,
elapsedMs: 2,
},
],
bounds,
budget: {
checksPlanned: 3,
checksPerformed: 3,
nodes: 42,
elapsedMs: 12,
truncated: false,
unknownReasons: [],
},
};
function props(
overrides: Partial<SetterQualityLabProps> = {},
): SetterQualityLabProps {
return {
size: 4,
onRunQuick: vi.fn(),
onRunMinimality: vi.fn(),
onCancel: vi.fn(),
onFocusCells: vi.fn(),
onFocusItem: vi.fn(),
...overrides,
};
}
describe("setter quality lab", () => {
it("runs distinct quick and bounded-minimality profiles and supports cancel", async () => {
const user = userEvent.setup();
const onRunQuick = vi.fn();
const onRunMinimality = vi.fn();
const onCancel = vi.fn();
const base = props({ onRunQuick, onRunMinimality, onCancel });
const { rerender } = render(<SetterQualityLab {...base} />);
fireEvent.change(screen.getByLabelText("Nodes per check"), {
target: { value: "123456" },
});
await user.click(
screen.getByRole("button", { name: "Run quick quality check" }),
);
expect(onRunQuick).toHaveBeenCalledWith({
perCheckMaxNodes: 123_456,
perCheckTimeoutMs: 10_000,
aggregateMaxChecks: 1_000,
aggregateMaxNodes: 20_000_000,
aggregateTimeoutMs: 30_000,
analysisDepth: "baseline",
proveMinimality: false,
});
await user.click(
screen.getByRole("button", { name: "Run full bounded minimality" }),
);
expect(onRunMinimality).toHaveBeenCalledWith(
expect.objectContaining({
analysisDepth: "full",
proveMinimality: true,
}),
);
rerender(<SetterQualityLab {...base} running="minimality" />);
expect(
screen.getByText("Full bounded minimality analysis running locally…"),
).toHaveAttribute("role", "status");
expect(
screen.getByRole("button", { name: "Run quick quality check" }),
).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Cancel analysis" }));
expect(onCancel).toHaveBeenCalledOnce();
});
it("renders an ambiguity witness, findings, heatmap and bounded metrics", async () => {
const user = userEvent.setup();
const onFocusCells = vi.fn();
const onFocusItem = vi.fn();
render(
<SetterQualityLab
{...props({ result: multiple, onFocusCells, onFocusItem })}
/>,
);
expect(
screen.getByRole("heading", { name: "Multiple completions found" }),
).toBeInTheDocument();
expect(screen.getByText("2 differing cells")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Focus differences" }));
expect(onFocusCells).toHaveBeenCalledWith([0, 1]);
await user.click(
screen.getByRole("button", {
name: /r1c1: critical; score 1\.00/u,
}),
);
expect(onFocusCells).toHaveBeenLastCalledWith([0]);
expect(
screen.getByRole("button", { name: /r1c2: unknown; score unknown/u }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Given 1 at r1c1" }));
expect(onFocusItem).toHaveBeenCalledWith(given);
const metrics = screen
.getByRole("heading", { name: "Checks and limits" })
.closest("section")!;
expect(within(metrics).getByText("3 / 3")).toBeInTheDocument();
expect(
within(metrics).getByText("2,000,000 nodes / 10,000 ms"),
).toBeInTheDocument();
expect(
within(metrics).getByText("1,000 checks / 20,000,000 nodes / 30,000 ms"),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Not minimal" }),
).toBeInTheDocument();
});
it("labels bounded unknowns and exposes contradiction suspects", async () => {
const user = userEvent.setup();
const onFocusItem = vi.fn();
const unknown: PuzzleQualityAnalysis = {
...multiple,
solutionStatus: "unknown",
ambiguityWitness: undefined,
contradiction: {
status: "incomplete",
core: [given, constraint],
necessary: [given],
removable: [],
unknown: [constraint],
reason: "The aggregate time budget ended localization.",
},
redundancy: {
givens: [
{
item: given,
classification: "unknown",
unknownReason: "aggregate-timeout",
},
],
constraints: [],
},
criticalityHeatmap: [
{
cell: 0,
score: null,
criticalWeight: 0,
redundantWeight: 0,
unknownWeight: 1,
},
],
minimality: {
status: "unknown",
redundant: [],
unknown: [constraint],
reason: "One or more clue checks were bounded.",
},
budget: {
...multiple.budget,
checksPerformed: 2,
truncated: true,
unknownReasons: ["aggregate-timeout"],
},
};
render(<SetterQualityLab {...props({ result: unknown, onFocusItem })} />);
expect(
screen.getByRole("heading", { name: "Solution status unknown" }),
).toBeInTheDocument();
expect(
screen.getAllByText(/incomplete \/ unknown/iu).length,
).toBeGreaterThan(1);
expect(
screen.getByText("The aggregate time budget ended localization."),
).toBeInTheDocument();
expect(screen.getByText("Aggregate timeout")).toBeInTheDocument();
const necessary = screen
.getByRole("heading", { name: "Proven necessary suspects" })
.closest("section")!;
await user.click(
within(necessary).getByRole("button", { name: "Given 1 at r1c1" }),
);
expect(onFocusItem).toHaveBeenCalledWith(given);
});
});
+596 -1
View File
@@ -1,9 +1,77 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { normalizePuzzle } from "../../src/domain";
import { SudokuBoard } from "../../src/components/SudokuBoard";
import { foggedCellsForPuzzle } from "../../src/components/fogVisibility";
describe("Sudoku board constraint visuals", () => {
it("orders retained underlays before semantic clues and overlays afterward", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
solution: [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3],
constraints: [
{ type: "diagonal", direction: "main" },
{ type: "fog", lights: [0], revealRadius: 0 },
],
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
visuals={[
{
type: "circle",
layer: "underlay",
center: { kind: "cell", cell: 0 },
radius: 0.3,
style: { fill: "#abcdef" },
},
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 15 },
text: "overlay",
style: { fill: "#123456" },
},
]}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
const underlay = container.querySelector(".safe-visual-layer--underlay")!;
const regions = container.querySelector(".region-boundaries")!;
const semantic = container.querySelector(".constraint-diagonal")!;
const overlay = container.querySelector(".safe-visual-layer--overlay")!;
const fogMask = container.querySelector(".fog-constraint-mask")!;
expect(
underlay.compareDocumentPosition(regions) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
underlay.compareDocumentPosition(semantic) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
semantic.compareDocumentPosition(overlay) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
overlay.compareDocumentPosition(fogMask) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(fogMask.querySelectorAll("rect").length).toBeGreaterThan(0);
expect(underlay.querySelector(".source-visual--circle")).toBeTruthy();
expect(overlay.querySelector(".source-visual--text")).toHaveTextContent(
"overlay",
);
});
it("distinguishes XV sums from directional inequalities", () => {
const puzzle = normalizePuzzle({
version: 1,
@@ -112,6 +180,412 @@ describe("Sudoku board constraint visuals", () => {
);
});
it("renders Pack 1 cell, global and outside clues with distinct semantics", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
constraints: [
{ type: "maximum", cell: 4 },
{ type: "minimum", cell: 5, negated: true },
{ type: "odd", cell: 9 },
{ type: "even", cell: 10 },
{ type: "disjoint-groups" },
{
type: "little-killer",
side: "top",
index: 1,
direction: "down-right",
sum: 7,
},
{ type: "sandwich", side: "left", index: 2, sum: 3 },
],
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
const maximumPath = container
.querySelector(".constraint-maximum path")
?.getAttribute("d");
const minimumPath = container
.querySelector(".constraint-minimum path")
?.getAttribute("d");
expect(maximumPath).toBeTruthy();
expect(minimumPath).toBeTruthy();
expect(minimumPath).not.toBe(maximumPath);
expect(container.querySelector(".constraint-minimum")).toHaveClass(
"is-negated",
);
expect(
container.querySelector(".constraint-odd circle"),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-even rect"),
).toBeInTheDocument();
expect(
container.querySelectorAll(".constraint-disjoint-groups path"),
).toHaveLength(4);
expect(
container.querySelector(
'.constraint-little-killer[data-direction="down-right"] .little-killer-arrow',
),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-little-killer text"),
).toHaveTextContent("7");
expect(
container.querySelector(".constraint-sandwich .outside-clue-kinds"),
).toHaveTextContent("1⋯N");
expect(
container.querySelector(".constraint-sandwich .outside-clue-value"),
).toHaveTextContent("3");
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"Disjoint groups rule: corresponding positions in every standard box contain each digit once.",
),
);
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"little killer sum 7 from the top, column 2, travelling down right.",
),
);
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"sandwich sum 3 from the left, row 3, between 1 and 4.",
),
);
expect(
screen.getByRole("gridcell", { name: "Row 3, column 2, empty" }),
).toHaveAccessibleDescription(
expect.stringMatching(/odd digit.*sandwich sum 3/iu),
);
});
it("renders Pack 2 lines and regions as distinct accessible clues", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
constraints: [
{ type: "between-line", cells: [0, 1, 2], negated: true },
{
type: "german-whisper",
cells: [4, 5, 6],
minimumDifference: 2,
},
{ type: "region-sum-line", cells: [8, 9, 10] },
{ type: "clone", cells: [0, 4], cloneCells: [3, 7] },
{ type: "extra-region", cells: [0, 3, 12, 15] },
],
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
expect(container.querySelector(".constraint-between-line")).toHaveClass(
"is-negated",
);
expect(
container.querySelectorAll(".constraint-between-line > circle"),
).toHaveLength(2);
expect(
container.querySelector(".constraint-german-whisper polyline"),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-region-sum-line polyline"),
).toBeInTheDocument();
expect(
container.querySelectorAll(
".constraint-region-sum-line .region-sum-divider",
),
).toHaveLength(1);
expect(
container.querySelectorAll(".constraint-clone .clone-cell-fill"),
).toHaveLength(4);
expect(
container.querySelectorAll(".constraint-clone .clone-boundary"),
).toHaveLength(2);
expect(
container.querySelector(".constraint-clone .clone-link"),
).toBeInTheDocument();
expect(
container.querySelectorAll(".constraint-clone .clone-label"),
).toHaveLength(2);
expect(
container.querySelectorAll(
".constraint-extra-region .extra-region-cell-fill",
),
).toHaveLength(4);
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"False clue: between line from row 1, column 1 through row 1, column 2 to row 1, column 3",
),
);
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"German whisper through row 2, column 1; row 2, column 2; row 2, column 3; adjacent digits differ by at least 2.",
),
);
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"clone regions pairing row 1, column 1; row 2, column 1 with row 1, column 4; row 2, column 4 in order.",
),
);
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"Extra region through row 1, column 1; row 1, column 4; row 4, column 1; row 4, column 4; every digit appears exactly once.",
),
);
expect(
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
).toHaveAccessibleDescription(
expect.stringMatching(/between line.*clone regions.*extra region/iu),
);
});
it("renders Pack 3 pattern lines and indexers with distinct non-colour shapes", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 6,
givens: new Array<number>(36).fill(0),
constraints: [
{ type: "modular-line", cells: [0, 1, 2], negated: true },
{ type: "entropic-line", cells: [6, 7, 8] },
{ type: "zipper-line", cells: [12, 13, 14, 15, 16] },
{ type: "double-arrow", cells: [18, 19, 20] },
{ type: "indexer", kind: "row", cell: 24 },
{ type: "indexer", kind: "column", cell: 25 },
{ type: "indexer", kind: "box", cell: 26 },
],
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
expect(container.querySelector(".constraint-modular-line")).toHaveClass(
"is-negated",
);
expect(
container.querySelectorAll(".constraint-modular-line .modular-line-node"),
).toHaveLength(3);
expect(
container.querySelector(
".constraint-entropic-line .entropic-line-underlay",
),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-entropic-line .entropic-line-path"),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-zipper-line .zipper-line-centre"),
).toBeInTheDocument();
expect(
container.querySelectorAll(".constraint-double-arrow > circle"),
).toHaveLength(2);
expect(
container.querySelectorAll(
".constraint-double-arrow .double-arrow-chevron",
),
).toHaveLength(2);
expect(
container.querySelector(".constraint-indexer--row circle"),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-indexer--column rect"),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-indexer--box rect"),
).toBeInTheDocument();
expect(
container.querySelector(".constraint-indexer--row text"),
).toHaveTextContent("R");
expect(
container.querySelector(".constraint-indexer--column text"),
).toHaveTextContent("C");
expect(
container.querySelector(".constraint-indexer--box text"),
).toHaveTextContent("B");
const board = screen.getByRole("grid", { name: "6 by 6 Sudoku grid" });
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"False clue: modular line through row 1, column 1; row 1, column 2; row 1, column 3",
),
);
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"entropic line through row 2, column 1; row 2, column 2; row 2, column 3",
),
);
expect(board).toHaveAccessibleDescription(
expect.stringContaining(
"row indexer at row 5, column 1; the marker digit selects a row in the same column",
),
);
});
it("masks fogged content and clues until a correct entry reveals them", () => {
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
solution,
constraints: [
{ type: "fog", lights: [0], revealRadius: 1 },
{ type: "even", cell: 10 },
],
});
const pointerDown = vi.fn();
const keyDown = vi.fn();
const wrongValues = new Array<number>(16).fill(0);
wrongValues[5] = 2;
wrongValues[10] = 3;
const { container, rerender } = render(
<SudokuBoard
puzzle={puzzle}
values={wrongValues}
candidates={new Array<number>(16).fill(15)}
selected={new Set([10])}
highlighted={new Set([10])}
conflicts={new Set([10])}
activeCell={1}
candidateOverlay={{
activeValues: [4],
candidateCells: [0, 10],
links: [
{
id: "hidden-link",
kind: "strong",
a: { cell: 0, value: 4 },
b: { cell: 10, value: 4 },
contexts: [{ kind: "house", label: "Test" }],
},
],
}}
guidedHintOverlay={{
focusCells: [10],
placements: [{ cell: 10, value: 4 }],
}}
onCellPointerDown={pointerDown}
onCellPointerEnter={vi.fn()}
onKeyDown={keyDown}
/>,
);
const hidden = screen.getByRole("gridcell", {
name: "Row 3, column 3, obscured by fog",
});
expect(hidden).toBeDisabled();
expect(hidden).toHaveClass("is-fogged");
expect(hidden).not.toHaveClass(
"is-selected",
"has-conflict",
"is-hint-focus",
);
expect(hidden).toHaveAccessibleDescription(
"Obscured by Fog of War. This cell cannot be selected until revealed.",
);
expect(hidden).toBeEmptyDOMElement();
expect(container.querySelector(".constraint-even")).not.toBeInTheDocument();
expect(
container.querySelectorAll(".fog-constraint-mask rect"),
).toHaveLength(12);
expect(
container.querySelector(".candidate-link-layer"),
).not.toBeInTheDocument();
expect(
screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }),
).not.toHaveAccessibleDescription(expect.stringContaining("even digit"));
fireEvent.pointerDown(hidden);
expect(pointerDown).not.toHaveBeenCalled();
fireEvent.pointerDown(
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
);
expect(pointerDown).toHaveBeenCalledWith(0, expect.anything());
fireEvent.keyDown(
screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
{ key: "ArrowRight" },
);
fireEvent.keyDown(
screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
{ key: "a", ctrlKey: true },
);
expect(keyDown).not.toHaveBeenCalled();
const correctValues = new Array<number>(16).fill(0);
correctValues[5] = 4;
rerender(
<SudokuBoard
puzzle={puzzle}
values={correctValues}
selected={new Set()}
activeCell={1}
onCellPointerDown={pointerDown}
onCellPointerEnter={vi.fn()}
onKeyDown={keyDown}
/>,
);
expect(
screen.getByRole("gridcell", { name: "Row 3, column 3, empty" }),
).toHaveAccessibleDescription(expect.stringContaining("even digit"));
expect(container.querySelector(".constraint-even")).toBeInTheDocument();
expect(
container.querySelectorAll(".fog-constraint-mask rect"),
).toHaveLength(7);
});
it("reveals given cells and their Chebyshev neighbourhood under fog", () => {
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
const givens = new Array<number>(16).fill(0);
givens[15] = 1;
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens,
solution,
constraints: [{ type: "fog", lights: [0], revealRadius: 1 }],
});
const fogged = foggedCellsForPuzzle(puzzle, givens);
expect(fogged.has(10)).toBe(false);
expect(fogged.has(15)).toBe(false);
expect(fogged.has(9)).toBe(true);
});
it("renders candidate filters and strong/weak graph overlays", () => {
const puzzle = normalizePuzzle({
version: 1,
@@ -164,6 +638,51 @@ describe("Sudoku board constraint visuals", () => {
);
});
it("keeps guided effects distinct from focus cells and exposes the preview", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
centerMarks={[0, 15, 15, ...new Array<number>(13).fill(0)]}
selected={new Set()}
activeCell={0}
guidedHintOverlay={{
focusCells: [0, 1, 2],
placements: [{ cell: 0, value: 4 }],
eliminations: [{ cell: 1, values: [2, 3] }],
}}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
expect(container.querySelectorAll(".is-hint-focus")).toHaveLength(3);
expect(container.querySelectorAll(".is-hint-placement")).toHaveLength(1);
expect(container.querySelectorAll(".is-hint-elimination")).toHaveLength(1);
expect(
container.querySelector(".hint-placement-preview"),
).toHaveTextContent("4");
expect(
container.querySelector(".hint-elimination-preview"),
).toHaveTextContent("23");
expect(
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
).toHaveAccessibleDescription(
expect.stringContaining("hint preview: place 4"),
);
expect(
screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
).toHaveAccessibleDescription(
expect.stringContaining("hint preview: remove 2, 3 from the candidates"),
);
});
it("exposes real row semantics and detailed per-cell state", () => {
const puzzle = normalizePuzzle({
version: 1,
@@ -215,4 +734,80 @@ describe("Sudoku board constraint visuals", () => {
expect(annotated).toHaveAttribute("aria-invalid", "true");
expect(annotated).toHaveAttribute("aria-selected", "true");
});
it("offers off, concise and detailed screen-reader candidate output", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
});
const baseProps = {
puzzle,
values: puzzle.givens,
cornerMarks: [3, ...new Array<number>(15).fill(0)],
centerMarks: [12, ...new Array<number>(15).fill(0)],
selected: new Set([0]),
activeCell: 0,
onCellPointerDown: vi.fn(),
onCellPointerEnter: vi.fn(),
onKeyDown: vi.fn(),
} as const;
const { rerender } = render(
<SudokuBoard {...baseProps} candidateVerbosity="detailed" />,
);
const cell = () =>
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" });
expect(cell()).toHaveAccessibleDescription(
expect.stringContaining("corner notes 1, 2"),
);
expect(cell()).toHaveAccessibleDescription(
expect.stringContaining("centre notes 3, 4"),
);
rerender(<SudokuBoard {...baseProps} candidateVerbosity="concise" />);
expect(cell()).toHaveAccessibleDescription(
expect.stringContaining("2 corner notes"),
);
expect(cell()).toHaveAccessibleDescription(
expect.stringContaining("2 centre notes"),
);
expect(cell()).not.toHaveAccessibleDescription(
expect.stringContaining("corner notes 1, 2"),
);
rerender(<SudokuBoard {...baseProps} candidateVerbosity="off" />);
expect(cell()).not.toHaveAccessibleDescription(
expect.stringMatching(/candidate|corner note|centre note/iu),
);
});
it("adds a non-colour pattern layer and names the pattern", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
colors={[1, ...new Array<number>(15).fill(0)]}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
expect(
container.querySelector(".has-color-1 .cell-color-pattern"),
).toBeInTheDocument();
expect(
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
).toHaveAccessibleDescription(
expect.stringContaining("colour 1: red, diagonal stripes"),
);
});
});
+188 -2
View File
@@ -2,6 +2,8 @@ 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";
import { encodePuzzleHash, fromDomainPuzzle } from "../../src/formats";
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
class WorkerStub {
addEventListener() {}
@@ -63,6 +65,37 @@ describe("Sudoku workbench", () => {
expect(undo).toBeDisabled();
});
it("fills a tracked candidate grid as one undoable maintenance action", async () => {
const user = userEvent.setup();
render(<Workbench />);
expect(
screen.getByText("Candidate tracking is currently inactive."),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Fill legal candidates" }),
);
expect(
screen.getByText("The guided candidate grid is active."),
).toBeInTheDocument();
expect(
screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).toHaveAccessibleDescription(expect.stringContaining("centre notes"));
await user.click(screen.getByRole("button", { name: "Undo" }));
expect(
screen.getByText("Candidate tracking is currently inactive."),
).toBeInTheDocument();
expect(
screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).not.toHaveAccessibleDescription(expect.stringContaining("centre notes"));
});
it("supports non-wrapping WAI-ARIA grid navigation", () => {
render(<Workbench />);
@@ -91,6 +124,94 @@ describe("Sudoku workbench", () => {
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
});
it("offers tap-by-tap multi-selection without drag selection", async () => {
const user = userEvent.setup();
render(<Workbench />);
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
const cell = (column: number) =>
within(grid).getByRole("gridcell", {
name: new RegExp(`^Row 1, column ${String(column)},`, "u"),
});
fireEvent.pointerDown(cell(3), { buttons: 1 });
const toggleMode = screen.getByRole("button", { name: "Tap multi-select" });
expect(toggleMode).toHaveAttribute("aria-pressed", "false");
await user.click(toggleMode);
expect(toggleMode).toHaveAttribute("aria-pressed", "true");
fireEvent.pointerDown(cell(4), { buttons: 1 });
expect(cell(3)).toHaveAttribute("aria-selected", "true");
expect(cell(4)).toHaveAttribute("aria-selected", "true");
fireEvent.pointerEnter(cell(5), { buttons: 1 });
expect(cell(5)).toHaveAttribute("aria-selected", "false");
fireEvent.pointerDown(cell(3), { buttons: 1 });
expect(cell(3)).toHaveAttribute("aria-selected", "false");
expect(cell(4)).toHaveAttribute("aria-selected", "true");
});
it("renders one sticky entry pad in a narrow viewport", () => {
const original = Object.getOwnPropertyDescriptor(window, "matchMedia");
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: (query: string) => ({
matches: query === "(max-width: 48rem)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
const { container, unmount } = render(<Workbench />);
expect(container.querySelectorAll(".mobile-number-pad")).toHaveLength(1);
expect(screen.getAllByRole("group", { name: "Entry mode" })).toHaveLength(
1,
);
unmount();
if (original === undefined) Reflect.deleteProperty(window, "matchMedia");
else Object.defineProperty(window, "matchMedia", original);
});
it("keeps fogged selections out of toolbar and helper output", async () => {
const user = userEvent.setup();
const previousHash = window.location.hash;
window.location.hash = encodePuzzleHash(
fromDomainPuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
solution: [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1],
constraints: [{ type: "fog", lights: [15], revealRadius: 0 }],
}),
);
const { unmount } = render(<Workbench />);
expect(
screen.getByRole("gridcell", {
name: "Row 1, column 1, obscured by fog",
}),
).toBeDisabled();
expect(screen.getByText("r4c4")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Helpers" }));
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
await user.clear(screen.getByLabelText("Target sum"));
await user.type(screen.getByLabelText("Target sum"), "4");
await user.click(
screen.getByRole("checkbox", {
name: "Use 1 board cell and candidates",
}),
);
expect(screen.getByText(/^r4c4:/u)).toBeInTheDocument();
unmount();
window.location.hash = previousHash;
});
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
const user = userEvent.setup();
render(<Workbench />);
@@ -257,8 +378,8 @@ describe("Sudoku workbench", () => {
).toBeInTheDocument();
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
await user.clear(screen.getByLabelText("Clue"));
await user.type(screen.getByLabelText("Clue"), "6562");
await user.clear(screen.getByLabelText("Sum"));
await user.type(screen.getByLabelText("Sum"), "6562");
expect(
screen.getByRole("button", { name: "Add / replace outside clue" }),
).toBeDisabled();
@@ -375,4 +496,69 @@ describe("Sudoku workbench", () => {
).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("keeps imported visuals, provenance and metadata after a board edit", async () => {
const user = userEvent.setup();
let downloaded: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
downloaded = value as Blob;
return "blob:preserved-workbench-export";
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
render(<Workbench />);
const imported = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 4,
givens: Array<number>(16).fill(0),
constraints: [],
title: "Preservation regression",
visuals: [
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 0 },
text: "source label",
style: { fill: "#123456" },
},
],
source: { format: "sudokupad", id: "original-source" },
metadata: { edition: "kept" },
};
await user.click(screen.getByRole("button", { name: "Import / export" }));
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
target: { value: JSON.stringify(imported) },
});
await user.click(screen.getByRole("button", { name: "Import locally" }));
expect(document.querySelector(".source-visual--text")).toHaveTextContent(
"source label",
);
fireEvent.pointerDown(
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
{ buttons: 1 },
);
await user.click(
within(screen.getByRole("group", { name: "Digits" })).getByRole(
"button",
{ name: "1" },
),
);
await user.click(screen.getByRole("button", { name: "Import / export" }));
await user.click(
screen.getByRole("button", { name: "Download project JSON" }),
);
const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
values?: number[];
visuals?: unknown[];
source?: unknown;
metadata?: unknown;
};
expect(exported.values?.[0]).toBe(1);
expect(exported.visuals).toEqual(imported.visuals);
expect(exported.source).toEqual(imported.source);
expect(exported.metadata).toEqual(imported.metadata);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
CONSTRAINT_REGISTRY,
CONSTRAINT_TYPES,
constraintAllowedFields,
constraintCells,
constraintLabel,
constraintMetadata,
isConstraintType,
type ConstraintType,
} from "../../src/domain";
const expectedTypes = [
"diagonal",
"anti-knight",
"anti-king",
"non-consecutive",
"disjoint-groups",
"killer-cage",
"thermo",
"arrow",
"kropki",
"xv",
"inequality",
"renban",
"palindrome",
"x-sum",
"skyscraper",
"quadruple",
"maximum",
"minimum",
"odd",
"even",
"little-killer",
"sandwich",
"between-line",
"german-whisper",
"region-sum-line",
"clone",
"extra-region",
"modular-line",
"entropic-line",
"zipper-line",
"double-arrow",
"indexer",
"fog",
] as const satisfies readonly ConstraintType[];
describe("constraint registry", () => {
it("contains one complete metadata entry for every constraint type", () => {
expect([...CONSTRAINT_TYPES].sort()).toEqual([...expectedTypes].sort());
expect(Object.keys(CONSTRAINT_REGISTRY).sort()).toEqual(
[...expectedTypes].sort(),
);
for (const type of expectedTypes) {
const metadata = constraintMetadata(type);
expect(metadata.type).toBe(type);
expect(metadata.label.length).toBeGreaterThan(0);
expect(metadata.fields[0]).toEqual({
key: "type",
kind: "discriminator",
required: true,
});
expect(new Set(metadata.fields.map(({ key }) => key)).size).toBe(
metadata.fields.length,
);
expect(constraintAllowedFields(type).has("negated")).toBe(
metadata.negatable,
);
}
});
it("provides labels, field metadata and a safe type guard", () => {
expect(constraintLabel("little-killer")).toBe("Little killer");
expect(constraintLabel("future-rule")).toBe("Future Rule");
expect(isConstraintType("sandwich")).toBe(true);
expect(isConstraintType("not-a-rule")).toBe(false);
expect(constraintAllowedFields("little-killer")).toEqual(
new Set(["type", "side", "index", "direction", "sum", "negated"]),
);
});
it("resolves global, line, outside and local footprints centrally", () => {
expect(constraintCells(4, { type: "diagonal", direction: "anti" })).toEqual(
[3, 6, 9, 12],
);
expect(constraintCells(4, { type: "disjoint-groups" })).toHaveLength(16);
expect(constraintCells(4, { type: "minimum", cell: 5 })).toEqual([
5, 1, 9, 4, 6,
]);
expect(constraintCells(4, { type: "odd", cell: 5 })).toEqual([5]);
expect(
constraintCells(4, {
type: "little-killer",
side: "top",
index: 0,
direction: "down-right",
sum: 10,
}),
).toEqual([0, 5, 10, 15]);
expect(
constraintCells(4, {
type: "sandwich",
side: "right",
index: 2,
sum: 3,
}),
).toEqual([11, 10, 9, 8]);
expect(
constraintCells(4, {
type: "clone",
cells: [0, 1],
cloneCells: [10, 11],
}),
).toEqual([0, 1, 10, 11]);
expect(
constraintCells(4, { type: "indexer", kind: "box", cell: 0 }),
).toEqual([0, 2, 8, 10]);
expect(
constraintCells(4, { type: "fog", lights: [0, 5], revealRadius: 1 }),
).toEqual([0, 5]);
});
});
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, it } from "vitest";
import {
candidatesForCell,
classicRegions,
compilePuzzle,
constraintIsFeasible,
normalizePuzzle,
validatePuzzle,
type PuzzleDefinition,
type VariantConstraint,
} from "../../src/domain";
import { solveExact } from "../../src/solver";
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,
};
}
const packOneConstraints: readonly VariantConstraint[] = [
{ type: "minimum", cell: 0 },
{ type: "odd", cell: 0 },
{ type: "even", cell: 1 },
{ type: "disjoint-groups" },
{
type: "little-killer",
side: "top",
index: 0,
direction: "down-right",
sum: 10,
},
{ type: "sandwich", side: "left", index: 0, sum: 5 },
];
describe("Pack 1 constraint validation", () => {
it("accepts and clones every production shape", () => {
const source = puzzle4({ constraints: packOneConstraints });
expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] });
const normalized = normalizePuzzle(source);
expect(normalized.constraints).toEqual(packOneConstraints);
expect(normalized.constraints).not.toBe(packOneConstraints);
});
it("rejects invalid cells, fields, directions, paths and unreachable sums", () => {
const invalid: readonly unknown[] = [
{ type: "minimum", cell: 16 },
{ type: "odd", cell: 0, surprise: true },
{ type: "even", cell: -1 },
{ type: "disjoint-groups", negated: true },
{
type: "little-killer",
side: "top",
index: 0,
direction: "up-right",
sum: 10,
},
{
type: "little-killer",
side: "top",
index: 0,
direction: "down-left",
sum: 4,
},
{
type: "little-killer",
side: "top",
index: 0,
direction: "down-right",
sum: 17,
},
{ type: "sandwich", side: "left", index: 0, sum: 1 },
{ type: "sandwich", side: "inside", index: 0, sum: 5 },
];
for (const constraint of invalid) {
const result = validatePuzzle({
...puzzle4(),
constraints: [constraint],
});
expect(result.valid, JSON.stringify(constraint)).toBe(false);
expect(
result.issues.some(({ path }) => path.startsWith("constraints[0]")),
).toBe(true);
}
});
it("rejects disjoint groups on jigsaw regions but accepts renamed standard boxes", () => {
const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3];
const rejected = validatePuzzle(
puzzle4({ regions: jigsaw, constraints: [{ type: "disjoint-groups" }] }),
);
expect(rejected.valid).toBe(false);
expect(rejected.issues).toContainEqual({
path: "constraints[0]",
message: "disjoint groups require the standard rectangular box layout",
});
const renamed = classicRegions(4).map((region) => [2, 0, 3, 1][region]!);
expect(
validatePuzzle(
puzzle4({
regions: renamed,
constraints: [{ type: "disjoint-groups" }],
}),
).valid,
).toBe(true);
});
});
describe("Pack 1 partial feasibility", () => {
it("prunes minimum, odd and even cells before the grid is complete", () => {
const values = new Array<number>(16).fill(0);
values[1] = 3;
expect(
candidatesForCell(
puzzle4({ constraints: [{ type: "minimum", cell: 0 }] }),
values,
0,
),
).toEqual([1, 2]);
expect(
candidatesForCell(
puzzle4({ constraints: [{ type: "odd", cell: 0 }] }),
new Array<number>(16).fill(0),
0,
),
).toEqual([1, 3]);
expect(
candidatesForCell(
puzzle4({ constraints: [{ type: "even", cell: 0 }] }),
new Array<number>(16).fill(0),
0,
),
).toEqual([2, 4]);
const impossibleMinimum = new Array<number>(16).fill(0);
impossibleMinimum[1] = 1;
expect(
constraintIsFeasible({ type: "minimum", cell: 0 }, impossibleMinimum, 4),
).toBe(false);
expect(
constraintIsFeasible(
{ type: "minimum", cell: 0, negated: true },
[2, 1, ...new Array<number>(14).fill(0)],
4,
),
).toBe(true);
});
it("compiles four disjoint houses and enforces their remote peers", () => {
const plain = normalizePuzzle(puzzle4());
const disjoint = compilePuzzle(
normalizePuzzle(puzzle4({ constraints: [{ type: "disjoint-groups" }] })),
);
expect(
disjoint.units.filter(({ kind }) => kind === "disjoint-group"),
).toHaveLength(4);
const values = new Array<number>(16).fill(0);
values[0] = 1;
expect(candidatesForCell(plain, values, 10)).toContain(1);
expect(candidatesForCell(disjoint, values, 10)).not.toContain(1);
});
it("uses the whole little-killer diagonal for partial sum bounds", () => {
const constraint = {
type: "little-killer",
side: "top",
index: 1,
direction: "down-right",
sum: 6,
} as const;
const values = new Array<number>(16).fill(0);
values[1] = 4;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), values, 6),
).toEqual([1]);
const complete = [...solved4];
expect(
constraintIsFeasible(
{
type: "little-killer",
side: "top",
index: 0,
direction: "down-right",
sum: 10,
},
complete,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{
type: "little-killer",
side: "top",
index: 0,
direction: "down-right",
sum: 10,
negated: true,
},
complete,
4,
),
).toBe(false);
});
it("computes exact sandwich possibilities from partial permutations", () => {
const partial = [1, 2, 0, 4, ...new Array<number>(12).fill(0)];
expect(
constraintIsFeasible(
{ type: "sandwich", side: "left", index: 0, sum: 5 },
partial,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "sandwich", side: "left", index: 0, sum: 3 },
partial,
4,
),
).toBe(false);
expect(
constraintIsFeasible(
{
type: "sandwich",
side: "left",
index: 0,
sum: 5,
negated: true,
},
solved4,
4,
),
).toBe(false);
expect(
constraintIsFeasible(
{
type: "sandwich",
side: "left",
index: 0,
sum: 3,
negated: true,
},
solved4,
4,
),
).toBe(true);
});
});
describe("Pack 1 exact solving", () => {
it.each(
packOneConstraints.map(
(constraint) => [constraint.type, constraint] as const,
),
)("solves a puzzle containing %s", (_type, constraint) => {
const givens: number[] = [...solved4];
givens[5] = 0;
givens[10] = 0;
const result = solveExact(puzzle4({ givens, constraints: [constraint] }));
expect(result.count).toBe(1);
expect(result.truncated).toBe(false);
expect(result.solutions[0]).toEqual(solved4);
});
});
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
candidatesForCell,
classicRegions,
compilePuzzle,
constraintIsFeasible,
normalizePuzzle,
validatePuzzle,
type PuzzleDefinition,
type VariantConstraint,
} from "../../src/domain";
import { solveExact } from "../../src/solver";
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,
};
}
const packTwoConstraints: readonly VariantConstraint[] = [
{ type: "between-line", cells: [0, 1, 2] },
{ type: "german-whisper", cells: [0, 3, 1] },
{ type: "region-sum-line", cells: [0, 1, 2] },
{ type: "clone", cells: [0, 1], cloneCells: [6, 7] },
{ type: "extra-region", cells: [0, 7, 9, 14] },
];
describe("Pack 2 constraint validation", () => {
it("accepts and clones every production shape", () => {
const source = puzzle4({ constraints: packTwoConstraints });
expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] });
const normalized = normalizePuzzle(source);
expect(normalized.constraints).toEqual(packTwoConstraints);
expect(normalized.constraints).not.toBe(packTwoConstraints);
});
it.each([
{ type: "between-line", cells: [0, 1] },
{ type: "between-line", cells: [0, 1, 0] },
{ type: "german-whisper", cells: [0] },
{ type: "german-whisper", cells: [0, 1], minimumDifference: 0 },
{ type: "german-whisper", cells: [0, 1], minimumDifference: 4 },
{ type: "region-sum-line", cells: [0, 1] },
{ type: "clone", cells: [0, 1], cloneCells: [8] },
{ type: "clone", cells: [0, 1], cloneCells: [8, 8] },
{ type: "extra-region", cells: [0, 1, 2] },
{ type: "extra-region", cells: [0, 1, 2, 3], negated: true },
] as const)("rejects malformed constraint $type", (constraint) => {
const result = validatePuzzle({ ...puzzle4(), constraints: [constraint] });
expect(result.valid).toBe(false);
expect(
result.issues.some(({ path }) => path.startsWith("constraints[0]")),
).toBe(true);
});
it("uses the puzzle's actual regions when checking region-sum crossings", () => {
expect(
validatePuzzle(
puzzle4({ constraints: [{ type: "region-sum-line", cells: [0, 1] }] }),
).issues,
).toContainEqual({
path: "constraints[0].cells",
message: "region-sum line must cross at least one region boundary",
});
expect(
validatePuzzle(
puzzle4({ constraints: [{ type: "region-sum-line", cells: [1, 2] }] }),
).valid,
).toBe(true);
});
});
describe("Pack 2 partial and completed semantics", () => {
it("enforces between-line interiors and its negated truth", () => {
const constraint = { type: "between-line", cells: [0, 1, 2] } as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[2] = 4;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1),
).toEqual([2, 3]);
expect(
constraintIsFeasible(
constraint,
[2, 0, 3, ...new Array<number>(13).fill(0)],
4,
),
).toBe(false);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
expect(
constraintIsFeasible(
{ ...constraint, negated: true },
[1, 4, 3, ...new Array<number>(13).fill(0)],
4,
),
).toBe(true);
});
it("runs exact dynamic feasibility for default and explicit German whispers", () => {
const constraint = {
type: "german-whisper",
cells: [0, 5, 10],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 2;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 5),
).toEqual([4]);
const impossible = new Array<number>(16).fill(0);
impossible[5] = 2;
expect(
constraintIsFeasible(
{ ...constraint, minimumDifference: 3 },
impossible,
4,
),
).toBe(false);
const complete = [1, 4, 2, ...new Array<number>(13).fill(0)];
const short = { type: "german-whisper", cells: [0, 1, 2] } as const;
expect(constraintIsFeasible(short, complete, 4)).toBe(true);
expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe(
false,
);
complete[1] = 2;
expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe(
true,
);
});
it("balances contiguous region sums using the supplied region map", () => {
const constraint = {
type: "region-sum-line",
cells: [0, 1, 2],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[2] = 3;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1),
).toEqual([2]);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
const unequal = [...solved4];
unequal[2] = 4;
expect(
constraintIsFeasible({ ...constraint, negated: true }, unequal, 4),
).toBe(true);
});
it("enforces ordered clone equality and exact negated state", () => {
const constraint = {
type: "clone",
cells: [0, 1],
cloneCells: [6, 7],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[1] = 2;
partial[6] = 1;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 7),
).toEqual([2]);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
const mismatch = [...solved4];
mismatch[7] = 3;
expect(constraintIsFeasible(constraint, mismatch, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, mismatch, 4),
).toBe(true);
});
it("compiles extra regions as all-different units and remote peers", () => {
const constraint = {
type: "extra-region",
cells: [0, 7, 9, 14],
} as const;
const compiled = compilePuzzle(
normalizePuzzle(puzzle4({ constraints: [constraint] })),
);
expect(
compiled.units.filter(({ kind }) => kind === "extra-region"),
).toEqual([{ kind: "extra-region", index: 0, cells: constraint.cells }]);
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
expect(candidatesForCell(compiled, partial, 7)).not.toContain(1);
expect(compilePuzzle(normalizePuzzle(puzzle4())).peers[7]?.has(0)).toBe(
false,
);
});
});
describe("Pack 2 exact solving", () => {
it.each(
packTwoConstraints.map(
(constraint) => [constraint.type, constraint] as const,
),
)("solves a puzzle containing %s", (_type, constraint) => {
const givens: number[] = [...solved4];
givens[5] = 0;
givens[10] = 0;
const result = solveExact(puzzle4({ givens, constraints: [constraint] }));
expect(result.count).toBe(1);
expect(result.truncated).toBe(false);
expect(result.solutions[0]).toEqual(solved4);
});
});
+355
View File
@@ -0,0 +1,355 @@
import { describe, expect, it } from "vitest";
import {
candidatesForCell,
classicRegions,
constraintIsFeasible,
normalizePuzzle,
validatePuzzle,
type PuzzleDefinition,
type VariantConstraint,
} from "../../src/domain";
import { solveExact } from "../../src/solver";
const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const solved6 = [
1, 2, 3, 4, 5, 6, 4, 5, 6, 1, 2, 3, 2, 3, 4, 5, 6, 1, 5, 6, 1, 2, 3, 4, 3, 4,
5, 6, 1, 2, 6, 1, 2, 3, 4, 5,
] as const;
function puzzle(
size: number,
overrides: Partial<PuzzleDefinition> = {},
): PuzzleDefinition {
return {
version: 1,
size,
givens: new Array<number>(size * size).fill(0),
regions: classicRegions(size),
constraints: [],
...overrides,
};
}
const semantic4: readonly VariantConstraint[] = [
{ type: "modular-line", cells: [0, 1, 2, 3] },
{ type: "zipper-line", cells: [0, 2, 1] },
{ type: "double-arrow", cells: [0, 2, 1] },
{ type: "indexer", kind: "row", cell: 7 },
{ type: "indexer", kind: "column", cell: 5 },
{ type: "indexer", kind: "box", cell: 14 },
];
describe("Pack 3 validation and cloning", () => {
it("accepts all production shapes and preserves canonical fog data", () => {
const definition = puzzle(4, {
solution: solved4,
constraints: [
...semantic4,
{ type: "fog", lights: [0, 5], revealRadius: 1 },
],
});
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
const normalized = normalizePuzzle(definition);
expect(normalized.constraints).toEqual(definition.constraints);
expect(normalized.constraints.find(({ type }) => type === "fog")).toEqual({
type: "fog",
lights: [0, 5],
revealRadius: 1,
});
expect(
validatePuzzle(
puzzle(6, {
constraints: [{ type: "entropic-line", cells: [0, 2, 4] }],
}),
).valid,
).toBe(true);
});
it.each([
[4, { type: "modular-line", cells: [0, 1] }],
[4, { type: "entropic-line", cells: [0, 1, 2] }],
[6, { type: "entropic-line", cells: [0, 1] }],
[4, { type: "zipper-line", cells: [0, 1, 2, 3] }],
[4, { type: "zipper-line", cells: [0, 1] }],
[4, { type: "double-arrow", cells: [0, 1] }],
[4, { type: "indexer", kind: "diagonal", cell: 0 }],
[4, { type: "indexer", kind: "row", cell: 16 }],
[4, { type: "fog", lights: [], revealRadius: 1 }],
[4, { type: "fog", lights: [0], revealRadius: 2 }],
] as const)(
"rejects malformed Pack 3 data on size %i",
(size, constraint) => {
const result = validatePuzzle({
...puzzle(size),
solution: size === 4 ? solved4 : solved6,
constraints: [constraint],
});
expect(result.valid).toBe(false);
expect(
result.issues.some(({ path }) => path.startsWith("constraints[0]")),
).toBe(true);
},
);
it("requires a complete valid trusted solution for fog", () => {
const withoutSolution = validatePuzzle(
puzzle(4, { constraints: [{ type: "fog", lights: [0] }] }),
);
expect(withoutSolution.issues).toContainEqual({
path: "constraints[0]",
message: "fog requires a complete trusted puzzle solution",
});
expect(
validatePuzzle(
puzzle(4, {
solution: new Array<number>(16).fill(0),
constraints: [{ type: "fog", lights: [0] }],
}),
).valid,
).toBe(false);
});
it("rejects box indexers on custom regions while row and column remain valid", () => {
const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3];
expect(
validatePuzzle(
puzzle(4, {
regions: jigsaw,
constraints: [{ type: "indexer", kind: "box", cell: 0 }],
}),
).issues,
).toContainEqual({
path: "constraints[0]",
message: "box indexers require the standard rectangular box layout",
});
expect(
validatePuzzle(
puzzle(4, {
regions: jigsaw,
constraints: [{ type: "indexer", kind: "row", cell: 0 }],
}),
).valid,
).toBe(true);
});
});
describe("Pack 3 line semantics", () => {
it("enforces modular residue windows and their negation", () => {
const constraint = {
type: "modular-line",
cells: [0, 1, 2, 3],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[1] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2),
).toEqual([3]);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
const invalid = [1, 2, 4, 1, ...new Array<number>(12).fill(0)];
expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
).toBe(true);
});
it("enforces equal entropic bands on divisible grids", () => {
const constraint = {
type: "entropic-line",
cells: [0, 1, 2],
} as const;
const partial = new Array<number>(36).fill(0);
partial[0] = 1;
partial[1] = 3;
expect(
candidatesForCell(puzzle(6, { constraints: [constraint] }), partial, 2),
).toEqual([5, 6]);
const valid = [1, 3, 5, ...new Array<number>(33).fill(0)];
const invalid = [1, 2, 5, ...new Array<number>(33).fill(0)];
expect(constraintIsFeasible(constraint, valid, 6)).toBe(true);
expect(constraintIsFeasible(constraint, invalid, 6)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, valid, 6),
).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 6),
).toBe(true);
});
it("enforces zipper pair sums around the centre", () => {
const constraint = { type: "zipper-line", cells: [0, 1, 2] } as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[1] = 3;
expect(
candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2),
).toEqual([2]);
const valid = [1, 3, 2, ...new Array<number>(13).fill(0)];
const invalid = [1, 3, 3, ...new Array<number>(13).fill(0)];
expect(constraintIsFeasible(constraint, valid, 4)).toBe(true);
expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, valid, 4),
).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
).toBe(true);
const impossibleCentre = new Array<number>(16).fill(0);
impossibleCentre[1] = 1;
expect(constraintIsFeasible(constraint, impossibleCentre, 4)).toBe(false);
});
it("balances double-arrow endpoints against all interior digits", () => {
const constraint = { type: "double-arrow", cells: [0, 1, 2] } as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[2] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 1),
).toEqual([3]);
const valid = [1, 3, 2, ...new Array<number>(13).fill(0)];
const invalid = [1, 4, 2, ...new Array<number>(13).fill(0)];
expect(constraintIsFeasible(constraint, valid, 4)).toBe(true);
expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, valid, 4),
).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
).toBe(true);
});
it("keeps sound partial bounds when peer rules may narrow remaining digits", () => {
const empty = new Array<number>(16).fill(0);
expect(
constraintIsFeasible(
{ type: "zipper-line", cells: [0, 1, 2, 3, 4] },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "double-arrow", cells: [0, 1, 2, 3] },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "modular-line", cells: [0, 1, 2], negated: true },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "zipper-line", cells: [0, 1, 2], negated: true },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "double-arrow", cells: [0, 1, 2], negated: true },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "indexer", kind: "row", cell: 0, negated: true },
empty,
4,
),
).toBe(true);
});
});
describe("Pack 3 indexers and fog", () => {
it("resolves row, column and rectangular-box targets", () => {
const row = { type: "indexer", kind: "row", cell: 1 } as const;
const rowValues = new Array<number>(16).fill(0);
rowValues[1] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [row] }), rowValues, 5),
).toEqual([1]);
const column = { type: "indexer", kind: "column", cell: 5 } as const;
expect(constraintIsFeasible(column, solved4, 4)).toBe(true);
const box = { type: "indexer", kind: "box", cell: 1 } as const;
const boxValues = new Array<number>(16).fill(0);
boxValues[1] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [box] }), boxValues, 3),
).toEqual([1]);
boxValues[3] = 2;
expect(constraintIsFeasible(box, boxValues, 4)).toBe(false);
expect(constraintIsFeasible({ ...box, negated: true }, boxValues, 4)).toBe(
true,
);
// In a 2x3-box grid, r2c5 has within-box position 5. Digit 3 points at
// the same position in box 3 (r4c2), which must contain box index 2.
const rectangularBox = {
type: "indexer",
kind: "box",
cell: 10,
} as const;
const rectangularValues = new Array<number>(36).fill(0);
rectangularValues[10] = 3;
expect(
candidatesForCell(
puzzle(6, { constraints: [rectangularBox] }),
rectangularValues,
19,
),
).toEqual([2]);
});
it("keeps fog entirely non-semantic in exact search", () => {
const givens = [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3];
const plain = solveExact(puzzle(4, { givens }));
const fogged = solveExact(
puzzle(4, {
givens,
solution: solved4,
constraints: [{ type: "fog", lights: [0], revealRadius: 1 }],
}),
);
expect(fogged.solutions).toEqual(plain.solutions);
expect(fogged.count).toBe(plain.count);
});
});
describe("Pack 3 exact solving", () => {
it.each(
semantic4.map((constraint) => [constraint.type, constraint] as const),
)("solves a 4x4 puzzle containing %s", (_type, constraint) => {
const givens: number[] = [...solved4];
givens[5] = 0;
givens[10] = 0;
const result = solveExact(puzzle(4, { givens, constraints: [constraint] }));
expect(result.count).toBe(1);
expect(result.solutions[0]).toEqual(solved4);
});
it("solves a 6x6 puzzle with an entropic line", () => {
const givens: number[] = [...solved6];
givens[2] = 0;
givens[20] = 0;
const result = solveExact(
puzzle(6, {
givens,
constraints: [{ type: "entropic-line", cells: [0, 2, 4] }],
}),
);
expect(result.count).toBe(1);
expect(result.solutions[0]).toEqual(solved6);
});
});
+4 -3
View File
@@ -98,9 +98,10 @@ describe("Sudoku Tools document format", () => {
};
const hash = encodePuzzleHash(source);
expect(hash).toMatch(/^#sudoku=v1\./u);
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual(
source,
);
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual({
...source,
source: { format: "sudoku-tools" },
});
});
it("round-trips grids for every supported size and symbol", () => {
+59 -2
View File
@@ -115,6 +115,63 @@ describe("fpuzzles interoperability", () => {
expect(imported.constraints).toEqual(expect.arrayContaining(expected));
});
it("round-trips the registered expansion-pack constraints", () => {
const solution = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 5, 6, 7, 8, 9, 1, 2, 3, 7, 8, 9, 1, 2, 3, 4,
5, 6, 2, 3, 4, 5, 6, 7, 8, 9, 1, 5, 6, 7, 8, 9, 1, 2, 3, 4, 8, 9, 1, 2, 3,
4, 5, 6, 7, 3, 4, 5, 6, 7, 8, 9, 1, 2, 6, 7, 8, 9, 1, 2, 3, 4, 5, 9, 1, 2,
3, 4, 5, 6, 7, 8,
];
const source: SudokuDocument = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 9,
givens: Array<number>(81).fill(0),
solution,
constraints: [
{ type: "disjoint-groups" },
{ type: "minimum", cell: 0 },
{ type: "odd", cell: 1 },
{ type: "even", cell: 2 },
{
type: "little-killer",
side: "top",
index: 0,
direction: "down-right",
sum: 45,
},
{ type: "sandwich", side: "left", index: 0, sum: 20 },
{ type: "between-line", cells: [0, 1, 2] },
{ type: "german-whisper", cells: [9, 10], minimumDifference: 4 },
{ type: "region-sum-line", cells: [18, 19, 20, 21] },
{ type: "clone", cells: [27, 28], cloneCells: [36, 37] },
{
type: "extra-region",
cells: [0, 10, 20, 30, 40, 50, 60, 70, 80],
},
{ type: "modular-line", cells: [45, 46, 47] },
{ type: "entropic-line", cells: [54, 55, 56] },
{ type: "zipper-line", cells: [63, 64, 65] },
{ type: "double-arrow", cells: [72, 73, 74] },
{ type: "indexer", kind: "row", cell: 3 },
{ type: "indexer", kind: "column", cell: 4 },
{ type: "indexer", kind: "box", cell: 5 },
{ type: "fog", lights: [0, 40], revealRadius: 1 },
],
};
const imported = parseFpuzzles(exportFpuzzles(source));
expect(imported.constraints).toEqual(
expect.arrayContaining(
source.constraints.map((constraint) =>
constraint.type === "fog"
? { type: "fog", lights: constraint.lights }
: constraint,
),
),
);
});
it("recognizes server-only short puzzle IDs without making a request", () => {
expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError(
NetworkPuzzleIdError,
@@ -220,9 +277,9 @@ describe("fpuzzles interoperability", () => {
parseFpuzzles({
size: 9,
grid: emptyGrid(),
odd: [{ cell: "R1C1" }],
nabner: [{ lines: [["R1C1", "R1C2"]] }],
}),
).toThrow(/odd cells.*silently weakening/u);
).toThrow(/Nabner lines.*silently weakening/u);
expect(() =>
parseFpuzzles({
size: 9,
+27 -17
View File
@@ -2,7 +2,6 @@ import { compressToBase64 } from "lz-string";
import { describe, expect, it, vi } from "vitest";
import {
NetworkPuzzleIdError,
UnsupportedPuzzleConstructsError,
importPenpa,
importPuzzle,
importSudokuPad,
@@ -91,27 +90,38 @@ describe("local puzzle interoperability", () => {
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);
it("preserves allowlisted SCL drawings without treating them as rules", async () => {
const source = {
...sclPuzzle(),
lines: [
{
wayPoints: [
[0.5, 0.5],
[1.5, 1.5],
],
},
],
overlays: [{ center: [1, 1], width: 1, height: 1, text: "?" }],
};
const parsed = parseSudokuPadPuzzle(source);
const imported = await importPuzzle(JSON.stringify(source));
expect(parsed.visuals).toHaveLength(3);
expect(parsed.source).toEqual({ format: "sudokupad", id: "local-scl" });
expect(imported.preview.preservedVisuals).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "overlay:polyline", count: 1 }),
expect.objectContaining({ key: "overlay:text", count: 1 }),
]),
);
expect(imported.preview.warnings.join(" ")).toMatch(/not solver-enforced/u);
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [{}],
}),
).toThrow(/visual lines/u);
).toThrow(/wayPoints|bounded/u);
});
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
+360
View File
@@ -0,0 +1,360 @@
import { describe, expect, it } from "vitest";
import { normalizePuzzle } from "../../src/domain";
import {
MAX_VISUAL_POINTS,
MAX_VISUAL_PRIMITIVES,
SUDOKU_DOCUMENT_SCHEMA,
cloneSudokuDocument,
exportFpuzzles,
exportSudokuPadJson,
exportSudokuPadPayload,
extractPreservedDocumentExtras,
fromDomainPuzzle,
importSudokuPad,
normalizeSudokuDocument,
parseFpuzzles,
parseSudokuPadPuzzle,
renderPuzzleSvg,
toDomainPuzzle,
type SafeVisualPrimitive,
type SudokuDocument,
} from "../../src/formats";
const REGIONS_4X4 = [0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3] as const;
function emptyCells(size = 4): Record<string, unknown>[][] {
return Array.from({ length: size }, () =>
Array.from({ length: size }, () => ({})),
);
}
function sourceVisuals(): SafeVisualPrimitive[] {
return [
{
type: "line",
layer: "underlay",
start: { kind: "coordinate", x: 0, y: 0 },
end: { kind: "coordinate", x: 4, y: 4 },
style: { stroke: "#123456", strokeWidth: 0.04, opacity: 0.8 },
},
{
type: "polyline",
layer: "overlay",
points: [
{ kind: "cell", cell: 0 },
{ kind: "cell", cell: 1 },
{ kind: "cell", cell: 5 },
],
style: { stroke: "#abcdef", fill: "transparent" },
},
{
type: "rectangle",
layer: "underlay",
center: { kind: "cell", cell: 6 },
width: 0.8,
height: 0.6,
cornerRadius: 0.1,
style: { fill: "#ffeedd", stroke: "#112233" },
},
{
type: "ellipse",
layer: "overlay",
center: { kind: "cell", cell: 9 },
radiusX: 0.35,
radiusY: 0.2,
style: { fill: "transparent", stroke: "#445566" },
},
{
type: "circle",
layer: "overlay",
center: { kind: "cell", cell: 10 },
radius: 0.25,
style: { fill: "#778899", opacity: 0.5 },
},
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 15 },
text: "safe label",
style: { fill: "#010203", fontSize: 0.4 },
},
];
}
function sourceDocument(): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 4,
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
values: [1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
cornerMarks: Array.from({ length: 16 }, (_unused, cell) =>
cell === 2 ? [3, 4] : [],
),
centerMarks: Array.from({ length: 16 }, (_unused, cell) =>
cell === 3 ? [2, 3] : [],
),
elapsedMs: 12_345,
regions: REGIONS_4X4,
constraints: [
{ type: "killer-cage", cells: [0, 1], sum: 3, noRepeat: true },
{ type: "thermo", cells: [4, 5] },
],
title: "Preserved fixture",
author: "Local setter",
id: "domain-id",
visuals: sourceVisuals(),
source: { format: "sudokupad", id: "source-id", version: "1" },
metadata: { edition: "nightly", featured: true, attempt: 2 },
};
}
describe("source-preserving interoperability", () => {
it("clones and explicitly merges non-domain source extras", () => {
const source = normalizeSudokuDocument(sourceDocument());
const clone = cloneSudokuDocument(source);
const extras = extractPreservedDocumentExtras(source);
const domain = normalizePuzzle(toDomainPuzzle(source));
const merged = fromDomainPuzzle(domain, extras);
expect(clone.visuals).toEqual(source.visuals);
expect(clone.visuals).not.toBe(source.visuals);
expect(clone.source).not.toBe(source.source);
expect(clone.metadata).not.toBe(source.metadata);
expect(merged.visuals).toEqual(source.visuals);
expect(merged.source).toEqual(source.source);
expect(merged.metadata).toEqual(source.metadata);
});
it("round-trips f-puzzles decorative primitives and source scalars", () => {
const grid = emptyCells();
grid[0]![0] = { value: 1, given: true };
const parsed = parseFpuzzles({
id: "fp-source",
size: 4,
grid,
successMessage: "Done",
line: [
{
lines: [["R1C1", "R1C2"]],
outlineC: "#123456",
width: 0.1,
},
],
rectangle: [
{
cells: ["R2C2"],
width: 0.8,
height: 0.6,
baseC: "#abcdef",
},
],
circle: [
{
cells: ["R3C3"],
width: 0.5,
height: 0.5,
outlineC: "#112233",
},
],
text: [{ cells: ["R4C4"], value: "A&B", fontC: "#445566" }],
});
expect(parsed.visuals?.map(({ type }) => type)).toEqual([
"polyline",
"rectangle",
"circle",
"text",
]);
expect(parsed.source).toEqual({ format: "fpuzzles", id: "fp-source" });
expect(parsed.metadata).toEqual({ successMessage: "Done" });
const exported = exportFpuzzles(parsed);
expect(exported.id).toBe("fp-source");
expect(exported.line).toBeInstanceOf(Array);
expect(exported.rectangle).toBeInstanceOf(Array);
expect(exported.circle).toBeInstanceOf(Array);
expect(exported.text).toBeInstanceOf(Array);
expect(exported.successMessage).toBe("Done");
});
it("exports real SCL JSON and payload with progress and exact semantics", () => {
const source = sourceDocument();
const json = exportSudokuPadJson(source, true);
const raw = JSON.parse(json) as Record<string, unknown>;
const payload = exportSudokuPadPayload(source);
const importedJson = importSudokuPad(json);
const importedPayload = importSudokuPad(payload);
expect(raw.id).toBe("source-id");
expect(raw.cells).toBeInstanceOf(Array);
expect(raw.regions).toBeInstanceOf(Array);
expect(raw.cages).toBeInstanceOf(Array);
expect(raw.lines).toBeInstanceOf(Array);
expect(raw.underlays).toBeInstanceOf(Array);
expect(raw.overlays).toBeInstanceOf(Array);
expect(payload).toMatch(/^scl/u);
for (const imported of [importedJson, importedPayload]) {
expect(imported.constraints).toEqual(source.constraints);
expect(imported.values).toEqual(source.values);
expect(imported.cornerMarks).toEqual(source.cornerMarks);
expect(imported.centerMarks).toEqual(source.centerMarks);
expect(imported.elapsedMs).toBe(source.elapsedMs);
expect(imported.regions).toEqual(source.regions);
expect(imported.metadata).toEqual(
expect.objectContaining({ edition: "nightly", featured: true }),
);
expect(imported.source).toEqual({
format: "sudokupad",
id: "source-id",
});
expect(imported.visuals?.length).toBeGreaterThanOrEqual(
source.visuals!.length,
);
}
});
it("does not promote retained metadata into SudokuPad rule fields", () => {
const source: SudokuDocument = {
...sourceDocument(),
constraints: [],
title: undefined,
author: undefined,
rules: undefined,
solution: undefined,
metadata: {
antiknight: true,
antiking: true,
nonconsecutive: true,
title: "not a title",
author: "not an author",
rules: "not a rule",
solution: "1234341221434321",
sudokuToolsConstraints: '[{"type":"anti-knight"}]',
edition: "retained",
},
};
const raw = JSON.parse(exportSudokuPadJson(source)) as {
metadata: Record<string, unknown>;
};
const imported = importSudokuPad(JSON.stringify(raw));
expect(raw.metadata).not.toHaveProperty("antiknight");
expect(raw.metadata).not.toHaveProperty("antiking");
expect(raw.metadata).not.toHaveProperty("nonconsecutive");
expect(raw.metadata).not.toHaveProperty("title");
expect(raw.metadata).not.toHaveProperty("author");
expect(raw.metadata).not.toHaveProperty("rules");
expect(raw.metadata).not.toHaveProperty("solution");
expect(raw.metadata.edition).toBe("retained");
expect(imported.constraints).toEqual([]);
expect(imported.title).toBeUndefined();
expect(imported.solution).toBeUndefined();
});
it.each(["onclick", "style", "html", "d", "href"])(
"rejects the executable or raw visual field %s",
(field) => {
expect(() =>
parseSudokuPadPuzzle({
cells: emptyCells(),
overlays: [
{
center: [0.5, 0.5],
width: 1,
height: 1,
[field]: "javascript:alert(1)",
},
],
}),
).toThrow(/not an allowlisted visual field/u);
},
);
it("rejects unsafe colours and non-finite or out-of-bounds geometry", () => {
expect(() =>
parseSudokuPadPuzzle({
cells: emptyCells(),
lines: [
{
wayPoints: [
[0.5, 0.5],
[1.5, 1.5],
],
color: "url(javascript:alert(1))",
},
],
}),
).toThrow(/hexadecimal colour/u);
expect(() =>
parseSudokuPadPuzzle({
cells: emptyCells(),
overlays: [
{ center: [Number.POSITIVE_INFINITY, 0], width: 1, height: 1 },
],
}),
).toThrow(/finite number/u);
expect(() =>
parseSudokuPadPuzzle({
cells: emptyCells(),
overlays: [{ center: [99, 99], width: 1, height: 1 }],
}),
).toThrow(/finite number/u);
});
it("enforces primitive and aggregate point limits", () => {
const visual = sourceVisuals()[0]!;
expect(() =>
normalizeSudokuDocument({
...sourceDocument(),
visuals: Array.from(
{ length: MAX_VISUAL_PRIMITIVES + 1 },
() => visual,
),
}),
).toThrow(/at most .* primitives/u);
const points = Array.from({ length: MAX_VISUAL_POINTS / 2 + 1 }, () => ({
kind: "coordinate" as const,
x: 0,
y: 0,
}));
expect(() =>
normalizeSudokuDocument({
...sourceDocument(),
visuals: [
{ type: "polyline", layer: "overlay", points },
{ type: "polyline", layer: "overlay", points },
],
}),
).toThrow(/more than .* anchors/u);
});
it("escapes imported text and emits no executable SVG attributes", () => {
const malicious =
'</text><script>alert(1)</script><image href="https://evil.invalid/x">';
const document = normalizeSudokuDocument({
...sourceDocument(),
visuals: [
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 0 },
text: malicious,
style: { fill: "#000000" },
},
],
});
const svg = renderPuzzleSvg(document);
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
expect(parsed.querySelector("parsererror")).toBeNull();
expect(parsed.querySelector("script, image")).toBeNull();
expect(
parsed.querySelector("[href], [src], [onclick], [onload]"),
).toBeNull();
expect(parsed.querySelector(".source-visual")?.textContent).toBe(malicious);
expect(svg).not.toContain("<script>");
expect(svg).not.toContain("<image");
});
});
+311
View File
@@ -0,0 +1,311 @@
import { describe, expect, it } from "vitest";
import {
classicRegions,
compilePuzzle,
normalizePuzzle,
} from "../../src/domain";
import {
findAic,
findAdvancedLogicalStep,
findFinnedFish,
findJellyfish,
findSimpleColouring,
findSkyscraper,
findTwoStringKite,
findUniqueRectangle,
findWWing,
findXChain,
findXYChain,
solveLogically,
type AdvancedLogicalContext,
} from "../../src/solver";
const size = 9;
const empty = normalizePuzzle({
version: 1,
size,
givens: new Array<number>(size * size).fill(0),
regions: classicRegions(size),
constraints: [],
});
const compiled = compilePuzzle(empty);
function cell(row: number, column: number): number {
return row * size + column;
}
function mask(values: readonly number[]): number {
return values.reduce((result, value) => result | (1 << value), 0);
}
function context(
candidates: ReadonlyArray<readonly [number, readonly number[]]>,
options: Pick<
AdvancedLogicalContext,
"uniquenessProven" | "uniquenessPatternsSafe"
> = {},
): AdvancedLogicalContext {
const masks = new Array<number>(size * size).fill(0);
for (const [candidateCell, values] of candidates) {
masks[candidateCell] = mask(values);
}
return {
size,
values: new Array<number>(size * size).fill(0),
masks,
regions: empty.regions,
peers: compiled.peers,
units: compiled.units,
...options,
};
}
function expectElimination(
result: ReturnType<typeof findJellyfish>,
technique: string,
target: number,
value: number,
): void {
expect(result?.technique).toBe(technique);
expect(result?.placements).toEqual([]);
expect(result?.eliminations).toContainEqual({
cell: target,
values: [value],
});
expect(result?.explanation.length).toBeGreaterThan(12);
}
describe("advanced logical techniques", () => {
it("finds a row-based Jellyfish", () => {
const target = cell(4, 0);
const result = findJellyfish(
context([
[cell(0, 0), [5]],
[cell(0, 1), [5]],
[cell(1, 1), [5]],
[cell(1, 2), [5]],
[cell(2, 2), [5]],
[cell(2, 3), [5]],
[cell(3, 0), [5]],
[cell(3, 3), [5]],
[target, [5, 8]],
]),
);
expectElimination(result, "jellyfish", target, 5);
expect(
findAdvancedLogicalStep(
context([
[cell(0, 0), [5]],
[cell(0, 1), [5]],
[cell(1, 1), [5]],
[cell(1, 2), [5]],
[cell(2, 2), [5]],
[cell(2, 3), [5]],
[cell(3, 0), [5]],
[cell(3, 3), [5]],
[target, [5, 8]],
]),
),
).toEqual(result);
});
it("finds Finned X-Wings and Finned Swordfish", () => {
const xWingTarget = cell(2, 0);
const xWing = findFinnedFish(
context([
[cell(0, 0), [5]],
[cell(0, 1), [5]],
[cell(0, 2), [5]],
[cell(1, 0), [5]],
[cell(1, 1), [5]],
[xWingTarget, [5, 8]],
]),
);
expectElimination(xWing, "finned-x-wing", xWingTarget, 5);
const swordfishTarget = cell(1, 0);
const swordfish = findFinnedFish(
context([
[cell(0, 0), [6]],
[cell(0, 1), [6]],
[cell(0, 2), [6]],
[cell(3, 3), [6]],
[cell(3, 6), [6]],
[cell(4, 3), [6]],
[cell(4, 6), [6]],
[swordfishTarget, [6, 8]],
]),
);
expectElimination(swordfish, "finned-swordfish", swordfishTarget, 6);
});
it("finds a Skyscraper", () => {
const target = cell(2, 5);
const result = findSkyscraper(
context([
[cell(0, 0), [7]],
[cell(0, 3), [7]],
[cell(1, 0), [7]],
[cell(1, 4), [7]],
[target, [2, 7]],
]),
);
expectElimination(result, "skyscraper", target, 7);
});
it("finds a Two-String Kite", () => {
const target = cell(7, 6);
const result = findTwoStringKite(
context([
[cell(0, 0), [6]],
[cell(0, 6), [6]],
[cell(1, 1), [6]],
[cell(7, 1), [6]],
[target, [3, 6]],
]),
);
expectElimination(result, "two-string-kite", target, 6);
});
it("applies a simple-colouring colour trap", () => {
const target = cell(1, 1);
const result = findSimpleColouring(
context([
[cell(0, 0), [4]],
[cell(0, 4), [4]],
[cell(4, 4), [4]],
[cell(4, 1), [4]],
[target, [4, 8]],
[cell(2, 2), [4, 7]],
[cell(7, 1), [4, 9]],
]),
);
expectElimination(result, "simple-colouring", target, 4);
});
it("finds a W-Wing", () => {
const target = cell(0, 4);
const result = findWWing(
context([
[cell(0, 0), [1, 2]],
[cell(4, 4), [1, 2]],
[cell(0, 3), [1, 3, 4]],
[cell(4, 3), [1, 3, 4]],
[target, [2, 4]],
]),
);
expectElimination(result, "w-wing", target, 2);
});
it("finds an X-Chain", () => {
const target = cell(1, 1);
const result = findXChain(
context([
[cell(0, 0), [5]],
[cell(0, 4), [5]],
[cell(3, 4), [5]],
[cell(3, 1), [5]],
[target, [5, 8]],
[cell(2, 2), [5, 7]],
[cell(7, 1), [5, 9]],
]),
);
expectElimination(result, "x-chain", target, 5);
});
it("finds an XY-Chain", () => {
const target = cell(4, 0);
const result = findXYChain(
context([
[cell(0, 0), [1, 2]],
[cell(0, 4), [2, 3]],
[cell(4, 4), [1, 3]],
[target, [1, 4, 5]],
]),
);
expectElimination(result, "xy-chain", target, 1);
});
it("finds a mixed Alternating Inference Chain", () => {
const target = cell(1, 1);
const result = findAic(
context([
[cell(0, 0), [1, 2]],
[cell(0, 4), [2, 4, 5]],
[cell(4, 4), [2, 3, 4]],
[cell(4, 8), [1, 3, 4]],
[cell(4, 1), [1, 5, 6]],
[target, [1, 7, 8]],
]),
);
expectElimination(result, "aic", target, 1);
});
it("never uses a Unique Rectangle without both safety gates", () => {
const roof = cell(1, 3);
const candidates = [
[cell(0, 0), [1, 2]],
[cell(0, 3), [1, 2]],
[cell(1, 0), [1, 2]],
[roof, [1, 2, 3]],
] as const;
expect(findUniqueRectangle(context(candidates))).toBeUndefined();
expect(
findUniqueRectangle(context(candidates, { uniquenessProven: true })),
).toBeUndefined();
expect(
findUniqueRectangle(
context(candidates, {
uniquenessProven: true,
uniquenessPatternsSafe: true,
}),
),
).toMatchObject({
technique: "unique-rectangle",
eliminations: [{ cell: roof, values: [1, 2] }],
});
});
it("integrates the explicit uniqueness proof gate into solveLogically", () => {
const digits = Array.from({ length: size }, (_unused, index) => index + 1);
const candidates = Array.from({ length: size * size }, () => [...digits]);
const withoutPair = digits.filter((value) => value !== 1 && value !== 2);
const first = cell(0, 0);
const second = cell(0, 3);
const third = cell(1, 0);
const roof = cell(1, 3);
for (let current = 0; current < size * size; current += 1) {
const row = Math.floor(current / size);
const column = current % size;
if (row === 0 || column === 0 || empty.regions[current] === 0) {
candidates[current] = [...withoutPair];
}
}
candidates[first] = [1, 2];
candidates[second] = [1, 2];
candidates[third] = [1, 2];
candidates[roof] = [1, 2, 3];
const ordinary = solveLogically(empty, { candidates, maxSteps: 1 });
expect(ordinary.steps[0]?.technique).not.toBe("unique-rectangle");
const provenUnique = solveLogically(empty, {
candidates,
maxSteps: 1,
uniquenessProven: true,
});
expect(provenUnique.steps[0]).toMatchObject({
technique: "unique-rectangle",
eliminations: [{ cell: roof, values: [1, 2] }],
});
});
});
+297
View File
@@ -0,0 +1,297 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import {
clueOrbit,
evaluateTechniqueProfile,
generateVariant,
generateVariantBatch,
minimizePuzzleWithReport,
solveExact,
type ClueSymmetry,
type DifficultyAssessment,
type PracticeTechnique,
} from "../../src/solver";
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const complete4: PuzzleDefinition = {
version: 1,
size: 4,
givens: solution4,
solution: solution4,
regions: classicRegions(4),
constraints: [],
};
describe("Generator 2.0 clue removal", () => {
it("builds all supported clue-symmetry orbits", () => {
expect(clueOrbit(4, 1, "none")).toEqual([1]);
expect(clueOrbit(4, 1, "rotational")).toEqual([1, 14]);
expect(clueOrbit(4, 1, "horizontal")).toEqual([1, 13]);
expect(clueOrbit(4, 1, "vertical")).toEqual([1, 2]);
expect(clueOrbit(4, 1, "diagonal-main")).toEqual([1, 4]);
expect(clueOrbit(4, 1, "diagonal-anti")).toEqual([1, 11]);
expect(clueOrbit(4, 1, "orthogonal")).toEqual([1, 7, 8, 14]);
expect(clueOrbit(4, 1, "four-way")).toEqual(clueOrbit(4, 1, "orthogonal"));
});
it.each([
"rotational",
"horizontal",
"vertical",
"diagonal-main",
"diagonal-anti",
"orthogonal",
] as const satisfies readonly ClueSymmetry[])(
"preserves %s symmetry during grouped removal",
(symmetry) => {
const result = minimizePuzzleWithReport(complete4, {
seed: `symmetry-${symmetry}`,
symmetry,
targetClues: 8,
maxChecks: 32,
});
for (let cell = 0; cell < 16; cell += 1) {
const present = (result.puzzle.givens[cell] ?? 0) !== 0;
expect(
clueOrbit(4, cell, symmetry).every(
(other) => ((result.puzzle.givens[other] ?? 0) !== 0) === present,
),
).toBe(true);
}
expect(result.minimality.symmetryPreserved).toBe(true);
},
);
it("proves individual-given minimality when every deletion check completes", () => {
const result = minimizePuzzleWithReport(complete4, {
seed: "minimal-proof",
symmetry: "rotational",
targetClues: 8,
minimalGivens: true,
maxChecks: 64,
solveTimeoutMs: 2_000,
});
expect(result.minimality.status).toBe("proven-minimal");
expect(result.minimality.unknownCells).toEqual([]);
for (let cell = 0; cell < result.puzzle.givens.length; cell += 1) {
if (result.puzzle.givens[cell] === 0) continue;
const givens = [...result.puzzle.givens];
givens[cell] = 0;
const exact = solveExact(
{ ...result.puzzle, givens },
{ maxSolutions: 2 },
);
expect(exact.count).toBe(2);
}
});
it("reports unknown instead of minimal after a tiny shared check cap", () => {
const result = minimizePuzzleWithReport(complete4, {
seed: "minimal-bounded",
targetClues: 16,
minimalGivens: true,
maxChecks: 1,
});
expect(result.minimality.status).toBe("unknown");
expect(result.minimality.checksPerformed).toBe(1);
expect(result.minimality.unknownCells.length).toBeGreaterThan(0);
expect(result.minimality.limitReasons).toContain("check-cap");
});
it("treats a truncated deletion search as unknown evidence", () => {
const result = minimizePuzzleWithReport(complete4, {
seed: "minimal-node-bound",
targetClues: 16,
minimalGivens: true,
maxChecks: 32,
solveMaxNodes: 1,
});
expect(result.minimality.status).toBe("unknown");
expect(result.minimality.limitReasons).toContain("node-cap");
expect(result.minimality.unknownCells.length).toBeGreaterThan(0);
});
it("never claims minimality before proving the input is unique", () => {
const ambiguous: PuzzleDefinition = {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
};
const result = minimizePuzzleWithReport(ambiguous, {
minimalGivens: true,
targetClues: 0,
maxChecks: 32,
});
expect(result.minimality.status).toBe("unknown");
expect(result.minimality.checksPerformed).toBe(1);
expect(result.minimality.limitReasons).toContain("not-unique");
expect(result.puzzle.givens).toEqual(ambiguous.givens);
});
});
describe("Generator 2.0 variants and profiles", () => {
it("combines multiple compatible variant families deterministically", () => {
const options = {
variants: ["kropki", "thermo"] as const,
size: 4,
seed: "mixed-local",
targetClues: 16,
maxChecks: 1,
constraintCount: 3,
};
const generated = generateVariant(options);
expect(generated.variant).toBe("mixed");
expect(generated.families).toEqual(["thermo", "kropki"]);
expect(
generated.puzzle.constraints.some(({ type }) => type === "thermo"),
).toBe(true);
expect(
generated.puzzle.constraints.some(({ type }) => type === "kropki"),
).toBe(true);
expect(generateVariant(options)).toEqual(generated);
});
it("scales local marking counts with explicit density", () => {
const common = {
variant: "inequality" as const,
size: 4,
seed: "density",
targetClues: 16,
maxChecks: 1,
constraintCount: 6,
};
const sparse = generateVariant({ ...common, constraintDensity: "sparse" });
const dense = generateVariant({ ...common, constraintDensity: "dense" });
expect(sparse.generatedConstraintCount).toBeLessThan(
dense.generatedConstraintCount,
);
expect(sparse.constraintDensity).toBe("sparse");
expect(dense.constraintDensity).toBe("dense");
const sparseKiller = generateVariant({
variant: "killer",
size: 4,
seed: "killer-density",
targetClues: 16,
maxChecks: 1,
constraintDensity: "sparse",
});
const denseKiller = generateVariant({
variant: "killer",
size: 4,
seed: "killer-density",
targetClues: 16,
maxChecks: 1,
constraintDensity: "dense",
});
expect(sparseKiller.generatedConstraintCount).toBeLessThanOrEqual(
denseKiller.generatedConstraintCount,
);
});
it("evaluates required, forbidden, count and hardest-technique evidence", () => {
const assessment: DifficultyAssessment = {
score: 52,
level: "medium",
label: "Medium",
uniqueness: "unique",
clueCount: 30,
emptyCount: 51,
logicalStatus: "solved",
logicalSteps: 7,
hardestTechnique: "x-wing",
techniqueCounts: {
"naked-single": 4,
"hidden-single": 2,
"x-wing": 1,
},
exactNodes: 100,
exactTruncated: false,
summary: "fixture",
};
const matched = evaluateTechniqueProfile(assessment, {
required: ["x-wing"],
forbidden: ["swordfish"],
counts: [{ technique: "naked-single", min: 3, max: 5 }],
hardestTechnique: "x-wing",
});
expect(matched.status).toBe("matched");
expect(matched.requirements.every(({ matched: ok }) => ok)).toBe(true);
expect(
evaluateTechniqueProfile(assessment, {
counts: [{ technique: "hidden-single", max: 1 }],
}).status,
).toBe("not-matched");
expect(
evaluateTechniqueProfile(
{ ...assessment, logicalStatus: "stuck" },
{ forbidden: ["aic"] },
),
).toMatchObject({ status: "not-matched", completePath: false });
});
it("accepts a generation profile only after its independent path matches", () => {
const options = {
variant: "classic" as const,
size: 4,
seed: "profile-path",
targetClues: 10,
maxChecks: 12,
};
const baseline = generateVariant(options);
expect(baseline.difficulty.logicalStatus).toBe("solved");
expect(baseline.difficulty.hardestTechnique).toBeDefined();
const hardest = baseline.difficulty.hardestTechnique as PracticeTechnique;
const profiled = generateVariant({
...options,
techniqueProfile: {
required: [hardest],
forbidden: ["aic"],
counts: [{ technique: hardest, min: 1, max: 16 }],
hardestTechnique: hardest,
},
maxTechniqueAttempts: 1,
});
expect(profiled.techniqueProfile).toMatchObject({
status: "matched",
completePath: true,
requestedHardestTechnique: hardest,
actualHardestTechnique: hardest,
hardestMatched: true,
});
});
it("returns a deterministic ranked batch with concise evidence summaries", () => {
const options = {
variant: "classic" as const,
size: 4,
seed: "batch",
targetClues: 10,
maxChecks: 12,
batchSize: 3,
ranking: "fewest-givens" as const,
};
const batch = generateVariantBatch(options);
expect(batch.completed).toBe(3);
expect(batch.truncated).toBe(false);
expect(batch.summaries.map(({ rank }) => rank)).toEqual([1, 2, 3]);
expect(batch.summaries.map(({ clueCount }) => clueCount)).toEqual(
[...batch.summaries.map(({ clueCount }) => clueCount)].sort(
(a, b) => a - b,
),
);
expect(generateVariantBatch(options)).toEqual(batch);
});
});
+110 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import {
PuzzleValidationError,
classicRegions,
type PuzzleDefinition,
} from "../../src/domain";
import { killerDigitCombinations, solveLogically } from "../../src/solver";
const easy: PuzzleDefinition = {
@@ -15,6 +19,14 @@ const easy: PuzzleDefinition = {
constraints: [],
};
const emptyFour: PuzzleDefinition = {
version: 1,
size: 4,
givens: Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
};
describe("logical solver", () => {
it("solves a standard puzzle and emits inspectable steps", () => {
const result = solveLogically(easy);
@@ -60,6 +72,103 @@ describe("logical solver", () => {
expect(result.candidates[0]).toEqual([1, 2, 3, 4]);
});
it("resumes after an elimination-only step from exposed candidates", () => {
const candidates = Array.from({ length: 16 }, () => [1, 2, 3, 4]);
candidates[0] = [1, 2];
candidates[1] = [1, 2];
candidates[4] = [1, 3];
candidates[5] = [1, 3];
const first = solveLogically(emptyFour, {
candidates,
maxSteps: 1,
});
expect(first.steps[0]).toMatchObject({
technique: "naked-pair",
focusCells: [0, 1],
placements: [],
});
expect(first.candidates[2]).toEqual([3, 4]);
expect(first.candidates[3]).toEqual([3, 4]);
const next = solveLogically(emptyFour, {
candidates: first.candidates,
maxSteps: 1,
});
expect(next.steps[0]?.placements).toEqual([]);
expect(next.steps[0]?.eliminations.length).toBeGreaterThan(0);
expect(next.steps[0]?.eliminations).not.toEqual(
first.steps[0]?.eliminations,
);
// The first elimination remains applied while the solver advances to a
// different logical effect. This is the key resume contract for guided
// hints that do not place a digit.
expect(next.candidates[2]).toEqual([3, 4]);
expect(next.candidates[3]).toEqual([3, 4]);
});
it("intersects supplied restrictions with candidates legal on the board", () => {
const candidates = Array.from({ length: 81 }, () =>
Array.from({ length: 9 }, (_, index) => index + 1),
);
const result = solveLogically(easy, { candidates, maxSteps: 1 });
expect(result.candidates[2]).not.toContain(3);
expect(result.candidates[2]).not.toContain(5);
expect(result.candidates[2]).not.toContain(6);
expect(result.candidates[2]).not.toContain(7);
expect(result.candidates[2]).not.toContain(8);
expect(result.candidates[2]).not.toContain(9);
});
it("reports a valid empty restriction as an invalid logical state", () => {
const candidates = Array.from({ length: 16 }, () => [1, 2, 3, 4]);
candidates[0] = [];
const result = solveLogically(emptyFour, { candidates });
expect(result.status).toBe("invalid");
expect(result.steps).toEqual([]);
expect(result.candidates[0]).toEqual([]);
});
it("rejects malformed candidate restrictions with precise issues", () => {
expect(() =>
solveLogically(emptyFour, {
candidates: Array.from({ length: 15 }, () => [1, 2, 3, 4]),
}),
).toThrow(PuzzleValidationError);
try {
solveLogically(emptyFour, {
candidates: [
[1, 1],
[0, 5],
...Array.from({ length: 14 }, () => [1, 2, 3, 4]),
],
});
throw new Error("Expected malformed candidates to be rejected");
} catch (error) {
expect(error).toBeInstanceOf(PuzzleValidationError);
expect((error as PuzzleValidationError).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "candidates[0][1]" }),
expect.objectContaining({ path: "candidates[1][0]" }),
expect.objectContaining({ path: "candidates[1][1]" }),
]),
);
}
expect(() =>
solveLogically(emptyFour, {
candidates: [
[1, 2, 3, 4],
null,
...Array.from({ length: 14 }, () => [1, 2, 3, 4]),
] as unknown as readonly (readonly number[])[],
}),
).toThrow(PuzzleValidationError);
});
it("returns bounded killer combinations", () => {
expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
+267
View File
@@ -0,0 +1,267 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import { analyzePuzzleQuality, qualityItemCells } from "../../src/solver";
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const unique4: 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("setter quality analysis", () => {
it("audits a unique puzzle without mutating it", () => {
const before = JSON.stringify(unique4);
const result = analyzePuzzleQuality(unique4, { proveMinimality: true });
expect(result.solutionStatus).toBe("unique");
expect(result.solution).toEqual(solution4);
expect(result.ambiguityWitness).toBeUndefined();
expect(result.contradiction.status).toBe("not-applicable");
expect(result.redundancy.givens).toHaveLength(
unique4.givens.filter(Boolean).length,
);
expect(
result.redundancy.givens.every(
({ classification }) => classification !== "unknown",
),
).toBe(true);
expect(
result.redundancy.givens.every(({ classification }) =>
["critical", "redundant"].includes(classification),
),
).toBe(true);
expect(result.criticalityHeatmap).toHaveLength(16);
expect(result.budget.checksPerformed).toBe(
1 + unique4.givens.filter(Boolean).length,
);
expect(result.budget.truncated).toBe(false);
expect(result.minimality?.status).toMatch(
/^(proven-minimal|not-minimal)$/u,
);
expect(JSON.stringify(unique4)).toBe(before);
});
it("returns two concrete solutions and their differing cells for ambiguity", () => {
const ambiguous: PuzzleDefinition = {
...unique4,
givens: [1, ...new Array<number>(15).fill(0)],
};
const result = analyzePuzzleQuality(ambiguous);
expect(result.solutionStatus).toBe("multiple");
expect(result.ambiguityWitness?.firstSolution).toHaveLength(16);
expect(result.ambiguityWitness?.secondSolution).toHaveLength(16);
expect(result.ambiguityWitness?.differences.length).toBeGreaterThan(0);
for (const difference of result.ambiguityWitness?.differences ?? []) {
expect(difference.first).not.toBe(difference.second);
}
expect(result.redundancy.givens).toEqual([
{
item: { kind: "given", cell: 0, value: 1 },
classification: "unknown",
unknownReason: "baseline-not-unique",
},
]);
});
it("localizes direct contradictory givens and reports each as critical", () => {
const contradictory: PuzzleDefinition = {
...unique4,
givens: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
const before = JSON.stringify(contradictory);
const result = analyzePuzzleQuality(contradictory);
expect(result.solutionStatus).toBe("unsatisfiable");
expect(
result.redundancy.givens.map((entry) => entry.classification),
).toEqual(["critical", "critical"]);
expect(result.contradiction.status).toBe("localized");
expect(result.contradiction.core).toEqual([
{ kind: "given", cell: 0, value: 1 },
{ kind: "given", cell: 1, value: 1 },
]);
expect(result.contradiction.necessary).toEqual(result.contradiction.core);
expect(result.contradiction.removable).toEqual([]);
expect(JSON.stringify(contradictory)).toBe(before);
});
it("finds a minimal contradictory core made from overlapping constraints", () => {
const contradictory: PuzzleDefinition = {
...unique4,
givens: [0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
constraints: [
{ type: "killer-cage", cells: [0], sum: 1 },
{ type: "killer-cage", cells: [0], sum: 2 },
],
};
const result = analyzePuzzleQuality(contradictory);
expect(result.solutionStatus).toBe("unsatisfiable");
expect(
result.redundancy.constraints.map(({ classification }) => classification),
).toEqual(["critical", "critical"]);
expect(result.redundancy.givens[0]?.classification).toBe("redundant");
expect(result.contradiction.status).toBe("localized");
expect(result.contradiction.core).toEqual([
{ kind: "constraint", index: 0, constraintType: "killer-cage" },
{ kind: "constraint", index: 1, constraintType: "killer-cage" },
]);
expect(result.contradiction.removable).toEqual([
{ kind: "given", cell: 5, value: 4 },
]);
expect(result.contradiction.unknown).toEqual([]);
});
it("identifies redundant givens and constraints and derives non-minimality", () => {
const overSpecified: PuzzleDefinition = {
...unique4,
givens: solution4,
solution: solution4,
constraints: [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "main" },
],
};
const result = analyzePuzzleQuality(overSpecified, {
proveMinimality: true,
});
expect(result.solutionStatus).toBe("unique");
expect(
result.redundancy.givens.every(
({ classification }) => classification === "redundant",
),
).toBe(true);
expect(
result.redundancy.constraints.every(
({ classification }) => classification === "redundant",
),
).toBe(true);
expect(result.minimality?.status).toBe("not-minimal");
expect(result.minimality?.redundant).toHaveLength(18);
expect(result.criticalityHeatmap[0]).toMatchObject({
score: 0,
criticalWeight: 0,
redundantWeight: 3,
unknownWeight: 0,
});
});
it("never turns per-check truncation into a uniqueness proof", () => {
const result = analyzePuzzleQuality(unique4, {
perCheckMaxNodes: 1,
aggregateMaxNodes: 100,
aggregateMaxChecks: 100,
proveMinimality: true,
});
expect(result.solutionStatus).toBe("unknown");
expect(result.checks).toHaveLength(1);
expect(result.checks[0]).toMatchObject({
solutionStatus: "unknown",
conclusive: false,
truncated: true,
limitReason: "node-cap",
unknownReason: "per-check-node-cap",
});
expect(
result.redundancy.givens.every(
({ classification }) => classification === "unknown",
),
).toBe(true);
expect(result.minimality?.status).toBe("not-applicable");
expect(result.budget.unknownReasons).toContain("per-check-node-cap");
expect(result.budget.unknownReasons).toContain("baseline-unknown");
});
it("shares an aggregate check budget across all removal checks", () => {
const result = analyzePuzzleQuality(
{ ...unique4, givens: solution4, solution: solution4 },
{
aggregateMaxChecks: 1,
aggregateMaxNodes: 1_000,
proveMinimality: true,
},
);
expect(result.solutionStatus).toBe("unique");
expect(result.budget.checksPerformed).toBe(1);
expect(result.budget.checksPlanned).toBe(17);
expect(result.budget.truncated).toBe(true);
expect(result.budget.unknownReasons).toContain("aggregate-check-cap");
expect(
result.redundancy.givens.every(
({ classification, unknownReason }) =>
classification === "unknown" &&
unknownReason === "aggregate-check-cap",
),
).toBe(true);
expect(result.minimality?.status).toBe("unknown");
});
it("leaves contradiction-core items unknown when the shared budget ends", () => {
const contradictory: PuzzleDefinition = {
...unique4,
givens: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
const result = analyzePuzzleQuality(contradictory, {
aggregateMaxChecks: 1,
});
expect(result.solutionStatus).toBe("unsatisfiable");
expect(result.contradiction.status).toBe("incomplete");
expect(result.contradiction.necessary).toEqual([]);
expect(result.contradiction.unknown).toEqual(result.contradiction.core);
expect(result.budget.checksPerformed).toBe(1);
expect(result.budget.unknownReasons).toContain("aggregate-check-cap");
});
it("supports a cheap baseline-only depth", () => {
const result = analyzePuzzleQuality(unique4, {
analysisDepth: "baseline",
proveMinimality: true,
});
expect(result.analysisDepth).toBe("baseline");
expect(result.solutionStatus).toBe("unique");
expect(result.redundancy).toEqual({ givens: [], constraints: [] });
expect(result.criticalityHeatmap).toEqual([]);
expect(result.contradiction.status).toBe("not-applicable");
expect(result.minimality?.status).toBe("not-applicable");
expect(result.budget.checksPlanned).toBe(1);
expect(result.budget.checksPerformed).toBe(1);
});
it("maps given, local and outside findings to their board cells", () => {
const puzzle: PuzzleDefinition = {
...unique4,
constraints: [
{ type: "killer-cage", cells: [4, 5], sum: 7 },
{ type: "x-sum", side: "top", index: 2, sum: 6 },
],
};
expect(
qualityItemCells(puzzle, { kind: "given", cell: 0, value: 1 }),
).toEqual([0]);
expect(
qualityItemCells(puzzle, {
kind: "constraint",
index: 0,
constraintType: "killer-cage",
}),
).toEqual([4, 5]);
expect(
qualityItemCells(puzzle, {
kind: "constraint",
index: 1,
constraintType: "x-sum",
}),
).toEqual([2, 6, 10, 14]);
});
});
+1 -1
View File
@@ -143,7 +143,7 @@ describe("aid-mémoire state", () => {
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",
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3: yellow, dots",
);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import type { LogicalStep } from "../../src/solver";
import {
applyLogicalStepToSession,
autoRemoveNotesAfterPlacements,
fillLegalCenterCandidates,
pruneInvalidNotes,
} from "../../src/state/candidateMaintenance";
import { createSession, maskValues } from "../../src/state/session";
function puzzle4(): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
};
}
describe("candidate maintenance", () => {
it("fills legal center candidates without changing corner notes or metadata", () => {
const session = createSession(new Array<number>(16).fill(0));
session.values[0] = 1;
session.cornerMarks[1] = 0b1111;
session.centerMarks[10] = 0b0010;
session.colors[3] = 4;
session.elapsedSeconds = 73;
session.paused = true;
const result = fillLegalCenterCandidates(session, puzzle4());
expect(result).not.toBe(session);
expect(result.centerMarks).not.toBe(session.centerMarks);
expect(result.centerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 2, 3, 4]);
expect(result.cornerMarks[1]).toBe(0b1111);
expect(result.colors[3]).toBe(4);
expect(result.elapsedSeconds).toBe(73);
expect(result.paused).toBe(true);
expect(session.centerMarks[10]).toBe(0b0010);
});
it("only removes illegal center and corner notes", () => {
const session = createSession(new Array<number>(16).fill(0));
session.values[0] = 1;
session.centerMarks.fill(0b1111);
session.cornerMarks.fill(0b1111 | (1 << 8));
const result = pruneInvalidNotes(session, puzzle4());
expect(result.centerMarks[0]).toBe(0);
expect(result.cornerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.cornerMarks[4]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 2, 3, 4]);
expect(result.cornerMarks[10]).toBe(0b1111);
expect(session.cornerMarks[1]).toBe(0b1111 | (1 << 8));
});
it("auto-removes notes after placements but does not invent notes on erase", () => {
const previous = createSession(new Array<number>(16).fill(0));
previous.centerMarks.fill(0b1111);
previous.cornerMarks.fill(0b1111);
const placed = createSession(new Array<number>(16).fill(0));
placed.centerMarks.fill(0b1111);
placed.cornerMarks.fill(0b1111);
placed.values[0] = 1;
const maintained = autoRemoveNotesAfterPlacements(
previous,
placed,
puzzle4(),
);
expect(maskValues(maintained.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(maintained.cornerMarks[4]!, 4)).toEqual([2, 3, 4]);
expect(maintained.centerMarks[10]).toBe(0b1111);
const erased = createSession(new Array<number>(16).fill(0));
erased.centerMarks[1] = 0b0001;
const previousFilled = createSession(new Array<number>(16).fill(0));
previousFilled.values[0] = 1;
const afterErase = autoRemoveNotesAfterPlacements(
previousFilled,
erased,
puzzle4(),
);
expect(afterErase.centerMarks[1]).toBe(0b0001);
expect(afterErase).not.toBe(erased);
});
it("applies a logical step to tracked candidates as one immutable transition", () => {
const base = createSession(new Array<number>(16).fill(0));
base.cornerMarks.fill(0b1111);
base.colors[10] = 3;
base.elapsedSeconds = 99;
const session = fillLegalCenterCandidates(base, puzzle4());
const step: LogicalStep = {
technique: "naked-single",
placements: [{ cell: 0, value: 1 }],
eliminations: [{ cell: 10, values: [2] }],
focusCells: [0, 10],
explanation: "Test step",
};
const result = applyLogicalStepToSession(session, step, puzzle4());
expect(result.values[0]).toBe(1);
expect(result.centerMarks[0]).toBe(0);
expect(result.cornerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 3, 4]);
expect(result.cornerMarks[1]).toBe(0b1111);
expect(result.colors[10]).toBe(3);
expect(result.elapsedSeconds).toBe(99);
expect(session.values[0]).toBe(0);
expect(session.centerMarks[10]).toBe(0b1111);
});
it("leaves absent tracked candidates absent and rejects stale steps", () => {
const session = createSession(new Array<number>(16).fill(0));
const elimination: LogicalStep = {
technique: "pointing",
placements: [],
eliminations: [{ cell: 1, values: [1] }],
focusCells: [1],
explanation: "Test elimination",
};
expect(
applyLogicalStepToSession(session, elimination, puzzle4()).centerMarks[1],
).toBe(0);
session.values[0] = 2;
const placement: LogicalStep = {
...elimination,
placements: [{ cell: 0, value: 1 }],
eliminations: [],
};
expect(() =>
applyLogicalStepToSession(session, placement, puzzle4()),
).toThrow(/filled cell/u);
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
createGameplayHistory,
parseGameplayHistory,
serializeGameplayHistory,
} from "../../src/state/playHistory";
import { createSession } from "../../src/state/session";
describe("gameplay history persistence", () => {
it("round-trips independent bounded history", () => {
const history = createGameplayHistory(
createSession(Array<number>(16).fill(0)),
);
const encoded = serializeGameplayHistory(history, 4);
const decoded = parseGameplayHistory(encoded, 4);
expect(decoded).toEqual(history);
(decoded.moments[0]!.state.values as number[])[0] = 4;
expect(history.moments[0]!.state.values[0]).toBe(0);
});
it("rejects dangling and oversized persisted state", () => {
const history = createGameplayHistory(
createSession(Array<number>(16).fill(0)),
);
const raw = JSON.parse(serializeGameplayHistory(history, 4)) as {
history: { currentMomentId: string };
};
raw.history.currentMomentId = "missing";
expect(() => parseGameplayHistory(JSON.stringify(raw), 4)).toThrow(
/missing active branch or moment/u,
);
expect(() => parseGameplayHistory("x".repeat(1_048_577), 4)).toThrow(
/too large/u,
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import {
BOARD_SCALE_STORAGE_KEY,
colorMarkDescription,
normalizeBoardScale,
parseCandidateVerbosity,
readStoredBoardScale,
writeStoredBoardScale,
} from "../../src/state/uiPreferences";
describe("UI accessibility preferences", () => {
it("clamps and snaps persisted board scale safely", () => {
expect(normalizeBoardScale(1.13)).toBe(1.25);
expect(normalizeBoardScale(0.1)).toBe(0.75);
expect(normalizeBoardScale(9)).toBe(2);
expect(normalizeBoardScale("not a scale")).toBe(1);
const storage = { getItem: vi.fn(() => "1.49") };
expect(readStoredBoardScale(storage)).toBe(1.5);
expect(storage.getItem).toHaveBeenCalledWith(BOARD_SCALE_STORAGE_KEY);
const setItem = vi.fn();
writeStoredBoardScale(1.74, { setItem });
expect(setItem).toHaveBeenCalledWith(BOARD_SCALE_STORAGE_KEY, "1.75");
});
it("survives unavailable storage and normalizes announcement settings", () => {
expect(
readStoredBoardScale({
getItem: () => {
throw new Error("blocked");
},
}),
).toBe(1);
expect(() =>
writeStoredBoardScale(1.5, {
setItem: () => {
throw new Error("blocked");
},
}),
).not.toThrow();
expect(parseCandidateVerbosity("concise")).toBe("concise");
expect(parseCandidateVerbosity("everything")).toBe("detailed");
});
it("gives every colour mark a non-colour identifier", () => {
expect(colorMarkDescription(1)).toBe("red, diagonal stripes");
expect(colorMarkDescription(8)).toBe("pink, rings");
expect(colorMarkDescription(99)).toBe("mark 99");
});
});
+114
View File
@@ -54,6 +54,69 @@ describe("local project library", () => {
expect(await library.list()).toEqual([]);
});
it("searches tags, exposes safe thumbnails and exports a selection", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, {
id: "killer",
title: "Evening Killer",
tags: ["killer", "hard"],
now: 1,
}),
);
await library.put(
createProjectRecord(puzzle, {
id: "classic",
title: "Morning Classic",
tags: ["classic"],
now: 2,
}),
);
expect(
(await library.list({ search: "kill" })).map(({ id }) => id),
).toEqual(["killer"]);
expect(
(await library.list({ tags: ["classic"] }))[0]?.thumbnail,
).toHaveLength(81);
expect((await library.exportSelected(["classic"])).projects).toHaveLength(
1,
);
});
it("keeps recoverable autosaves separate from explicit projects", async () => {
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(puzzle, {
id: "draft",
title: "Draft",
now: 1,
});
await library.putAutosave(record);
expect((await library.getAutosave())?.id).toBe("draft");
expect(await library.list()).toEqual([]);
await library.clearAutosave();
expect(await library.getAutosave()).toBeUndefined();
});
it("duplicates selected projects with independent IDs and progress", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, {
id: "source",
title: "Source",
tags: ["classic"],
now: 10,
progress: { version: 1, values: Array<number>(81).fill(0) },
}),
);
expect(await library.duplicateSelected(["source", "missing"])).toBe(1);
const records = await library.exportAll();
expect(records.projects).toHaveLength(2);
const copy = records.projects.find((record) => record.id !== "source");
expect(copy).toMatchObject({ title: "Source copy", tags: ["classic"] });
expect(copy?.id).not.toBe("source");
});
it("exports and imports an explicitly versioned library", async () => {
const source = new ProjectLibrary({ indexedDB: null });
await source.put(createProjectRecord(puzzle, { id: "one", now: 10 }));
@@ -63,6 +126,41 @@ describe("local project library", () => {
expect(await target.get("one")).toEqual(await source.get("one"));
});
it("retains source extras through explicit saves, autosaves and reopening", async () => {
const sourcePuzzle = {
...puzzle,
visuals: [
{
type: "text" as const,
layer: "overlay" as const,
position: { kind: "cell" as const, cell: 0 },
text: "retained",
style: { fill: "#123456" },
},
],
source: { format: "sudokupad" as const, id: "source-identity" },
metadata: { edition: "local" },
};
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(sourcePuzzle, {
id: "preserved",
title: "Preserved",
now: 10,
});
await library.put(record);
await library.putAutosave({ ...record, updatedAt: 11 });
const exported = await library.exportAll();
const reopened = await library.get("preserved");
const autosave = await library.getAutosave();
for (const stored of [reopened, autosave, exported.projects[0]]) {
expect(stored?.puzzle.visuals).toEqual(sourcePuzzle.visuals);
expect(stored?.puzzle.source).toEqual(sourcePuzzle.source);
expect(stored?.puzzle.metadata).toEqual(sourcePuzzle.metadata);
}
});
it("falls back when IndexedDB cannot be opened", async () => {
const broken = {
open: () => {
@@ -90,4 +188,20 @@ describe("local project library", () => {
}),
).toThrow(/must contain 81/u);
});
it("bounds tags and rejects executable thumbnail markup", () => {
const record = createProjectRecord(puzzle, { id: "bounded", now: 1 });
expect(() =>
normalizeProjectRecord({
...record,
tags: Array.from({ length: 21 }, (_, index) => `tag-${String(index)}`),
}),
).toThrow(/at most 20/u);
expect(() =>
normalizeProjectRecord({
...record,
thumbnail: `<svg onload="alert(1)">${".".repeat(58)}`,
}),
).toThrow(/safe row-major grid preview/u);
});
});