243 lines
7.8 KiB
TypeScript
243 lines
7.8 KiB
TypeScript
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"]] }],
|
|
xsum: [{ cell: "R0C1", value: "15" }],
|
|
skyscraper: [{ cell: "R2C10", value: "2" }],
|
|
quadruple: [
|
|
{ cells: ["R1C1", "R1C2", "R2C1", "R2C2"], values: [1, 3, 8] },
|
|
],
|
|
maximum: [{ cell: "R2C2" }],
|
|
});
|
|
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 },
|
|
{ type: "x-sum", side: "top", index: 0, sum: 15 },
|
|
{ type: "skyscraper", side: "right", index: 1, count: 2 },
|
|
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
|
|
{ type: "maximum", cell: 10 },
|
|
]),
|
|
);
|
|
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] },
|
|
{ type: "x-sum", side: "left", index: 0, sum: 6 },
|
|
{ type: "skyscraper", side: "right", index: 1, count: 2 },
|
|
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
|
|
{ type: "maximum", cell: 10 },
|
|
],
|
|
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("refuses to weaken false clues during fpuzzles export", () => {
|
|
const source: SudokuDocument = {
|
|
schema: SUDOKU_DOCUMENT_SCHEMA,
|
|
version: 1,
|
|
size: 9,
|
|
givens: Array<number>(81).fill(0),
|
|
constraints: [
|
|
{
|
|
type: "x-sum",
|
|
side: "top",
|
|
index: 0,
|
|
sum: 11,
|
|
negated: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(() => exportFpuzzles(source)).toThrow(/individually false clue/u);
|
|
});
|
|
|
|
it("rejects out-of-range outside clues during fpuzzles export", () => {
|
|
const source = {
|
|
schema: SUDOKU_DOCUMENT_SCHEMA,
|
|
version: 1,
|
|
size: 9,
|
|
givens: Array<number>(81).fill(0),
|
|
} as const;
|
|
|
|
for (const constraint of [
|
|
{ type: "x-sum", side: "top", index: -1, sum: 15 },
|
|
{ type: "x-sum", side: "bottom", index: 9, sum: 15 },
|
|
{ type: "x-sum", side: "right", index: 0, sum: 0 },
|
|
{ type: "x-sum", side: "left", index: 0, sum: 46 },
|
|
{ type: "skyscraper", side: "right", index: -1, count: 2 },
|
|
{ type: "skyscraper", side: "left", index: 9, count: 2 },
|
|
{ type: "skyscraper", side: "bottom", index: 0, count: 0 },
|
|
{ type: "skyscraper", side: "top", index: 0, count: 10 },
|
|
] as const) {
|
|
expect(() =>
|
|
exportFpuzzles({
|
|
...source,
|
|
constraints: [constraint],
|
|
}),
|
|
).toThrow(/outside the supported range/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 quadruples spanning more than four cells", () => {
|
|
expect(() =>
|
|
parseFpuzzles({
|
|
size: 9,
|
|
grid: emptyGrid(),
|
|
quadruple: [
|
|
{
|
|
cells: ["R1C1", "R1C2", "R2C1", "R2C2", "R3C3"],
|
|
values: [1, 2],
|
|
},
|
|
],
|
|
}),
|
|
).toThrow(/one to four cells and clue digits/u);
|
|
expect(() =>
|
|
parseFpuzzles({
|
|
size: 9,
|
|
grid: emptyGrid(),
|
|
quadruple: [{ cells: ["R1C1"], values: [1, 2] }],
|
|
}),
|
|
).toThrow(/one to four cells and clue digits/u);
|
|
expect(() =>
|
|
parseFpuzzles({
|
|
size: 9,
|
|
grid: emptyGrid(),
|
|
quadruple: [
|
|
{
|
|
cells: ["R1C1", "R1C3", "R2C1", "R2C3"],
|
|
values: [1, 2],
|
|
},
|
|
],
|
|
}),
|
|
).toThrow(/four cells surrounding one grid intersection/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,
|
|
);
|
|
}
|
|
});
|
|
});
|