feat: add gameplay assists and variant generator
This commit is contained in:
@@ -32,6 +32,16 @@ test("loads standalone and keeps the core play workflow local", async ({
|
||||
grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
|
||||
).toBeVisible();
|
||||
|
||||
const placedFour = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 4",
|
||||
});
|
||||
await placedFour.click({ modifiers: ["Control"] });
|
||||
expect(await grid.locator(".is-digit-highlighted").count()).toBeGreaterThan(
|
||||
1,
|
||||
);
|
||||
await placedFour.click({ modifiers: ["Control"] });
|
||||
await expect(grid.locator(".is-digit-highlighted")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Helpers" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Sudoku helpers" }),
|
||||
@@ -54,3 +64,76 @@ test("loads standalone and keeps the core play workflow local", async ({
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("replaces setter cages and generates a rated variant", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
await page.getByRole("button", { name: "New blank" }).click();
|
||||
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
await grid.getByRole("gridcell", { name: "Row 1, column 1, empty" }).click();
|
||||
await grid
|
||||
.getByRole("gridcell", { name: "Row 1, column 2, empty" })
|
||||
.click({ modifiers: ["Shift"] });
|
||||
|
||||
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("3");
|
||||
await page.getByRole("button", { name: "Add / replace cage" }).click();
|
||||
await expect(page.getByText("3 cage · 2 cells")).toBeVisible();
|
||||
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("4");
|
||||
await page.getByRole("button", { name: "Add / replace cage" }).click();
|
||||
await expect(page.getByText("3 cage · 2 cells")).toHaveCount(0);
|
||||
await expect(page.getByText("4 cage · 2 cells")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Remove selected cage" }).click();
|
||||
await expect(page.getByText("4 cage · 2 cells")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Generate", exact: true }).click();
|
||||
await page.getByLabel("Variant").selectOption("thermo");
|
||||
await page
|
||||
.getByRole("combobox", { name: "Grid", exact: true })
|
||||
.selectOption("4");
|
||||
await page.getByLabel("Requested profile").selectOption("beginner");
|
||||
await page.getByLabel("Seed").fill("browser-thermo");
|
||||
await page.getByRole("button", { name: "Generate Thermo" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Thermo · browser-thermo" }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByText("Difficulty assessment")).toBeVisible();
|
||||
await expect(page.locator(".difficulty-card .status-pill")).toHaveText(
|
||||
"unique",
|
||||
);
|
||||
await expect(page.locator(".constraint-thermo")).not.toHaveCount(0);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("uses distinct numbered sum badges and directional inequalities", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
const examples = page.getByLabel("Open built-in puzzle");
|
||||
|
||||
await examples.selectOption("xv");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Five or ten" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".constraint-xv--5 text").first()).toHaveText("5");
|
||||
await expect(page.locator(".constraint-xv--10 text").first()).toHaveText(
|
||||
"10",
|
||||
);
|
||||
|
||||
await examples.selectOption("inequality");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Lesser and greater" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".constraint-inequality")).not.toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(".constraint-inequality-tip").first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { VariantConstraint } from "../../src/domain";
|
||||
import {
|
||||
removeKillerCagesAtCells,
|
||||
replaceOverlappingKillerCages,
|
||||
selectionTouchesKillerCage,
|
||||
} from "../../src/components/constraintEditing";
|
||||
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{ type: "killer-cage", cells: [2, 3], sum: 7 },
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
];
|
||||
|
||||
describe("setter cage editing", () => {
|
||||
it("replaces every overlapping cage while preserving other rules", () => {
|
||||
expect(
|
||||
replaceOverlappingKillerCages(constraints, {
|
||||
type: "killer-cage",
|
||||
cells: [1, 2],
|
||||
sum: 5,
|
||||
}),
|
||||
).toEqual([
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
{ type: "killer-cage", cells: [1, 2], sum: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes cages by any selected member cell", () => {
|
||||
expect(removeKillerCagesAtCells(constraints, [1])).toEqual([
|
||||
{ type: "killer-cage", cells: [2, 3], sum: 7 },
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
]);
|
||||
expect(selectionTouchesKillerCage(constraints, [1])).toBe(true);
|
||||
expect(selectionTouchesKillerCage(constraints, [4])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DigitCompletionBar } from "../../src/components/DigitCompletionBar";
|
||||
import { digitCompletions } from "../../src/state/gameplayHelpers";
|
||||
|
||||
describe("digit completion bar", () => {
|
||||
it("renders complete digits muted and excess digits as overdone", () => {
|
||||
const values = new Array<number>(81).fill(0);
|
||||
values.fill(1, 0, 9);
|
||||
values.fill(2, 9, 19);
|
||||
|
||||
render(
|
||||
<DigitCompletionBar
|
||||
size={9}
|
||||
completions={digitCompletions(9, values)}
|
||||
highlightedDigit={null}
|
||||
highlightingEnabled
|
||||
onHighlight={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 1: complete/u }),
|
||||
).toHaveClass("is-done");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 2: overdone by 1/u }),
|
||||
).toHaveClass("is-overdone");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 3: 9 remaining/u }),
|
||||
).toHaveClass("is-undone");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
|
||||
|
||||
describe("Sudoku generator workspace", () => {
|
||||
it("offers only reliable sizes and submits an explicit local recipe", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={onGenerate}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "anti-king");
|
||||
const grid = screen.getByLabelText("Grid");
|
||||
expect(
|
||||
within(grid)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent),
|
||||
).toEqual(["6 × 6", "9 × 9"]);
|
||||
expect(
|
||||
screen.queryByLabelText("Requested markings"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
|
||||
expect(screen.getByLabelText("Requested markings")).toBeInTheDocument();
|
||||
await user.selectOptions(screen.getByLabelText("Grid"), "4");
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Requested profile"),
|
||||
"easy",
|
||||
);
|
||||
await user.clear(screen.getByLabelText("Seed"));
|
||||
await user.type(screen.getByLabelText("Seed"), "repeatable-demo");
|
||||
await user.click(screen.getByRole("button", { name: "Generate Thermo" }));
|
||||
|
||||
expect(onGenerate).toHaveBeenCalledWith({
|
||||
variant: "thermo",
|
||||
size: 4,
|
||||
targetDifficulty: "easy",
|
||||
symmetry: "rotational",
|
||||
constraintCount: 8,
|
||||
seed: "repeatable-demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes independent rating for the current puzzle", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace busy={false} onGenerate={vi.fn()} onRate={onRate} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Rate current puzzle" }),
|
||||
);
|
||||
expect(onRate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizePuzzle } from "../../src/domain";
|
||||
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
||||
|
||||
describe("Sudoku board constraint visuals", () => {
|
||||
it("distinguishes XV sums from directional inequalities", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
constraints: [
|
||||
{ type: "xv", a: 0, b: 1, total: 5 },
|
||||
{ type: "xv", a: 4, b: 5, total: 10 },
|
||||
{ type: "inequality", lesser: 8, greater: 9 },
|
||||
],
|
||||
});
|
||||
|
||||
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(".constraint-xv--5 text")).toHaveTextContent(
|
||||
"5",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".constraint-xv--10 text"),
|
||||
).toHaveTextContent("10");
|
||||
expect(
|
||||
container.querySelector(".constraint-inequality path"),
|
||||
).toHaveAttribute("d", "M0.12 -0.17L-0.12 0L0.12 0.17");
|
||||
expect(
|
||||
container.querySelector(".constraint-inequality-tip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,102 @@ describe("Sudoku workbench", () => {
|
||||
expect(undo).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
|
||||
const completion = screen.getByRole("group", {
|
||||
name: "Highlight matching digits",
|
||||
});
|
||||
expect(within(completion).getAllByRole("button")).toHaveLength(9);
|
||||
|
||||
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
const four = within(grid).getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 4",
|
||||
});
|
||||
fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
|
||||
|
||||
const highlighted = grid.querySelectorAll(".is-digit-highlighted");
|
||||
expect(highlighted.length).toBeGreaterThan(1);
|
||||
expect(four).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
|
||||
expect(grid.querySelectorAll(".is-digit-highlighted")).toHaveLength(0);
|
||||
|
||||
await user.click(screen.getByLabelText("Show digit completion bar"));
|
||||
expect(
|
||||
screen.queryByRole("group", { name: "Highlight matching digits" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces and removes setter cages through the selected cells", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New blank" }));
|
||||
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
fireEvent.pointerDown(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Row 1, column 2, empty",
|
||||
}),
|
||||
{ buttons: 1, shiftKey: true },
|
||||
);
|
||||
|
||||
const cageSum = screen.getByRole("spinbutton", { name: "Cage sum" });
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "3");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(screen.getByText("3 cage · 2 cells")).toBeInTheDocument();
|
||||
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "4");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(screen.queryByText("3 cage · 2 cells")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("4 cage · 2 cells")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove selected cage" }),
|
||||
);
|
||||
expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the expanded built-in variant examples", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Workbench />);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Open built-in puzzle"),
|
||||
"thermo",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Warm fronts" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-thermo").length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Open built-in puzzle"),
|
||||
"xv",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Five or ten" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent(
|
||||
"5",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".constraint-xv--10 text"),
|
||||
).toHaveTextContent("10");
|
||||
});
|
||||
|
||||
it("switches between setting, solving, playing and helper workspaces", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
@@ -75,6 +171,14 @@ describe("Sudoku workbench", () => {
|
||||
screen.getByRole("heading", { name: "Enter givens" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Generate" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Sudoku generator" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Rate current puzzle" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Solve" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Solve and verify" }),
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SAMPLE_PUZZLES } from "../../src/data/samples";
|
||||
import { solveExact, solveLogically } from "../../src/solver";
|
||||
import {
|
||||
CLASSIC_SAMPLE,
|
||||
SAMPLE_CATALOG,
|
||||
SAMPLE_PUZZLES,
|
||||
} from "../../src/data/samples";
|
||||
import {
|
||||
GENERATOR_VARIANTS,
|
||||
solveExact,
|
||||
solveLogically,
|
||||
} from "../../src/solver";
|
||||
|
||||
describe("bundled original samples", () => {
|
||||
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => {
|
||||
@@ -10,6 +18,39 @@ describe("bundled original samples", () => {
|
||||
});
|
||||
|
||||
it("solves the generated classic with the supported logical techniques", () => {
|
||||
expect(solveLogically(SAMPLE_PUZZLES[0]).status).toBe("solved");
|
||||
expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved");
|
||||
});
|
||||
|
||||
it("provides a named example for every advertised generator variant", () => {
|
||||
expect(new Set(SAMPLE_CATALOG.map(({ id }) => id)).size).toBe(
|
||||
SAMPLE_CATALOG.length,
|
||||
);
|
||||
for (const { id } of GENERATOR_VARIANTS) {
|
||||
expect(SAMPLE_CATALOG.some((entry) => entry.id === id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("also demonstrates every supported global chess/adjacency rule", () => {
|
||||
const constraintTypes = new Set(
|
||||
SAMPLE_PUZZLES.flatMap((puzzle) =>
|
||||
(puzzle.constraints ?? []).map(({ type }) => type),
|
||||
),
|
||||
);
|
||||
expect(constraintTypes).toEqual(
|
||||
new Set([
|
||||
"diagonal",
|
||||
"anti-knight",
|
||||
"anti-king",
|
||||
"non-consecutive",
|
||||
"killer-cage",
|
||||
"thermo",
|
||||
"arrow",
|
||||
"kropki",
|
||||
"xv",
|
||||
"inequality",
|
||||
"renban",
|
||||
"palindrome",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
|
||||
import {
|
||||
evaluateDifficulty,
|
||||
generateVariant,
|
||||
GENERATOR_VARIANTS,
|
||||
solveExact,
|
||||
type GeneratorVariant,
|
||||
} from "../../src/solver";
|
||||
|
||||
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
|
||||
|
||||
const puzzle4: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3],
|
||||
solution: solution4,
|
||||
regions: classicRegions(4),
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("difficulty assessment", () => {
|
||||
it("rates a unique puzzle from deterministic solver evidence", () => {
|
||||
const first = evaluateDifficulty(puzzle4);
|
||||
const second = evaluateDifficulty(puzzle4);
|
||||
expect(first.uniqueness).toBe("unique");
|
||||
expect(first.score).not.toBeNull();
|
||||
expect(first).toEqual(second);
|
||||
expect(first.logicalSteps).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not rate puzzles with multiple solutions", () => {
|
||||
const assessment = evaluateDifficulty({
|
||||
...puzzle4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
solution: undefined,
|
||||
});
|
||||
expect(assessment.uniqueness).toBe("multiple");
|
||||
expect(assessment.level).toBe("unrated");
|
||||
expect(assessment.score).toBeNull();
|
||||
});
|
||||
|
||||
it("never claims uniqueness after a bounded search is cut short", () => {
|
||||
const assessment = evaluateDifficulty(puzzle4, { exactMaxNodes: 1 });
|
||||
expect(assessment.uniqueness).toBe("unknown");
|
||||
expect(assessment.score).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("variant generator", () => {
|
||||
it("advertises only explicit, material variant definitions", () => {
|
||||
expect(GENERATOR_VARIANTS.map(({ id }) => id)).toEqual([
|
||||
"classic",
|
||||
"diagonal",
|
||||
"anti-knight",
|
||||
"anti-king",
|
||||
"non-consecutive",
|
||||
"killer",
|
||||
"thermo",
|
||||
"arrow",
|
||||
"kropki",
|
||||
"xv",
|
||||
"inequality",
|
||||
"renban",
|
||||
"palindrome",
|
||||
]);
|
||||
expect(
|
||||
GENERATOR_VARIANTS.every(
|
||||
({ description, supportedSizes }) =>
|
||||
description.length > 0 && supportedSizes.length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["classic", 4],
|
||||
["diagonal", 4],
|
||||
["anti-knight", 4],
|
||||
["anti-king", 6],
|
||||
["non-consecutive", 6],
|
||||
["killer", 4],
|
||||
["thermo", 4],
|
||||
["kropki", 4],
|
||||
["inequality", 4],
|
||||
["renban", 4],
|
||||
["palindrome", 4],
|
||||
["arrow", 6],
|
||||
["xv", 6],
|
||||
] as const)("generates a unique %s puzzle", (variant, size) => {
|
||||
const generated = generateVariant({
|
||||
variant,
|
||||
size,
|
||||
seed: `test-${variant}`,
|
||||
targetDifficulty: "beginner",
|
||||
maxChecks: size * size,
|
||||
solveTimeoutMs: 2_000,
|
||||
});
|
||||
const exact = solveExact(generated.puzzle, {
|
||||
maxSolutions: 2,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect(exact.count).toBe(1);
|
||||
expect(exact.truncated).toBe(false);
|
||||
expect(exact.solutions[0]).toEqual(generated.puzzle.solution);
|
||||
expect(generated.difficulty.uniqueness).toBe("unique");
|
||||
if (variant === "classic") {
|
||||
expect(generated.puzzle.constraints).toHaveLength(0);
|
||||
} else if (variant === "diagonal") {
|
||||
expect(
|
||||
generated.puzzle.constraints.filter(({ type }) => type === "diagonal"),
|
||||
).toHaveLength(2);
|
||||
} else if (variant === "killer") {
|
||||
expect(
|
||||
generated.puzzle.constraints.some(({ type }) => type === "killer-cage"),
|
||||
).toBe(true);
|
||||
} else {
|
||||
expect(
|
||||
generated.puzzle.constraints.some(({ type }) => type === variant),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["classic", "killer", "thermo", "arrow"] as const)(
|
||||
"is deterministic for %s generation",
|
||||
(variant) => {
|
||||
const size = variant === "arrow" ? 6 : 4;
|
||||
const options = {
|
||||
variant,
|
||||
size,
|
||||
seed: "repeatable",
|
||||
targetClues: Math.ceil((size * size) / 2),
|
||||
maxChecks: size * size,
|
||||
} as const;
|
||||
expect(generateVariant(options)).toEqual(generateVariant(options));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["diagonal", "anti-knight", "anti-king", "killer", "arrow"] as const)(
|
||||
"keeps practical 9x9 %s generation bounded",
|
||||
(variant) => {
|
||||
const generated = generateVariant({
|
||||
variant,
|
||||
size: 9,
|
||||
seed: `practical-${variant}`,
|
||||
targetClues: 50,
|
||||
maxChecks: 12,
|
||||
solveMaxNodes: 500_000,
|
||||
solveTimeoutMs: 2_000,
|
||||
});
|
||||
expect(generated.puzzle.size).toBe(9);
|
||||
expect(generated.difficulty.uniqueness).toBe("unique");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["anti-knight", 9],
|
||||
["anti-king", 9],
|
||||
["non-consecutive", 6],
|
||||
] as const)(
|
||||
"constructs supported global %s grids across deterministic seeds",
|
||||
(variant, size) => {
|
||||
for (let seed = 0; seed < 5; seed += 1) {
|
||||
const generated = generateVariant({
|
||||
variant,
|
||||
size,
|
||||
seed,
|
||||
targetClues: size * size,
|
||||
maxChecks: 1,
|
||||
solveMaxNodes: 500_000,
|
||||
solveTimeoutMs: 2_000,
|
||||
});
|
||||
expect(generated.difficulty.uniqueness).toBe("unique");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects sizes outside a variant's reliable advertised range", () => {
|
||||
expect(() => generateVariant({ variant: "arrow", size: 4 })).toThrow(
|
||||
/supports sizes 6, 9/,
|
||||
);
|
||||
expect(() =>
|
||||
generateVariant({ variant: "non-consecutive", size: 9 }),
|
||||
).toThrow(/supports sizes 6/);
|
||||
});
|
||||
|
||||
it("labels generated rules only for the requested variant", () => {
|
||||
const generated = generateVariant({
|
||||
variant: "xv",
|
||||
size: 6,
|
||||
seed: "rules",
|
||||
targetClues: 30,
|
||||
maxChecks: 6,
|
||||
});
|
||||
expect(generated.puzzle.rules).toMatch(/numbered pair badge/i);
|
||||
expect(generated.puzzle.rules).toMatch(/Only marked pairs/i);
|
||||
expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/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,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
digitCompletions,
|
||||
matchingDigitCells,
|
||||
matchingDigitCellsAt,
|
||||
placedDigitAt,
|
||||
toggledDigitHighlight,
|
||||
} from "../../src/state/gameplayHelpers";
|
||||
|
||||
describe("digit completion helpers", () => {
|
||||
it.each([4, 6, 9, 12, 16])(
|
||||
"reports undone, done and overdone digits on a %i by %i grid",
|
||||
(size) => {
|
||||
const values = new Array<number>(size * size).fill(0);
|
||||
values.fill(1, 0, size - 1);
|
||||
values.fill(2, size, size * 2);
|
||||
values.fill(3, size * 2, size * 3 + 1);
|
||||
|
||||
const progress = digitCompletions(size, values);
|
||||
|
||||
expect(progress).toHaveLength(size);
|
||||
expect(progress[0]).toEqual({
|
||||
digit: 1,
|
||||
placed: size - 1,
|
||||
target: size,
|
||||
remaining: 1,
|
||||
excess: 0,
|
||||
status: "undone",
|
||||
});
|
||||
expect(progress[1]).toEqual({
|
||||
digit: 2,
|
||||
placed: size,
|
||||
target: size,
|
||||
remaining: 0,
|
||||
excess: 0,
|
||||
status: "done",
|
||||
});
|
||||
expect(progress[2]).toEqual({
|
||||
digit: 3,
|
||||
placed: size + 1,
|
||||
target: size,
|
||||
remaining: 0,
|
||||
excess: 1,
|
||||
status: "overdone",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores empty, invalid and out-of-grid values without mutating input", () => {
|
||||
const values = [1, 1, 0, -1, 5, Number.NaN, ...new Array(12).fill(0), 1];
|
||||
const before = [...values];
|
||||
|
||||
expect(digitCompletions(4, values)[0]).toMatchObject({
|
||||
placed: 2,
|
||||
remaining: 2,
|
||||
status: "undone",
|
||||
});
|
||||
expect(values).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([0, 3, 17, 4.5, Number.NaN])(
|
||||
"rejects unsupported puzzle size %s",
|
||||
(size) => {
|
||||
expect(() => digitCompletions(size, [])).toThrow(RangeError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("digit match highlighting", () => {
|
||||
const values = [4, 0, 2, 4, 0, 3, 4, 0, 2, 0, 3, 0, 4, 2, 0, 3];
|
||||
|
||||
it("resolves a placed digit and all of its matching cells", () => {
|
||||
expect(placedDigitAt(4, values, 3)).toBe(4);
|
||||
expect(matchingDigitCells(4, values, 4)).toEqual([0, 3, 6, 12]);
|
||||
expect(matchingDigitCellsAt(4, values, 3)).toEqual([0, 3, 6, 12]);
|
||||
});
|
||||
|
||||
it("treats empty, invalid and out-of-grid cells as no match", () => {
|
||||
expect(placedDigitAt(4, values, 1)).toBeNull();
|
||||
expect(placedDigitAt(4, values, -1)).toBeNull();
|
||||
expect(placedDigitAt(4, values, 16)).toBeNull();
|
||||
expect(matchingDigitCellsAt(4, values, 1)).toEqual([]);
|
||||
expect(matchingDigitCells(4, values, 0)).toEqual([]);
|
||||
expect(matchingDigitCells(4, values, 5)).toEqual([]);
|
||||
});
|
||||
|
||||
it("toggles repeated Ctrl/Cmd-style activation and preserves empty clicks", () => {
|
||||
expect(toggledDigitHighlight(4, values, 0, null)).toBe(4);
|
||||
expect(toggledDigitHighlight(4, values, 3, 4)).toBeNull();
|
||||
expect(toggledDigitHighlight(4, values, 2, 4)).toBe(2);
|
||||
expect(toggledDigitHighlight(4, values, 1, 4)).toBe(4);
|
||||
});
|
||||
|
||||
it("never returns cells beyond the declared grid", () => {
|
||||
expect(matchingDigitCells(4, [...values, 4, 4], 4)).toEqual([0, 3, 6, 12]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user