Files
sudoku-tools/tests/formats/sourcePreservation.test.ts

361 lines
11 KiB
TypeScript

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<string, unknown>[][] {
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<string, unknown>;
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<string, unknown>;
};
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 =
'</text><script>alert(1)</script><image href="https://evil.invalid/x">';
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("<script>");
expect(svg).not.toContain("<image");
});
});