feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user