506 lines
14 KiB
TypeScript
506 lines
14 KiB
TypeScript
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<number>(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<number>(16).fill(0);
|
|
values[1] = 4;
|
|
expect(candidatesForCell(puzzle4([constraint]), values, 0)).toEqual([2]);
|
|
|
|
const reusedOutsidePrefix = new Array<number>(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<number>(16).fill(0),
|
|
0,
|
|
),
|
|
).toEqual([4]);
|
|
const impossiblePrefix = new Array<number>(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<number>(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<number>(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<number>(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<number>(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<number>(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<number>(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<number>(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<number>(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<readonly [VariantConstraint, boolean]> = [
|
|
[{ 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<number>(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.");
|
|
});
|
|
});
|