176 lines
4.9 KiB
TypeScript
176 lines
4.9 KiB
TypeScript
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();
|
|
});
|
|
});
|