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(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(81).fill(0), cornerMarks: Array.from({ length: 81 }, () => [1, 2]), centerMarks: Array.from({ length: 81 }, () => [3]), colors: Array(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(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: `${".".repeat(58)}`, }), ).toThrow(/safe row-major grid preview/u); }); });