import { describe, expect, it } from "vitest"; import { normalizePuzzle } from "../../src/domain"; import { NetworkPuzzleIdError, SUDOKU_DOCUMENT_SCHEMA, addressFromCellIndex, cellIndexFromAddress, exportFpuzzles, exportFpuzzlesUrl, importFpuzzles, parseFpuzzles, toDomainPuzzle, type SudokuDocument, } from "../../src/formats"; const emptyGrid = () => Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => ({}))); describe("fpuzzles interoperability", () => { it("imports common givens, regions and variant constraints", () => { const grid = emptyGrid(); for (let row = 0; row < 9; row += 1) { for (let column = 0; column < 9; column += 1) { grid[row]![column] = { region: Math.floor(row / 3) * 3 + Math.floor(column / 3), }; } } grid[0]![0] = { value: 5, given: true, region: 0 }; grid[0]![1] = { value: 3, region: 0, centerPencilMarks: [7, 2], cornerPencilMarks: [9, 1], }; const puzzle = parseFpuzzles({ size: 9, title: "Variants", author: "Setter", ruleset: "Normal Sudoku rules apply.", grid, "diagonal+": true, antiknight: true, antikingsmove: true, nonconsecutive: true, killercage: [{ cells: ["R1C1", "R1C2"], value: "8" }], thermometer: [{ lines: [["R2C1", "R2C2", "R2C3"]] }], arrow: [{ cells: ["R3C1"], lines: [["R3C1", "R3C2"]] }], difference: [{ cells: ["R4C1", "R4C2"], value: "1" }], ratio: [{ cells: ["R4C2", "R4C3"], value: "2" }], xv: [{ cells: ["R5C1", "R5C2"], value: "V" }], 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); expect(puzzle.regions?.slice(0, 2)).toEqual([0, 0]); expect(puzzle.centerMarks?.[1]).toEqual([2, 7]); expect(puzzle.cornerMarks?.[1]).toEqual([1, 9]); expect(puzzle.constraints).toEqual( expect.arrayContaining([ { type: "killer-cage", cells: [0, 1], sum: 8, noRepeat: true }, { 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(); }); it("exports and imports a self-contained SudokuPad fpuzzles URL", () => { const source: SudokuDocument = { schema: SUDOKU_DOCUMENT_SCHEMA, version: 1, size: 9, givens: [5, ...Array(80).fill(0)], constraints: [ { type: "diagonal", direction: "anti" }, { type: "killer-cage", cells: [0, 1], sum: 8 }, { type: "thermo", cells: [9, 10, 11] }, { type: "arrow", bulb: [18], line: [19] }, { type: "kropki", a: 27, b: 28, kind: "white" }, { type: "xv", a: 36, b: 37, total: 10 }, { 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", }; const exported = exportFpuzzles(source); expect(exported.grid).toBeInstanceOf(Array); const imported = importFpuzzles(exportFpuzzlesUrl(source)); expect(imported.givens).toEqual(source.givens); const expected = source.constraints.map((constraint) => constraint.type === "killer-cage" ? { ...constraint, noRepeat: true } : constraint, ); expect(imported.constraints).toHaveLength(expected.length); expect(imported.constraints).toEqual(expect.arrayContaining(expected)); }); it("round-trips the registered expansion-pack constraints", () => { const solution = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 5, 6, 7, 8, 9, 1, 2, 3, 7, 8, 9, 1, 2, 3, 4, 5, 6, 2, 3, 4, 5, 6, 7, 8, 9, 1, 5, 6, 7, 8, 9, 1, 2, 3, 4, 8, 9, 1, 2, 3, 4, 5, 6, 7, 3, 4, 5, 6, 7, 8, 9, 1, 2, 6, 7, 8, 9, 1, 2, 3, 4, 5, 9, 1, 2, 3, 4, 5, 6, 7, 8, ]; const source: SudokuDocument = { schema: SUDOKU_DOCUMENT_SCHEMA, version: 1, size: 9, givens: Array(81).fill(0), solution, constraints: [ { type: "disjoint-groups" }, { type: "minimum", cell: 0 }, { type: "odd", cell: 1 }, { type: "even", cell: 2 }, { type: "little-killer", side: "top", index: 0, direction: "down-right", sum: 45, }, { type: "sandwich", side: "left", index: 0, sum: 20 }, { type: "between-line", cells: [0, 1, 2] }, { type: "german-whisper", cells: [9, 10], minimumDifference: 4 }, { type: "region-sum-line", cells: [18, 19, 20, 21] }, { type: "clone", cells: [27, 28], cloneCells: [36, 37] }, { type: "extra-region", cells: [0, 10, 20, 30, 40, 50, 60, 70, 80], }, { type: "modular-line", cells: [45, 46, 47] }, { type: "entropic-line", cells: [54, 55, 56] }, { type: "zipper-line", cells: [63, 64, 65] }, { type: "double-arrow", cells: [72, 73, 74] }, { type: "indexer", kind: "row", cell: 3 }, { type: "indexer", kind: "column", cell: 4 }, { type: "indexer", kind: "box", cell: 5 }, { type: "fog", lights: [0, 40], revealRadius: 1 }, ], }; const imported = parseFpuzzles(exportFpuzzles(source)); expect(imported.constraints).toEqual( expect.arrayContaining( source.constraints.map((constraint) => constraint.type === "fog" ? { type: "fog", lights: constraint.lights } : constraint, ), ), ); }); it("recognizes server-only short puzzle IDs without making a request", () => { expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError( NetworkPuzzleIdError, ); expect(() => importFpuzzles("https://sudokupad.app/?puzzleid=abc123"), ).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(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(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({ size: 9, grid: emptyGrid(), difference: [{ cells: ["R1C1", "R1C2"], value: "2" }], }), ).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({ size: 9, grid: emptyGrid(), nabner: [{ lines: [["R1C1", "R1C2"]] }], }), ).toThrow(/Nabner lines.*silently weakening/u); expect(() => parseFpuzzles({ size: 9, grid: emptyGrid(), futureConstraint: [{ cells: ["R1C1"] }], }), ).toThrow(/Unknown fpuzzles field/u); }); it("round-trips canonical fpuzzles cell addresses", () => { for (let cell = 0; cell < 256; cell += 1) { expect(cellIndexFromAddress(addressFromCellIndex(cell, 16), 16)).toBe( cell, ); } }); });