45f5c505c9
Story whiteboard built on React Flow v11: free-form canvas of typed nodes (character, world, chapter, note, image, group) with draggable leads-to/contrasts/causes edges, cross-link selectors (chapter/entity), linked-node badges, and an "Add beats to board" import from the plot. Plot beats feature set: - Status/locking (draft/revised/locked) with a progress bar - Beat templates (three-act, save-the-cat, seven-point, custom) - Timeline route with search/chapter/character filters - Entity cross-links (entityIds) on beats and board nodes - getBoard added to the StorageAdapter contract (local + server) and REST endpoints; loadBoard now restores persisted board state Storage & server: - PlotBeat type (status, entityIds) across core types, Dexie schema, and server mapping/routes/migration (002_plot_beats.sql) - SQLite-safe column names (beat_order, entity_ids) - Server seed data for plot beats UI: accent Button variant, Badge variants, plot/timeline/whiteboard stylesheets. Tests: templates module, plot-beat store round-trip, board getBoard. typecheck, lint, test, and build all green.
97 lines
2.4 KiB
TypeScript
97 lines
2.4 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { createAutosave } from "./autosave.js";
|
|
import type { StorageAdapter } from "./storage.js";
|
|
import type { DocumentRef } from "./types.js";
|
|
|
|
function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
|
|
const saved: DocumentRef[] = [];
|
|
return {
|
|
saved,
|
|
async listProjects() {
|
|
return [];
|
|
},
|
|
async createProject() {
|
|
throw new Error("not implemented");
|
|
},
|
|
async getProject() {
|
|
throw new Error("not implemented");
|
|
},
|
|
async createBook() {
|
|
throw new Error("not implemented");
|
|
},
|
|
async listBooks() {
|
|
return [];
|
|
},
|
|
async listChapters() {
|
|
return [];
|
|
},
|
|
async getChapter() {
|
|
return null;
|
|
},
|
|
async saveEntity() {},
|
|
async listEntities() {
|
|
return [];
|
|
},
|
|
async getEntity() {
|
|
return null;
|
|
},
|
|
async deleteEntity() {},
|
|
async searchEntities() {
|
|
return [];
|
|
},
|
|
async saveBoard() {},
|
|
async getBoard() {
|
|
return null;
|
|
},
|
|
async savePlotBeat() {},
|
|
async listPlotBeats() {
|
|
return [];
|
|
},
|
|
async deletePlotBeat() {},
|
|
async saveAISession() {},
|
|
async saveDocument(doc: DocumentRef) {
|
|
saved.push(doc);
|
|
},
|
|
onEvent() {
|
|
return () => {};
|
|
},
|
|
};
|
|
}
|
|
|
|
const ref: DocumentRef = {
|
|
id: "doc1",
|
|
projectId: "p1",
|
|
title: "Chapter 1",
|
|
rev: 0,
|
|
doc: { type: "doc", content: [] },
|
|
};
|
|
|
|
describe("autosave", () => {
|
|
it("debounces saves and reports status transitions", async () => {
|
|
const adapter = mockAdapter();
|
|
const as = createAutosave(adapter, { intervalMs: 50 });
|
|
const statuses: string[] = [];
|
|
as.onStatus((s) => statuses.push(s.status));
|
|
|
|
as.save(ref);
|
|
as.save(ref); // second save within window should not double-fire
|
|
expect(as.state.dirty).toBe(true);
|
|
expect(as.state.saving).toBe(true);
|
|
|
|
await vi.waitFor(() => expect(adapter.saved.length).toBe(1), { timeout: 500 });
|
|
expect(adapter.saved[0]!.id).toBe("doc1");
|
|
expect(as.state.dirty).toBe(false);
|
|
expect(as.state.saving).toBe(false);
|
|
expect(as.indicatorLabel()).toMatch(/✓ saved/);
|
|
});
|
|
|
|
it("flush persists immediately and clears dirty state", async () => {
|
|
const adapter = mockAdapter();
|
|
const as = createAutosave(adapter, { intervalMs: 10_000 });
|
|
as.save(ref);
|
|
await as.flush();
|
|
expect(adapter.saved.length).toBe(1);
|
|
expect(as.state.dirty).toBe(false);
|
|
});
|
|
});
|