docs(tests): explain legacy history and subgraph synchronization fixtures
This commit is contained in:
@@ -19,12 +19,14 @@ test("serialized autosaves use successive versions and coalesce queued documents
|
||||
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"');
|
||||
@@ -79,6 +81,8 @@ test("failed snapshot loads and mismatched boards prevent editing rather than cr
|
||||
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");
|
||||
@@ -130,6 +134,8 @@ test("editable local uploads and touching edges never enter transport snapshots"
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -13,10 +13,13 @@ import { managedImageSource } from '../src/lib/canvas-v2.ts';
|
||||
const childId = '11111111-1111-4111-8111-111111111111';
|
||||
// 大尺寸 frame 引用子 board 和输出 port,父文字含 AI 配置,边包含完整曲线/动画/marker 数据。
|
||||
function fixture() {
|
||||
// frame 的局部入口与 child 中 inside 的身份通过 port 绑定;缩放 .2 影响投影,不改 child 原坐标。
|
||||
const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000;
|
||||
frame.data={...frame.data,subgraph:{boardId:childId},presentation:{collapsed:false,contentScale:.2,expandedSize:{width:5000,height:400},collapsedSize:{width:240,height:140}},ports:[{id:'output',direction:'output',nodeId:'inside',handle:'right'}]};
|
||||
// AI 字段只是序列化配置,fixture 不创建任务、调用模型或产生计费。
|
||||
const text=makeCanvasNode('text',{x:900,y:100});text.id='outside';text.data.ai={enabled:true,modelId:'dynamic-model',prompt:'hello',propagate:false,parameters:{}};
|
||||
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='inside';
|
||||
// knots.position 是绝对点,in/out 是控制向量;其余字段承载线条展示、动画和执行配置。
|
||||
const edge={id:'edge',source:'frame',target:'outside',sourceHandle:'port:output',targetHandle:'left',data:{appearance:{color:'#123456',width:2,line:'dashed',head:{type:'triangle',filled:false,size:12},tail:{type:'circle',size:8}},animation:{mode:'executing',effect:'flow',direction:'forward',durationMs:1200,color:'#ffffff'},path:{type:'bezier-chain',knots:[{id:'k',position:{x:700,y:150},in:{x:-30,y:0},out:{x:30,y:0},mode:'smooth'}]},execution:{enabled:true}}};
|
||||
return {frame,text,inside,root:{...emptyCanvas(),schema_version:2,nodes:[frame,text],edges:[edge]},child:{...emptyCanvas(),schema_version:2,nodes:[inside]}};
|
||||
}
|
||||
@@ -45,6 +48,7 @@ test('curve coordinates and vectors invert exactly between owner and projected c
|
||||
// 插入中点把一段 cubic 拆为两段;101 个参数样本与原曲线距离小于 1e-8,验证形状而非仅端点。
|
||||
test('adding a curve point preserves both halves of the original cubic',()=>{
|
||||
const from={point:{x:0,y:0},normal:{x:1,y:0}},to={point:{x:500,y:200},normal:{x:-1,y:0}};
|
||||
// 在 t=.5 处分段;左/右段参数为 2t 与 2(t-.5),才能比较原曲线的相同位置。
|
||||
const old=curveSegments(from,to)[0], split=insertCurveKnot(from,to,undefined), next=curveSegments(from,to,split.path);
|
||||
assert.equal(next.length,2);for(let i=0;i<=100;i++){const t=i/100,a=cubicPoint(old,t),b=cubicPoint(next[t<=.5?0:1],t<=.5?t*2:(t-.5)*2);assert.ok(Math.hypot(a.x-b.x,a.y-b.y)<1e-8);}
|
||||
});
|
||||
@@ -74,6 +78,7 @@ test('connection plan uses the lowest common ancestor and exactly both ownership
|
||||
const {connectionTransactionPlan}=await import('../src/lib/canvas-subgraphs.ts');const {root,child}=fixture();
|
||||
const nested='33333333-3333-4333-8333-333333333333';const frame=makeCanvasNode('frame',{x:0,y:0});frame.data.subgraph={boardId:nested};child.nodes.push(frame);
|
||||
const docs=new Map([['root',root],[childId,child],[nested,{...emptyCanvas(),schema_version:2,nodes:[]}]]);
|
||||
// a/b 是规划用符号端点,没有对应节点;此例只测 owner 路径,不证明端点存在或连接可提交。
|
||||
const result=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'right'},{boardId:'root',id:'b',handle:'left'});
|
||||
assert.equal(result.boardId,'root');assert.deepEqual(new Set(result.participants),new Set(['root',childId,nested]));assert.equal(result.source.node_id,'a');
|
||||
const inner=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'bottom'},{boardId:childId,id:'b',handle:'top'});assert.equal(inner.boardId,childId);assert.deepEqual(new Set(inner.participants),new Set([childId,nested]));
|
||||
@@ -84,6 +89,7 @@ test('incompatible drafts cannot overwrite migrated V2 ownership',async()=>{
|
||||
const changed=structuredClone(root);changed.nodes[0].data.subgraph.boardId='55555555-5555-4555-8555-555555555555';assert.equal(canRestoreCanvasDraft(changed,root),false);assert.equal(canRestoreCanvasDraft(structuredClone(root),root),true);
|
||||
});
|
||||
// 同时拖选父 frame 与 child 的投影结果,只应用共同位移一次,反推 owner 局部坐标保持原值。
|
||||
// 直接提供移动表来验证坐标换算,没有模拟浏览器指针事件。
|
||||
test('selecting both parent frame and child applies a shared drag once',async()=>{
|
||||
const {referenceAfterMoves}=await import('../src/lib/canvas-tree.ts');const {root,child}=fixture();const docs=new Map([['root',root],[childId,child]]),view=projectCanvasTree('root',docs);
|
||||
const projected=view.nodes.find(node=>node.id===childId+'::inside');const delta={x:50,y:80};const moved={x:projected.position.x+delta.x,y:projected.position.y+delta.y};
|
||||
|
||||
@@ -18,6 +18,7 @@ function seeded() {
|
||||
test("node dragging updates its position; undo and redo retain the current viewport", () => {
|
||||
let state = seeded();
|
||||
const id = state.present.nodes[0].id;
|
||||
// 显式 checkpoint 模拟手势开始;位置通知本身不构成完整手势生命周期测试。
|
||||
state = canvasReducer(state, { type: "checkpoint" });
|
||||
state = canvasReducer(state, { type: "nodes", changes: [{ id, type: "position", position: { x: 220, y: -30 }, dragging: true }] });
|
||||
assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 });
|
||||
@@ -37,6 +38,7 @@ test("deleting a node removes attached edges and undo restores both", () => {
|
||||
assert.equal(removed.present.edges.length, 0);
|
||||
const afterEdgeNotification = canvasReducer(removed, { type: "edges", changes: [{ id: "edge", type: "remove" }] });
|
||||
assert.equal(afterEdgeNotification.past.length, 1);
|
||||
// 重复删除通知与节点删除属于同一结果,一次撤销应从原历史快照恢复二者。
|
||||
const restored = canvasReducer(afterEdgeNotification, { type: "undo" });
|
||||
assert.equal(restored.present.nodes.length, 2);
|
||||
assert.equal(restored.present.edges.length, 1);
|
||||
@@ -77,6 +79,7 @@ test("import rejects dangling connections, duplicate nodes, unsafe images and in
|
||||
(doc) => { doc.nodes.push(makeCanvasNode("image", { x: 0, y: 0 }, "javascript:alert(1)")); },
|
||||
(doc) => { doc.viewport.zoom = 0; },
|
||||
]) {
|
||||
// 每种破坏都使用新文档,避免上一种错误残留导致本轮因错误原因而通过。
|
||||
const document = seeded().present;
|
||||
mutate(document);
|
||||
assert.throws(() => decodeCanvas(JSON.stringify(document)));
|
||||
@@ -92,6 +95,7 @@ test("draft storage is isolated by workspace and graph", () => {
|
||||
// 50 次 replace 后 past 受限为 40;undo 产生 redo,新 add 必须清空 redo。
|
||||
test("undo history is bounded and new edits clear the redo stack", () => {
|
||||
let state = seeded();
|
||||
// replace 即使内容等价也在这条旧 reducer 路径记录历史;本例验证条目上限,不测字节容量。
|
||||
for (let i = 0; i < 50; i++) state = canvasReducer(state, { type: "replace", document: { ...state.present, edges: [] } });
|
||||
assert.equal(state.past.length, 40);
|
||||
state = canvasReducer(state, { type: "undo" });
|
||||
|
||||
Reference in New Issue
Block a user