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(); }); });