Files
sudoku-tools/tests/domain/extraBusyConstraints.test.ts
T

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.");
});
});