feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user