feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 < B & "C"");
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user