docs(tests): explain legacy canvas and workspace unit coverage
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* 旧 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) => {
|
||||
@@ -23,6 +31,7 @@ test("serialized autosaves use successive versions and coalesce queued documents
|
||||
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() }, () => {});
|
||||
@@ -30,6 +39,7 @@ test("version conflicts stop automatic writes and preserve the latest pending co
|
||||
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() }, () => {});
|
||||
@@ -37,6 +47,7 @@ test("explicit retry sends the newest document after a failed write", async () =
|
||||
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 }, () => {});
|
||||
@@ -48,6 +59,7 @@ test("selection and measured dimensions do not trigger redundant writes or leak
|
||||
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 = [];
|
||||
@@ -57,11 +69,13 @@ test("all ID/slug combinations load the same canonical board and snapshot", asyn
|
||||
}
|
||||
});
|
||||
|
||||
// 快照读取失败或 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");
|
||||
@@ -72,6 +86,7 @@ test("dirty local recovery and legacy drafts are offered for explicit migration;
|
||||
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" };
|
||||
@@ -85,6 +100,7 @@ test("canonical fingerprints ignore server JSON key ordering", async () => {
|
||||
});
|
||||
|
||||
|
||||
// 先挂起两种读取再检查 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");
|
||||
@@ -92,6 +108,7 @@ test("board metadata and snapshot begin concurrently without a request waterfall
|
||||
});
|
||||
|
||||
|
||||
// 微小 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:[]};
|
||||
@@ -104,6 +121,7 @@ test("viewport restoration roundoff does not create an autosave", async () => {
|
||||
});
|
||||
|
||||
|
||||
// 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=[];
|
||||
|
||||
Reference in New Issue
Block a user