Files
sudoku-tools/tests/solver/variantGenerator.test.ts
T

233 lines
6.7 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("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);
});
});