import { describe, expect, it } from "vitest"; import { classicRegions, type PuzzleDefinition } from "../../src/domain"; import { clueOrbit, evaluateTechniqueProfile, generateVariant, generateVariantBatch, minimizePuzzleWithReport, solveExact, type ClueSymmetry, type DifficultyAssessment, type PracticeTechnique, } from "../../src/solver"; const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const; const complete4: PuzzleDefinition = { version: 1, size: 4, givens: solution4, solution: solution4, regions: classicRegions(4), constraints: [], }; describe("Generator 2.0 clue removal", () => { it("builds all supported clue-symmetry orbits", () => { expect(clueOrbit(4, 1, "none")).toEqual([1]); expect(clueOrbit(4, 1, "rotational")).toEqual([1, 14]); expect(clueOrbit(4, 1, "horizontal")).toEqual([1, 13]); expect(clueOrbit(4, 1, "vertical")).toEqual([1, 2]); expect(clueOrbit(4, 1, "diagonal-main")).toEqual([1, 4]); expect(clueOrbit(4, 1, "diagonal-anti")).toEqual([1, 11]); expect(clueOrbit(4, 1, "orthogonal")).toEqual([1, 7, 8, 14]); expect(clueOrbit(4, 1, "four-way")).toEqual(clueOrbit(4, 1, "orthogonal")); }); it.each([ "rotational", "horizontal", "vertical", "diagonal-main", "diagonal-anti", "orthogonal", ] as const satisfies readonly ClueSymmetry[])( "preserves %s symmetry during grouped removal", (symmetry) => { const result = minimizePuzzleWithReport(complete4, { seed: `symmetry-${symmetry}`, symmetry, targetClues: 8, maxChecks: 32, }); for (let cell = 0; cell < 16; cell += 1) { const present = (result.puzzle.givens[cell] ?? 0) !== 0; expect( clueOrbit(4, cell, symmetry).every( (other) => ((result.puzzle.givens[other] ?? 0) !== 0) === present, ), ).toBe(true); } expect(result.minimality.symmetryPreserved).toBe(true); }, ); it("proves individual-given minimality when every deletion check completes", () => { const result = minimizePuzzleWithReport(complete4, { seed: "minimal-proof", symmetry: "rotational", targetClues: 8, minimalGivens: true, maxChecks: 64, solveTimeoutMs: 2_000, }); expect(result.minimality.status).toBe("proven-minimal"); expect(result.minimality.unknownCells).toEqual([]); for (let cell = 0; cell < result.puzzle.givens.length; cell += 1) { if (result.puzzle.givens[cell] === 0) continue; const givens = [...result.puzzle.givens]; givens[cell] = 0; const exact = solveExact( { ...result.puzzle, givens }, { maxSolutions: 2 }, ); expect(exact.count).toBe(2); } }); it("reports unknown instead of minimal after a tiny shared check cap", () => { const result = minimizePuzzleWithReport(complete4, { seed: "minimal-bounded", targetClues: 16, minimalGivens: true, maxChecks: 1, }); expect(result.minimality.status).toBe("unknown"); expect(result.minimality.checksPerformed).toBe(1); expect(result.minimality.unknownCells.length).toBeGreaterThan(0); expect(result.minimality.limitReasons).toContain("check-cap"); }); it("treats a truncated deletion search as unknown evidence", () => { const result = minimizePuzzleWithReport(complete4, { seed: "minimal-node-bound", targetClues: 16, minimalGivens: true, maxChecks: 32, solveMaxNodes: 1, }); expect(result.minimality.status).toBe("unknown"); expect(result.minimality.limitReasons).toContain("node-cap"); expect(result.minimality.unknownCells.length).toBeGreaterThan(0); }); it("never claims minimality before proving the input is unique", () => { const ambiguous: PuzzleDefinition = { version: 1, size: 4, givens: new Array(16).fill(0), regions: classicRegions(4), constraints: [], }; const result = minimizePuzzleWithReport(ambiguous, { minimalGivens: true, targetClues: 0, maxChecks: 32, }); expect(result.minimality.status).toBe("unknown"); expect(result.minimality.checksPerformed).toBe(1); expect(result.minimality.limitReasons).toContain("not-unique"); expect(result.puzzle.givens).toEqual(ambiguous.givens); }); }); describe("Generator 2.0 variants and profiles", () => { it("combines multiple compatible variant families deterministically", () => { const options = { variants: ["kropki", "thermo"] as const, size: 4, seed: "mixed-local", targetClues: 16, maxChecks: 1, constraintCount: 3, }; const generated = generateVariant(options); expect(generated.variant).toBe("mixed"); expect(generated.families).toEqual(["thermo", "kropki"]); expect( generated.puzzle.constraints.some(({ type }) => type === "thermo"), ).toBe(true); expect( generated.puzzle.constraints.some(({ type }) => type === "kropki"), ).toBe(true); expect(generateVariant(options)).toEqual(generated); }); it("scales local marking counts with explicit density", () => { const common = { variant: "inequality" as const, size: 4, seed: "density", targetClues: 16, maxChecks: 1, constraintCount: 6, }; const sparse = generateVariant({ ...common, constraintDensity: "sparse" }); const dense = generateVariant({ ...common, constraintDensity: "dense" }); expect(sparse.generatedConstraintCount).toBeLessThan( dense.generatedConstraintCount, ); expect(sparse.constraintDensity).toBe("sparse"); expect(dense.constraintDensity).toBe("dense"); const sparseKiller = generateVariant({ variant: "killer", size: 4, seed: "killer-density", targetClues: 16, maxChecks: 1, constraintDensity: "sparse", }); const denseKiller = generateVariant({ variant: "killer", size: 4, seed: "killer-density", targetClues: 16, maxChecks: 1, constraintDensity: "dense", }); expect(sparseKiller.generatedConstraintCount).toBeLessThanOrEqual( denseKiller.generatedConstraintCount, ); }); it("evaluates required, forbidden, count and hardest-technique evidence", () => { const assessment: DifficultyAssessment = { score: 52, level: "medium", label: "Medium", uniqueness: "unique", clueCount: 30, emptyCount: 51, logicalStatus: "solved", logicalSteps: 7, hardestTechnique: "x-wing", techniqueCounts: { "naked-single": 4, "hidden-single": 2, "x-wing": 1, }, exactNodes: 100, exactTruncated: false, summary: "fixture", }; const matched = evaluateTechniqueProfile(assessment, { required: ["x-wing"], forbidden: ["swordfish"], counts: [{ technique: "naked-single", min: 3, max: 5 }], hardestTechnique: "x-wing", }); expect(matched.status).toBe("matched"); expect(matched.requirements.every(({ matched: ok }) => ok)).toBe(true); expect( evaluateTechniqueProfile(assessment, { counts: [{ technique: "hidden-single", max: 1 }], }).status, ).toBe("not-matched"); expect( evaluateTechniqueProfile( { ...assessment, logicalStatus: "stuck" }, { forbidden: ["aic"] }, ), ).toMatchObject({ status: "not-matched", completePath: false }); }); it("accepts a generation profile only after its independent path matches", () => { const options = { variant: "classic" as const, size: 4, seed: "profile-path", targetClues: 10, maxChecks: 12, }; const baseline = generateVariant(options); expect(baseline.difficulty.logicalStatus).toBe("solved"); expect(baseline.difficulty.hardestTechnique).toBeDefined(); const hardest = baseline.difficulty.hardestTechnique as PracticeTechnique; const profiled = generateVariant({ ...options, techniqueProfile: { required: [hardest], forbidden: ["aic"], counts: [{ technique: hardest, min: 1, max: 16 }], hardestTechnique: hardest, }, maxTechniqueAttempts: 1, }); expect(profiled.techniqueProfile).toMatchObject({ status: "matched", completePath: true, requestedHardestTechnique: hardest, actualHardestTechnique: hardest, hardestMatched: true, }); }); it("returns a deterministic ranked batch with concise evidence summaries", () => { const options = { variant: "classic" as const, size: 4, seed: "batch", targetClues: 10, maxChecks: 12, batchSize: 3, ranking: "fewest-givens" as const, }; const batch = generateVariantBatch(options); expect(batch.completed).toBe(3); expect(batch.truncated).toBe(false); expect(batch.summaries.map(({ rank }) => rank)).toEqual([1, 2, 3]); expect(batch.summaries.map(({ clueCount }) => clueCount)).toEqual( [...batch.summaries.map(({ clueCount }) => clueCount)].sort( (a, b) => a - b, ), ); expect(generateVariantBatch(options)).toEqual(batch); }); });