feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
SUDOKU_DOCUMENT_SCHEMA,
cloneSudokuDocument,
normalizeSudokuDocument,
parseSudokuDocument,
serializeSudokuDocument,
type SudokuDocument,
} from "../../src/formats";
import {
aidMemoireToPortable,
createAidMemoire,
enterAidMemoireCell,
labelAidMemoireCell,
MAX_AID_MEMOIRE_CELLS,
} from "../../src/state/aidMemoire";
function baseDocument(): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 9,
givens: Array<number>(81).fill(0),
constraints: [],
};
}
describe("portable aid-mémoire document data", () => {
it("normalizes, serializes and deeply clones scratch cells", () => {
let state = createAidMemoire(9, {
enabled: true,
cellCount: 3,
columns: 2,
});
state = labelAidMemoireCell(state, 0, "Prime");
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
state = enterAidMemoireCell(state, 0, "center", 7, 9);
state = enterAidMemoireCell(state, 0, "color", 4, 9);
const source: SudokuDocument = {
...baseDocument(),
aidMemoire: aidMemoireToPortable(state, 9),
};
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
expect(parsed.aidMemoire).toEqual(source.aidMemoire);
const clone = cloneSudokuDocument(parsed);
(clone.aidMemoire!.cells[0]!.cornerMarks as number[])[0] = 9;
expect(parsed.aidMemoire?.cells[0]?.cornerMarks).toEqual([2]);
});
it("rejects unbounded layouts and out-of-range scratch symbols", () => {
expect(() =>
normalizeSudokuDocument({
...baseDocument(),
aidMemoire: {
version: 1,
enabled: true,
columns: 1,
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
},
}),
).toThrow(/aidMemoire.cells must contain 1 to 36/u);
expect(() =>
normalizeSudokuDocument({
...baseDocument(),
aidMemoire: {
version: 1,
enabled: true,
columns: 1,
cells: [{ label: "Bad", value: 10 }],
},
}),
).toThrow(/value must be an integer from 0 to 9/u);
});
});
+30 -1
View File
@@ -35,7 +35,30 @@ describe("Sudoku Tools document format", () => {
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 }],
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({
@@ -59,6 +82,12 @@ describe("Sudoku Tools document format", () => {
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", () => {
+95
View File
@@ -52,6 +52,12 @@ describe("fpuzzles interoperability", () => {
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);
@@ -64,6 +70,10 @@ describe("fpuzzles interoperability", () => {
{ 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();
@@ -85,6 +95,10 @@ describe("fpuzzles interoperability", () => {
{ 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",
};
@@ -110,6 +124,53 @@ describe("fpuzzles interoperability", () => {
).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({
@@ -120,6 +181,40 @@ describe("fpuzzles interoperability", () => {
).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({
+160
View File
@@ -0,0 +1,160 @@
import fc from "fast-check";
import { compressToBase64, compressToEncodedURIComponent } from "lz-string";
import { describe, expect, it } from "vitest";
import {
LzStringOutputLimitError,
decompressFromBase64Bounded,
decompressFromBase64OrUriComponentBounded,
decompressFromEncodedURIComponentBounded,
} from "../../src/formats/boundedLz";
import {
MAX_DOCUMENT_BYTES,
PUZZLE_HASH_PREFIX,
SudokuFormatError,
decodePuzzleHash,
importFpuzzles,
importPuzzle,
importSudokuPad,
parseFpuzzles,
} from "../../src/formats";
function emptyGrid(size = 4) {
return Array.from({ length: size }, () =>
Array.from({ length: size }, () => ({})),
);
}
function expectLimitExceeded(action: () => unknown): void {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(SudokuFormatError);
expect(error).toMatchObject({ code: "LIMIT_EXCEEDED" });
return;
}
throw new Error("Expected the import to reject an oversized payload.");
}
describe("bounded puzzle imports", () => {
it("keeps both LZ-String wire formats compatible for arbitrary text", () => {
fc.assert(
fc.property(fc.string({ maxLength: 512 }), (source) => {
const byteLength = new TextEncoder().encode(source).byteLength;
const limit = Math.max(1, byteLength);
expect(
decompressFromBase64Bounded(compressToBase64(source), limit),
).toBe(source);
expect(
decompressFromEncodedURIComponentBounded(
compressToEncodedURIComponent(source),
limit,
),
).toBe(source);
}),
{ numRuns: 100 },
);
});
it("counts UTF-8 bytes while expanding and stops before oversized output", () => {
const source = "🧩".repeat(1_024);
const compressed = compressToEncodedURIComponent(source);
const exactBytes = new TextEncoder().encode(source).byteLength;
expect(
decompressFromEncodedURIComponentBounded(compressed, exactBytes),
).toBe(source);
expect(() =>
decompressFromEncodedURIComponentBounded(compressed, exactBytes - 1),
).toThrow(LzStringOutputLimitError);
});
it("selects URI-safe payloads before an invalid Base64 interpretation", () => {
const title = "AE(a^'XS2pclC*+Q: ?8IJnG(Fe-nR";
const grid = emptyGrid();
const fpuzzlesJson = JSON.stringify({ size: 4, grid, title });
const fpuzzlesPayload = compressToEncodedURIComponent(fpuzzlesJson);
const sudokuPadJson = JSON.stringify({
id: "local-scl",
cells: grid,
metadata: {
title,
author: "Tester",
rules: "Normal rules apply.",
antiknight: true,
},
cages: [
{
cells: [
[0, 0],
[0, 1],
],
value: "3",
unique: true,
},
],
});
const sudokuPadPayload = compressToEncodedURIComponent(sudokuPadJson);
expect(fpuzzlesPayload).toMatch(/[-$]/u);
expect(sudokuPadPayload).toMatch(/[-$]/u);
expect(
decompressFromBase64Bounded(fpuzzlesPayload, MAX_DOCUMENT_BYTES),
).not.toBe(fpuzzlesJson);
expect(
decompressFromBase64Bounded(sudokuPadPayload, MAX_DOCUMENT_BYTES),
).not.toBe(sudokuPadJson);
expect(
decompressFromBase64OrUriComponentBounded(
fpuzzlesPayload,
MAX_DOCUMENT_BYTES,
),
).toBe(fpuzzlesJson);
expect(importFpuzzles(`fpuzzles${fpuzzlesPayload}`).title).toBe(title);
expect(importSudokuPad(`ctc${sudokuPadPayload}`).title).toBe(title);
});
it("rejects compressed bombs in every LZ-backed import format", () => {
const oversized = " ".repeat(MAX_DOCUMENT_BYTES + 1);
const base64 = compressToBase64(oversized);
const uri = compressToEncodedURIComponent(oversized);
expectLimitExceeded(() => importFpuzzles(`fpuzzles${base64}`));
expectLimitExceeded(() => importSudokuPad(`ctc${base64}`));
expectLimitExceeded(() => decodePuzzleHash(`${PUZZLE_HASH_PREFIX}${uri}`));
});
it("checks raw JSON character and byte limits before parsing", async () => {
const tooManyCharacters = `{${" ".repeat(MAX_DOCUMENT_BYTES)}}`;
await expect(importPuzzle(tooManyCharacters)).rejects.toMatchObject({
code: "LIMIT_EXCEEDED",
});
const tooManyUtf8Bytes = `{"future":"${"é".repeat(
Math.floor(MAX_DOCUMENT_BYTES / 2),
)}"}`;
expect(tooManyUtf8Bytes.length).toBeLessThan(MAX_DOCUMENT_BYTES);
await expect(importPuzzle(tooManyUtf8Bytes)).rejects.toMatchObject({
code: "LIMIT_EXCEEDED",
});
});
it("rejects unknown fpuzzles inequality markers", () => {
for (const value of [undefined, "≤", "left", 1]) {
expect(() =>
parseFpuzzles({
size: 4,
grid: emptyGrid(),
inequality: [{ cells: ["R1C1", "R1C2"], value }],
}),
).toThrow(/inequality\.value must be either/u);
}
expect(
parseFpuzzles({
size: 4,
grid: emptyGrid(),
inequality: [{ cells: ["R1C1", "R1C2"], value: "<" }],
}).constraints,
).toContainEqual({ type: "inequality", lesser: 0, greater: 1 });
});
});
+175
View File
@@ -0,0 +1,175 @@
import { compressToBase64 } from "lz-string";
import { describe, expect, it, vi } from "vitest";
import {
NetworkPuzzleIdError,
UnsupportedPuzzleConstructsError,
importPenpa,
importPuzzle,
importSudokuPad,
parsePenpaText,
parseSudokuPadPuzzle,
} from "../../src/formats";
const penpaText = [
"square,4,4,40,0,1,1,320,320,18,18,1,1,1,1,Title: Tiny,Author: Test,,Normal Sudoku rules apply.,ON,false",
"[0,0,0,0]",
"{}",
JSON.stringify({
number: { 18: [1, 1, "1"], 45: [4, 1, "1"] },
thermo: [[19, 20]],
arrows: [[26, 27]],
}),
JSON.stringify({ number: { 21: [2, 2, "1"] } }),
"[18,1,1,1,5,1,1,1,5,1,1,1,5,1,1,1]",
"[]",
].join("\n");
const compressedPenpa =
"bY/LCsIwEEX3+Yow64sksb6y8wd0YXchi4gRxbTRpEGk9N+lKoIgZ4bLncWBybfikkc1IiAgITFV4rVy+ZoP9bkLXvP63D6wLt0pJs1rnztgE1PjAt+VQ7wUnkrwmbvrNTwm2G5wdCF7Zka5gLCsH1hPbWn2PpHuSS5Jm9FPkiyompE21acOoO7kUxNJGyNXUMJakEsp3vN4UnOohbW/QiVJGwX1NgzMfH+Y/U/LjH0C";
function sclPuzzle() {
const cells = Array.from({ length: 4 }, () =>
Array.from({ length: 4 }, () => ({})),
);
cells[0]![0] = { value: "1" };
cells[0]![1] = {
value: 2,
given: false,
pencilMarks: [4, 3],
centremarks: [2],
};
return {
id: "local-scl",
cells,
metadata: {
title: "Local SCL",
author: "Tester",
rules: "Normal rules apply.",
antiknight: true,
},
cages: [
{
cells: [
[0, 0],
[0, 1],
],
value: "3",
unique: true,
},
],
};
}
describe("local puzzle interoperability", () => {
it("imports bounded SudokuPad/CTC data and retains semantic features", () => {
const parsed = parseSudokuPadPuzzle(sclPuzzle());
expect(parsed.size).toBe(4);
expect(parsed.givens.slice(0, 2)).toEqual([1, 0]);
expect(parsed.values?.slice(0, 2)).toEqual([1, 2]);
expect(parsed.cornerMarks?.[1]).toEqual([3, 4]);
expect(parsed.constraints).toEqual(
expect.arrayContaining([
{
type: "killer-cage",
cells: [0, 1],
sum: 3,
noRepeat: true,
},
{ type: "anti-knight" },
]),
);
});
it("imports a self-contained ctc payload locally", async () => {
const inline = `ctc${compressToBase64(JSON.stringify(sclPuzzle()))}`;
const direct = importSudokuPad(inline);
const detected = await importPuzzle(`https://sudokupad.app/${inline}`);
expect(direct.title).toBe("Local SCL");
expect(detected.format).toBe("sudokupad");
expect(detected.document.givens[0]).toBe(1);
});
it("rejects visual-only SCL constructs instead of weakening them", () => {
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [
{
wayPoints: [
[0.5, 0.5],
[1.5, 1.5],
],
},
],
overlays: [{ text: "?" }],
}),
).toThrow(UnsupportedPuzzleConstructsError);
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [{}],
}),
).toThrow(/visual lines/u);
});
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
const parsed = parsePenpaText(penpaText);
expect(parsed.size).toBe(4);
expect(parsed.title).toBe("Tiny");
expect(parsed.author).toBe("Test");
expect(parsed.givens[0]).toBe(1);
expect(parsed.givens[15]).toBe(4);
expect(parsed.values?.[3]).toBe(2);
expect(parsed.constraints).toEqual([
{ type: "thermo", cells: [1, 2] },
{ type: "arrow", bulb: [4], line: [5] },
]);
});
it("detects an already-decompressed Penpa+ text record", async () => {
const parsed = await importPuzzle(penpaText);
expect(parsed.format).toBe("penpa");
expect(parsed.label).toBe("Penpa+ text");
expect(parsed.document.constraints).toHaveLength(2);
});
it("decompresses a complete Penpa+ long URL without fetching", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const parsed = await importPenpa(
`https://swaroopg92.github.io/penpa-edit/#m=solve&p=${compressedPenpa}`,
);
expect(parsed.givens[0]).toBe(1);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("rejects unsupported Penpa+ drawings with a compatibility list", () => {
const rows = penpaText.split("\n");
rows[3] = JSON.stringify({
number: { 18: [1, 1, "1"] },
line: { "18,19": 3 },
symbol: { 20: [1, "circle_L", 1] },
});
expect(() => parsePenpaText(rows.join("\n"))).toThrow(
/problem line, problem symbol/u,
);
});
it("never resolves a server-only SudokuPad short ID", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
await expect(
importPuzzle("https://sudokupad.app/serverOnly42"),
).rejects.toBeInstanceOf(NetworkPuzzleIdError);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
buildJpegPdf,
renderPuzzleSvg,
type SudokuDocument,
} from "../../src/formats";
function visualDocument(): SudokuDocument {
const values = Array.from({ length: 16 }, () => 0);
values[0] = 1;
values[15] = 4;
const cornerMarks = Array.from({ length: 16 }, () => [] as number[]);
cornerMarks[1] = [2, 3];
const centerMarks = Array.from({ length: 16 }, () => [] as number[]);
centerMarks[2] = [1, 4];
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size: 4,
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
values,
cornerMarks,
centerMarks,
colors: [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
constraints: [
{ type: "diagonal", direction: "main" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [4, 5] },
{ type: "arrow", bulb: [8], line: [9, 10] },
{ type: "kropki", a: 2, b: 3, kind: "white" },
{ type: "xv", a: 6, b: 7, total: 5 },
{ type: "inequality", lesser: 10, greater: 11 },
{ type: "renban", cells: [12, 13] },
{ type: "palindrome", cells: [14, 15] },
{ type: "maximum", cell: 5 },
{ type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 2] },
{ type: "x-sum", side: "top", index: 0, sum: 1 },
{ type: "skyscraper", side: "left", index: 2, count: 2 },
{ type: "anti-knight" },
],
title: 'A < B & "C"',
author: "Synthetic test",
};
}
describe("visual puzzle export", () => {
it("renders a standalone SVG with clues, regions, givens and progress", () => {
const svg = renderPuzzleSvg(visualDocument());
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
expect(parsed.querySelector("parsererror")).toBeNull();
expect(parsed.documentElement.localName).toBe("svg");
expect(svg).toContain("A &lt; B &amp; &quot;C&quot;");
expect(svg).toContain('class="constraint diagonal" x1="58" y1="132"');
expect(svg).toContain('class="constraint cage"');
expect(svg).toContain('class="constraint thermo"');
expect(svg).toContain('class="constraint arrow"');
expect(svg).toContain('class="constraint outside x-sum"');
expect(svg).toContain('class="constraint xv total-5"');
expect(svg).toContain(">5</text>");
expect(svg).not.toContain(">V</text>");
expect(svg).toContain('class="inequality-tip"');
expect(svg).toContain('class="cell-value given"');
expect(svg).toContain('class="cell-value progress"');
expect(svg).toContain('class="corner-note" x=');
expect(svg).toContain('class="center-note" x=');
expect(svg).not.toMatch(/<(?:script|image)|(?:href|src)=/iu);
});
it("can omit all solving progress from the visual", () => {
const svg = renderPuzzleSvg(visualDocument(), {
includeProgress: false,
includeNotes: false,
});
expect(svg).toContain('class="cell-value given"');
expect(svg).not.toContain('class="cell-value progress"');
expect(svg).not.toContain('class="corner-note" x=');
expect(svg).not.toContain('class="center-note" x=');
expect(svg).not.toContain('class="cell-color"');
});
it("builds a bounded single-page PDF around local JPEG bytes", () => {
const pdf = buildJpegPdf(
new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
320,
240,
);
const text = new TextDecoder("latin1").decode(pdf);
expect(text.startsWith("%PDF-1.4")).toBe(true);
expect(text).toContain("/DCTDecode");
expect(text).toContain("xref\n0 6");
expect(text.endsWith("%%EOF\n")).toBe(true);
});
it("rejects invalid or unbounded PDF image input", () => {
expect(() => buildJpegPdf(new Uint8Array([0, 1, 2, 3]), 320, 240)).toThrow(
/JPEG/u,
);
expect(() =>
buildJpegPdf(new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), 99_999, 240),
).toThrow(/dimensions/u);
});
it("validates progress before embedding it in a visual", () => {
const document = visualDocument();
const invalidMarks = Array.from({ length: 16 }, () => [] as number[]);
invalidMarks[0] = [99];
expect(() =>
renderPuzzleSvg({ ...document, cornerMarks: invalidMarks }),
).toThrow(/cornerMarks/u);
});
});