118 lines
8.8 KiB
JavaScript
118 lines
8.8 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
|
|
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
|
|
import { loadBoardCanvas } from "../src/lib/canvas-loader.ts";
|
|
function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; }
|
|
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
|
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
test("serialized autosaves use successive versions and coalesce queued documents", async () => {
|
|
const calls = [], first = deferred();
|
|
const writer = new CanvasSync(async (path, options) => {
|
|
calls.push({ path, ...options });
|
|
if (calls.length === 1) return first.promise;
|
|
return { version: 3 };
|
|
}, canvasPath("space", "board"), { version: 1, document: emptyCanvas() }, () => {});
|
|
const a = writer.enqueue(document("A")); await tick();
|
|
writer.enqueue(document("B")); writer.enqueue(document("C"));
|
|
assert.equal(calls.length, 1);
|
|
first.resolve({ version: 2 }); await a;
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(calls[0].headers["If-Match"], '"1"'); assert.equal(calls[1].headers["If-Match"], '"2"');
|
|
assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "C"); assert.equal(writer.version, 3);
|
|
});
|
|
|
|
test("version conflicts stop automatic writes and preserve the latest pending content", async () => {
|
|
const calls = [];
|
|
const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {});
|
|
await writer.enqueue(document("A")); await writer.enqueue(document("B"));
|
|
assert.equal(calls.length, 1); assert.equal(writer.error, "version_conflict"); assert.equal(writer.version, 7);
|
|
});
|
|
|
|
test("explicit retry sends the newest document after a failed write", async () => {
|
|
const calls = [];
|
|
const writer = new CanvasSync(async (_path, options) => { calls.push(options); if (calls.length === 1) throw new Error("upstream_unavailable"); return { version: 2 }; }, "canvas", { version: 1, document: emptyCanvas() }, () => {});
|
|
await writer.enqueue(document("A")); await writer.enqueue(document("B")); await writer.retry();
|
|
assert.equal(calls.length, 2); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "B"); assert.equal(writer.error, "");
|
|
});
|
|
|
|
test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => {
|
|
const initial = document("keep"); let calls = 0;
|
|
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {});
|
|
const changed = structuredClone(initial); changed.nodes[0].selected = true; changed.nodes[0].measured = { width: 260, height: 200 };
|
|
await writer.enqueue(changed); assert.equal(calls, 0);
|
|
const payload = canvasPayload(changed);
|
|
for (const key of ["selected", "style", "measured", "dragging", "zIndex"]) assert.equal(payload.nodes[0][key], undefined);
|
|
const first = writer.enqueue(changed); const second = writer.enqueue(document("new")); await Promise.all([first, second]);
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test("all ID/slug combinations load the same canonical board and snapshot", async () => {
|
|
for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) {
|
|
const calls = [];
|
|
const result = await loadBoardCanvas(async (path) => { calls.push(path); return path.endsWith("/canvas") ? { version: 4, document: document("saved") } : { id: "board-id", slug: "my-board", workspace_id: "space-id", kind: "board", title: "Saved" }; }, workspace, board);
|
|
assert.equal(result.board.id, "board-id"); assert.equal(result.board.workspace_id, "space-id"); assert.equal(result.snapshot.version, 4);
|
|
assert(calls.includes(`workspaces/${workspace}/materials/${board}/canvas`));
|
|
}
|
|
});
|
|
|
|
test("failed snapshot loads and mismatched boards prevent editing rather than creating an empty replacement", async () => {
|
|
await assert.rejects(loadBoardCanvas(async (path) => { if (path.endsWith("/canvas")) throw new Error("upstream_unavailable"); return { id: "board", workspace_id: "space", kind: "board" }; }, "space", "board"), /upstream/);
|
|
await assert.rejects(loadBoardCanvas(async (path) => path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "other", workspace_id: "space", kind: "board" }, "space", "board"), /invalid_reference/);
|
|
});
|
|
|
|
test("dirty local recovery and legacy drafts are offered for explicit migration; clean backups never replace server data", () => {
|
|
const remote = { version: 3, document: document("server") };
|
|
const local = document("local");
|
|
const storage = (values) => ({ getItem: (key) => values[key] || null });
|
|
assert.equal(readLocalRecovery(storage({ key: JSON.stringify(local) }), "key", remote).nodes[0].data.text, "local");
|
|
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: local, dirty: true, baseVersion: 2 }) }), "key", remote).nodes[0].data.text, "local");
|
|
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: local, dirty: false }), key: JSON.stringify(local) }), "key", remote), null);
|
|
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: remote.document, dirty: true }) }), "key", remote), null);
|
|
});
|
|
|
|
test("canonical fingerprints ignore server JSON key ordering", async () => {
|
|
const remote = document("same"), local = document("same");
|
|
remote.nodes[0].data.textStyle = { bold: true, color: "#123456" };
|
|
local.nodes[0].data.textStyle = { color: "#123456", bold: true };
|
|
let calls = 0;
|
|
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: remote }, () => {});
|
|
await writer.enqueue(local);
|
|
assert.equal(calls, 0);
|
|
const storage = { getItem: () => JSON.stringify({ document: local, dirty: true }) };
|
|
assert.equal(readLocalRecovery(storage, "key", { version: 1, document: remote }), null);
|
|
});
|
|
|
|
|
|
test("board metadata and snapshot begin concurrently without a request waterfall", async () => {
|
|
const started = []; const releases = [];
|
|
const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board");
|
|
assert.equal(started.length, 2); releases.forEach((release) => release()); await loading;
|
|
});
|
|
|
|
|
|
test("viewport restoration roundoff does not create an autosave", async () => {
|
|
const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
|
|
const state={past:[],present:doc,future:[]};
|
|
const next=canvasReducer(state,{type:"viewport",viewport:{x:doc.viewport.x+1e-10,y:doc.viewport.y,zoom:doc.viewport.zoom+1e-12}});
|
|
assert.equal(next,state);let writes=0;const writer=new CanvasSync(async()=>{writes++;return {version:2};},"canvas",{version:1,document:doc},()=>{});
|
|
await writer.enqueue(next.present);assert.equal(writes,0);
|
|
const edited=canvasReducer(state,{type:"viewport",viewport:{...doc.viewport,x:doc.viewport.x+10}});await writer.enqueue(edited.present);assert.equal(writes,0);
|
|
edited.present.nodes[0]={...edited.present.nodes[0],data:{...edited.present.nodes[0].data,text:"changed"}};
|
|
await writer.enqueue(edited.present);assert.equal(writes,1);
|
|
});
|
|
|
|
|
|
test("editable local uploads and touching edges never enter transport snapshots",async()=>{
|
|
const pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local";
|
|
const initial=emptyCanvas(),calls=[];
|
|
const writer=new CanvasSync(async(_path,options)=>{calls.push(JSON.parse(options.body).document);return {version:calls.length+1};},"canvas",{version:1,document:initial},()=>{});
|
|
const local={...initial,nodes:[pending]};await writer.enqueue(local);assert.equal(calls.length,0);
|
|
const other=makeCanvasNode("text",{x:1,y:2});
|
|
const edited={...local,nodes:[pending,other],edges:[{id:"pending-edge",source:pending.id,target:other.id}]};await writer.enqueue(edited);
|
|
assert.equal(calls.length,1);assert.equal(calls[0].nodes.length,1);assert.equal(calls[0].edges.length,0);assert.ok(!JSON.stringify(calls[0]).includes("blob:"));assert.ok(!writer.document.nodes.some(node=>node.pendingUpload));
|
|
const {pendingUpload,...ready}=pending;ready.data={...ready.data,src:"https://example.com/original.png"};ready.position={x:300,y:400};
|
|
await writer.enqueue({...edited,nodes:[ready,other]});assert.equal(calls.length,2);assert.equal(calls[1].nodes.find(node=>node.id===ready.id).position.x,300);assert.equal(calls[1].edges.length,1);
|
|
});
|