feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
CONSTRAINT_REGISTRY,
CONSTRAINT_TYPES,
constraintAllowedFields,
constraintCells,
constraintLabel,
constraintMetadata,
isConstraintType,
type ConstraintType,
} from "../../src/domain";
const expectedTypes = [
"diagonal",
"anti-knight",
"anti-king",
"non-consecutive",
"disjoint-groups",
"killer-cage",
"thermo",
"arrow",
"kropki",
"xv",
"inequality",
"renban",
"palindrome",
"x-sum",
"skyscraper",
"quadruple",
"maximum",
"minimum",
"odd",
"even",
"little-killer",
"sandwich",
"between-line",
"german-whisper",
"region-sum-line",
"clone",
"extra-region",
"modular-line",
"entropic-line",
"zipper-line",
"double-arrow",
"indexer",
"fog",
] as const satisfies readonly ConstraintType[];
describe("constraint registry", () => {
it("contains one complete metadata entry for every constraint type", () => {
expect([...CONSTRAINT_TYPES].sort()).toEqual([...expectedTypes].sort());
expect(Object.keys(CONSTRAINT_REGISTRY).sort()).toEqual(
[...expectedTypes].sort(),
);
for (const type of expectedTypes) {
const metadata = constraintMetadata(type);
expect(metadata.type).toBe(type);
expect(metadata.label.length).toBeGreaterThan(0);
expect(metadata.fields[0]).toEqual({
key: "type",
kind: "discriminator",
required: true,
});
expect(new Set(metadata.fields.map(({ key }) => key)).size).toBe(
metadata.fields.length,
);
expect(constraintAllowedFields(type).has("negated")).toBe(
metadata.negatable,
);
}
});
it("provides labels, field metadata and a safe type guard", () => {
expect(constraintLabel("little-killer")).toBe("Little killer");
expect(constraintLabel("future-rule")).toBe("Future Rule");
expect(isConstraintType("sandwich")).toBe(true);
expect(isConstraintType("not-a-rule")).toBe(false);
expect(constraintAllowedFields("little-killer")).toEqual(
new Set(["type", "side", "index", "direction", "sum", "negated"]),
);
});
it("resolves global, line, outside and local footprints centrally", () => {
expect(constraintCells(4, { type: "diagonal", direction: "anti" })).toEqual(
[3, 6, 9, 12],
);
expect(constraintCells(4, { type: "disjoint-groups" })).toHaveLength(16);
expect(constraintCells(4, { type: "minimum", cell: 5 })).toEqual([
5, 1, 9, 4, 6,
]);
expect(constraintCells(4, { type: "odd", cell: 5 })).toEqual([5]);
expect(
constraintCells(4, {
type: "little-killer",
side: "top",
index: 0,
direction: "down-right",
sum: 10,
}),
).toEqual([0, 5, 10, 15]);
expect(
constraintCells(4, {
type: "sandwich",
side: "right",
index: 2,
sum: 3,
}),
).toEqual([11, 10, 9, 8]);
expect(
constraintCells(4, {
type: "clone",
cells: [0, 1],
cloneCells: [10, 11],
}),
).toEqual([0, 1, 10, 11]);
expect(
constraintCells(4, { type: "indexer", kind: "box", cell: 0 }),
).toEqual([0, 2, 8, 10]);
expect(
constraintCells(4, { type: "fog", lights: [0, 5], revealRadius: 1 }),
).toEqual([0, 5]);
});
});
+273
View File
@@ -0,0 +1,273 @@
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);
});
});
+222
View File
@@ -0,0 +1,222 @@
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 packTwoConstraints: readonly VariantConstraint[] = [
{ type: "between-line", cells: [0, 1, 2] },
{ type: "german-whisper", cells: [0, 3, 1] },
{ type: "region-sum-line", cells: [0, 1, 2] },
{ type: "clone", cells: [0, 1], cloneCells: [6, 7] },
{ type: "extra-region", cells: [0, 7, 9, 14] },
];
describe("Pack 2 constraint validation", () => {
it("accepts and clones every production shape", () => {
const source = puzzle4({ constraints: packTwoConstraints });
expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] });
const normalized = normalizePuzzle(source);
expect(normalized.constraints).toEqual(packTwoConstraints);
expect(normalized.constraints).not.toBe(packTwoConstraints);
});
it.each([
{ type: "between-line", cells: [0, 1] },
{ type: "between-line", cells: [0, 1, 0] },
{ type: "german-whisper", cells: [0] },
{ type: "german-whisper", cells: [0, 1], minimumDifference: 0 },
{ type: "german-whisper", cells: [0, 1], minimumDifference: 4 },
{ type: "region-sum-line", cells: [0, 1] },
{ type: "clone", cells: [0, 1], cloneCells: [8] },
{ type: "clone", cells: [0, 1], cloneCells: [8, 8] },
{ type: "extra-region", cells: [0, 1, 2] },
{ type: "extra-region", cells: [0, 1, 2, 3], negated: true },
] as const)("rejects malformed constraint $type", (constraint) => {
const result = validatePuzzle({ ...puzzle4(), constraints: [constraint] });
expect(result.valid).toBe(false);
expect(
result.issues.some(({ path }) => path.startsWith("constraints[0]")),
).toBe(true);
});
it("uses the puzzle's actual regions when checking region-sum crossings", () => {
expect(
validatePuzzle(
puzzle4({ constraints: [{ type: "region-sum-line", cells: [0, 1] }] }),
).issues,
).toContainEqual({
path: "constraints[0].cells",
message: "region-sum line must cross at least one region boundary",
});
expect(
validatePuzzle(
puzzle4({ constraints: [{ type: "region-sum-line", cells: [1, 2] }] }),
).valid,
).toBe(true);
});
});
describe("Pack 2 partial and completed semantics", () => {
it("enforces between-line interiors and its negated truth", () => {
const constraint = { type: "between-line", cells: [0, 1, 2] } as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[2] = 4;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1),
).toEqual([2, 3]);
expect(
constraintIsFeasible(
constraint,
[2, 0, 3, ...new Array<number>(13).fill(0)],
4,
),
).toBe(false);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
expect(
constraintIsFeasible(
{ ...constraint, negated: true },
[1, 4, 3, ...new Array<number>(13).fill(0)],
4,
),
).toBe(true);
});
it("runs exact dynamic feasibility for default and explicit German whispers", () => {
const constraint = {
type: "german-whisper",
cells: [0, 5, 10],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 2;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 5),
).toEqual([4]);
const impossible = new Array<number>(16).fill(0);
impossible[5] = 2;
expect(
constraintIsFeasible(
{ ...constraint, minimumDifference: 3 },
impossible,
4,
),
).toBe(false);
const complete = [1, 4, 2, ...new Array<number>(13).fill(0)];
const short = { type: "german-whisper", cells: [0, 1, 2] } as const;
expect(constraintIsFeasible(short, complete, 4)).toBe(true);
expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe(
false,
);
complete[1] = 2;
expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe(
true,
);
});
it("balances contiguous region sums using the supplied region map", () => {
const constraint = {
type: "region-sum-line",
cells: [0, 1, 2],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[2] = 3;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1),
).toEqual([2]);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
const unequal = [...solved4];
unequal[2] = 4;
expect(
constraintIsFeasible({ ...constraint, negated: true }, unequal, 4),
).toBe(true);
});
it("enforces ordered clone equality and exact negated state", () => {
const constraint = {
type: "clone",
cells: [0, 1],
cloneCells: [6, 7],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[1] = 2;
partial[6] = 1;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 7),
).toEqual([2]);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
const mismatch = [...solved4];
mismatch[7] = 3;
expect(constraintIsFeasible(constraint, mismatch, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, mismatch, 4),
).toBe(true);
});
it("compiles extra regions as all-different units and remote peers", () => {
const constraint = {
type: "extra-region",
cells: [0, 7, 9, 14],
} as const;
const compiled = compilePuzzle(
normalizePuzzle(puzzle4({ constraints: [constraint] })),
);
expect(
compiled.units.filter(({ kind }) => kind === "extra-region"),
).toEqual([{ kind: "extra-region", index: 0, cells: constraint.cells }]);
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
expect(candidatesForCell(compiled, partial, 7)).not.toContain(1);
expect(compilePuzzle(normalizePuzzle(puzzle4())).peers[7]?.has(0)).toBe(
false,
);
});
});
describe("Pack 2 exact solving", () => {
it.each(
packTwoConstraints.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);
});
});
+355
View File
@@ -0,0 +1,355 @@
import { describe, expect, it } from "vitest";
import {
candidatesForCell,
classicRegions,
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;
const solved6 = [
1, 2, 3, 4, 5, 6, 4, 5, 6, 1, 2, 3, 2, 3, 4, 5, 6, 1, 5, 6, 1, 2, 3, 4, 3, 4,
5, 6, 1, 2, 6, 1, 2, 3, 4, 5,
] as const;
function puzzle(
size: number,
overrides: Partial<PuzzleDefinition> = {},
): PuzzleDefinition {
return {
version: 1,
size,
givens: new Array<number>(size * size).fill(0),
regions: classicRegions(size),
constraints: [],
...overrides,
};
}
const semantic4: readonly VariantConstraint[] = [
{ type: "modular-line", cells: [0, 1, 2, 3] },
{ type: "zipper-line", cells: [0, 2, 1] },
{ type: "double-arrow", cells: [0, 2, 1] },
{ type: "indexer", kind: "row", cell: 7 },
{ type: "indexer", kind: "column", cell: 5 },
{ type: "indexer", kind: "box", cell: 14 },
];
describe("Pack 3 validation and cloning", () => {
it("accepts all production shapes and preserves canonical fog data", () => {
const definition = puzzle(4, {
solution: solved4,
constraints: [
...semantic4,
{ type: "fog", lights: [0, 5], revealRadius: 1 },
],
});
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
const normalized = normalizePuzzle(definition);
expect(normalized.constraints).toEqual(definition.constraints);
expect(normalized.constraints.find(({ type }) => type === "fog")).toEqual({
type: "fog",
lights: [0, 5],
revealRadius: 1,
});
expect(
validatePuzzle(
puzzle(6, {
constraints: [{ type: "entropic-line", cells: [0, 2, 4] }],
}),
).valid,
).toBe(true);
});
it.each([
[4, { type: "modular-line", cells: [0, 1] }],
[4, { type: "entropic-line", cells: [0, 1, 2] }],
[6, { type: "entropic-line", cells: [0, 1] }],
[4, { type: "zipper-line", cells: [0, 1, 2, 3] }],
[4, { type: "zipper-line", cells: [0, 1] }],
[4, { type: "double-arrow", cells: [0, 1] }],
[4, { type: "indexer", kind: "diagonal", cell: 0 }],
[4, { type: "indexer", kind: "row", cell: 16 }],
[4, { type: "fog", lights: [], revealRadius: 1 }],
[4, { type: "fog", lights: [0], revealRadius: 2 }],
] as const)(
"rejects malformed Pack 3 data on size %i",
(size, constraint) => {
const result = validatePuzzle({
...puzzle(size),
solution: size === 4 ? solved4 : solved6,
constraints: [constraint],
});
expect(result.valid).toBe(false);
expect(
result.issues.some(({ path }) => path.startsWith("constraints[0]")),
).toBe(true);
},
);
it("requires a complete valid trusted solution for fog", () => {
const withoutSolution = validatePuzzle(
puzzle(4, { constraints: [{ type: "fog", lights: [0] }] }),
);
expect(withoutSolution.issues).toContainEqual({
path: "constraints[0]",
message: "fog requires a complete trusted puzzle solution",
});
expect(
validatePuzzle(
puzzle(4, {
solution: new Array<number>(16).fill(0),
constraints: [{ type: "fog", lights: [0] }],
}),
).valid,
).toBe(false);
});
it("rejects box indexers on custom regions while row and column remain valid", () => {
const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3];
expect(
validatePuzzle(
puzzle(4, {
regions: jigsaw,
constraints: [{ type: "indexer", kind: "box", cell: 0 }],
}),
).issues,
).toContainEqual({
path: "constraints[0]",
message: "box indexers require the standard rectangular box layout",
});
expect(
validatePuzzle(
puzzle(4, {
regions: jigsaw,
constraints: [{ type: "indexer", kind: "row", cell: 0 }],
}),
).valid,
).toBe(true);
});
});
describe("Pack 3 line semantics", () => {
it("enforces modular residue windows and their negation", () => {
const constraint = {
type: "modular-line",
cells: [0, 1, 2, 3],
} as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[1] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2),
).toEqual([3]);
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
expect(
constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
).toBe(false);
const invalid = [1, 2, 4, 1, ...new Array<number>(12).fill(0)];
expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
).toBe(true);
});
it("enforces equal entropic bands on divisible grids", () => {
const constraint = {
type: "entropic-line",
cells: [0, 1, 2],
} as const;
const partial = new Array<number>(36).fill(0);
partial[0] = 1;
partial[1] = 3;
expect(
candidatesForCell(puzzle(6, { constraints: [constraint] }), partial, 2),
).toEqual([5, 6]);
const valid = [1, 3, 5, ...new Array<number>(33).fill(0)];
const invalid = [1, 2, 5, ...new Array<number>(33).fill(0)];
expect(constraintIsFeasible(constraint, valid, 6)).toBe(true);
expect(constraintIsFeasible(constraint, invalid, 6)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, valid, 6),
).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 6),
).toBe(true);
});
it("enforces zipper pair sums around the centre", () => {
const constraint = { type: "zipper-line", cells: [0, 1, 2] } as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[1] = 3;
expect(
candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2),
).toEqual([2]);
const valid = [1, 3, 2, ...new Array<number>(13).fill(0)];
const invalid = [1, 3, 3, ...new Array<number>(13).fill(0)];
expect(constraintIsFeasible(constraint, valid, 4)).toBe(true);
expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, valid, 4),
).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
).toBe(true);
const impossibleCentre = new Array<number>(16).fill(0);
impossibleCentre[1] = 1;
expect(constraintIsFeasible(constraint, impossibleCentre, 4)).toBe(false);
});
it("balances double-arrow endpoints against all interior digits", () => {
const constraint = { type: "double-arrow", cells: [0, 1, 2] } as const;
const partial = new Array<number>(16).fill(0);
partial[0] = 1;
partial[2] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 1),
).toEqual([3]);
const valid = [1, 3, 2, ...new Array<number>(13).fill(0)];
const invalid = [1, 4, 2, ...new Array<number>(13).fill(0)];
expect(constraintIsFeasible(constraint, valid, 4)).toBe(true);
expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, valid, 4),
).toBe(false);
expect(
constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
).toBe(true);
});
it("keeps sound partial bounds when peer rules may narrow remaining digits", () => {
const empty = new Array<number>(16).fill(0);
expect(
constraintIsFeasible(
{ type: "zipper-line", cells: [0, 1, 2, 3, 4] },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "double-arrow", cells: [0, 1, 2, 3] },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "modular-line", cells: [0, 1, 2], negated: true },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "zipper-line", cells: [0, 1, 2], negated: true },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "double-arrow", cells: [0, 1, 2], negated: true },
empty,
4,
),
).toBe(true);
expect(
constraintIsFeasible(
{ type: "indexer", kind: "row", cell: 0, negated: true },
empty,
4,
),
).toBe(true);
});
});
describe("Pack 3 indexers and fog", () => {
it("resolves row, column and rectangular-box targets", () => {
const row = { type: "indexer", kind: "row", cell: 1 } as const;
const rowValues = new Array<number>(16).fill(0);
rowValues[1] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [row] }), rowValues, 5),
).toEqual([1]);
const column = { type: "indexer", kind: "column", cell: 5 } as const;
expect(constraintIsFeasible(column, solved4, 4)).toBe(true);
const box = { type: "indexer", kind: "box", cell: 1 } as const;
const boxValues = new Array<number>(16).fill(0);
boxValues[1] = 2;
expect(
candidatesForCell(puzzle(4, { constraints: [box] }), boxValues, 3),
).toEqual([1]);
boxValues[3] = 2;
expect(constraintIsFeasible(box, boxValues, 4)).toBe(false);
expect(constraintIsFeasible({ ...box, negated: true }, boxValues, 4)).toBe(
true,
);
// In a 2x3-box grid, r2c5 has within-box position 5. Digit 3 points at
// the same position in box 3 (r4c2), which must contain box index 2.
const rectangularBox = {
type: "indexer",
kind: "box",
cell: 10,
} as const;
const rectangularValues = new Array<number>(36).fill(0);
rectangularValues[10] = 3;
expect(
candidatesForCell(
puzzle(6, { constraints: [rectangularBox] }),
rectangularValues,
19,
),
).toEqual([2]);
});
it("keeps fog entirely non-semantic in exact search", () => {
const givens = [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3];
const plain = solveExact(puzzle(4, { givens }));
const fogged = solveExact(
puzzle(4, {
givens,
solution: solved4,
constraints: [{ type: "fog", lights: [0], revealRadius: 1 }],
}),
);
expect(fogged.solutions).toEqual(plain.solutions);
expect(fogged.count).toBe(plain.count);
});
});
describe("Pack 3 exact solving", () => {
it.each(
semantic4.map((constraint) => [constraint.type, constraint] as const),
)("solves a 4x4 puzzle containing %s", (_type, constraint) => {
const givens: number[] = [...solved4];
givens[5] = 0;
givens[10] = 0;
const result = solveExact(puzzle(4, { givens, constraints: [constraint] }));
expect(result.count).toBe(1);
expect(result.solutions[0]).toEqual(solved4);
});
it("solves a 6x6 puzzle with an entropic line", () => {
const givens: number[] = [...solved6];
givens[2] = 0;
givens[20] = 0;
const result = solveExact(
puzzle(6, {
givens,
constraints: [{ type: "entropic-line", cells: [0, 2, 4] }],
}),
);
expect(result.count).toBe(1);
expect(result.solutions[0]).toEqual(solved6);
});
});