Files

178 lines
5.8 KiB
TypeScript

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