feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user