import fc from "fast-check"; import { describe, expect, it } from "vitest"; import { allCandidates, candidatesForCell, classicRegions, createEmptyPuzzle, findConflicts, normalizePuzzle, validatePuzzle, type PuzzleDefinition, type VariantConstraint, } from "../../src/domain"; const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const; function puzzle4(overrides: Partial = {}): PuzzleDefinition { return { version: 1, size: 4, givens: new Array(16).fill(0), regions: classicRegions(4), constraints: [], ...overrides, }; } describe("puzzle domain", () => { it("creates valid rectangular regions for every supported size", () => { fc.assert( fc.property(fc.integer({ min: 4, max: 16 }), (size) => { const regions = classicRegions(size); expect(regions).toHaveLength(size * size); for (let region = 0; region < size; region += 1) { expect(regions.filter((entry) => entry === region)).toHaveLength( size, ); } expect(validatePuzzle(createEmptyPuzzle(size)).valid).toBe(true); }), ); }); it("strictly rejects unknown, oversized, malformed, and contradictory data", () => { expect(validatePuzzle({ ...puzzle4(), surprise: true }).valid).toBe(false); expect(validatePuzzle({ ...puzzle4(), title: "x".repeat(257) }).valid).toBe( false, ); expect( validatePuzzle({ ...puzzle4(), givens: [1, 1, ...new Array(14).fill(0)], }).valid, ).toBe(false); expect( validatePuzzle({ ...puzzle4(), regions: new Array(16).fill(0) }) .valid, ).toBe(false); expect( validatePuzzle({ ...puzzle4(), constraints: [{ type: "thermo", cells: [0, 0], extra: true }], }).valid, ).toBe(false); }); it("accepts all supported constraint shapes and preserves bounded metadata", () => { const constraints: VariantConstraint[] = [ { type: "diagonal", direction: "main" }, { type: "diagonal", direction: "anti" }, { type: "anti-knight" }, { type: "anti-king" }, { type: "non-consecutive" }, { type: "killer-cage", cells: [0, 1], sum: 3 }, { type: "thermo", cells: [0, 1, 2] }, { type: "arrow", bulb: [0], line: [1, 2] }, { type: "kropki", a: 0, b: 1, kind: "white" }, { type: "kropki", a: 0, b: 4, kind: "black" }, { type: "xv", a: 0, b: 1, total: 5 }, { type: "inequality", lesser: 0, greater: 1 }, { type: "renban", cells: [0, 1, 2] }, { type: "palindrome", cells: [0, 5] }, ]; const definition = puzzle4({ id: "demo", title: "Variant", rules: "Local rules", constraints, }); const result = validatePuzzle(definition); expect(result).toEqual({ valid: true, issues: [] }); expect(normalizePuzzle(definition)).toMatchObject({ id: "demo", rules: "Local rules", }); }); it("validates an optional solution against givens and constraints", () => { const constraints: VariantConstraint[] = [ { type: "diagonal", direction: "main" }, { type: "diagonal", direction: "anti" }, { type: "killer-cage", cells: [0, 1], sum: 3 }, { type: "thermo", cells: [0, 1, 2, 3] }, { type: "arrow", bulb: [3], line: [0, 2] }, { type: "kropki", a: 0, b: 1, kind: "white" }, { type: "kropki", a: 0, b: 1, kind: "black" }, { type: "xv", a: 0, b: 3, total: 5 }, { type: "inequality", lesser: 0, greater: 1 }, { type: "renban", cells: [0, 1, 2, 3] }, { type: "palindrome", cells: [0, 6] }, ]; expect( validatePuzzle( puzzle4({ givens: [1, ...new Array(15).fill(0)], solution: solved4, constraints, }), ).valid, ).toBe(true); const wrong = [...solved4]; wrong[0] = 2; expect( validatePuzzle( puzzle4({ givens: [1, ...new Array(15).fill(0)], solution: wrong, }), ).valid, ).toBe(false); }); it("computes classic candidates and reports exact conflict cells", () => { const values = [1, 2, 3, 0, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3]; const puzzle = puzzle4({ givens: new Array(16).fill(0) }); expect(candidatesForCell(puzzle, values, 3)).toEqual([4]); expect(allCandidates(puzzle, values)[3]).toEqual([4]); const conflict = [...values]; conflict[3] = 1; expect( findConflicts(puzzle, conflict).some( ({ cells }) => cells.includes(0) && cells.includes(3), ), ).toBe(true); }); it("enforces anti, adjacency, line, sum, ratio and equality constraints in candidates", () => { const scenarios: Array< readonly [VariantConstraint, number, number, boolean] > = [ [{ type: "anti-knight" }, 6, 1, false], [{ type: "anti-king" }, 5, 1, false], [{ type: "non-consecutive" }, 1, 2, false], [{ type: "killer-cage", cells: [0, 1], sum: 3 }, 1, 4, false], [{ type: "thermo", cells: [0, 1, 2, 3] }, 1, 1, false], [{ type: "arrow", bulb: [0], line: [1, 2] }, 2, 4, false], [{ type: "kropki", a: 0, b: 1, kind: "black" }, 1, 3, false], [{ type: "xv", a: 0, b: 1, total: 5 }, 1, 3, false], [{ type: "inequality", lesser: 0, greater: 1 }, 1, 1, false], [{ type: "renban", cells: [0, 1, 2] }, 2, 4, false], [{ type: "palindrome", cells: [0, 5] }, 5, 2, false], ]; for (const [constraint, cell, candidate, expected] of scenarios) { const values = new Array(16).fill(0); values[0] = 1; if (constraint.type === "arrow") values[1] = 1; if (constraint.type === "renban") values[1] = 2; expect( candidatesForCell( puzzle4({ constraints: [constraint] }), values, cell, ).includes(candidate), ).toBe(expected); } }); });