import { describe, expect, it } from "vitest"; import { candidatesForCell, classicRegions, compilePuzzle, constraintIsFeasible, 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(constraints: readonly VariantConstraint[]): PuzzleDefinition { return { version: 1, size: 4, givens: new Array(16).fill(0), regions: classicRegions(4), constraints, }; } describe("outside and local clue constraints", () => { it("strictly validates and clones every new bounded shape", () => { const constraints: VariantConstraint[] = [ { type: "x-sum", side: "left", index: 0, sum: 6 }, { type: "skyscraper", side: "bottom", index: 1, count: 2 }, { type: "quadruple", cells: [1, 2, 5, 6], digits: [2, 2, 4] }, { type: "maximum", cell: 5 }, ]; const definition = puzzle4(constraints); expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] }); const normalized = normalizePuzzle(definition); expect(normalized.constraints).toEqual(constraints); expect(normalized.constraints).not.toBe(constraints); expect(normalized.constraints[2]).not.toBe(constraints[2]); const malformed = [ { type: "x-sum", side: "near", index: 0, sum: 6 }, { type: "x-sum", side: "left", index: 4, sum: 6 }, { type: "x-sum", side: "left", index: 0, sum: 2 }, { type: "skyscraper", side: "top", index: 0, count: 0 }, { type: "quadruple", cells: [0, 1], digits: [1, 2, 3] }, { type: "quadruple", cells: [0, 2, 4, 6], digits: [1, 2] }, { type: "maximum", cell: 16 }, { type: "maximum", cell: 5, negated: "yes", }, ]; for (const constraint of malformed) { expect(validatePuzzle(puzzle4([constraint as never])).valid).toBe(false); } }); it("allows bounded impossible numbers only for false numeric clues", () => { expect( validatePuzzle( puzzle4([ { type: "killer-cage", cells: [0, 1, 2], sum: 42, negated: true, }, ]), ).valid, ).toBe(true); expect( validatePuzzle( puzzle4([{ type: "killer-cage", cells: [0, 1, 2], sum: 42 }]), ).valid, ).toBe(false); for (const constraint of [ { type: "x-sum", side: "left", index: 0, sum: 42 }, { type: "skyscraper", side: "top", index: 0, count: 42 }, ] as const) { expect( validatePuzzle(puzzle4([{ ...constraint, negated: true }])).valid, ).toBe(true); expect(validatePuzzle(puzzle4([constraint])).valid).toBe(false); } expect( validatePuzzle( puzzle4([ { type: "x-sum", side: "left", index: 0, sum: 257, negated: true, }, ]), ).valid, ).toBe(false); }); it("attaches outside clues to their full lines and local clues to every affected cell", () => { const compiled = compilePuzzle( normalizePuzzle( puzzle4([ { type: "x-sum", side: "top", index: 1, sum: 6 }, { type: "skyscraper", side: "right", index: 2, count: 2 }, { type: "quadruple", cells: [1, 2, 5, 6], digits: [2] }, { type: "maximum", cell: 5 }, ]), ), ); expect(compiled.constraintsByCell[13]).toContain(0); expect(compiled.constraintsByCell[8]).toContain(1); expect(compiled.constraintsByCell[6]).toContain(2); for (const cell of [5, 1, 9, 4, 6]) { expect(compiled.constraintsByCell[cell]).toContain(3); } }); it("uses the first digit and exact distinct subset sums to prune X-sums", () => { const constraint = { type: "x-sum", side: "left", index: 0, sum: 6, } as const; const values = new Array(16).fill(0); values[1] = 4; expect(candidatesForCell(puzzle4([constraint]), values, 0)).toEqual([2]); const reusedOutsidePrefix = new Array(16).fill(0); reusedOutsidePrefix.splice(0, 4, 3, 1, 0, 2); expect(constraintIsFeasible(constraint, reusedOutsidePrefix, 4)).toBe( false, ); expect( constraintIsFeasible( { type: "x-sum", side: "left", index: 0, sum: 1 }, solved4, 4, ), ).toBe(true); expect( constraintIsFeasible( { type: "x-sum", side: "bottom", index: 0, sum: 6 }, solved4, 4, ), ).toBe(true); const conflict = findConflicts( puzzle4([{ type: "x-sum", side: "left", index: 0, sum: 6 }]), solved4, ).find(({ kind }) => kind === "constraint"); expect(conflict?.cells).toEqual([0, 1, 2, 3]); }); it("enforces exact skyscraper visibility and safely prunes partial lines", () => { expect( constraintIsFeasible( { type: "skyscraper", side: "left", index: 0, count: 4 }, solved4, 4, ), ).toBe(true); expect( constraintIsFeasible( { type: "skyscraper", side: "right", index: 0, count: 1 }, solved4, 4, ), ).toBe(true); expect( constraintIsFeasible( { type: "skyscraper", side: "left", index: 0, count: 3 }, solved4, 4, ), ).toBe(false); expect( candidatesForCell( puzzle4([{ type: "skyscraper", side: "left", index: 0, count: 1 }]), new Array(16).fill(0), 0, ), ).toEqual([4]); const impossiblePrefix = new Array(16).fill(0); impossiblePrefix[0] = 2; expect( constraintIsFeasible( { type: "skyscraper", side: "left", index: 0, count: 4 }, impossiblePrefix, 4, ), ).toBe(false); expect( constraintIsFeasible( { type: "skyscraper", side: "top", index: 0, count: 8 }, new Array(16 * 16).fill(0), 16, ), ).toBe(true); }); it("honours quadruple multiplicity in candidates and completed boards", () => { const constraint = { type: "quadruple", cells: [1, 2, 5, 6], digits: [2, 2, 4], } as const; const values = new Array(16).fill(0); values[1] = 2; values[2] = 3; values[5] = 4; expect(candidatesForCell(puzzle4([constraint]), values, 6)).toEqual([2]); const wrong = [...values]; wrong[6] = 1; expect(constraintIsFeasible(constraint, wrong, 4)).toBe(false); const right = [...values]; right[6] = 2; expect(constraintIsFeasible(constraint, right, 4)).toBe(true); }); it("prunes false clues as soon as their positive statement is fixed", () => { const xSumValues = new Array(16).fill(0); xSumValues.splice(0, 3, 3, 1, 2); expect( constraintIsFeasible( { type: "x-sum", side: "left", index: 0, sum: 6, negated: true }, xSumValues, 4, ), ).toBe(false); expect( constraintIsFeasible( { type: "x-sum", side: "left", index: 0, sum: 7, negated: true }, xSumValues, 4, ), ).toBe(true); const quadrupleValues = new Array(16).fill(0); quadrupleValues[0] = 1; quadrupleValues[1] = 2; expect( constraintIsFeasible( { type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 2], negated: true, }, quadrupleValues, 4, ), ).toBe(false); const maximumValues = new Array(16).fill(0); maximumValues[5] = 4; expect( constraintIsFeasible( { type: "maximum", cell: 5, negated: true }, maximumValues, 4, ), ).toBe(false); }); it("requires a maximum cell to exceed every orthogonal neighbour", () => { const values = new Array(16).fill(0); values[1] = 4; expect( candidatesForCell(puzzle4([{ type: "maximum", cell: 5 }]), values, 5), ).toEqual([]); expect(constraintIsFeasible({ type: "maximum", cell: 5 }, solved4, 4)).toBe( true, ); expect(constraintIsFeasible({ type: "maximum", cell: 6 }, solved4, 4)).toBe( false, ); const conflict = findConflicts( puzzle4([{ type: "maximum", cell: 6 }]), solved4, ).find(({ kind }) => kind === "constraint"); expect(conflict?.cells).toEqual([6, 2, 10, 5, 7]); }); }); describe("false-clue semantics", () => { it("validates and clones polarity for every local clue type", () => { const constraints: VariantConstraint[] = [ { type: "killer-cage", cells: [0, 6], sum: 42, negated: true, }, { type: "thermo", cells: [0, 1], negated: true }, { type: "arrow", bulb: [0], line: [1], negated: true }, { type: "kropki", a: 0, b: 1, kind: "white", negated: true }, { type: "xv", a: 0, b: 1, total: 5, negated: true }, { type: "inequality", lesser: 0, greater: 1, negated: true }, { type: "renban", cells: [0, 1], negated: true }, { type: "palindrome", cells: [0, 1], negated: true }, { type: "x-sum", side: "left", index: 0, sum: 2, negated: true, }, { type: "skyscraper", side: "left", index: 0, count: 1, negated: true, }, { type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 1], negated: true, }, { type: "maximum", cell: 5, negated: true }, ]; const definition = puzzle4(constraints); expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] }); const normalized = normalizePuzzle(definition); expect(normalized.constraints).toEqual(constraints); expect(normalized.constraints).not.toBe(constraints); expect(normalized.constraints[0]).not.toBe(constraints[0]); }); it("does not turn a false cage's no-repeat rule into an unconditional peer", () => { const falseCage = compilePuzzle( normalizePuzzle( puzzle4([ { type: "killer-cage", cells: [0, 6], sum: 3, negated: true, }, ]), ), ); const ordinaryCage = compilePuzzle( normalizePuzzle( puzzle4([{ type: "killer-cage", cells: [0, 6], sum: 3 }]), ), ); expect(falseCage.peers[0]?.has(6)).toBe(false); expect(ordinaryCage.peers[0]?.has(6)).toBe(true); const repeated = new Array(16).fill(0); repeated[0] = 1; repeated[6] = 1; expect( constraintIsFeasible( { type: "killer-cage", cells: [0, 6], sum: 3, negated: true, }, repeated, 4, ), ).toBe(true); }); it("inverts completed dots, XV, thermometers and cages", () => { const cases: Array< readonly [VariantConstraint, readonly number[], boolean] > = [ [ { type: "kropki", a: 0, b: 1, kind: "white", negated: true }, [1, 2], false, ], [ { type: "kropki", a: 0, b: 1, kind: "white", negated: true }, [1, 3], true, ], [{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 4], false], [{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 3], true], [{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 2, 3], false], [{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 3, 2], true], [ { type: "killer-cage", cells: [0, 1], sum: 3, negated: true }, [1, 2], false, ], [ { type: "killer-cage", cells: [0, 1], sum: 4, negated: true }, [1, 2], true, ], ]; for (const [constraint, relevant, expected] of cases) { const values = new Array(16).fill(0); relevant.forEach((value, cell) => { values[cell] = value; }); expect( constraintIsFeasible(constraint, values, 4), JSON.stringify(constraint), ).toBe(expected); } }); it("inverts each completed outside and local clue", () => { const cases: Array = [ [{ type: "x-sum", side: "left", index: 0, sum: 1, negated: true }, false], [{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true }, true], [ { type: "skyscraper", side: "left", index: 0, count: 4, negated: true, }, false, ], [ { type: "skyscraper", side: "left", index: 0, count: 3, negated: true, }, true, ], [ { type: "quadruple", cells: [1, 2, 5, 6], digits: [1, 2, 3, 4], negated: true, }, false, ], [ { type: "quadruple", cells: [1, 2, 5, 6], digits: [2, 2, 4], negated: true, }, true, ], [{ type: "maximum", cell: 5, negated: true }, false], [{ type: "maximum", cell: 6, negated: true }, true], ]; for (const [constraint, expected] of cases) { expect(constraintIsFeasible(constraint, solved4, 4)).toBe(expected); } }); it("keeps partial false clues feasible and prunes a completed true pair", () => { const falseDot = { type: "kropki", a: 0, b: 1, kind: "white", negated: true, } as const; const partial = new Array(16).fill(0); partial[0] = 1; expect(constraintIsFeasible(falseDot, partial, 4)).toBe(true); expect(candidatesForCell(puzzle4([falseDot]), partial, 1)).toEqual([3, 4]); const falseThermo = { type: "thermo", cells: [0, 1, 2], negated: true, } as const; partial[1] = 2; expect(constraintIsFeasible(falseThermo, partial, 4)).toBe(true); }); it("reports a true false-clue as the contradiction", () => { const conflict = findConflicts( puzzle4([{ type: "xv", a: 0, b: 3, total: 5, negated: true }]), solved4, ).find(({ kind }) => kind === "constraint"); expect(conflict?.message).toBe("xv clue is true but must be false."); }); });