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(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); }); });