feat: add project management features including open/save functionality and recovery support

This commit is contained in:
syntaxbullet
2026-07-10 23:46:01 +02:00
parent 6d652c3264
commit 9697075d29
15 changed files with 613 additions and 2 deletions

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import { documentAddArtboardCommand, documentRenameArtboardCommand } from "@commands/document";
import { projectOpenCommand } from "@commands/project";
import { createCommandRegistry } from "@commands/registry";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import type { RecoveryStorage } from "@platform/projectStorage";
import { createProjectLifecycle, RECOVERY_STORAGE_KEY } from "./lifecycle";
import { parseProject, serializeProject } from "./format";
describe("project lifecycle", () => {
test("autosaves document changes after the debounce", async () => {
const app = createTestApp();
const storage = memoryStorage();
let scheduled: (() => void) | undefined;
const lifecycle = createProjectLifecycle({
store: app.store,
storage,
schedule(callback) {
scheduled = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
},
cancel() {},
});
const artboardId = app.store.getState().document.artboards[0]!.id;
app.store.dispatch(commandIds.documentRenameArtboard, { id: artboardId, name: "Recovered board" });
expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull();
scheduled?.();
await Promise.resolve();
expect(parseProject((await storage.read(RECOVERY_STORAGE_KEY))!).document.artboards[0]!.name).toBe("Recovered board");
lifecycle.dispose();
});
test("recovers a document through the project command and clears history", async () => {
const app = createTestApp();
const recovered = createTestApp("Recovered").store.getState().document;
const storage = memoryStorage([[RECOVERY_STORAGE_KEY, serializeProject(recovered)]]);
const lifecycle = createProjectLifecycle({ store: app.store, storage });
expect((await lifecycle.recover())?.document.name).toBe("Recovered");
expect(app.store.getState().document.name).toBe("Recovered");
expect(app.store.getState().history).toEqual({ past: [], future: [] });
lifecycle.dispose();
});
test("discards corrupted recovery and clears recovery after explicit save", async () => {
const app = createTestApp();
const storage = memoryStorage([[RECOVERY_STORAGE_KEY, "broken"]]);
const lifecycle = createProjectLifecycle({ store: app.store, storage });
expect(await lifecycle.recover()).toBeUndefined();
expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull();
await storage.write(RECOVERY_STORAGE_KEY, lifecycle.snapshot());
await lifecycle.markSaved();
expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull();
lifecycle.dispose();
});
test("explicit save cancels a pending recovery write", async () => {
const app = createTestApp();
const storage = memoryStorage();
let scheduled: (() => void) | undefined;
let cancelled = false;
const lifecycle = createProjectLifecycle({
store: app.store,
storage,
schedule(callback) {
scheduled = callback;
return 1;
},
cancel() { cancelled = true; },
});
const artboardId = app.store.getState().document.artboards[0]!.id;
app.store.dispatch(commandIds.documentRenameArtboard, { id: artboardId, name: "Saved" });
await lifecycle.markSaved();
expect(cancelled).toBe(true);
expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull();
expect(scheduled).toBeDefined();
lifecycle.dispose();
});
});
function memoryStorage(entries: [string, string][] = []): RecoveryStorage {
const values = new Map(entries);
return {
read: async (key) => values.get(key) ?? null,
write: async (key, value) => { values.set(key, value); },
remove: async (key) => { values.delete(key); },
};
}
function createTestApp(name = "Test") {
const store = createAppStore(createInitialAppState(name), createCommandRegistry([projectOpenCommand, documentAddArtboardCommand, documentRenameArtboardCommand]));
store.dispatch(commandIds.documentAddArtboard, { id: `${name}-artboard`, name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
return { store };
}