361 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|