feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allCandidates,
|
||||
candidatesForCell,
|
||||
classicRegions,
|
||||
createEmptyPuzzle,
|
||||
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(overrides: Partial<PuzzleDefinition> = {}): PuzzleDefinition {
|
||||
return {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("puzzle domain", () => {
|
||||
it("creates valid rectangular regions for every supported size", () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 4, max: 16 }), (size) => {
|
||||
const regions = classicRegions(size);
|
||||
expect(regions).toHaveLength(size * size);
|
||||
for (let region = 0; region < size; region += 1) {
|
||||
expect(regions.filter((entry) => entry === region)).toHaveLength(
|
||||
size,
|
||||
);
|
||||
}
|
||||
expect(validatePuzzle(createEmptyPuzzle(size)).valid).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("strictly rejects unknown, oversized, malformed, and contradictory data", () => {
|
||||
expect(validatePuzzle({ ...puzzle4(), surprise: true }).valid).toBe(false);
|
||||
expect(validatePuzzle({ ...puzzle4(), title: "x".repeat(257) }).valid).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
validatePuzzle({
|
||||
...puzzle4(),
|
||||
givens: [1, 1, ...new Array<number>(14).fill(0)],
|
||||
}).valid,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePuzzle({ ...puzzle4(), regions: new Array<number>(16).fill(0) })
|
||||
.valid,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePuzzle({
|
||||
...puzzle4(),
|
||||
constraints: [{ type: "thermo", cells: [0, 0], extra: true }],
|
||||
}).valid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts all supported constraint shapes and preserves bounded metadata", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "diagonal", direction: "main" },
|
||||
{ type: "diagonal", direction: "anti" },
|
||||
{ type: "anti-knight" },
|
||||
{ type: "anti-king" },
|
||||
{ type: "non-consecutive" },
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{ type: "thermo", cells: [0, 1, 2] },
|
||||
{ type: "arrow", bulb: [0], line: [1, 2] },
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white" },
|
||||
{ type: "kropki", a: 0, b: 4, kind: "black" },
|
||||
{ type: "xv", a: 0, b: 1, total: 5 },
|
||||
{ type: "inequality", lesser: 0, greater: 1 },
|
||||
{ type: "renban", cells: [0, 1, 2] },
|
||||
{ type: "palindrome", cells: [0, 5] },
|
||||
];
|
||||
const definition = puzzle4({
|
||||
id: "demo",
|
||||
title: "Variant",
|
||||
rules: "Local rules",
|
||||
constraints,
|
||||
});
|
||||
const result = validatePuzzle(definition);
|
||||
expect(result).toEqual({ valid: true, issues: [] });
|
||||
expect(normalizePuzzle(definition)).toMatchObject({
|
||||
id: "demo",
|
||||
rules: "Local rules",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates an optional solution against givens and constraints", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "diagonal", direction: "main" },
|
||||
{ type: "diagonal", direction: "anti" },
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{ type: "thermo", cells: [0, 1, 2, 3] },
|
||||
{ type: "arrow", bulb: [3], line: [0, 2] },
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white" },
|
||||
{ type: "kropki", a: 0, b: 1, kind: "black" },
|
||||
{ type: "xv", a: 0, b: 3, total: 5 },
|
||||
{ type: "inequality", lesser: 0, greater: 1 },
|
||||
{ type: "renban", cells: [0, 1, 2, 3] },
|
||||
{ type: "palindrome", cells: [0, 6] },
|
||||
];
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4({
|
||||
givens: [1, ...new Array<number>(15).fill(0)],
|
||||
solution: solved4,
|
||||
constraints,
|
||||
}),
|
||||
).valid,
|
||||
).toBe(true);
|
||||
const wrong = [...solved4];
|
||||
wrong[0] = 2;
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4({
|
||||
givens: [1, ...new Array<number>(15).fill(0)],
|
||||
solution: wrong,
|
||||
}),
|
||||
).valid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("computes classic candidates and reports exact conflict cells", () => {
|
||||
const values = [1, 2, 3, 0, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3];
|
||||
const puzzle = puzzle4({ givens: new Array<number>(16).fill(0) });
|
||||
expect(candidatesForCell(puzzle, values, 3)).toEqual([4]);
|
||||
expect(allCandidates(puzzle, values)[3]).toEqual([4]);
|
||||
const conflict = [...values];
|
||||
conflict[3] = 1;
|
||||
expect(
|
||||
findConflicts(puzzle, conflict).some(
|
||||
({ cells }) => cells.includes(0) && cells.includes(3),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces anti, adjacency, line, sum, ratio and equality constraints in candidates", () => {
|
||||
const scenarios: Array<
|
||||
readonly [VariantConstraint, number, number, boolean]
|
||||
> = [
|
||||
[{ type: "anti-knight" }, 6, 1, false],
|
||||
[{ type: "anti-king" }, 5, 1, false],
|
||||
[{ type: "non-consecutive" }, 1, 2, false],
|
||||
[{ type: "killer-cage", cells: [0, 1], sum: 3 }, 1, 4, false],
|
||||
[{ type: "thermo", cells: [0, 1, 2, 3] }, 1, 1, false],
|
||||
[{ type: "arrow", bulb: [0], line: [1, 2] }, 2, 4, false],
|
||||
[{ type: "kropki", a: 0, b: 1, kind: "black" }, 1, 3, false],
|
||||
[{ type: "xv", a: 0, b: 1, total: 5 }, 1, 3, false],
|
||||
[{ type: "inequality", lesser: 0, greater: 1 }, 1, 1, false],
|
||||
[{ type: "renban", cells: [0, 1, 2] }, 2, 4, false],
|
||||
[{ type: "palindrome", cells: [0, 5] }, 5, 2, false],
|
||||
];
|
||||
for (const [constraint, cell, candidate, expected] of scenarios) {
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[0] = 1;
|
||||
if (constraint.type === "arrow") values[1] = 1;
|
||||
if (constraint.type === "renban") values[1] = 2;
|
||||
expect(
|
||||
candidatesForCell(
|
||||
puzzle4({ constraints: [constraint] }),
|
||||
values,
|
||||
cell,
|
||||
).includes(candidate),
|
||||
).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user