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
+114
View File
@@ -54,6 +54,69 @@ describe("local project library", () => {
expect(await library.list()).toEqual([]);
});
it("searches tags, exposes safe thumbnails and exports a selection", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, {
id: "killer",
title: "Evening Killer",
tags: ["killer", "hard"],
now: 1,
}),
);
await library.put(
createProjectRecord(puzzle, {
id: "classic",
title: "Morning Classic",
tags: ["classic"],
now: 2,
}),
);
expect(
(await library.list({ search: "kill" })).map(({ id }) => id),
).toEqual(["killer"]);
expect(
(await library.list({ tags: ["classic"] }))[0]?.thumbnail,
).toHaveLength(81);
expect((await library.exportSelected(["classic"])).projects).toHaveLength(
1,
);
});
it("keeps recoverable autosaves separate from explicit projects", async () => {
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(puzzle, {
id: "draft",
title: "Draft",
now: 1,
});
await library.putAutosave(record);
expect((await library.getAutosave())?.id).toBe("draft");
expect(await library.list()).toEqual([]);
await library.clearAutosave();
expect(await library.getAutosave()).toBeUndefined();
});
it("duplicates selected projects with independent IDs and progress", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, {
id: "source",
title: "Source",
tags: ["classic"],
now: 10,
progress: { version: 1, values: Array<number>(81).fill(0) },
}),
);
expect(await library.duplicateSelected(["source", "missing"])).toBe(1);
const records = await library.exportAll();
expect(records.projects).toHaveLength(2);
const copy = records.projects.find((record) => record.id !== "source");
expect(copy).toMatchObject({ title: "Source copy", tags: ["classic"] });
expect(copy?.id).not.toBe("source");
});
it("exports and imports an explicitly versioned library", async () => {
const source = new ProjectLibrary({ indexedDB: null });
await source.put(createProjectRecord(puzzle, { id: "one", now: 10 }));
@@ -63,6 +126,41 @@ describe("local project library", () => {
expect(await target.get("one")).toEqual(await source.get("one"));
});
it("retains source extras through explicit saves, autosaves and reopening", async () => {
const sourcePuzzle = {
...puzzle,
visuals: [
{
type: "text" as const,
layer: "overlay" as const,
position: { kind: "cell" as const, cell: 0 },
text: "retained",
style: { fill: "#123456" },
},
],
source: { format: "sudokupad" as const, id: "source-identity" },
metadata: { edition: "local" },
};
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(sourcePuzzle, {
id: "preserved",
title: "Preserved",
now: 10,
});
await library.put(record);
await library.putAutosave({ ...record, updatedAt: 11 });
const exported = await library.exportAll();
const reopened = await library.get("preserved");
const autosave = await library.getAutosave();
for (const stored of [reopened, autosave, exported.projects[0]]) {
expect(stored?.puzzle.visuals).toEqual(sourcePuzzle.visuals);
expect(stored?.puzzle.source).toEqual(sourcePuzzle.source);
expect(stored?.puzzle.metadata).toEqual(sourcePuzzle.metadata);
}
});
it("falls back when IndexedDB cannot be opened", async () => {
const broken = {
open: () => {
@@ -90,4 +188,20 @@ describe("local project library", () => {
}),
).toThrow(/must contain 81/u);
});
it("bounds tags and rejects executable thumbnail markup", () => {
const record = createProjectRecord(puzzle, { id: "bounded", now: 1 });
expect(() =>
normalizeProjectRecord({
...record,
tags: Array.from({ length: 21 }, (_, index) => `tag-${String(index)}`),
}),
).toThrow(/at most 20/u);
expect(() =>
normalizeProjectRecord({
...record,
thumbnail: `<svg onload="alert(1)">${".".repeat(58)}`,
}),
).toThrow(/safe row-major grid preview/u);
});
});