import { describe, expect, it } from "vitest"; import { normalizePuzzle } from "../../src/domain"; import { MAX_VISUAL_POINTS, MAX_VISUAL_PRIMITIVES, SUDOKU_DOCUMENT_SCHEMA, cloneSudokuDocument, exportFpuzzles, exportSudokuPadJson, exportSudokuPadPayload, extractPreservedDocumentExtras, fromDomainPuzzle, importSudokuPad, normalizeSudokuDocument, parseFpuzzles, parseSudokuPadPuzzle, renderPuzzleSvg, toDomainPuzzle, type SafeVisualPrimitive, type SudokuDocument, } from "../../src/formats"; const REGIONS_4X4 = [0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3] as const; function emptyCells(size = 4): Record[][] { return Array.from({ length: size }, () => Array.from({ length: size }, () => ({})), ); } function sourceVisuals(): SafeVisualPrimitive[] { return [ { type: "line", layer: "underlay", start: { kind: "coordinate", x: 0, y: 0 }, end: { kind: "coordinate", x: 4, y: 4 }, style: { stroke: "#123456", strokeWidth: 0.04, opacity: 0.8 }, }, { type: "polyline", layer: "overlay", points: [ { kind: "cell", cell: 0 }, { kind: "cell", cell: 1 }, { kind: "cell", cell: 5 }, ], style: { stroke: "#abcdef", fill: "transparent" }, }, { type: "rectangle", layer: "underlay", center: { kind: "cell", cell: 6 }, width: 0.8, height: 0.6, cornerRadius: 0.1, style: { fill: "#ffeedd", stroke: "#112233" }, }, { type: "ellipse", layer: "overlay", center: { kind: "cell", cell: 9 }, radiusX: 0.35, radiusY: 0.2, style: { fill: "transparent", stroke: "#445566" }, }, { type: "circle", layer: "overlay", center: { kind: "cell", cell: 10 }, radius: 0.25, style: { fill: "#778899", opacity: 0.5 }, }, { type: "text", layer: "overlay", position: { kind: "cell", cell: 15 }, text: "safe label", style: { fill: "#010203", fontSize: 0.4 }, }, ]; } function sourceDocument(): SudokuDocument { return { schema: SUDOKU_DOCUMENT_SCHEMA, version: 1, size: 4, givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], values: [1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], cornerMarks: Array.from({ length: 16 }, (_unused, cell) => cell === 2 ? [3, 4] : [], ), centerMarks: Array.from({ length: 16 }, (_unused, cell) => cell === 3 ? [2, 3] : [], ), elapsedMs: 12_345, regions: REGIONS_4X4, constraints: [ { type: "killer-cage", cells: [0, 1], sum: 3, noRepeat: true }, { type: "thermo", cells: [4, 5] }, ], title: "Preserved fixture", author: "Local setter", id: "domain-id", visuals: sourceVisuals(), source: { format: "sudokupad", id: "source-id", version: "1" }, metadata: { edition: "nightly", featured: true, attempt: 2 }, }; } describe("source-preserving interoperability", () => { it("clones and explicitly merges non-domain source extras", () => { const source = normalizeSudokuDocument(sourceDocument()); const clone = cloneSudokuDocument(source); const extras = extractPreservedDocumentExtras(source); const domain = normalizePuzzle(toDomainPuzzle(source)); const merged = fromDomainPuzzle(domain, extras); expect(clone.visuals).toEqual(source.visuals); expect(clone.visuals).not.toBe(source.visuals); expect(clone.source).not.toBe(source.source); expect(clone.metadata).not.toBe(source.metadata); expect(merged.visuals).toEqual(source.visuals); expect(merged.source).toEqual(source.source); expect(merged.metadata).toEqual(source.metadata); }); it("round-trips f-puzzles decorative primitives and source scalars", () => { const grid = emptyCells(); grid[0]![0] = { value: 1, given: true }; const parsed = parseFpuzzles({ id: "fp-source", size: 4, grid, successMessage: "Done", line: [ { lines: [["R1C1", "R1C2"]], outlineC: "#123456", width: 0.1, }, ], rectangle: [ { cells: ["R2C2"], width: 0.8, height: 0.6, baseC: "#abcdef", }, ], circle: [ { cells: ["R3C3"], width: 0.5, height: 0.5, outlineC: "#112233", }, ], text: [{ cells: ["R4C4"], value: "A&B", fontC: "#445566" }], }); expect(parsed.visuals?.map(({ type }) => type)).toEqual([ "polyline", "rectangle", "circle", "text", ]); expect(parsed.source).toEqual({ format: "fpuzzles", id: "fp-source" }); expect(parsed.metadata).toEqual({ successMessage: "Done" }); const exported = exportFpuzzles(parsed); expect(exported.id).toBe("fp-source"); expect(exported.line).toBeInstanceOf(Array); expect(exported.rectangle).toBeInstanceOf(Array); expect(exported.circle).toBeInstanceOf(Array); expect(exported.text).toBeInstanceOf(Array); expect(exported.successMessage).toBe("Done"); }); it("exports real SCL JSON and payload with progress and exact semantics", () => { const source = sourceDocument(); const json = exportSudokuPadJson(source, true); const raw = JSON.parse(json) as Record; const payload = exportSudokuPadPayload(source); const importedJson = importSudokuPad(json); const importedPayload = importSudokuPad(payload); expect(raw.id).toBe("source-id"); expect(raw.cells).toBeInstanceOf(Array); expect(raw.regions).toBeInstanceOf(Array); expect(raw.cages).toBeInstanceOf(Array); expect(raw.lines).toBeInstanceOf(Array); expect(raw.underlays).toBeInstanceOf(Array); expect(raw.overlays).toBeInstanceOf(Array); expect(payload).toMatch(/^scl/u); for (const imported of [importedJson, importedPayload]) { expect(imported.constraints).toEqual(source.constraints); expect(imported.values).toEqual(source.values); expect(imported.cornerMarks).toEqual(source.cornerMarks); expect(imported.centerMarks).toEqual(source.centerMarks); expect(imported.elapsedMs).toBe(source.elapsedMs); expect(imported.regions).toEqual(source.regions); expect(imported.metadata).toEqual( expect.objectContaining({ edition: "nightly", featured: true }), ); expect(imported.source).toEqual({ format: "sudokupad", id: "source-id", }); expect(imported.visuals?.length).toBeGreaterThanOrEqual( source.visuals!.length, ); } }); it("does not promote retained metadata into SudokuPad rule fields", () => { const source: SudokuDocument = { ...sourceDocument(), constraints: [], title: undefined, author: undefined, rules: undefined, solution: undefined, metadata: { antiknight: true, antiking: true, nonconsecutive: true, title: "not a title", author: "not an author", rules: "not a rule", solution: "1234341221434321", sudokuToolsConstraints: '[{"type":"anti-knight"}]', edition: "retained", }, }; const raw = JSON.parse(exportSudokuPadJson(source)) as { metadata: Record; }; const imported = importSudokuPad(JSON.stringify(raw)); expect(raw.metadata).not.toHaveProperty("antiknight"); expect(raw.metadata).not.toHaveProperty("antiking"); expect(raw.metadata).not.toHaveProperty("nonconsecutive"); expect(raw.metadata).not.toHaveProperty("title"); expect(raw.metadata).not.toHaveProperty("author"); expect(raw.metadata).not.toHaveProperty("rules"); expect(raw.metadata).not.toHaveProperty("solution"); expect(raw.metadata.edition).toBe("retained"); expect(imported.constraints).toEqual([]); expect(imported.title).toBeUndefined(); expect(imported.solution).toBeUndefined(); }); it.each(["onclick", "style", "html", "d", "href"])( "rejects the executable or raw visual field %s", (field) => { expect(() => parseSudokuPadPuzzle({ cells: emptyCells(), overlays: [ { center: [0.5, 0.5], width: 1, height: 1, [field]: "javascript:alert(1)", }, ], }), ).toThrow(/not an allowlisted visual field/u); }, ); it("rejects unsafe colours and non-finite or out-of-bounds geometry", () => { expect(() => parseSudokuPadPuzzle({ cells: emptyCells(), lines: [ { wayPoints: [ [0.5, 0.5], [1.5, 1.5], ], color: "url(javascript:alert(1))", }, ], }), ).toThrow(/hexadecimal colour/u); expect(() => parseSudokuPadPuzzle({ cells: emptyCells(), overlays: [ { center: [Number.POSITIVE_INFINITY, 0], width: 1, height: 1 }, ], }), ).toThrow(/finite number/u); expect(() => parseSudokuPadPuzzle({ cells: emptyCells(), overlays: [{ center: [99, 99], width: 1, height: 1 }], }), ).toThrow(/finite number/u); }); it("enforces primitive and aggregate point limits", () => { const visual = sourceVisuals()[0]!; expect(() => normalizeSudokuDocument({ ...sourceDocument(), visuals: Array.from( { length: MAX_VISUAL_PRIMITIVES + 1 }, () => visual, ), }), ).toThrow(/at most .* primitives/u); const points = Array.from({ length: MAX_VISUAL_POINTS / 2 + 1 }, () => ({ kind: "coordinate" as const, x: 0, y: 0, })); expect(() => normalizeSudokuDocument({ ...sourceDocument(), visuals: [ { type: "polyline", layer: "overlay", points }, { type: "polyline", layer: "overlay", points }, ], }), ).toThrow(/more than .* anchors/u); }); it("escapes imported text and emits no executable SVG attributes", () => { const malicious = ''; const document = normalizeSudokuDocument({ ...sourceDocument(), visuals: [ { type: "text", layer: "overlay", position: { kind: "cell", cell: 0 }, text: malicious, style: { fill: "#000000" }, }, ], }); const svg = renderPuzzleSvg(document); const parsed = new DOMParser().parseFromString(svg, "image/svg+xml"); expect(parsed.querySelector("parsererror")).toBeNull(); expect(parsed.querySelector("script, image")).toBeNull(); expect( parsed.querySelector("[href], [src], [onclick], [onload]"), ).toBeNull(); expect(parsed.querySelector(".source-visual")?.textContent).toBe(malicious); expect(svg).not.toContain("