feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+4 -3
View File
@@ -98,9 +98,10 @@ describe("Sudoku Tools document format", () => {
};
const hash = encodePuzzleHash(source);
expect(hash).toMatch(/^#sudoku=v1\./u);
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual(
source,
);
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual({
...source,
source: { format: "sudoku-tools" },
});
});
it("round-trips grids for every supported size and symbol", () => {
+59 -2
View File
@@ -115,6 +115,63 @@ describe("fpuzzles interoperability", () => {
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<number>(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,
@@ -220,9 +277,9 @@ describe("fpuzzles interoperability", () => {
parseFpuzzles({
size: 9,
grid: emptyGrid(),
odd: [{ cell: "R1C1" }],
nabner: [{ lines: [["R1C1", "R1C2"]] }],
}),
).toThrow(/odd cells.*silently weakening/u);
).toThrow(/Nabner lines.*silently weakening/u);
expect(() =>
parseFpuzzles({
size: 9,
+27 -17
View File
@@ -2,7 +2,6 @@ import { compressToBase64 } from "lz-string";
import { describe, expect, it, vi } from "vitest";
import {
NetworkPuzzleIdError,
UnsupportedPuzzleConstructsError,
importPenpa,
importPuzzle,
importSudokuPad,
@@ -91,27 +90,38 @@ describe("local puzzle interoperability", () => {
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);
it("preserves allowlisted SCL drawings without treating them as rules", async () => {
const source = {
...sclPuzzle(),
lines: [
{
wayPoints: [
[0.5, 0.5],
[1.5, 1.5],
],
},
],
overlays: [{ center: [1, 1], width: 1, height: 1, text: "?" }],
};
const parsed = parseSudokuPadPuzzle(source);
const imported = await importPuzzle(JSON.stringify(source));
expect(parsed.visuals).toHaveLength(3);
expect(parsed.source).toEqual({ format: "sudokupad", id: "local-scl" });
expect(imported.preview.preservedVisuals).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "overlay:polyline", count: 1 }),
expect.objectContaining({ key: "overlay:text", count: 1 }),
]),
);
expect(imported.preview.warnings.join(" ")).toMatch(/not solver-enforced/u);
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [{}],
}),
).toThrow(/visual lines/u);
).toThrow(/wayPoints|bounded/u);
});
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
+360
View File
@@ -0,0 +1,360 @@
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");
});
});