feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -116,6 +116,12 @@ test("replaces setter cages and generates a rated variant", async ({
|
||||
test("uses distinct numbered sum badges and directional inequalities", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
const examples = page.getByLabel("Open built-in puzzle");
|
||||
|
||||
@@ -136,4 +142,109 @@ test("uses distinct numbered sum badges and directional inequalities", async ({
|
||||
await expect(
|
||||
page.locator(".constraint-inequality-tip").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await examples.selectOption("truth-and-lies");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Truth and lies" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".constraint-outside")).toHaveCount(4);
|
||||
await expect(page.locator(".constraint-quadruple")).toHaveCount(2);
|
||||
await expect(page.locator(".constraint-maximum")).toHaveCount(2);
|
||||
await expect(page.locator(".constraint-outside.is-negated")).toHaveCount(2);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const outsideBox = await page
|
||||
.locator(".constraint-outside")
|
||||
.first()
|
||||
.boundingBox();
|
||||
const maximumBox = await page
|
||||
.locator(".constraint-maximum")
|
||||
.first()
|
||||
.boundingBox();
|
||||
expect(outsideBox?.width ?? 0).toBeGreaterThan(18);
|
||||
expect(outsideBox?.height ?? 0).toBeGreaterThan(12);
|
||||
expect(maximumBox?.width ?? 0).toBeGreaterThan(12);
|
||||
expect(maximumBox?.height ?? 0).toBeGreaterThan(12);
|
||||
await expect(
|
||||
page.locator(".constraint-thermo .constraint-false-mark").first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps navigation, scratch work, branches and analysis tools local", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
const first = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 4",
|
||||
});
|
||||
await first.focus();
|
||||
await first.press("End");
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: /Row 1, column 9,/u }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: /Row 9, column 9,/u }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Control+Home");
|
||||
await expect(first).toBeFocused();
|
||||
|
||||
await page.getByLabel("Show aid-mémoire scratch cells").check();
|
||||
const scratch = page.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
const scratchCell = scratch.getByRole("gridcell").first();
|
||||
await scratchCell.click();
|
||||
await page
|
||||
.getByRole("group", { name: "Digits" })
|
||||
.getByRole("button", { name: "2", exact: true })
|
||||
.click();
|
||||
await expect(scratch.getByRole("gridcell").first()).toHaveAccessibleName(
|
||||
/value 2/u,
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "History & branches" }).click();
|
||||
await page.getByLabel("Savepoint name").fill("Before test branch");
|
||||
await page.getByRole("button", { name: "Save current grid" }).click();
|
||||
await expect(
|
||||
page.getByText("Before test branch", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Hypothesis name").fill("Try a two");
|
||||
await page.getByRole("button", { name: "Start hypothesis" }).click();
|
||||
|
||||
const empty = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 3, empty",
|
||||
});
|
||||
await empty.click();
|
||||
await page
|
||||
.getByRole("group", { name: "Digits" })
|
||||
.getByRole("button", { name: "2", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Hypothesis: Try a two" }).click();
|
||||
await page.getByRole("button", { name: "Discard and return" }).click();
|
||||
await expect(empty).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Helpers" }).click();
|
||||
await page.getByRole("tab", { name: "Sum Lab" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Generalized Sum Lab" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Candidate links" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Links and houses" }),
|
||||
).toBeVisible();
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AidMemoire } from "../../src/components/AidMemoire";
|
||||
import {
|
||||
configureAidMemoire,
|
||||
createAidMemoire,
|
||||
type AidMemoireState,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import type { EntryMode } from "../../src/state/session";
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
readOnly = false,
|
||||
}: {
|
||||
initial?: AidMemoireState;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [state, setState] = useState(
|
||||
initial ?? createAidMemoire(9, { enabled: true }),
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [active, setActive] = useState(false);
|
||||
const [mode, setMode] = useState<EntryMode>("value");
|
||||
return (
|
||||
<AidMemoire
|
||||
size={9}
|
||||
state={state}
|
||||
selectedCell={selected}
|
||||
active={active}
|
||||
readOnly={readOnly}
|
||||
mode={mode}
|
||||
onStateChange={setState}
|
||||
onSelect={(cell) => {
|
||||
setSelected(cell);
|
||||
setActive(true);
|
||||
}}
|
||||
onMode={setMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire component", () => {
|
||||
it("renders normal-like cells with keyboard entry and full descriptions", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
expect(within(grid).getAllByRole("row")).toHaveLength(1);
|
||||
const first = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
});
|
||||
expect(first).toHaveAttribute("aria-colindex", "1");
|
||||
await user.click(first);
|
||||
await user.keyboard("4");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 4",
|
||||
}),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const second = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty",
|
||||
});
|
||||
second.focus();
|
||||
await user.keyboard("c7");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty, centre marks 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Centre" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses row-aware WAI grid navigation without horizontal wrapping", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
const rows = screen.getAllByRole("row");
|
||||
expect(rows[0]).toHaveAttribute("aria-rowindex", "1");
|
||||
expect(rows[2]).toHaveAttribute("aria-rowindex", "3");
|
||||
expect(cells[2]).toHaveAttribute("aria-colindex", "1");
|
||||
|
||||
cells[1]!.focus();
|
||||
fireEvent.keyDown(cells[1]!, { key: "ArrowRight" });
|
||||
expect(document.activeElement).toBe(cells[1]);
|
||||
fireEvent.keyDown(cells[1]!, { key: "Home" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "End", ctrlKey: true });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
fireEvent.keyDown(cells[4]!, { key: "PageUp" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "PageDown" });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
});
|
||||
|
||||
it("keeps read-only replay cells navigable without allowing edits", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
4,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} readOnly />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
|
||||
cells[0]!.focus();
|
||||
fireEvent.keyDown(cells[0]!, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(cells[2]);
|
||||
fireEvent.keyDown(cells[2]!, { key: "5" });
|
||||
expect(cells[2]).toHaveAccessibleName("Aid-mémoire cell 3, label 3, empty");
|
||||
});
|
||||
|
||||
it("edits labels, reflows, clears entries and resets with confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(<Harness />);
|
||||
|
||||
const label = screen.getByLabelText("Selected cell label");
|
||||
await user.clear(label);
|
||||
await user.type(label, "Pseudo A");
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Configure scratch layout"));
|
||||
await user.click(screen.getByRole("button", { name: "Compact grid" }));
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(3);
|
||||
await user.click(screen.getByRole("button", { name: "Clear entries" }));
|
||||
await user.click(screen.getByRole("button", { name: "Reset all" }));
|
||||
expect(window.confirm).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(9);
|
||||
expect(screen.getByLabelText("Selected cell label")).toHaveValue("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function mainDigit(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire Workbench integration", () => {
|
||||
it("routes the shared keypad, undo and redo to a selected scratch cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
const toggle = screen.getByLabelText("Show aid-mémoire scratch cells");
|
||||
await user.click(toggle);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
await user.click(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
);
|
||||
await user.click(mainDigit("5"));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Redo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.queryByRole("grid", { name: "9 aid-mémoire scratch cells" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch labels and entries from the local Library", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.clear(screen.getByLabelText("Selected cell label"));
|
||||
await user.type(screen.getByLabelText("Selected cell label"), "Pseudo A");
|
||||
await user.click(mainDigit("6"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save current" }));
|
||||
expect(
|
||||
await screen.findByText("Current puzzle and progress saved locally."),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("7"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /A first classic/u }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch state with a savepoint and reverses that restore", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.click(mainDigit("5"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "Scratch five");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("6"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CandidateLab } from "../../src/components/CandidateLab";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
import type { CandidateOverlay } from "../../src/helpers";
|
||||
|
||||
function mask(...values: number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
|
||||
}
|
||||
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
|
||||
describe("candidate lab", () => {
|
||||
it("inspects selected cells, filters digits and focuses a link overlay", async () => {
|
||||
const user = userEvent.setup();
|
||||
const overlays: (CandidateOverlay | undefined)[] = [];
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(1, 2);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={(overlay) => overlays.push(overlay)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selected = screen.getByRole("heading", {
|
||||
name: "Selected cells",
|
||||
}).parentElement!;
|
||||
expect(within(selected).getByText("r1c1")).toBeInTheDocument();
|
||||
expect(within(selected).getByText("1 2")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 1" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(overlays.at(-1)?.activeValues).toEqual([1]);
|
||||
expect(overlays.at(-1)?.candidateCells).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
const link = screen.getByRole("button", { name: /r1c1\(1\).*r1c2\(1\)/u });
|
||||
await user.click(link);
|
||||
await waitFor(() => expect(overlays.at(-1)?.links).toHaveLength(1));
|
||||
expect(link).toHaveAttribute("aria-pressed", "true");
|
||||
await user.click(screen.getByRole("button", { name: "Show all" }));
|
||||
expect(link).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("reveals weak links on demand within a chosen house", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(3);
|
||||
candidateMasks[1] = mask(3);
|
||||
candidateMasks[2] = mask(3);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Candidate house scope"),
|
||||
"unit:0",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 3" }),
|
||||
);
|
||||
expect(screen.getByText(/No links match this scope/u)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /weak · Row 1/u }),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("clears the board overlay when it unmounts", async () => {
|
||||
const onOverlayChange = vi.fn();
|
||||
const { unmount } = render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={new Array<number>(16).fill(0)}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={onOverlayChange}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(onOverlayChange).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(onOverlayChange).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function digitButton(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("gameplay history integration", () => {
|
||||
it("restores savepoints and preserves discarded hypotheses for replay", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 3, empty",
|
||||
}),
|
||||
{ buttons: 1 },
|
||||
);
|
||||
await user.click(digitButton("2"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Branches, savepoints and replay",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "After two");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
expect(screen.getByText("After two")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(digitButton("3"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Hypothesis name"), "Try three");
|
||||
await user.click(screen.getByRole("button", { name: "Start hypothesis" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(digitButton("3"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Discard and return" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(screen.getByText("discarded")).toBeInTheDocument();
|
||||
await user.click(
|
||||
within(screen.getByRole("list", { name: "Solve history" })).getAllByRole(
|
||||
"button",
|
||||
{ name: /Set r1c3 to 3/u },
|
||||
)[0]!,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Set r1c3 to 3" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Return to live grid" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -59,4 +59,42 @@ describe("Sudoku generator workspace", () => {
|
||||
);
|
||||
expect(onRate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("submits a bounded evidence-backed practice target", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={onGenerate}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Practice technique"),
|
||||
"x-wing",
|
||||
);
|
||||
const attempts = screen.getByLabelText("Mining attempts");
|
||||
await user.clear(attempts);
|
||||
await user.type(attempts, "7");
|
||||
await user.clear(screen.getByLabelText("Seed"));
|
||||
await user.type(screen.getByLabelText("Seed"), "x-wing-training");
|
||||
await user.click(screen.getByRole("button", { name: "Generate Classic" }));
|
||||
|
||||
expect(onGenerate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requiredTechnique: "x-wing",
|
||||
maxTechniqueAttempts: 7,
|
||||
seed: "x-wing-training",
|
||||
}),
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "killer");
|
||||
expect(
|
||||
within(screen.getByLabelText("Practice technique")).getByRole("option", {
|
||||
name: "Killer Cage",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HelpersWorkspace } from "../../src/components/HelpersWorkspace";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
|
||||
function mask(...digits: number[]): number {
|
||||
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
||||
}
|
||||
|
||||
describe("Sum Lab helper", () => {
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(9));
|
||||
|
||||
it("eliminates and restores combinations without changing the inputs", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Generalized Sum Lab" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("4", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
const combination = screen.getByRole("button", {
|
||||
name: "1 + 9; eliminate",
|
||||
});
|
||||
await user.click(combination);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; restore" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
screen.getByText("3", { selector: ".metric-row strong" }).parentElement,
|
||||
).toHaveTextContent("3 active of 4");
|
||||
expect(screen.getByRole("button", { name: "Restore all" })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Restore all" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; eliminate" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("filters through selected board-cell candidate masks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = Array<number>(81).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(8, 9);
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[0, 1]}
|
||||
candidateMasks={candidateMasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Use 2 board cells and candidates",
|
||||
}),
|
||||
);
|
||||
|
||||
const positions = screen.getByRole("list");
|
||||
expect(within(positions).getByText("r1c1: 1 2")).toBeInTheDocument();
|
||||
expect(within(positions).getByText("r1c2: 8 9")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "3 + 7; eliminate" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets digit bounds when the puzzle size changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<HelpersWorkspace
|
||||
key={9}
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(9);
|
||||
|
||||
const smallerPuzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
rerender(
|
||||
<HelpersWorkspace
|
||||
key={4}
|
||||
size={4}
|
||||
puzzle={smallerPuzzle}
|
||||
values={smallerPuzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(16).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(4);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ImportExportDialog } from "../../src/components/ImportExportDialog";
|
||||
import { createEmptyPuzzle } from "../../src/domain";
|
||||
import type { PuzzleDefinition } from "../../src/domain/types";
|
||||
import { fromDomainPuzzle } from "../../src/formats";
|
||||
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
const TEST_AID_MEMOIRE: PortableAidMemoire = {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: [
|
||||
{
|
||||
label: "A",
|
||||
value: 2,
|
||||
cornerMarks: [],
|
||||
centerMarks: [],
|
||||
color: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function localScl(overrides: Record<string, unknown> = {}) {
|
||||
const cells = Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 4 }, () => ({})),
|
||||
);
|
||||
cells[0]![0] = { value: 1 };
|
||||
return JSON.stringify({
|
||||
id: "synthetic-local-test",
|
||||
cells,
|
||||
metadata: { title: "Synthetic local puzzle" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
options: {
|
||||
readonly puzzle?: PuzzleDefinition;
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
} = {},
|
||||
) {
|
||||
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
|
||||
const onClose = vi.fn();
|
||||
const onImport = vi.fn();
|
||||
render(
|
||||
<ImportExportDialog
|
||||
open
|
||||
puzzle={puzzle}
|
||||
session={createSession(puzzle.givens)}
|
||||
aidMemoire={options.aidMemoire}
|
||||
onClose={onClose}
|
||||
onImport={onImport}
|
||||
/>,
|
||||
);
|
||||
return { onClose, onImport };
|
||||
}
|
||||
|
||||
describe("ImportExportDialog interoperability", () => {
|
||||
it("reports a detected compatible format before importing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose, onImport } = renderDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: localScl() },
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/SudokuPad\/CTC JSON: 4×4, 1 givens and 0 constraints/u,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports unsupported constructs and never fetches short IDs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
renderDialog();
|
||||
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: "https://sudokupad.app/serverOnly42" },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/server-hosted SudokuPad puzzle ID/u),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("downloads the current puzzle as a self-contained SVG", async () => {
|
||||
const user = userEvent.setup();
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:visual-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Download SVG" }));
|
||||
expect(
|
||||
await screen.findByText("SVG rendered and downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(downloaded?.type).toContain("image/svg+xml");
|
||||
expect(await downloaded?.text()).toContain("<svg");
|
||||
});
|
||||
|
||||
it("round-trips aid-mémoire progress through project import and export", async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzle = createEmptyPuzzle(4);
|
||||
const { onImport } = renderDialog({
|
||||
puzzle,
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
});
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:project-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download project JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Project JSON downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(JSON.parse((await downloaded?.text()) ?? "{}").aidMemoire).toEqual(
|
||||
TEST_AID_MEMOIRE,
|
||||
);
|
||||
|
||||
const importedDocument = {
|
||||
...fromDomainPuzzle(puzzle),
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
};
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: JSON.stringify(importedDocument) },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4 }),
|
||||
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an f-puzzles export incompatibility without throwing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog({
|
||||
puzzle: {
|
||||
...createEmptyPuzzle(4),
|
||||
constraints: [{ type: "xv", a: 0, b: 1, total: 5, negated: true }],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download f-puzzles JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/cannot preserve an individually false clue/u),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SolveWorkspace } from "../../src/components/SolveWorkspace";
|
||||
import type { ExactSolveResult } from "../../src/solver";
|
||||
|
||||
const limited: ExactSolveResult = {
|
||||
solutions: [],
|
||||
count: 0,
|
||||
truncated: true,
|
||||
limitReason: "timeout",
|
||||
nodes: 100,
|
||||
elapsedMs: 10_000,
|
||||
};
|
||||
|
||||
describe("solve result reporting", () => {
|
||||
it("does not report zero solutions at a safety limit as unsatisfiable", () => {
|
||||
const props = {
|
||||
size: 9,
|
||||
busy: false,
|
||||
onLogical: vi.fn(),
|
||||
onExact: vi.fn(),
|
||||
onApplyValues: vi.fn(),
|
||||
onFocusCells: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<SolveWorkspace {...props} exact={limited} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/solvability is not established/u),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("No completion satisfies every supported rule."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SolveWorkspace
|
||||
{...props}
|
||||
exact={{ ...limited, truncated: false, limitReason: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No completion satisfies every supported rule."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizePuzzle } from "../../src/domain";
|
||||
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
||||
@@ -41,4 +41,178 @@ describe("Sudoku board constraint visuals", () => {
|
||||
container.querySelector(".constraint-inequality-tip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders false local clues and combines dual-purpose outside labels", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
constraints: [
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
{ type: "thermo", cells: [8, 9], negated: true },
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 3],
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "top",
|
||||
index: 0,
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "top",
|
||||
index: 0,
|
||||
count: 42,
|
||||
negated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set([0])}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".sudoku-board-frame")).toHaveClass(
|
||||
"has-outside-clues",
|
||||
);
|
||||
expect(container.querySelectorAll(".constraint-outside")).toHaveLength(1);
|
||||
expect(container.querySelector(".outside-clue-kinds")).toHaveTextContent(
|
||||
"Σ · ▥",
|
||||
);
|
||||
expect(container.querySelector(".outside-clue-value")).toHaveTextContent(
|
||||
"≠42",
|
||||
);
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(4);
|
||||
expect(
|
||||
container.querySelector(".constraint-thermo.is-negated polyline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-thermo .constraint-false-mark"),
|
||||
).toHaveTextContent("≠");
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("False clue: X-sum 42 from the top, column 1."),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("False clue: maximum at row 2, column 2."),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders candidate filters and strong/weak graph overlays", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
});
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set([0])}
|
||||
activeCell={0}
|
||||
candidateOverlay={{
|
||||
activeValues: [1],
|
||||
candidateCells: [0, 1],
|
||||
links: [
|
||||
{
|
||||
id: "strong",
|
||||
kind: "strong",
|
||||
a: { cell: 0, value: 1 },
|
||||
b: { cell: 1, value: 1 },
|
||||
contexts: [{ kind: "house", label: "Row 1" }],
|
||||
},
|
||||
{
|
||||
id: "weak",
|
||||
kind: "weak",
|
||||
a: { cell: 0, value: 2 },
|
||||
b: { cell: 0, value: 3 },
|
||||
contexts: [{ kind: "cell", label: "Cell r1c1" }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelectorAll(".is-candidate-highlighted"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll(".candidate-overlay-link--strong"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelectorAll(".candidate-overlay-link--weak"),
|
||||
).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".candidate-overlay-node")).toHaveLength(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes real row semantics and detailed per-cell state", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }],
|
||||
});
|
||||
render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
cornerMarks={[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
centerMarks={new Array<number>(16).fill(0)}
|
||||
candidates={[0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
colors={[0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
selected={new Set([1])}
|
||||
conflicts={new Set([1])}
|
||||
activeCell={1}
|
||||
showCandidates
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAttribute("aria-rowcount", "4");
|
||||
expect(board).toHaveAttribute("aria-colcount", "4");
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("Home and End move to the first and last cell"),
|
||||
);
|
||||
expect(screen.getAllByRole("row")).toHaveLength(4);
|
||||
const given = screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 1",
|
||||
});
|
||||
expect(given).toHaveAttribute("aria-readonly", "true");
|
||||
expect(given).toHaveAccessibleDescription(
|
||||
expect.stringMatching(/region 1.*given digit.*killer cage 3/i),
|
||||
);
|
||||
const annotated = screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 2, empty",
|
||||
});
|
||||
expect(annotated).toHaveAccessibleDescription(
|
||||
expect.stringMatching(
|
||||
/region 1.*corner notes 1.*automatic candidates 1, 2.*colour 2.*conflict/i,
|
||||
),
|
||||
);
|
||||
expect(annotated).toHaveAttribute("aria-colindex", "2");
|
||||
expect(annotated).toHaveAttribute("aria-invalid", "true");
|
||||
expect(annotated).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,34 @@ describe("Sudoku workbench", () => {
|
||||
expect(undo).toBeDisabled();
|
||||
});
|
||||
|
||||
it("supports non-wrapping WAI-ARIA grid navigation", () => {
|
||||
render(<Workbench />);
|
||||
|
||||
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
const cell = (row: number, column: number) =>
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: new RegExp(`^Row ${String(row)}, column ${String(column)},`, "u"),
|
||||
});
|
||||
|
||||
cell(1, 1).focus();
|
||||
fireEvent.keyDown(cell(1, 1), { key: "ArrowLeft" });
|
||||
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.keyDown(cell(1, 1), { key: "End" });
|
||||
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(1, 9), { key: "ArrowRight" });
|
||||
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.keyDown(cell(1, 9), { key: "ArrowDown" });
|
||||
expect(cell(2, 9)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(2, 9), { key: "Home" });
|
||||
expect(cell(2, 1)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(2, 1), { key: "PageDown" });
|
||||
expect(cell(9, 1)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(9, 1), { key: "Home", ctrlKey: true });
|
||||
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
@@ -124,6 +152,30 @@ describe("Sudoku workbench", () => {
|
||||
screen.getByRole("button", { name: "Remove selected cage" }),
|
||||
);
|
||||
expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Cage digits may repeat"));
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "2");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("false · 2 cage · 2 cells · repeats allowed"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Cage digits may repeat"));
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "3");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("false · 2 cage · 2 cells · repeats allowed"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("false · 3 cage · 2 cells")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the expanded built-in variant examples", async () => {
|
||||
@@ -157,6 +209,66 @@ describe("Sudoku workbench", () => {
|
||||
expect(
|
||||
container.querySelector(".constraint-xv--10 text"),
|
||||
).toHaveTextContent("10");
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Open built-in puzzle"),
|
||||
"truth-and-lies",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Truth and lies" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".constraint-outside")).toHaveLength(4);
|
||||
expect(container.querySelectorAll(".constraint-quadruple")).toHaveLength(2);
|
||||
expect(container.querySelectorAll(".constraint-maximum")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sets and batch-toggles false local and outside clues", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Workbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New blank" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add quadruple" }),
|
||||
).toBeDisabled();
|
||||
await user.clear(screen.getByLabelText("Quadruple digits"));
|
||||
await user.type(screen.getByLabelText("Quadruple digits"), "1");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add quadruple" }),
|
||||
).toBeDisabled();
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Maximum cell" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("false · maximum · r1c1")).toBeInTheDocument();
|
||||
expect(screen.getByText("false · Σ3 · top 1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Require true for false · maximum · r1c1",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove false · Σ3 · top 1",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
|
||||
|
||||
await user.clear(screen.getByLabelText("Clue"));
|
||||
await user.type(screen.getByLabelText("Clue"), "6562");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Make all clues true" }),
|
||||
);
|
||||
expect(screen.getByText("maximum · r1c1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Σ3 · top 1")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("switches between setting, solving, playing and helper workspaces", async () => {
|
||||
@@ -206,6 +318,38 @@ describe("Sudoku workbench", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("projects candidate filters and links onto the board only while inspecting", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Workbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Helpers" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Candidate links" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Links and houses" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
|
||||
expect(
|
||||
container.querySelector(".candidate-link-layer"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 1" }),
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted")
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(
|
||||
container.querySelector(".candidate-link-layer"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("makes the import boundary and self-contained local sharing explicit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
|
||||
@@ -11,11 +11,16 @@ import {
|
||||
} from "../../src/solver";
|
||||
|
||||
describe("bundled original samples", () => {
|
||||
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => {
|
||||
const result = solveExact(puzzle, { maxSolutions: 2 });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
const fastUniquenessSamples = SAMPLE_CATALOG.map(({ puzzle }) => puzzle);
|
||||
|
||||
it.each(fastUniquenessSamples)(
|
||||
"ships $title as a unique puzzle",
|
||||
(puzzle) => {
|
||||
const result = solveExact(puzzle, { maxSolutions: 2 });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.truncated).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("solves the generated classic with the supported logical techniques", () => {
|
||||
expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved");
|
||||
@@ -50,6 +55,10 @@ describe("bundled original samples", () => {
|
||||
"inequality",
|
||||
"renban",
|
||||
"palindrome",
|
||||
"x-sum",
|
||||
"skyscraper",
|
||||
"quadruple",
|
||||
"maximum",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
candidatesForCell,
|
||||
classicRegions,
|
||||
compilePuzzle,
|
||||
constraintIsFeasible,
|
||||
findConflicts,
|
||||
normalizePuzzle,
|
||||
validatePuzzle,
|
||||
type PuzzleDefinition,
|
||||
type VariantConstraint,
|
||||
} from "../../src/domain";
|
||||
|
||||
const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
|
||||
|
||||
function puzzle4(constraints: readonly VariantConstraint[]): PuzzleDefinition {
|
||||
return {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints,
|
||||
};
|
||||
}
|
||||
|
||||
describe("outside and local clue constraints", () => {
|
||||
it("strictly validates and clones every new bounded shape", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6 },
|
||||
{ type: "skyscraper", side: "bottom", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2, 2, 4] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
];
|
||||
const definition = puzzle4(constraints);
|
||||
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
|
||||
|
||||
const normalized = normalizePuzzle(definition);
|
||||
expect(normalized.constraints).toEqual(constraints);
|
||||
expect(normalized.constraints).not.toBe(constraints);
|
||||
expect(normalized.constraints[2]).not.toBe(constraints[2]);
|
||||
|
||||
const malformed = [
|
||||
{ type: "x-sum", side: "near", index: 0, sum: 6 },
|
||||
{ type: "x-sum", side: "left", index: 4, sum: 6 },
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 2 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 0 },
|
||||
{ type: "quadruple", cells: [0, 1], digits: [1, 2, 3] },
|
||||
{ type: "quadruple", cells: [0, 2, 4, 6], digits: [1, 2] },
|
||||
{ type: "maximum", cell: 16 },
|
||||
{
|
||||
type: "maximum",
|
||||
cell: 5,
|
||||
negated: "yes",
|
||||
},
|
||||
];
|
||||
for (const constraint of malformed) {
|
||||
expect(validatePuzzle(puzzle4([constraint as never])).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows bounded impossible numbers only for false numeric clues", () => {
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 1, 2],
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
).valid,
|
||||
).toBe(true);
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([{ type: "killer-cage", cells: [0, 1, 2], sum: 42 }]),
|
||||
).valid,
|
||||
).toBe(false);
|
||||
|
||||
for (const constraint of [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 42 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 42 },
|
||||
] as const) {
|
||||
expect(
|
||||
validatePuzzle(puzzle4([{ ...constraint, negated: true }])).valid,
|
||||
).toBe(true);
|
||||
expect(validatePuzzle(puzzle4([constraint])).valid).toBe(false);
|
||||
}
|
||||
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 257,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
).valid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches outside clues to their full lines and local clues to every affected cell", () => {
|
||||
const compiled = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([
|
||||
{ type: "x-sum", side: "top", index: 1, sum: 6 },
|
||||
{ type: "skyscraper", side: "right", index: 2, count: 2 },
|
||||
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(compiled.constraintsByCell[13]).toContain(0);
|
||||
expect(compiled.constraintsByCell[8]).toContain(1);
|
||||
expect(compiled.constraintsByCell[6]).toContain(2);
|
||||
for (const cell of [5, 1, 9, 4, 6]) {
|
||||
expect(compiled.constraintsByCell[cell]).toContain(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the first digit and exact distinct subset sums to prune X-sums", () => {
|
||||
const constraint = {
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 6,
|
||||
} as const;
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 4;
|
||||
expect(candidatesForCell(puzzle4([constraint]), values, 0)).toEqual([2]);
|
||||
|
||||
const reusedOutsidePrefix = new Array<number>(16).fill(0);
|
||||
reusedOutsidePrefix.splice(0, 4, 3, 1, 0, 2);
|
||||
expect(constraintIsFeasible(constraint, reusedOutsidePrefix, 4)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 1 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "bottom", index: 0, sum: 6 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "x-sum", side: "left", index: 0, sum: 6 }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
expect(conflict?.cells).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("enforces exact skyscraper visibility and safely prunes partial lines", () => {
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 4 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "right", index: 0, count: 1 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 3 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
candidatesForCell(
|
||||
puzzle4([{ type: "skyscraper", side: "left", index: 0, count: 1 }]),
|
||||
new Array<number>(16).fill(0),
|
||||
0,
|
||||
),
|
||||
).toEqual([4]);
|
||||
const impossiblePrefix = new Array<number>(16).fill(0);
|
||||
impossiblePrefix[0] = 2;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 4 },
|
||||
impossiblePrefix,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 8 },
|
||||
new Array<number>(16 * 16).fill(0),
|
||||
16,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("honours quadruple multiplicity in candidates and completed boards", () => {
|
||||
const constraint = {
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [2, 2, 4],
|
||||
} as const;
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 2;
|
||||
values[2] = 3;
|
||||
values[5] = 4;
|
||||
expect(candidatesForCell(puzzle4([constraint]), values, 6)).toEqual([2]);
|
||||
|
||||
const wrong = [...values];
|
||||
wrong[6] = 1;
|
||||
expect(constraintIsFeasible(constraint, wrong, 4)).toBe(false);
|
||||
const right = [...values];
|
||||
right[6] = 2;
|
||||
expect(constraintIsFeasible(constraint, right, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("prunes false clues as soon as their positive statement is fixed", () => {
|
||||
const xSumValues = new Array<number>(16).fill(0);
|
||||
xSumValues.splice(0, 3, 3, 1, 2);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true },
|
||||
xSumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 7, negated: true },
|
||||
xSumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const quadrupleValues = new Array<number>(16).fill(0);
|
||||
quadrupleValues[0] = 1;
|
||||
quadrupleValues[1] = 2;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 2],
|
||||
negated: true,
|
||||
},
|
||||
quadrupleValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const maximumValues = new Array<number>(16).fill(0);
|
||||
maximumValues[5] = 4;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
maximumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a maximum cell to exceed every orthogonal neighbour", () => {
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 4;
|
||||
expect(
|
||||
candidatesForCell(puzzle4([{ type: "maximum", cell: 5 }]), values, 5),
|
||||
).toEqual([]);
|
||||
|
||||
expect(constraintIsFeasible({ type: "maximum", cell: 5 }, solved4, 4)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(constraintIsFeasible({ type: "maximum", cell: 6 }, solved4, 4)).toBe(
|
||||
false,
|
||||
);
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "maximum", cell: 6 }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
expect(conflict?.cells).toEqual([6, 2, 10, 5, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("false-clue semantics", () => {
|
||||
it("validates and clones polarity for every local clue type", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
{ type: "thermo", cells: [0, 1], negated: true },
|
||||
{ type: "arrow", bulb: [0], line: [1], negated: true },
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
{ type: "xv", a: 0, b: 1, total: 5, negated: true },
|
||||
{ type: "inequality", lesser: 0, greater: 1, negated: true },
|
||||
{ type: "renban", cells: [0, 1], negated: true },
|
||||
{ type: "palindrome", cells: [0, 1], negated: true },
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 2,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 1,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 1],
|
||||
negated: true,
|
||||
},
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
];
|
||||
|
||||
const definition = puzzle4(constraints);
|
||||
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
|
||||
const normalized = normalizePuzzle(definition);
|
||||
expect(normalized.constraints).toEqual(constraints);
|
||||
expect(normalized.constraints).not.toBe(constraints);
|
||||
expect(normalized.constraints[0]).not.toBe(constraints[0]);
|
||||
});
|
||||
|
||||
it("does not turn a false cage's no-repeat rule into an unconditional peer", () => {
|
||||
const falseCage = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 3,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
const ordinaryCage = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([{ type: "killer-cage", cells: [0, 6], sum: 3 }]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(falseCage.peers[0]?.has(6)).toBe(false);
|
||||
expect(ordinaryCage.peers[0]?.has(6)).toBe(true);
|
||||
|
||||
const repeated = new Array<number>(16).fill(0);
|
||||
repeated[0] = 1;
|
||||
repeated[6] = 1;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 3,
|
||||
negated: true,
|
||||
},
|
||||
repeated,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("inverts completed dots, XV, thermometers and cages", () => {
|
||||
const cases: Array<
|
||||
readonly [VariantConstraint, readonly number[], boolean]
|
||||
> = [
|
||||
[
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
[1, 2],
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
[1, 3],
|
||||
true,
|
||||
],
|
||||
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 4], false],
|
||||
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 3], true],
|
||||
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 2, 3], false],
|
||||
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 3, 2], true],
|
||||
[
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3, negated: true },
|
||||
[1, 2],
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 4, negated: true },
|
||||
[1, 2],
|
||||
true,
|
||||
],
|
||||
];
|
||||
for (const [constraint, relevant, expected] of cases) {
|
||||
const values = new Array<number>(16).fill(0);
|
||||
relevant.forEach((value, cell) => {
|
||||
values[cell] = value;
|
||||
});
|
||||
expect(
|
||||
constraintIsFeasible(constraint, values, 4),
|
||||
JSON.stringify(constraint),
|
||||
).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("inverts each completed outside and local clue", () => {
|
||||
const cases: Array<readonly [VariantConstraint, boolean]> = [
|
||||
[{ type: "x-sum", side: "left", index: 0, sum: 1, negated: true }, false],
|
||||
[{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true }, true],
|
||||
[
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 4,
|
||||
negated: true,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 3,
|
||||
negated: true,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [1, 2, 3, 4],
|
||||
negated: true,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [2, 2, 4],
|
||||
negated: true,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[{ type: "maximum", cell: 5, negated: true }, false],
|
||||
[{ type: "maximum", cell: 6, negated: true }, true],
|
||||
];
|
||||
for (const [constraint, expected] of cases) {
|
||||
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps partial false clues feasible and prunes a completed true pair", () => {
|
||||
const falseDot = {
|
||||
type: "kropki",
|
||||
a: 0,
|
||||
b: 1,
|
||||
kind: "white",
|
||||
negated: true,
|
||||
} as const;
|
||||
const partial = new Array<number>(16).fill(0);
|
||||
partial[0] = 1;
|
||||
expect(constraintIsFeasible(falseDot, partial, 4)).toBe(true);
|
||||
expect(candidatesForCell(puzzle4([falseDot]), partial, 1)).toEqual([3, 4]);
|
||||
|
||||
const falseThermo = {
|
||||
type: "thermo",
|
||||
cells: [0, 1, 2],
|
||||
negated: true,
|
||||
} as const;
|
||||
partial[1] = 2;
|
||||
expect(constraintIsFeasible(falseThermo, partial, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a true false-clue as the contradiction", () => {
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "xv", a: 0, b: 3, total: 5, negated: true }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
|
||||
expect(conflict?.message).toBe("xv clue is true but must be false.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
cloneSudokuDocument,
|
||||
normalizeSudokuDocument,
|
||||
parseSudokuDocument,
|
||||
serializeSudokuDocument,
|
||||
type SudokuDocument,
|
||||
} from "../../src/formats";
|
||||
import {
|
||||
aidMemoireToPortable,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
labelAidMemoireCell,
|
||||
MAX_AID_MEMOIRE_CELLS,
|
||||
} from "../../src/state/aidMemoire";
|
||||
|
||||
function baseDocument(): SudokuDocument {
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
constraints: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("portable aid-mémoire document data", () => {
|
||||
it("normalizes, serializes and deeply clones scratch cells", () => {
|
||||
let state = createAidMemoire(9, {
|
||||
enabled: true,
|
||||
cellCount: 3,
|
||||
columns: 2,
|
||||
});
|
||||
state = labelAidMemoireCell(state, 0, "Prime");
|
||||
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
|
||||
state = enterAidMemoireCell(state, 0, "center", 7, 9);
|
||||
state = enterAidMemoireCell(state, 0, "color", 4, 9);
|
||||
const source: SudokuDocument = {
|
||||
...baseDocument(),
|
||||
aidMemoire: aidMemoireToPortable(state, 9),
|
||||
};
|
||||
|
||||
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
|
||||
expect(parsed.aidMemoire).toEqual(source.aidMemoire);
|
||||
const clone = cloneSudokuDocument(parsed);
|
||||
(clone.aidMemoire!.cells[0]!.cornerMarks as number[])[0] = 9;
|
||||
expect(parsed.aidMemoire?.cells[0]?.cornerMarks).toEqual([2]);
|
||||
});
|
||||
|
||||
it("rejects unbounded layouts and out-of-range scratch symbols", () => {
|
||||
expect(() =>
|
||||
normalizeSudokuDocument({
|
||||
...baseDocument(),
|
||||
aidMemoire: {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
|
||||
},
|
||||
}),
|
||||
).toThrow(/aidMemoire.cells must contain 1 to 36/u);
|
||||
expect(() =>
|
||||
normalizeSudokuDocument({
|
||||
...baseDocument(),
|
||||
aidMemoire: {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: [{ label: "Bad", value: 10 }],
|
||||
},
|
||||
}),
|
||||
).toThrow(/value must be an integer from 0 to 9/u);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,30 @@ describe("Sudoku Tools document format", () => {
|
||||
centerMarks: Array.from({ length: 81 }, () => [7, 4]),
|
||||
colors: Array.from({ length: 81 }, (_, index) => index % 9),
|
||||
elapsedMs: 12_345,
|
||||
constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }],
|
||||
constraints: [
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "top",
|
||||
index: 8,
|
||||
sum: 1111,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 4,
|
||||
count: 898,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 9, 10],
|
||||
digits: [1, 3, 8],
|
||||
negated: true,
|
||||
},
|
||||
{ type: "maximum", cell: 10, negated: true },
|
||||
],
|
||||
};
|
||||
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
|
||||
expect(parsed).toEqual({
|
||||
@@ -59,6 +82,12 @@ describe("Sudoku Tools document format", () => {
|
||||
values: [2, ...Array<number>(15).fill(0)],
|
||||
}),
|
||||
).toThrow(/preserve its given digit/u);
|
||||
expect(() =>
|
||||
normalizeSudokuDocument({
|
||||
...document(Array(81).fill(0)),
|
||||
constraints: [{ type: "quadruple", cells: [0], digits: [1, 2] }],
|
||||
}),
|
||||
).toThrow(/no more digits than clue cells/u);
|
||||
});
|
||||
|
||||
it("round-trips compact share hashes", () => {
|
||||
|
||||
@@ -52,6 +52,12 @@ describe("fpuzzles interoperability", () => {
|
||||
inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }],
|
||||
renban: [{ lines: [["R7C1", "R7C2"]] }],
|
||||
palindrome: [{ lines: [["R8C1", "R8C2"]] }],
|
||||
xsum: [{ cell: "R0C1", value: "15" }],
|
||||
skyscraper: [{ cell: "R2C10", value: "2" }],
|
||||
quadruple: [
|
||||
{ cells: ["R1C1", "R1C2", "R2C1", "R2C2"], values: [1, 3, 8] },
|
||||
],
|
||||
maximum: [{ cell: "R2C2" }],
|
||||
});
|
||||
expect(puzzle.givens[0]).toBe(5);
|
||||
expect(puzzle.values?.[1]).toBe(3);
|
||||
@@ -64,6 +70,10 @@ describe("fpuzzles interoperability", () => {
|
||||
{ type: "kropki", a: 27, b: 28, kind: "white" },
|
||||
{ type: "kropki", a: 28, b: 29, kind: "black" },
|
||||
{ type: "inequality", lesser: 46, greater: 45 },
|
||||
{ type: "x-sum", side: "top", index: 0, sum: 15 },
|
||||
{ type: "skyscraper", side: "right", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
|
||||
{ type: "maximum", cell: 10 },
|
||||
]),
|
||||
);
|
||||
expect(() => normalizePuzzle(toDomainPuzzle(puzzle))).not.toThrow();
|
||||
@@ -85,6 +95,10 @@ describe("fpuzzles interoperability", () => {
|
||||
{ type: "inequality", lesser: 45, greater: 46 },
|
||||
{ type: "renban", cells: [54, 55] },
|
||||
{ type: "palindrome", cells: [63, 64] },
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6 },
|
||||
{ type: "skyscraper", side: "right", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
|
||||
{ type: "maximum", cell: 10 },
|
||||
],
|
||||
title: "Round trip",
|
||||
};
|
||||
@@ -110,6 +124,53 @@ describe("fpuzzles interoperability", () => {
|
||||
).toThrow(/local-only app cannot fetch/u);
|
||||
});
|
||||
|
||||
it("refuses to weaken false clues during fpuzzles export", () => {
|
||||
const source: SudokuDocument = {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
constraints: [
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "top",
|
||||
index: 0,
|
||||
sum: 11,
|
||||
negated: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => exportFpuzzles(source)).toThrow(/individually false clue/u);
|
||||
});
|
||||
|
||||
it("rejects out-of-range outside clues during fpuzzles export", () => {
|
||||
const source = {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
} as const;
|
||||
|
||||
for (const constraint of [
|
||||
{ type: "x-sum", side: "top", index: -1, sum: 15 },
|
||||
{ type: "x-sum", side: "bottom", index: 9, sum: 15 },
|
||||
{ type: "x-sum", side: "right", index: 0, sum: 0 },
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 46 },
|
||||
{ type: "skyscraper", side: "right", index: -1, count: 2 },
|
||||
{ type: "skyscraper", side: "left", index: 9, count: 2 },
|
||||
{ type: "skyscraper", side: "bottom", index: 0, count: 0 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 10 },
|
||||
] as const) {
|
||||
expect(() =>
|
||||
exportFpuzzles({
|
||||
...source,
|
||||
constraints: [constraint],
|
||||
}),
|
||||
).toThrow(/outside the supported range/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported generalized dots instead of weakening them", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
@@ -120,6 +181,40 @@ describe("fpuzzles interoperability", () => {
|
||||
).toThrow(/Difference-2 dots are not supported/u);
|
||||
});
|
||||
|
||||
it("rejects quadruples spanning more than four cells", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
quadruple: [
|
||||
{
|
||||
cells: ["R1C1", "R1C2", "R2C1", "R2C2", "R3C3"],
|
||||
values: [1, 2],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/one to four cells and clue digits/u);
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
quadruple: [{ cells: ["R1C1"], values: [1, 2] }],
|
||||
}),
|
||||
).toThrow(/one to four cells and clue digits/u);
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
quadruple: [
|
||||
{
|
||||
cells: ["R1C1", "R1C3", "R2C1", "R2C3"],
|
||||
values: [1, 2],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/four cells surrounding one grid intersection/u);
|
||||
});
|
||||
|
||||
it("rejects unsupported or unknown rules instead of silently weakening them", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import fc from "fast-check";
|
||||
import { compressToBase64, compressToEncodedURIComponent } from "lz-string";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
LzStringOutputLimitError,
|
||||
decompressFromBase64Bounded,
|
||||
decompressFromBase64OrUriComponentBounded,
|
||||
decompressFromEncodedURIComponentBounded,
|
||||
} from "../../src/formats/boundedLz";
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
PUZZLE_HASH_PREFIX,
|
||||
SudokuFormatError,
|
||||
decodePuzzleHash,
|
||||
importFpuzzles,
|
||||
importPuzzle,
|
||||
importSudokuPad,
|
||||
parseFpuzzles,
|
||||
} from "../../src/formats";
|
||||
|
||||
function emptyGrid(size = 4) {
|
||||
return Array.from({ length: size }, () =>
|
||||
Array.from({ length: size }, () => ({})),
|
||||
);
|
||||
}
|
||||
|
||||
function expectLimitExceeded(action: () => unknown): void {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SudokuFormatError);
|
||||
expect(error).toMatchObject({ code: "LIMIT_EXCEEDED" });
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected the import to reject an oversized payload.");
|
||||
}
|
||||
|
||||
describe("bounded puzzle imports", () => {
|
||||
it("keeps both LZ-String wire formats compatible for arbitrary text", () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ maxLength: 512 }), (source) => {
|
||||
const byteLength = new TextEncoder().encode(source).byteLength;
|
||||
const limit = Math.max(1, byteLength);
|
||||
expect(
|
||||
decompressFromBase64Bounded(compressToBase64(source), limit),
|
||||
).toBe(source);
|
||||
expect(
|
||||
decompressFromEncodedURIComponentBounded(
|
||||
compressToEncodedURIComponent(source),
|
||||
limit,
|
||||
),
|
||||
).toBe(source);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("counts UTF-8 bytes while expanding and stops before oversized output", () => {
|
||||
const source = "🧩".repeat(1_024);
|
||||
const compressed = compressToEncodedURIComponent(source);
|
||||
const exactBytes = new TextEncoder().encode(source).byteLength;
|
||||
|
||||
expect(
|
||||
decompressFromEncodedURIComponentBounded(compressed, exactBytes),
|
||||
).toBe(source);
|
||||
expect(() =>
|
||||
decompressFromEncodedURIComponentBounded(compressed, exactBytes - 1),
|
||||
).toThrow(LzStringOutputLimitError);
|
||||
});
|
||||
|
||||
it("selects URI-safe payloads before an invalid Base64 interpretation", () => {
|
||||
const title = "AE(a^'XS2pclC*+Q: ?8IJnG(Fe-nR";
|
||||
const grid = emptyGrid();
|
||||
const fpuzzlesJson = JSON.stringify({ size: 4, grid, title });
|
||||
const fpuzzlesPayload = compressToEncodedURIComponent(fpuzzlesJson);
|
||||
const sudokuPadJson = JSON.stringify({
|
||||
id: "local-scl",
|
||||
cells: grid,
|
||||
metadata: {
|
||||
title,
|
||||
author: "Tester",
|
||||
rules: "Normal rules apply.",
|
||||
antiknight: true,
|
||||
},
|
||||
cages: [
|
||||
{
|
||||
cells: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
],
|
||||
value: "3",
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const sudokuPadPayload = compressToEncodedURIComponent(sudokuPadJson);
|
||||
|
||||
expect(fpuzzlesPayload).toMatch(/[-$]/u);
|
||||
expect(sudokuPadPayload).toMatch(/[-$]/u);
|
||||
expect(
|
||||
decompressFromBase64Bounded(fpuzzlesPayload, MAX_DOCUMENT_BYTES),
|
||||
).not.toBe(fpuzzlesJson);
|
||||
expect(
|
||||
decompressFromBase64Bounded(sudokuPadPayload, MAX_DOCUMENT_BYTES),
|
||||
).not.toBe(sudokuPadJson);
|
||||
expect(
|
||||
decompressFromBase64OrUriComponentBounded(
|
||||
fpuzzlesPayload,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
),
|
||||
).toBe(fpuzzlesJson);
|
||||
expect(importFpuzzles(`fpuzzles${fpuzzlesPayload}`).title).toBe(title);
|
||||
expect(importSudokuPad(`ctc${sudokuPadPayload}`).title).toBe(title);
|
||||
});
|
||||
|
||||
it("rejects compressed bombs in every LZ-backed import format", () => {
|
||||
const oversized = " ".repeat(MAX_DOCUMENT_BYTES + 1);
|
||||
const base64 = compressToBase64(oversized);
|
||||
const uri = compressToEncodedURIComponent(oversized);
|
||||
|
||||
expectLimitExceeded(() => importFpuzzles(`fpuzzles${base64}`));
|
||||
expectLimitExceeded(() => importSudokuPad(`ctc${base64}`));
|
||||
expectLimitExceeded(() => decodePuzzleHash(`${PUZZLE_HASH_PREFIX}${uri}`));
|
||||
});
|
||||
|
||||
it("checks raw JSON character and byte limits before parsing", async () => {
|
||||
const tooManyCharacters = `{${" ".repeat(MAX_DOCUMENT_BYTES)}}`;
|
||||
await expect(importPuzzle(tooManyCharacters)).rejects.toMatchObject({
|
||||
code: "LIMIT_EXCEEDED",
|
||||
});
|
||||
|
||||
const tooManyUtf8Bytes = `{"future":"${"é".repeat(
|
||||
Math.floor(MAX_DOCUMENT_BYTES / 2),
|
||||
)}"}`;
|
||||
expect(tooManyUtf8Bytes.length).toBeLessThan(MAX_DOCUMENT_BYTES);
|
||||
await expect(importPuzzle(tooManyUtf8Bytes)).rejects.toMatchObject({
|
||||
code: "LIMIT_EXCEEDED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown fpuzzles inequality markers", () => {
|
||||
for (const value of [undefined, "≤", "left", 1]) {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 4,
|
||||
grid: emptyGrid(),
|
||||
inequality: [{ cells: ["R1C1", "R1C2"], value }],
|
||||
}),
|
||||
).toThrow(/inequality\.value must be either/u);
|
||||
}
|
||||
|
||||
expect(
|
||||
parseFpuzzles({
|
||||
size: 4,
|
||||
grid: emptyGrid(),
|
||||
inequality: [{ cells: ["R1C1", "R1C2"], value: "<" }],
|
||||
}).constraints,
|
||||
).toContainEqual({ type: "inequality", lesser: 0, greater: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { compressToBase64 } from "lz-string";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NetworkPuzzleIdError,
|
||||
UnsupportedPuzzleConstructsError,
|
||||
importPenpa,
|
||||
importPuzzle,
|
||||
importSudokuPad,
|
||||
parsePenpaText,
|
||||
parseSudokuPadPuzzle,
|
||||
} from "../../src/formats";
|
||||
|
||||
const penpaText = [
|
||||
"square,4,4,40,0,1,1,320,320,18,18,1,1,1,1,Title: Tiny,Author: Test,,Normal Sudoku rules apply.,ON,false",
|
||||
"[0,0,0,0]",
|
||||
"{}",
|
||||
JSON.stringify({
|
||||
number: { 18: [1, 1, "1"], 45: [4, 1, "1"] },
|
||||
thermo: [[19, 20]],
|
||||
arrows: [[26, 27]],
|
||||
}),
|
||||
JSON.stringify({ number: { 21: [2, 2, "1"] } }),
|
||||
"[18,1,1,1,5,1,1,1,5,1,1,1,5,1,1,1]",
|
||||
"[]",
|
||||
].join("\n");
|
||||
|
||||
const compressedPenpa =
|
||||
"bY/LCsIwEEX3+Yow64sksb6y8wd0YXchi4gRxbTRpEGk9N+lKoIgZ4bLncWBybfikkc1IiAgITFV4rVy+ZoP9bkLXvP63D6wLt0pJs1rnztgE1PjAt+VQ7wUnkrwmbvrNTwm2G5wdCF7Zka5gLCsH1hPbWn2PpHuSS5Jm9FPkiyompE21acOoO7kUxNJGyNXUMJakEsp3vN4UnOohbW/QiVJGwX1NgzMfH+Y/U/LjH0C";
|
||||
|
||||
function sclPuzzle() {
|
||||
const cells = Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 4 }, () => ({})),
|
||||
);
|
||||
cells[0]![0] = { value: "1" };
|
||||
cells[0]![1] = {
|
||||
value: 2,
|
||||
given: false,
|
||||
pencilMarks: [4, 3],
|
||||
centremarks: [2],
|
||||
};
|
||||
return {
|
||||
id: "local-scl",
|
||||
cells,
|
||||
metadata: {
|
||||
title: "Local SCL",
|
||||
author: "Tester",
|
||||
rules: "Normal rules apply.",
|
||||
antiknight: true,
|
||||
},
|
||||
cages: [
|
||||
{
|
||||
cells: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
],
|
||||
value: "3",
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("local puzzle interoperability", () => {
|
||||
it("imports bounded SudokuPad/CTC data and retains semantic features", () => {
|
||||
const parsed = parseSudokuPadPuzzle(sclPuzzle());
|
||||
|
||||
expect(parsed.size).toBe(4);
|
||||
expect(parsed.givens.slice(0, 2)).toEqual([1, 0]);
|
||||
expect(parsed.values?.slice(0, 2)).toEqual([1, 2]);
|
||||
expect(parsed.cornerMarks?.[1]).toEqual([3, 4]);
|
||||
expect(parsed.constraints).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 1],
|
||||
sum: 3,
|
||||
noRepeat: true,
|
||||
},
|
||||
{ type: "anti-knight" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("imports a self-contained ctc payload locally", async () => {
|
||||
const inline = `ctc${compressToBase64(JSON.stringify(sclPuzzle()))}`;
|
||||
const direct = importSudokuPad(inline);
|
||||
const detected = await importPuzzle(`https://sudokupad.app/${inline}`);
|
||||
|
||||
expect(direct.title).toBe("Local SCL");
|
||||
expect(detected.format).toBe("sudokupad");
|
||||
expect(detected.document.givens[0]).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects visual-only SCL constructs instead of weakening them", () => {
|
||||
expect(() =>
|
||||
parseSudokuPadPuzzle({
|
||||
...sclPuzzle(),
|
||||
lines: [
|
||||
{
|
||||
wayPoints: [
|
||||
[0.5, 0.5],
|
||||
[1.5, 1.5],
|
||||
],
|
||||
},
|
||||
],
|
||||
overlays: [{ text: "?" }],
|
||||
}),
|
||||
).toThrow(UnsupportedPuzzleConstructsError);
|
||||
expect(() =>
|
||||
parseSudokuPadPuzzle({
|
||||
...sclPuzzle(),
|
||||
lines: [{}],
|
||||
}),
|
||||
).toThrow(/visual lines/u);
|
||||
});
|
||||
|
||||
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
|
||||
const parsed = parsePenpaText(penpaText);
|
||||
|
||||
expect(parsed.size).toBe(4);
|
||||
expect(parsed.title).toBe("Tiny");
|
||||
expect(parsed.author).toBe("Test");
|
||||
expect(parsed.givens[0]).toBe(1);
|
||||
expect(parsed.givens[15]).toBe(4);
|
||||
expect(parsed.values?.[3]).toBe(2);
|
||||
expect(parsed.constraints).toEqual([
|
||||
{ type: "thermo", cells: [1, 2] },
|
||||
{ type: "arrow", bulb: [4], line: [5] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects an already-decompressed Penpa+ text record", async () => {
|
||||
const parsed = await importPuzzle(penpaText);
|
||||
|
||||
expect(parsed.format).toBe("penpa");
|
||||
expect(parsed.label).toBe("Penpa+ text");
|
||||
expect(parsed.document.constraints).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("decompresses a complete Penpa+ long URL without fetching", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const parsed = await importPenpa(
|
||||
`https://swaroopg92.github.io/penpa-edit/#m=solve&p=${compressedPenpa}`,
|
||||
);
|
||||
|
||||
expect(parsed.givens[0]).toBe(1);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects unsupported Penpa+ drawings with a compatibility list", () => {
|
||||
const rows = penpaText.split("\n");
|
||||
rows[3] = JSON.stringify({
|
||||
number: { 18: [1, 1, "1"] },
|
||||
line: { "18,19": 3 },
|
||||
symbol: { 20: [1, "circle_L", 1] },
|
||||
});
|
||||
|
||||
expect(() => parsePenpaText(rows.join("\n"))).toThrow(
|
||||
/problem line, problem symbol/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("never resolves a server-only SudokuPad short ID", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await expect(
|
||||
importPuzzle("https://sudokupad.app/serverOnly42"),
|
||||
).rejects.toBeInstanceOf(NetworkPuzzleIdError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
buildJpegPdf,
|
||||
renderPuzzleSvg,
|
||||
type SudokuDocument,
|
||||
} from "../../src/formats";
|
||||
|
||||
function visualDocument(): SudokuDocument {
|
||||
const values = Array.from({ length: 16 }, () => 0);
|
||||
values[0] = 1;
|
||||
values[15] = 4;
|
||||
const cornerMarks = Array.from({ length: 16 }, () => [] as number[]);
|
||||
cornerMarks[1] = [2, 3];
|
||||
const centerMarks = Array.from({ length: 16 }, () => [] as number[]);
|
||||
centerMarks[2] = [1, 4];
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: 4,
|
||||
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
values,
|
||||
cornerMarks,
|
||||
centerMarks,
|
||||
colors: [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
constraints: [
|
||||
{ type: "diagonal", direction: "main" },
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{ type: "thermo", cells: [4, 5] },
|
||||
{ type: "arrow", bulb: [8], line: [9, 10] },
|
||||
{ type: "kropki", a: 2, b: 3, kind: "white" },
|
||||
{ type: "xv", a: 6, b: 7, total: 5 },
|
||||
{ type: "inequality", lesser: 10, greater: 11 },
|
||||
{ type: "renban", cells: [12, 13] },
|
||||
{ type: "palindrome", cells: [14, 15] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
{ type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 2] },
|
||||
{ type: "x-sum", side: "top", index: 0, sum: 1 },
|
||||
{ type: "skyscraper", side: "left", index: 2, count: 2 },
|
||||
{ type: "anti-knight" },
|
||||
],
|
||||
title: 'A < B & "C"',
|
||||
author: "Synthetic test",
|
||||
};
|
||||
}
|
||||
|
||||
describe("visual puzzle export", () => {
|
||||
it("renders a standalone SVG with clues, regions, givens and progress", () => {
|
||||
const svg = renderPuzzleSvg(visualDocument());
|
||||
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
|
||||
expect(parsed.querySelector("parsererror")).toBeNull();
|
||||
expect(parsed.documentElement.localName).toBe("svg");
|
||||
expect(svg).toContain("A < B & "C"");
|
||||
expect(svg).toContain('class="constraint diagonal" x1="58" y1="132"');
|
||||
expect(svg).toContain('class="constraint cage"');
|
||||
expect(svg).toContain('class="constraint thermo"');
|
||||
expect(svg).toContain('class="constraint arrow"');
|
||||
expect(svg).toContain('class="constraint outside x-sum"');
|
||||
expect(svg).toContain('class="constraint xv total-5"');
|
||||
expect(svg).toContain(">5</text>");
|
||||
expect(svg).not.toContain(">V</text>");
|
||||
expect(svg).toContain('class="inequality-tip"');
|
||||
expect(svg).toContain('class="cell-value given"');
|
||||
expect(svg).toContain('class="cell-value progress"');
|
||||
expect(svg).toContain('class="corner-note" x=');
|
||||
expect(svg).toContain('class="center-note" x=');
|
||||
expect(svg).not.toMatch(/<(?:script|image)|(?:href|src)=/iu);
|
||||
});
|
||||
|
||||
it("can omit all solving progress from the visual", () => {
|
||||
const svg = renderPuzzleSvg(visualDocument(), {
|
||||
includeProgress: false,
|
||||
includeNotes: false,
|
||||
});
|
||||
|
||||
expect(svg).toContain('class="cell-value given"');
|
||||
expect(svg).not.toContain('class="cell-value progress"');
|
||||
expect(svg).not.toContain('class="corner-note" x=');
|
||||
expect(svg).not.toContain('class="center-note" x=');
|
||||
expect(svg).not.toContain('class="cell-color"');
|
||||
});
|
||||
|
||||
it("builds a bounded single-page PDF around local JPEG bytes", () => {
|
||||
const pdf = buildJpegPdf(
|
||||
new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
|
||||
320,
|
||||
240,
|
||||
);
|
||||
const text = new TextDecoder("latin1").decode(pdf);
|
||||
|
||||
expect(text.startsWith("%PDF-1.4")).toBe(true);
|
||||
expect(text).toContain("/DCTDecode");
|
||||
expect(text).toContain("xref\n0 6");
|
||||
expect(text.endsWith("%%EOF\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid or unbounded PDF image input", () => {
|
||||
expect(() => buildJpegPdf(new Uint8Array([0, 1, 2, 3]), 320, 240)).toThrow(
|
||||
/JPEG/u,
|
||||
);
|
||||
expect(() =>
|
||||
buildJpegPdf(new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), 99_999, 240),
|
||||
).toThrow(/dimensions/u);
|
||||
});
|
||||
|
||||
it("validates progress before embedding it in a visual", () => {
|
||||
const document = visualDocument();
|
||||
const invalidMarks = Array.from({ length: 16 }, () => [] as number[]);
|
||||
invalidMarks[0] = [99];
|
||||
|
||||
expect(() =>
|
||||
renderPuzzleSvg({ ...document, cornerMarks: invalidMarks }),
|
||||
).toThrow(/cornerMarks/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
import {
|
||||
candidateCellsForValues,
|
||||
candidateUnitIndicesForCells,
|
||||
candidateValuesFromMask,
|
||||
deriveCandidateLinks,
|
||||
inspectCandidateCells,
|
||||
inspectCandidateHouse,
|
||||
} from "../../src/helpers";
|
||||
|
||||
function mask(...values: number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
|
||||
}
|
||||
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
|
||||
describe("candidate inspection", () => {
|
||||
it("decodes masks and identifies the houses touching selected cells", () => {
|
||||
expect(candidateValuesFromMask(mask(1, 3, 4), 4)).toEqual([1, 3, 4]);
|
||||
expect(candidateUnitIndicesForCells(puzzle, [0])).toEqual([0, 1, 2]);
|
||||
|
||||
const cells = inspectCandidateCells(
|
||||
puzzle,
|
||||
[mask(1, 4), ...new Array<number>(15).fill(0)],
|
||||
[0],
|
||||
);
|
||||
expect(cells).toEqual([
|
||||
{
|
||||
cell: 0,
|
||||
values: [1, 4],
|
||||
houseLabels: ["Row 1", "Column 1", "Region 1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports missing values, candidate positions and conjugate pairs in a house", () => {
|
||||
const values = [4, 0, 0, 2, ...new Array<number>(12).fill(0)];
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[1] = mask(1, 3);
|
||||
masks[2] = mask(1, 3);
|
||||
|
||||
expect(inspectCandidateHouse(puzzle, values, masks, 0)).toMatchObject({
|
||||
label: "Row 1",
|
||||
missingValues: [1, 3],
|
||||
positions: [
|
||||
{ value: 1, cells: [1, 2], linkKind: "strong" },
|
||||
{ value: 3, cells: [1, 2], linkKind: "strong" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate link graph", () => {
|
||||
it("derives strong house and bivalue links and merges shared contexts", () => {
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[0] = mask(1, 2);
|
||||
masks[1] = mask(1, 3);
|
||||
|
||||
const links = deriveCandidateLinks(puzzle, masks);
|
||||
const houseLink = links.find(
|
||||
({ a, b }) =>
|
||||
a.cell === 0 && a.value === 1 && b.cell === 1 && b.value === 1,
|
||||
);
|
||||
expect(houseLink).toMatchObject({ kind: "strong" });
|
||||
expect(houseLink?.contexts.map(({ label }) => label)).toEqual([
|
||||
"Row 1",
|
||||
"Region 1",
|
||||
]);
|
||||
expect(links).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "strong",
|
||||
a: { cell: 0, value: 1 },
|
||||
b: { cell: 0, value: 2 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps larger candidate groups weak and supports digit and house scopes", () => {
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[0] = mask(2, 4);
|
||||
masks[1] = mask(2);
|
||||
masks[2] = mask(2);
|
||||
masks[3] = mask(3, 4);
|
||||
|
||||
const links = deriveCandidateLinks(puzzle, masks, {
|
||||
unitIndices: [0],
|
||||
values: [2],
|
||||
});
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links.every(({ kind }) => kind === "weak")).toBe(true);
|
||||
expect(links.every(({ a, b }) => a.value === 2 && b.value === 2)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(candidateCellsForValues(masks, [4])).toEqual([0, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeSumLab, sumCombinationKey } from "../../src/helpers/sumLab";
|
||||
|
||||
function mask(...digits: number[]): number {
|
||||
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
||||
}
|
||||
|
||||
describe("generalized sum lab", () => {
|
||||
it("enumerates deterministic, sorted combinations", () => {
|
||||
const result = analyzeSumLab({ cellCount: 2, target: 10 });
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
]);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("supports a digit range, repeats, required and excluded digits", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 3,
|
||||
target: 15,
|
||||
minimumDigit: 3,
|
||||
maximumDigit: 7,
|
||||
allowRepeats: true,
|
||||
requiredDigits: [3],
|
||||
excludedDigits: [4],
|
||||
});
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[3, 5, 7],
|
||||
[3, 6, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses per-cell candidate masks and derives positional possibilities", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 10,
|
||||
candidateMasks: [mask(1, 2), mask(8, 9)],
|
||||
});
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
]);
|
||||
expect(result.assignments).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
]);
|
||||
expect(result.possibleByCell).toEqual([
|
||||
[1, 2],
|
||||
[8, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes toggled combinations from every live deduction", () => {
|
||||
const eliminatedKeys = new Set([sumCombinationKey([1, 9])]);
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 10,
|
||||
eliminatedKeys,
|
||||
});
|
||||
|
||||
expect(result.combinations).toHaveLength(4);
|
||||
expect(result.eliminatedCombinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
]);
|
||||
expect(result.activeCombinations.map(({ digits }) => digits)).toEqual([
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
]);
|
||||
expect(result.possibleDigits).not.toContain(1);
|
||||
expect(result.possibleDigits).not.toContain(9);
|
||||
});
|
||||
|
||||
it("reports which independent safety bound stopped an analysis", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 8,
|
||||
target: 36,
|
||||
allowRepeats: true,
|
||||
maxSearchNodes: 1,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncationReason).toBe("search");
|
||||
expect(result.exploredNodes).toBe(2);
|
||||
});
|
||||
|
||||
it("validates positional candidates and contradictory digit filters", () => {
|
||||
expect(() =>
|
||||
analyzeSumLab({ cellCount: 2, target: 3, candidateMasks: [mask(1)] }),
|
||||
).toThrow(/exactly 2/u);
|
||||
expect(
|
||||
analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 3,
|
||||
requiredDigits: [1],
|
||||
excludedDigits: [1],
|
||||
}).combinations,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves combination invariants over generated inputs", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3, max: 12 }),
|
||||
fc.integer({ min: 1, max: 6 }),
|
||||
fc.integer({ min: 0, max: 72 }),
|
||||
fc.boolean(),
|
||||
(maximumDigit, cellCount, target, allowRepeats) => {
|
||||
if (target > maximumDigit * cellCount) return;
|
||||
const result = analyzeSumLab({
|
||||
cellCount,
|
||||
target,
|
||||
maximumDigit,
|
||||
allowRepeats,
|
||||
});
|
||||
const keys = new Set<string>();
|
||||
for (const combination of result.combinations) {
|
||||
expect(combination.digits).toHaveLength(cellCount);
|
||||
expect(
|
||||
combination.digits.reduce((total, digit) => total + digit, 0),
|
||||
).toBe(target);
|
||||
expect(combination.digits).toEqual(
|
||||
[...combination.digits].sort((left, right) => left - right),
|
||||
);
|
||||
if (!allowRepeats) {
|
||||
expect(new Set(combination.digits)).toHaveProperty(
|
||||
"size",
|
||||
cellCount,
|
||||
);
|
||||
}
|
||||
keys.add(combination.key);
|
||||
}
|
||||
expect(keys.size).toBe(result.combinations.length);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,28 @@ describe("logical solver", () => {
|
||||
expect(result.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not apply positive cage reductions to a false cage", () => {
|
||||
const result = solveLogically({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints: [
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 1],
|
||||
sum: 3,
|
||||
noRepeat: false,
|
||||
negated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("stuck");
|
||||
expect(result.steps).toEqual([]);
|
||||
expect(result.candidates[0]).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("returns bounded killer combinations", () => {
|
||||
expect(
|
||||
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
|
||||
|
||||
@@ -196,6 +196,35 @@ describe("variant generator", () => {
|
||||
expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/i);
|
||||
});
|
||||
|
||||
it("mines deterministically for a requested practice technique", () => {
|
||||
const options = {
|
||||
variant: "classic",
|
||||
size: 9,
|
||||
seed: "mine-practice",
|
||||
targetDifficulty: "hard",
|
||||
requiredTechnique: "naked-pair",
|
||||
maxTechniqueAttempts: 6,
|
||||
maxChecks: 72,
|
||||
} as const;
|
||||
const generated = generateVariant(options);
|
||||
expect(generated.requestedTechnique).toBe("naked-pair");
|
||||
expect(generated.generationAttempts).toBe(4);
|
||||
expect(generated.difficulty.techniqueCounts["naked-pair"]).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(generateVariant(options)).toEqual(generated);
|
||||
});
|
||||
|
||||
it("keeps Killer-cage practice tied to the Killer generator", () => {
|
||||
expect(() =>
|
||||
generateVariant({
|
||||
variant: "classic",
|
||||
size: 4,
|
||||
requiredTechnique: "killer-cage",
|
||||
}),
|
||||
).toThrow(/requires the Killer generator/i);
|
||||
});
|
||||
|
||||
it("keeps the public variant ID type exhaustive", () => {
|
||||
const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aidMemoireCellDescription,
|
||||
aidMemoireFromPortable,
|
||||
aidMemoireToPortable,
|
||||
clearAidMemoireEntries,
|
||||
compactAidMemoireColumns,
|
||||
configureAidMemoire,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
eraseAidMemoireCell,
|
||||
labelAidMemoireCell,
|
||||
MAX_AID_MEMOIRE_CELLS,
|
||||
normalizePortableAidMemoire,
|
||||
resetAidMemoire,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import { maskValues } from "../../src/state/session";
|
||||
|
||||
describe("aid-mémoire state", () => {
|
||||
it("creates a bounded row and preserves existing cells while reflowing", () => {
|
||||
let state = createAidMemoire(9, { enabled: true });
|
||||
expect(state.cells).toHaveLength(9);
|
||||
expect(state.columns).toBe(9);
|
||||
expect(state.cells.map((cell) => cell.label)).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
]);
|
||||
|
||||
state = labelAidMemoireCell(state, 0, "Prime");
|
||||
state = enterAidMemoireCell(state, 0, "value", 7, 9);
|
||||
state = configureAidMemoire(state, 9, 12, 4);
|
||||
expect(state.cells).toHaveLength(12);
|
||||
expect(state.columns).toBe(4);
|
||||
expect(state.cells[0]).toMatchObject({ label: "Prime", value: 7 });
|
||||
expect(state.cells[11]?.label).toBe("12");
|
||||
|
||||
state = configureAidMemoire(state, 9, 1_000, 1_000);
|
||||
expect(state.cells).toHaveLength(MAX_AID_MEMOIRE_CELLS);
|
||||
expect(state.columns).toBe(MAX_AID_MEMOIRE_CELLS);
|
||||
state = configureAidMemoire(state, 9, Number.NaN, Number.NaN);
|
||||
expect(state.cells).toHaveLength(9);
|
||||
expect(state.columns).toBe(1);
|
||||
state = configureAidMemoire(state, 9, 3.9, 2.8);
|
||||
expect(state.cells).toHaveLength(3);
|
||||
expect(state.columns).toBe(2);
|
||||
expect(compactAidMemoireColumns(9)).toBe(3);
|
||||
expect(compactAidMemoireColumns(Number.NaN)).toBe(1);
|
||||
});
|
||||
|
||||
it("edits values, both mark types and colours like normal cells", () => {
|
||||
let state = createAidMemoire(9, { enabled: true, cellCount: 1 });
|
||||
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
|
||||
state = enterAidMemoireCell(state, 0, "corner", 8, 9);
|
||||
state = enterAidMemoireCell(state, 0, "center", 4, 9);
|
||||
state = enterAidMemoireCell(state, 0, "color", 6, 9);
|
||||
expect(maskValues(state.cells[0]!.cornerMarks, 9)).toEqual([2, 8]);
|
||||
expect(maskValues(state.cells[0]!.centerMarks, 9)).toEqual([4]);
|
||||
expect(state.cells[0]?.color).toBe(6);
|
||||
|
||||
state = enterAidMemoireCell(state, 0, "value", 5, 9);
|
||||
expect(state.cells[0]).toMatchObject({
|
||||
value: 5,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
color: 6,
|
||||
});
|
||||
expect(enterAidMemoireCell(state, 0, "center", 3, 9)).toBe(state);
|
||||
expect(enterAidMemoireCell(state, 0, "value", 2.5, 9)).toBe(state);
|
||||
state = eraseAidMemoireCell(state, 0, "value");
|
||||
state = enterAidMemoireCell(state, 0, "color", 6, 9);
|
||||
expect(state.cells[0]).toMatchObject({ value: 0, color: 0 });
|
||||
});
|
||||
|
||||
it("clears entries without losing layout or labels and can reset all", () => {
|
||||
let state = createAidMemoire(6, {
|
||||
enabled: true,
|
||||
cellCount: 4,
|
||||
columns: 2,
|
||||
});
|
||||
state = labelAidMemoireCell(state, 0, "Used");
|
||||
state = enterAidMemoireCell(state, 0, "value", 3, 6);
|
||||
const cleared = clearAidMemoireEntries(state);
|
||||
expect(cleared).toMatchObject({ enabled: true, columns: 2 });
|
||||
expect(cleared.cells[0]).toMatchObject({ label: "Used", value: 0 });
|
||||
|
||||
const reset = resetAidMemoire(cleared, 6);
|
||||
expect(reset).toMatchObject({ enabled: true, columns: 6 });
|
||||
expect(reset.cells).toHaveLength(6);
|
||||
expect(reset.cells[0]?.label).toBe("1");
|
||||
});
|
||||
|
||||
it("round-trips a stable portable representation", () => {
|
||||
const portable = normalizePortableAidMemoire(
|
||||
{
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{
|
||||
label: "Primes",
|
||||
value: 0,
|
||||
cornerMarks: [7, 2, 2],
|
||||
centerMarks: [5, 3],
|
||||
color: 4,
|
||||
},
|
||||
{ label: "Used", value: 8, color: 0 },
|
||||
],
|
||||
},
|
||||
9,
|
||||
);
|
||||
expect(portable.cells[0]).toMatchObject({
|
||||
cornerMarks: [2, 7],
|
||||
centerMarks: [3, 5],
|
||||
});
|
||||
expect(
|
||||
aidMemoireToPortable(aidMemoireFromPortable(portable, 9), 9),
|
||||
).toEqual(portable);
|
||||
});
|
||||
|
||||
it("rejects unsafe portable layouts and provides complete cell narration", () => {
|
||||
expect(() =>
|
||||
normalizePortableAidMemoire(
|
||||
{
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
|
||||
},
|
||||
9,
|
||||
),
|
||||
).toThrow(/1 to 36/u);
|
||||
|
||||
let state = createAidMemoire(9, { cellCount: 1 });
|
||||
state = labelAidMemoireCell(state, 0, "Odd candidates");
|
||||
state = enterAidMemoireCell(state, 0, "corner", 1, 9);
|
||||
state = enterAidMemoireCell(state, 0, "center", 7, 9);
|
||||
state = enterAidMemoireCell(state, 0, "color", 3, 9);
|
||||
expect(aidMemoireCellDescription(state.cells[0]!, 0, 9)).toBe(
|
||||
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navigateGridCell } from "../../src/state/gridNavigation";
|
||||
|
||||
describe("accessible grid navigation", () => {
|
||||
it("moves with arrows without wrapping at edges", () => {
|
||||
expect(navigateGridCell(4, 3, "ArrowRight")).toBe(5);
|
||||
expect(navigateGridCell(5, 3, "ArrowRight")).toBe(5);
|
||||
expect(navigateGridCell(3, 3, "ArrowLeft")).toBe(3);
|
||||
expect(navigateGridCell(1, 3, "ArrowUp")).toBe(1);
|
||||
expect(navigateGridCell(7, 3, "ArrowDown")).toBe(7);
|
||||
});
|
||||
|
||||
it("supports row, grid and column-edge shortcuts", () => {
|
||||
expect(navigateGridCell(4, 3, "Home")).toBe(3);
|
||||
expect(navigateGridCell(4, 3, "End")).toBe(5);
|
||||
expect(navigateGridCell(4, 3, "Home", true)).toBe(0);
|
||||
expect(navigateGridCell(4, 3, "End", true)).toBe(8);
|
||||
expect(navigateGridCell(4, 3, "PageUp")).toBe(1);
|
||||
expect(navigateGridCell(4, 3, "PageDown")).toBe(7);
|
||||
});
|
||||
|
||||
it("ignores non-navigation keys and rejects unsafe inputs", () => {
|
||||
expect(navigateGridCell(0, 9, "Enter")).toBeNull();
|
||||
expect(() => navigateGridCell(-1, 9, "Home")).toThrow(/outside/i);
|
||||
expect(() => navigateGridCell(0, 0, "Home")).toThrow(/size/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeHypothesis,
|
||||
beginHypothesis,
|
||||
createGameplayHistory,
|
||||
createSavepoint,
|
||||
deleteSavepoint,
|
||||
describeGameplayChange,
|
||||
finishHypothesis,
|
||||
gameplayMoment,
|
||||
recordGameplayMoment,
|
||||
restoreSavepoint,
|
||||
sessionFromGameplayState,
|
||||
} from "../../src/state/playHistory";
|
||||
import {
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
describe("gameplay history", () => {
|
||||
it("records immutable states with useful move labels", () => {
|
||||
const initial = createSession([0, 0, 0, 0]);
|
||||
const changed = { ...initial, values: [0, 3, 0, 0] };
|
||||
const label = describeGameplayChange(initial, changed, 2);
|
||||
const history = recordGameplayMoment(
|
||||
createGameplayHistory(initial),
|
||||
changed,
|
||||
label,
|
||||
);
|
||||
|
||||
expect(label).toBe("Set r1c2 to 3");
|
||||
expect(history.moments.map((moment) => moment.label)).toEqual([
|
||||
"Puzzle opened",
|
||||
"Set r1c2 to 3",
|
||||
]);
|
||||
changed.values[1] = 4;
|
||||
expect(history.moments[1]!.state.values[1]).toBe(3);
|
||||
});
|
||||
|
||||
it("creates, restores and deletes full named savepoints", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
session.values[1] = 2;
|
||||
session.centerMarks[2] = 5;
|
||||
session.colors[3] = 4;
|
||||
session.elapsedSeconds = 75;
|
||||
let history = createSavepoint(
|
||||
createGameplayHistory(session),
|
||||
session,
|
||||
"Before chain",
|
||||
);
|
||||
|
||||
session.values[1] = 4;
|
||||
session.centerMarks[2] = 0;
|
||||
const transition = restoreSavepoint(history, history.savepoints[0]!.id);
|
||||
const restored = sessionFromGameplayState(transition.state);
|
||||
expect(restored.values[1]).toBe(2);
|
||||
expect(restored.centerMarks[2]).toBe(5);
|
||||
expect(restored.colors[3]).toBe(4);
|
||||
expect(restored.elapsedSeconds).toBe(75);
|
||||
expect(transition.history.moments.at(-1)?.label).toBe(
|
||||
"Restored “Before chain”",
|
||||
);
|
||||
|
||||
history = deleteSavepoint(
|
||||
transition.history,
|
||||
transition.history.savepoints[0]!.id,
|
||||
);
|
||||
expect(history.savepoints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("captures aid-mémoire state in moments and savepoints", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
let aidMemoire = createAidMemoire(2, {
|
||||
enabled: true,
|
||||
cellCount: 2,
|
||||
});
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 1, 2);
|
||||
const history = createSavepoint(
|
||||
createGameplayHistory(session, aidMemoire),
|
||||
session,
|
||||
"Scratch one",
|
||||
aidMemoire,
|
||||
);
|
||||
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 2, 2);
|
||||
const restored = restoreSavepoint(history, history.savepoints[0]!.id);
|
||||
expect(restored.state.aidMemoire?.cells[0]?.value).toBe(1);
|
||||
expect(history.moments[0]?.state.aidMemoire?.cells[0]?.value).toBe(1);
|
||||
expect(aidMemoire.cells[0]?.value).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps discarded hypotheses available while restoring their exact base", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
session.values[0] = 1;
|
||||
let transition = beginHypothesis(
|
||||
createGameplayHistory(session),
|
||||
session,
|
||||
"Try a 2",
|
||||
);
|
||||
expect(activeHypothesis(transition.history)?.name).toBe("Try a 2");
|
||||
|
||||
const branchSession = sessionFromGameplayState(transition.state);
|
||||
branchSession.values[1] = 2;
|
||||
transition = {
|
||||
history: recordGameplayMoment(
|
||||
transition.history,
|
||||
branchSession,
|
||||
"Set r1c2 to 2",
|
||||
),
|
||||
state: transition.state,
|
||||
};
|
||||
const result = finishHypothesis(
|
||||
transition.history,
|
||||
branchSession,
|
||||
"discard",
|
||||
);
|
||||
|
||||
expect(result.state.values).toEqual([1, 0, 0, 0]);
|
||||
expect(result.history.branches[0]?.status).toBe("discarded");
|
||||
expect(
|
||||
result.history.moments.some(
|
||||
(moment) =>
|
||||
moment.branchId === result.history.branches[0]!.id &&
|
||||
moment.state.values[1] === 2,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.history.moments.at(-1)?.label).toBe(
|
||||
"Discarded hypothesis “Try a 2”",
|
||||
);
|
||||
});
|
||||
|
||||
it("can branch from a replayed moment and merge accepted changes", () => {
|
||||
const initial = createSession([0, 0, 0, 0]);
|
||||
const first = { ...initial, values: [1, 0, 0, 0] };
|
||||
const second = { ...initial, values: [1, 2, 0, 0] };
|
||||
let history = recordGameplayMoment(
|
||||
createGameplayHistory(initial),
|
||||
first,
|
||||
"First move",
|
||||
);
|
||||
const replayId = history.currentMomentId;
|
||||
history = recordGameplayMoment(history, second, "Second move");
|
||||
|
||||
let transition = beginHypothesis(history, second, "Alternative", replayId);
|
||||
expect(transition.state.values).toEqual([1, 0, 0, 0]);
|
||||
const alternative = sessionFromGameplayState(transition.state);
|
||||
alternative.values[2] = 3;
|
||||
transition = {
|
||||
history: recordGameplayMoment(
|
||||
transition.history,
|
||||
alternative,
|
||||
"Alternative move",
|
||||
),
|
||||
state: transition.state,
|
||||
};
|
||||
const kept = finishHypothesis(transition.history, alternative, "keep");
|
||||
|
||||
expect(kept.state.values).toEqual([1, 0, 3, 0]);
|
||||
expect(kept.history.branches.at(-1)?.status).toBe("kept");
|
||||
expect(
|
||||
gameplayMoment(kept.history, kept.history.currentMomentId)?.label,
|
||||
).toBe("Kept hypothesis “Alternative”");
|
||||
});
|
||||
|
||||
it("rejects blank checkpoint and hypothesis names", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
const history = createGameplayHistory(session);
|
||||
expect(() => createSavepoint(history, session, " ")).toThrow(
|
||||
"Savepoint name cannot be empty",
|
||||
);
|
||||
expect(() => beginHypothesis(history, session, " ")).toThrow(
|
||||
"Hypothesis name cannot be empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
|
||||
import {
|
||||
ProjectLibrary,
|
||||
createProjectRecord,
|
||||
normalizeProjectRecord,
|
||||
} from "../../src/storage";
|
||||
import {
|
||||
aidMemoireToPortable,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
} from "../../src/state/aidMemoire";
|
||||
|
||||
const puzzle = {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1 as const,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("aid-mémoire Library progress", () => {
|
||||
it("persists bounded scratch state in an independent local clone", async () => {
|
||||
let aidMemoire = createAidMemoire(9, {
|
||||
enabled: true,
|
||||
cellCount: 4,
|
||||
columns: 2,
|
||||
});
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 2, "value", 6, 9);
|
||||
const record = createProjectRecord(puzzle, {
|
||||
id: "aid-progress",
|
||||
now: 10,
|
||||
progress: {
|
||||
version: 1,
|
||||
values: Array<number>(81).fill(0),
|
||||
aidMemoire: aidMemoireToPortable(aidMemoire, 9),
|
||||
},
|
||||
});
|
||||
const library = new ProjectLibrary({ indexedDB: null });
|
||||
await library.put(record);
|
||||
|
||||
const loaded = await library.get("aid-progress");
|
||||
expect(loaded?.progress?.aidMemoire).toEqual(record.progress?.aidMemoire);
|
||||
(loaded!.progress!.aidMemoire!.cells[2]!.centerMarks as number[]).push(4);
|
||||
expect(
|
||||
(await library.get("aid-progress"))?.progress?.aidMemoire?.cells[2]
|
||||
?.centerMarks,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects malformed scratch progress at the storage boundary", () => {
|
||||
const record = createProjectRecord(puzzle, {
|
||||
id: "bad-aid-progress",
|
||||
now: 1,
|
||||
});
|
||||
expect(() =>
|
||||
normalizeProjectRecord({
|
||||
...record,
|
||||
progress: {
|
||||
version: 1,
|
||||
values: Array<number>(81).fill(0),
|
||||
aidMemoire: {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 2,
|
||||
cells: [{}],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/columns must be an integer from 1 to 1/u);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user