Files
sudoku-tools/tests/formats/document.test.ts
T

134 lines
4.1 KiB
TypeScript

import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
MAX_DOCUMENT_BYTES,
SUDOKU_DOCUMENT_SCHEMA,
SudokuFormatError,
decodePuzzleHash,
encodePuzzleHash,
normalizeSudokuDocument,
parsePlainGrid,
parseSudokuDocument,
serializePlainGrid,
serializeSudokuDocument,
type SudokuDocument,
} from "../../src/formats";
function document(givens: readonly number[], size = 9): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size,
givens,
constraints: [],
};
}
describe("Sudoku Tools document format", () => {
it("normalizes and round-trips a versioned document", () => {
const source: SudokuDocument = {
...document(Array<number>(81).fill(0)),
title: "Local puzzle",
solution: Array.from({ length: 81 }, (_, index) => (index % 9) + 1),
candidates: Array.from({ length: 81 }, () => [3, 1, 3]),
cornerMarks: Array.from({ length: 81 }, () => [8, 2, 8]),
centerMarks: Array.from({ length: 81 }, () => [7, 4]),
colors: Array.from({ length: 81 }, (_, index) => index % 9),
elapsedMs: 12_345,
constraints: [
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{
type: "x-sum",
side: "top",
index: 8,
sum: 1111,
negated: true,
},
{
type: "skyscraper",
side: "left",
index: 4,
count: 898,
negated: true,
},
{
type: "quadruple",
cells: [0, 1, 9, 10],
digits: [1, 3, 8],
negated: true,
},
{ type: "maximum", cell: 10, negated: true },
],
};
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
expect(parsed).toEqual({
...source,
candidates: Array.from({ length: 81 }, () => [1, 3]),
cornerMarks: Array.from({ length: 81 }, () => [2, 8]),
centerMarks: Array.from({ length: 81 }, () => [4, 7]),
});
});
it("rejects unsupported versions and oversized input", () => {
expect(() =>
normalizeSudokuDocument({ ...document(Array(81).fill(0)), version: 2 }),
).toThrow(/Unsupported puzzle document version/u);
expect(() =>
parseSudokuDocument(" ".repeat(MAX_DOCUMENT_BYTES + 1)),
).toThrowError(SudokuFormatError);
expect(() =>
normalizeSudokuDocument({
...document([1, ...Array<number>(15).fill(0)], 4),
values: [2, ...Array<number>(15).fill(0)],
}),
).toThrow(/preserve its given digit/u);
expect(() =>
normalizeSudokuDocument({
...document(Array(81).fill(0)),
constraints: [{ type: "quadruple", cells: [0], digits: [1, 2] }],
}),
).toThrow(/no more digits than clue cells/u);
});
it("round-trips compact share hashes", () => {
const source = {
...document(Array<number>(81).fill(0)),
title: "Hash # & Unicode 🧩",
constraints: [{ type: "diagonal", direction: "main" } as const],
};
const hash = encodePuzzleHash(source);
expect(hash).toMatch(/^#sudoku=v1\./u);
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual(
source,
);
});
it("round-trips grids for every supported size and symbol", () => {
fc.assert(
fc.property(
fc.integer({ min: 4, max: 16 }).chain((size) =>
fc
.array(fc.integer({ min: 0, max: size }), {
minLength: size * size,
maxLength: size * size,
})
.map((givens) => ({ size, givens })),
),
({ size, givens }) => {
const text = serializePlainGrid(document(givens, size));
expect(parsePlainGrid(text)).toMatchObject({ size, givens });
},
),
{ numRuns: 80 },
);
});
it("accepts human grid separators and diagnoses bad lengths", () => {
const row = "1 . . 4";
const parsed = parsePlainGrid(`${row}\n${row}\n${row}\n${row}`);
expect(parsed.size).toBe(4);
expect(parsed.givens.slice(0, 4)).toEqual([1, 0, 0, 4]);
expect(() => parsePlainGrid("1234")).toThrow(/side length/u);
});
});