204 lines
5.8 KiB
TypeScript
204 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|