feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
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 }],
|
||||
};
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePuzzle } from "../../src/domain";
|
||||
import {
|
||||
NetworkPuzzleIdError,
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
addressFromCellIndex,
|
||||
cellIndexFromAddress,
|
||||
exportFpuzzles,
|
||||
exportFpuzzlesUrl,
|
||||
importFpuzzles,
|
||||
parseFpuzzles,
|
||||
toDomainPuzzle,
|
||||
type SudokuDocument,
|
||||
} from "../../src/formats";
|
||||
|
||||
const emptyGrid = () =>
|
||||
Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => ({})));
|
||||
|
||||
describe("fpuzzles interoperability", () => {
|
||||
it("imports common givens, regions and variant constraints", () => {
|
||||
const grid = emptyGrid();
|
||||
for (let row = 0; row < 9; row += 1) {
|
||||
for (let column = 0; column < 9; column += 1) {
|
||||
grid[row]![column] = {
|
||||
region: Math.floor(row / 3) * 3 + Math.floor(column / 3),
|
||||
};
|
||||
}
|
||||
}
|
||||
grid[0]![0] = { value: 5, given: true, region: 0 };
|
||||
grid[0]![1] = {
|
||||
value: 3,
|
||||
region: 0,
|
||||
centerPencilMarks: [7, 2],
|
||||
cornerPencilMarks: [9, 1],
|
||||
};
|
||||
const puzzle = parseFpuzzles({
|
||||
size: 9,
|
||||
title: "Variants",
|
||||
author: "Setter",
|
||||
ruleset: "Normal Sudoku rules apply.",
|
||||
grid,
|
||||
"diagonal+": true,
|
||||
antiknight: true,
|
||||
antikingsmove: true,
|
||||
nonconsecutive: true,
|
||||
killercage: [{ cells: ["R1C1", "R1C2"], value: "8" }],
|
||||
thermometer: [{ lines: [["R2C1", "R2C2", "R2C3"]] }],
|
||||
arrow: [{ cells: ["R3C1"], lines: [["R3C1", "R3C2"]] }],
|
||||
difference: [{ cells: ["R4C1", "R4C2"], value: "1" }],
|
||||
ratio: [{ cells: ["R4C2", "R4C3"], value: "2" }],
|
||||
xv: [{ cells: ["R5C1", "R5C2"], value: "V" }],
|
||||
inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }],
|
||||
renban: [{ lines: [["R7C1", "R7C2"]] }],
|
||||
palindrome: [{ lines: [["R8C1", "R8C2"]] }],
|
||||
});
|
||||
expect(puzzle.givens[0]).toBe(5);
|
||||
expect(puzzle.values?.[1]).toBe(3);
|
||||
expect(puzzle.regions?.slice(0, 2)).toEqual([0, 0]);
|
||||
expect(puzzle.centerMarks?.[1]).toEqual([2, 7]);
|
||||
expect(puzzle.cornerMarks?.[1]).toEqual([1, 9]);
|
||||
expect(puzzle.constraints).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 8, noRepeat: true },
|
||||
{ type: "kropki", a: 27, b: 28, kind: "white" },
|
||||
{ type: "kropki", a: 28, b: 29, kind: "black" },
|
||||
{ type: "inequality", lesser: 46, greater: 45 },
|
||||
]),
|
||||
);
|
||||
expect(() => normalizePuzzle(toDomainPuzzle(puzzle))).not.toThrow();
|
||||
});
|
||||
|
||||
it("exports and imports a self-contained SudokuPad fpuzzles URL", () => {
|
||||
const source: SudokuDocument = {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: [5, ...Array<number>(80).fill(0)],
|
||||
constraints: [
|
||||
{ type: "diagonal", direction: "anti" },
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 8 },
|
||||
{ type: "thermo", cells: [9, 10, 11] },
|
||||
{ type: "arrow", bulb: [18], line: [19] },
|
||||
{ type: "kropki", a: 27, b: 28, kind: "white" },
|
||||
{ type: "xv", a: 36, b: 37, total: 10 },
|
||||
{ type: "inequality", lesser: 45, greater: 46 },
|
||||
{ type: "renban", cells: [54, 55] },
|
||||
{ type: "palindrome", cells: [63, 64] },
|
||||
],
|
||||
title: "Round trip",
|
||||
};
|
||||
const exported = exportFpuzzles(source);
|
||||
expect(exported.grid).toBeInstanceOf(Array);
|
||||
const imported = importFpuzzles(exportFpuzzlesUrl(source));
|
||||
expect(imported.givens).toEqual(source.givens);
|
||||
const expected = source.constraints.map((constraint) =>
|
||||
constraint.type === "killer-cage"
|
||||
? { ...constraint, noRepeat: true }
|
||||
: constraint,
|
||||
);
|
||||
expect(imported.constraints).toHaveLength(expected.length);
|
||||
expect(imported.constraints).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
|
||||
it("recognizes server-only short puzzle IDs without making a request", () => {
|
||||
expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError(
|
||||
NetworkPuzzleIdError,
|
||||
);
|
||||
expect(() =>
|
||||
importFpuzzles("https://sudokupad.app/?puzzleid=abc123"),
|
||||
).toThrow(/local-only app cannot fetch/u);
|
||||
});
|
||||
|
||||
it("rejects unsupported generalized dots instead of weakening them", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
difference: [{ cells: ["R1C1", "R1C2"], value: "2" }],
|
||||
}),
|
||||
).toThrow(/Difference-2 dots are not supported/u);
|
||||
});
|
||||
|
||||
it("rejects unsupported or unknown rules instead of silently weakening them", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
odd: [{ cell: "R1C1" }],
|
||||
}),
|
||||
).toThrow(/odd cells.*silently weakening/u);
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
futureConstraint: [{ cells: ["R1C1"] }],
|
||||
}),
|
||||
).toThrow(/Unknown fpuzzles field/u);
|
||||
});
|
||||
|
||||
it("round-trips canonical fpuzzles cell addresses", () => {
|
||||
for (let cell = 0; cell < 256; cell += 1) {
|
||||
expect(cellIndexFromAddress(addressFromCellIndex(cell, 16), 16)).toBe(
|
||||
cell,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user