Files
Endoleap/tests/canvas-sync.test.mjs
T

142 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧 CanvasSync 完整快照自动保存及旧加载/草稿恢复测试。正在发送的请求串行,排队文档只保留最新份。
* 显式 retry 可以发送最新文档,与 headless 冻结请求的未知结果查询/原字节重试是不同契约,不可相互替代。
*/
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";
// 返回固定 text ID 的新快照,便于只改变内容而不被随机 ID 差异触发多余保存。
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; }
// 手动完成首个请求以检查在途期间版本和队列合并,reject 也供故障场景使用。
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
// setImmediate 让保存泵进入 request,再添加后续文档,避免只测同一同步调用窗口。
const tick = () => new Promise((resolve) => setImmediate(resolve));
// A 请求挂起时排队 B/C 不并发发送;A ACK=2 后只发送 C,并使用连续 If-Match 1→2。
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 });
// 首次响应手动释放,后续立即 ACK,便于检查下一次发送使用首个 ACK 返回的版本。
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);
// 等待保存泵排空,核对最终发出 C 而非中间 B。
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);
});
// 首次 version_conflict 停止自动写,后续 B 不再发送,旧服务版本和错误保留。
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);
});
// 故障后手动 retry 发送当前最新 B,成功清错误;没有承诺保留最初 A 的冻结字节。
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, "");
});
// selected/measured 的变更不产生保存,payload 去 UI 字段;真正内容变化才调用一次接口。
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);
});
// workspace/board 的 ID 与 slug 四组合都得到 canonical board 身份及保存快照,路径仍按传入引用构造。
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`));
}
});
// 快照读取失败或 board 身份不符直接拒绝,不以空文档允许用户覆盖远端。
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/);
});
// dirty 及旧草稿可提供显式恢复候选;clean 备份或与远端相同内容返回 null,不能静默覆盖服务。
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");
// getItem 内存替身::recovery 存 dirty 封装,裸 key 存旧草稿。
// 同时提供 clean recovery 和旧草稿,检查 clean 记录不会被旧键绕过。
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);
});
// 指纹按内容规范化键序,textStyle 属性顺序不同不触发保存,也不被当作未保存草稿差异。
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);
});
// 先挂起两种读取再检查 started=2,metadata/snapshot 必须并发启动。
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;
});
// 微小 viewport 浮点误差保持同一 state,正常 viewport 也不单独保存;随后内容变化才写一次。
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);
});
// pendingUpload/Blob 节点和触边只在本地可编辑;传输排除它们,确认后的新 src、最新坐标和连线才一起进入快照。
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));
// 固定 HTTPS 地址模拟可传输节点,没有执行真实上传确认;保留 ID 并去掉本地占位标记。
// 最新位置和此前被过滤的附边应随节点一起发送。
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);
});