Files
sudoku-tools/tests/formats/interoperability.test.ts

186 lines
5.3 KiB
TypeScript

import { compressToBase64 } from "lz-string";
import { describe, expect, it, vi } from "vitest";
import {
NetworkPuzzleIdError,
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("preserves allowlisted SCL drawings without treating them as rules", async () => {
const source = {
...sclPuzzle(),
lines: [
{
wayPoints: [
[0.5, 0.5],
[1.5, 1.5],
],
},
],
overlays: [{ center: [1, 1], width: 1, height: 1, text: "?" }],
};
const parsed = parseSudokuPadPuzzle(source);
const imported = await importPuzzle(JSON.stringify(source));
expect(parsed.visuals).toHaveLength(3);
expect(parsed.source).toEqual({ format: "sudokupad", id: "local-scl" });
expect(imported.preview.preservedVisuals).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "overlay:polyline", count: 1 }),
expect.objectContaining({ key: "overlay:text", count: 1 }),
]),
);
expect(imported.preview.warnings.join(" ")).toMatch(/not solver-enforced/u);
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [{}],
}),
).toThrow(/wayPoints|bounded/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();
});
});