208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
|
|
import {
|
|
PROJECT_RECORD_SCHEMA,
|
|
ProjectLibrary,
|
|
createProjectRecord,
|
|
normalizeProjectRecord,
|
|
} from "../../src/storage";
|
|
|
|
const puzzle = {
|
|
schema: SUDOKU_DOCUMENT_SCHEMA,
|
|
version: 1 as const,
|
|
size: 9,
|
|
givens: Array<number>(81).fill(0),
|
|
constraints: [],
|
|
};
|
|
|
|
describe("local project library", () => {
|
|
it("stores independent bounded clones in the memory fallback", async () => {
|
|
const library = new ProjectLibrary({ indexedDB: null });
|
|
const record = createProjectRecord(puzzle, {
|
|
id: "one",
|
|
title: "First",
|
|
now: 100,
|
|
progress: {
|
|
version: 1,
|
|
values: Array<number>(81).fill(0),
|
|
cornerMarks: Array.from({ length: 81 }, () => [1, 2]),
|
|
centerMarks: Array.from({ length: 81 }, () => [3]),
|
|
colors: Array<number>(81).fill(2),
|
|
elapsedMs: 1234,
|
|
},
|
|
});
|
|
await library.put(record);
|
|
const loaded = await library.get("one");
|
|
expect(loaded).toEqual(record);
|
|
(loaded?.puzzle.givens as number[])[0] = 9;
|
|
expect((await library.get("one"))?.puzzle.givens[0]).toBe(0);
|
|
expect(library.mode).toBe("memory");
|
|
});
|
|
|
|
it("lists newest first, deletes and clears", async () => {
|
|
const library = new ProjectLibrary({ indexedDB: null });
|
|
await library.put(
|
|
createProjectRecord(puzzle, { id: "old", title: "Old", now: 1 }),
|
|
);
|
|
await library.put(
|
|
createProjectRecord(puzzle, { id: "new", title: "New", now: 2 }),
|
|
);
|
|
expect((await library.list()).map(({ id }) => id)).toEqual(["new", "old"]);
|
|
expect(await library.delete("old")).toBe(true);
|
|
expect(await library.delete("missing")).toBe(false);
|
|
await library.clear();
|
|
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 }));
|
|
const exported = await source.exportAll();
|
|
const target = new ProjectLibrary({ indexedDB: null });
|
|
expect(await target.importAll(exported)).toBe(1);
|
|
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: () => {
|
|
throw new Error("blocked");
|
|
},
|
|
} as unknown as IDBFactory;
|
|
const library = new ProjectLibrary({ indexedDB: broken });
|
|
expect(await library.ready()).toBe("memory");
|
|
await library.put(createProjectRecord(puzzle, { id: "fallback", now: 1 }));
|
|
expect(await library.get("fallback")).toBeDefined();
|
|
});
|
|
|
|
it("rejects malformed record versions and progress", () => {
|
|
expect(() =>
|
|
normalizeProjectRecord({
|
|
schema: PROJECT_RECORD_SCHEMA,
|
|
version: 2,
|
|
}),
|
|
).toThrow(/Unsupported project record/u);
|
|
const record = createProjectRecord(puzzle, { id: "bad", now: 1 });
|
|
expect(() =>
|
|
normalizeProjectRecord({
|
|
...record,
|
|
progress: { version: 1, values: [0] },
|
|
}),
|
|
).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);
|
|
});
|
|
});
|