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