274 lines
7.2 KiB
TypeScript
274 lines
7.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
candidatesForCell,
|
|
classicRegions,
|
|
compilePuzzle,
|
|
constraintIsFeasible,
|
|
normalizePuzzle,
|
|
validatePuzzle,
|
|
type PuzzleDefinition,
|
|
type VariantConstraint,
|
|
} from "../../src/domain";
|
|
import { solveExact } from "../../src/solver";
|
|
|
|
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> = {}): PuzzleDefinition {
|
|
return {
|
|
version: 1,
|
|
size: 4,
|
|
givens: new Array<number>(16).fill(0),
|
|
regions: classicRegions(4),
|
|
constraints: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const packOneConstraints: readonly VariantConstraint[] = [
|
|
{ type: "minimum", cell: 0 },
|
|
{ type: "odd", cell: 0 },
|
|
{ type: "even", cell: 1 },
|
|
{ type: "disjoint-groups" },
|
|
{
|
|
type: "little-killer",
|
|
side: "top",
|
|
index: 0,
|
|
direction: "down-right",
|
|
sum: 10,
|
|
},
|
|
{ type: "sandwich", side: "left", index: 0, sum: 5 },
|
|
];
|
|
|
|
describe("Pack 1 constraint validation", () => {
|
|
it("accepts and clones every production shape", () => {
|
|
const source = puzzle4({ constraints: packOneConstraints });
|
|
expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] });
|
|
const normalized = normalizePuzzle(source);
|
|
expect(normalized.constraints).toEqual(packOneConstraints);
|
|
expect(normalized.constraints).not.toBe(packOneConstraints);
|
|
});
|
|
|
|
it("rejects invalid cells, fields, directions, paths and unreachable sums", () => {
|
|
const invalid: readonly unknown[] = [
|
|
{ type: "minimum", cell: 16 },
|
|
{ type: "odd", cell: 0, surprise: true },
|
|
{ type: "even", cell: -1 },
|
|
{ type: "disjoint-groups", negated: true },
|
|
{
|
|
type: "little-killer",
|
|
side: "top",
|
|
index: 0,
|
|
direction: "up-right",
|
|
sum: 10,
|
|
},
|
|
{
|
|
type: "little-killer",
|
|
side: "top",
|
|
index: 0,
|
|
direction: "down-left",
|
|
sum: 4,
|
|
},
|
|
{
|
|
type: "little-killer",
|
|
side: "top",
|
|
index: 0,
|
|
direction: "down-right",
|
|
sum: 17,
|
|
},
|
|
{ type: "sandwich", side: "left", index: 0, sum: 1 },
|
|
{ type: "sandwich", side: "inside", index: 0, sum: 5 },
|
|
];
|
|
for (const constraint of invalid) {
|
|
const result = validatePuzzle({
|
|
...puzzle4(),
|
|
constraints: [constraint],
|
|
});
|
|
expect(result.valid, JSON.stringify(constraint)).toBe(false);
|
|
expect(
|
|
result.issues.some(({ path }) => path.startsWith("constraints[0]")),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects disjoint groups on jigsaw regions but accepts renamed standard boxes", () => {
|
|
const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3];
|
|
const rejected = validatePuzzle(
|
|
puzzle4({ regions: jigsaw, constraints: [{ type: "disjoint-groups" }] }),
|
|
);
|
|
expect(rejected.valid).toBe(false);
|
|
expect(rejected.issues).toContainEqual({
|
|
path: "constraints[0]",
|
|
message: "disjoint groups require the standard rectangular box layout",
|
|
});
|
|
|
|
const renamed = classicRegions(4).map((region) => [2, 0, 3, 1][region]!);
|
|
expect(
|
|
validatePuzzle(
|
|
puzzle4({
|
|
regions: renamed,
|
|
constraints: [{ type: "disjoint-groups" }],
|
|
}),
|
|
).valid,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Pack 1 partial feasibility", () => {
|
|
it("prunes minimum, odd and even cells before the grid is complete", () => {
|
|
const values = new Array<number>(16).fill(0);
|
|
values[1] = 3;
|
|
expect(
|
|
candidatesForCell(
|
|
puzzle4({ constraints: [{ type: "minimum", cell: 0 }] }),
|
|
values,
|
|
0,
|
|
),
|
|
).toEqual([1, 2]);
|
|
expect(
|
|
candidatesForCell(
|
|
puzzle4({ constraints: [{ type: "odd", cell: 0 }] }),
|
|
new Array<number>(16).fill(0),
|
|
0,
|
|
),
|
|
).toEqual([1, 3]);
|
|
expect(
|
|
candidatesForCell(
|
|
puzzle4({ constraints: [{ type: "even", cell: 0 }] }),
|
|
new Array<number>(16).fill(0),
|
|
0,
|
|
),
|
|
).toEqual([2, 4]);
|
|
|
|
const impossibleMinimum = new Array<number>(16).fill(0);
|
|
impossibleMinimum[1] = 1;
|
|
expect(
|
|
constraintIsFeasible({ type: "minimum", cell: 0 }, impossibleMinimum, 4),
|
|
).toBe(false);
|
|
expect(
|
|
constraintIsFeasible(
|
|
{ type: "minimum", cell: 0, negated: true },
|
|
[2, 1, ...new Array<number>(14).fill(0)],
|
|
4,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("compiles four disjoint houses and enforces their remote peers", () => {
|
|
const plain = normalizePuzzle(puzzle4());
|
|
const disjoint = compilePuzzle(
|
|
normalizePuzzle(puzzle4({ constraints: [{ type: "disjoint-groups" }] })),
|
|
);
|
|
expect(
|
|
disjoint.units.filter(({ kind }) => kind === "disjoint-group"),
|
|
).toHaveLength(4);
|
|
const values = new Array<number>(16).fill(0);
|
|
values[0] = 1;
|
|
expect(candidatesForCell(plain, values, 10)).toContain(1);
|
|
expect(candidatesForCell(disjoint, values, 10)).not.toContain(1);
|
|
});
|
|
|
|
it("uses the whole little-killer diagonal for partial sum bounds", () => {
|
|
const constraint = {
|
|
type: "little-killer",
|
|
side: "top",
|
|
index: 1,
|
|
direction: "down-right",
|
|
sum: 6,
|
|
} as const;
|
|
const values = new Array<number>(16).fill(0);
|
|
values[1] = 4;
|
|
expect(
|
|
candidatesForCell(puzzle4({ constraints: [constraint] }), values, 6),
|
|
).toEqual([1]);
|
|
|
|
const complete = [...solved4];
|
|
expect(
|
|
constraintIsFeasible(
|
|
{
|
|
type: "little-killer",
|
|
side: "top",
|
|
index: 0,
|
|
direction: "down-right",
|
|
sum: 10,
|
|
},
|
|
complete,
|
|
4,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
constraintIsFeasible(
|
|
{
|
|
type: "little-killer",
|
|
side: "top",
|
|
index: 0,
|
|
direction: "down-right",
|
|
sum: 10,
|
|
negated: true,
|
|
},
|
|
complete,
|
|
4,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("computes exact sandwich possibilities from partial permutations", () => {
|
|
const partial = [1, 2, 0, 4, ...new Array<number>(12).fill(0)];
|
|
expect(
|
|
constraintIsFeasible(
|
|
{ type: "sandwich", side: "left", index: 0, sum: 5 },
|
|
partial,
|
|
4,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
constraintIsFeasible(
|
|
{ type: "sandwich", side: "left", index: 0, sum: 3 },
|
|
partial,
|
|
4,
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
constraintIsFeasible(
|
|
{
|
|
type: "sandwich",
|
|
side: "left",
|
|
index: 0,
|
|
sum: 5,
|
|
negated: true,
|
|
},
|
|
solved4,
|
|
4,
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
constraintIsFeasible(
|
|
{
|
|
type: "sandwich",
|
|
side: "left",
|
|
index: 0,
|
|
sum: 3,
|
|
negated: true,
|
|
},
|
|
solved4,
|
|
4,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Pack 1 exact solving", () => {
|
|
it.each(
|
|
packOneConstraints.map(
|
|
(constraint) => [constraint.type, constraint] as const,
|
|
),
|
|
)("solves a puzzle containing %s", (_type, constraint) => {
|
|
const givens: number[] = [...solved4];
|
|
givens[5] = 0;
|
|
givens[10] = 0;
|
|
const result = solveExact(puzzle4({ givens, constraints: [constraint] }));
|
|
expect(result.count).toBe(1);
|
|
expect(result.truncated).toBe(false);
|
|
expect(result.solutions[0]).toEqual(solved4);
|
|
});
|
|
});
|