/** * 分区解散和重新挂接测试。解散只移除 owning canvas,将其直接成员提升到父 canvas,保留入口元素及其连线。 * 几何换算必须保留世界姿态,子分区只改父绑定;声明的 canvas 引用跟随重映射,普通字符串不自动替换。 * 最后一例用真实 Foundry 协议适配器和内存服务检查单请求、丢 ACK 恢复及跨重开的 undo/redo。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createEditor,createRegistry,builtinComponents,defineComponent,movable,editable,validateDocument,applyChanges,canvasTransform,toWorld,unitScale} from '../../src/lib/canvas-core/index.ts'; import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts'; import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts'; import {loadingCommand} from '../../src/lib/canvas-runtime/loading.ts'; // 统一节点身份、owner 和几何;frame 复用该外壳,单独补充内容缩放及单位缩放。 const node=(id,owner='P',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:120,height:80,rotation:5},data:{title:id,text:'kept'}}); // 已知 bezier-chain 路径包含锚点和手柄,解散时可以换算;未知路径类型则应原子拒绝。 const curve={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:60},in:{x:-5,y:0},out:{x:5,y:0}}]}; // 连线用端点引用和 owner 表达图关系,data.label 供检查结构修改不会清空内容。 const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,source:{elementId:source},target:{elementId:target},path,data:{label:'keep'}}); // 自定义 references 类型只声明 views[*].canvas 为引用,literal 即使同值也不是引用;保留未知扩展字段。 const registry=createRegistry([...builtinComponents(),defineComponent({id:'references',dataVersion:1,structure:'node',validate:()=>true,features:[movable(),editable()],copy:{references:[{path:['views','*','canvas'],target:'canvas',external:'preserve'}],omit:[]}})]); // 三层 P→S→T,包含跨层连线、入口连线和显式引用;父 frame 带旋转与缩放以暴露坐标换算错误。 const source=()=>createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G',future:'retain'}}],[ {...node('F','P','frame'),geometry:{x:100,y:70,width:500,height:400,rotation:33},data:{title:'F',text:'',presentation:{contentScale:2,unitScale:1.2},ports:[{id:'in',nodeId:'A'}]}}, {...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75,unitScale:.8}}},node('A','S'),node('B','T'),node('C'), {...node('R','P','references'),data:{views:[{canvas:'S',future:true}],literal:'S'}},edge('incoming','C','F'),edge('cross','C','B'),edge('inside','A','G','S',curve), ]); // 目标 S 与其入口 F 的第一代身份必须共同匹配,不能仅凭 canvasId 解散任意分区。 const command={type:'canvas.dissolve',canvasId:'S',entry:{id:'F',incarnation:1}}; // 浮点姿态用 1e-7 容差比较,避免旋转缩放的舍入误差干扰结构语义检查。 const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`); // 把元素中心、大小、角度和单位比例换算到世界坐标,比较解散前后用户实际看到的姿态。 function pose(document,id){const element=document.elements.get(id),t=canvasTransform(document,element.ownerCanvasId),g=element.geometry;return{...toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width:g.width*t.scale,height:g.height*t.scale,rotation:(g.rotation+t.rotation)%360,units:unitScale(element.data.presentation)*t.scale};} // 集中验证提升结果:S 消失但入口、元素数和身份保留,直接成员迁移,孙层成员与父扩展信息保留。 function checkPromoted(before,after){ validateDocument(after,registry);assert.equal(after.canvases.has('S'),false);assert.deepEqual(after.elements.get('F'),before.elements.get('F'));assert.deepEqual(after.elements.get('incoming'),before.elements.get('incoming')); assert.equal(after.elements.size,before.elements.size);assert.equal(after.elements.get('A').ownerCanvasId,'P');assert.equal(after.elements.get('G').ownerCanvasId,'P');assert.equal(after.elements.get('B').ownerCanvasId,'T');assert.deepEqual(after.elements.get('B'),before.elements.get('B'));assert.deepEqual(after.canvases.get('T').parent,{...before.canvases.get('T').parent,canvasId:'P'}); // 分别比较直接节点、子分区入口和孙层节点的世界姿态,防止只验证 owner 而漏掉嵌套坐标变化。 for(const id of['A','G','B']){const a=pose(before,id),b=pose(after,id);for(const key of Object.keys(a))near(a[key],b[key]);assert.equal(after.elements.get(id).incarnation,before.elements.get(id).incarnation);} // 内部连线提升到父 canvas 后,路径锚点需换算到同一世界位置;原跨层连线的 owner 本来就是 P。 assert.equal(after.elements.get('inside').ownerCanvasId,'P');const a=toWorld(canvasTransform(before,'S'),curve.knots[0].position),b=toWorld(canvasTransform(after,'P'),after.elements.get('inside').path.knots[0].position);near(a.x,b.x);near(a.y,b.y); // 仅重写注册表声明的 views.canvas;literal 字符串和 future 扩展值原样保留。 assert.equal(after.elements.get('cross').ownerCanvasId,'P');assert.deepEqual(after.elements.get('R').data,{views:[{canvas:'P',future:true}],literal:'S'}); } // can 只查询能力且不发布;dispatch 一次通知、一个操作和一个 undo 单元,undo 精确还原,redo 再次得到相同世界姿态。 test('dissolve promotes direct members once, preserves world poses, nested ownership, entry lines and declared references',()=>{ const document=source(),editor=createEditor({document,registry}),before=editor.getSnapshot();let notifications=0;editor.subscribe(()=>notifications++); assert.equal(editor.can(command).enabled,true);assert.strictEqual(editor.getSnapshot(),before); const result=editor.dispatch(command,{label:'Dissolve partition'});assert.equal(result.status,'applied');assert.equal(result.operationIds.length,1);assert.equal(notifications,1);assert.equal(editor.getSnapshot().history.undo.length,1);checkPromoted(document,editor.getSnapshot().document); assert.equal(editor.history.undo().status,'applied');assert.deepEqual(new Map(editor.getSnapshot().document.canvases),new Map(document.canvases));assert.deepEqual(new Map(editor.getSnapshot().document.elements),new Map(document.elements)); assert.equal(editor.history.redo().status,'applied');checkPromoted(document,editor.getSnapshot().document);assert.equal(editor.getSnapshot().history.undo.length,1); }); // 根 canvas、旧入口身份、错误入口、成员/引用策略拒绝、未知元素和未知路径都不能留下半次提升;Root、query 和订阅次数保持不变。 test('dissolve rejects root, stale entry, member policy, unsupported member and opaque curve without partial publication',()=>{ const document=source();for(const options of[ {command:{...command,canvasId:'P'}}, {command:{...command,entry:{id:'F',incarnation:9}}}, {command:{...command,entry:{id:'C',incarnation:1}}}, {policy:command=>command.type==='element.move'&&command.target.id==='A'?{enabled:false,code:'member_locked'}:{enabled:true}}, {policy:command=>command.type==='element.data'&&command.target.id==='R'?{enabled:false,code:'reference_locked'}:{enabled:true}}, {document:createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='A'?{...element,componentId:'future'}:element))}, {document:createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='inside'?{...element,path:{type:'future',preserve:true}}:element))}, ]){ const editor=createEditor({document:options.document??document,registry,policy:options.policy}),before=editor.getSnapshot(),queries=editor.getQueries();let notifications=0;editor.subscribe(()=>notifications++); assert.equal(editor.dispatch(options.command??command).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.strictEqual(editor.getQueries(),queries);assert.equal(notifications,0); } }); // 空 canvas 解散只移除绑定,入口元素不删除、不分配替代元素、不产生元素墓碑;undo 恢复原 canvas 集合。 test('an empty owning canvas dissolves without deleting its entry or allocating replacement elements',()=>{ const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame')]),editor=createEditor({document,registry}); assert.equal(editor.dispatch(command).status,'applied');assert.deepEqual([...editor.getSnapshot().document.elements],[...document.elements]);assert.equal(editor.getSnapshot().document.tombstones.size,0);assert.equal(editor.history.undo().status,'applied');assert.deepEqual([...editor.getSnapshot().document.canvases],[...document.canvases]); }); // reparent 只改 canvas 父入口绑定,不递归改子孙元素 owner;制造父子循环时整体拒绝。 test('reparent changes the canvas binding without recursively rewriting descendant owners and cycles remain atomic failures',()=>{ const original=source(),editor=createEditor({document:original,registry});assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'S',parent:{canvasId:'P',entryElementId:'C'}}).status,'applied'); const document=editor.getSnapshot().document;assert.deepEqual(document.canvases.get('S').parent,{canvasId:'P',entryElementId:'C'});for(const id of['A','G','B'])assert.equal(document.elements.get(id).ownerCanvasId,original.elements.get(id).ownerCanvasId);assert.equal(document.canvases.get('T').parent.canvasId,'S');validateDocument(document,registry); const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'P',parent:{canvasId:'T',entryElementId:'B'}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before); }); // 渐进加载时,即使直接成员已到齐也不能忽略尚未加载的引用元素;完整捕获源可解散,旧 v3 加载描述不足以放行。 test('dissolve waits for the complete captured source instead of promoting a partially loaded membership',()=>{ const full=source(),prefix=createDocument('P',[...full.canvases.values()],[...full.elements.values()].filter(element=>element.id!=='R')),versions=new Map([...full.canvases.keys()].map(id=>[id,1])),state={version:5,sourceToken:'dissolve-source',declaredCanvasIds:[...full.canvases.keys()],sourceCanvases:[...full.canvases.values()],elementOwners:Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId])),canvasElementCounts:Object.fromEntries([...full.canvases.keys()].map(id=>[id,[...full.elements.values()].filter(element=>element.ownerCanvasId===id).length])),receivedElements:prefix.elements.size,totalElements:full.elements.size}; assert.equal(loadingCommand(command,prefix,registry,state,prefix,{document:prefix,versions}).enabled,false); assert.equal(loadingCommand(command,full,registry,{...state,receivedElements:full.elements.size},full,{document:full,versions}).enabled,true); assert.equal(loadingCommand(command,full,registry,{...state,version:3},full,{document:full,versions}).enabled,false); }); // 符号 ID 转 UUID 后走真实编译适配器;一次请求覆盖 P/S/T,丢 ACK 只查询原请求,重开保留 undo/redo,最终共三次文档写入。 test('dissolve uses one atomic protocol request, recovers a lost ACK by its original identity, then undoes and redoes after reopen',async()=>{ const symbolic=source(),ids=Object.fromEntries([...new Set([...symbolic.canvases.keys(),...symbolic.elements.keys()])].map((id,index)=>[id,`10000000-0000-4000-8000-${String(index+1).padStart(12,'0')}`])),inverse=Object.fromEntries(Object.entries(ids).map(([name,id])=>[id,name])); // 为协议测试双向重命名所有结构 ID:canvas、入口、owner、端点、端口、声明引用及墓碑;普通内容字符串不替换。 const rename=(document,ids)=>{const map=id=>ids[id]??id;return createDocument(map(document.projectId),[...document.canvases.values()].map(canvas=>({...canvas,id:map(canvas.id),parent:canvas.parent?{...canvas.parent,canvasId:map(canvas.parent.canvasId),entryElementId:map(canvas.parent.entryElementId)}:null})),[...document.elements.values()].map(element=>({...element,id:map(element.id),ownerCanvasId:map(element.ownerCanvasId),...(element.structure==='connection'?{source:{...element.source,elementId:map(element.source.elementId)},target:{...element.target,elementId:map(element.target.elementId)}}:{}),data:{...element.data,...(element.data.ports?{ports:element.data.ports.map(port=>({...port,nodeId:map(port.nodeId)}))}:{}),...(element.data.views?{views:element.data.views.map(view=>({...view,canvas:map(view.canvas)}))}:{})}})),new Map([...document.tombstones].map(([id,incarnation])=>[map(id),incarnation])));}; const original=rename(symbolic,ids),workspace='20000000-0000-4000-8000-000000000001';let server=original,image,owner,now=1000,lose=true;const versions=new Map([...original.canvases.keys()].map(id=>[id,1])),receipts=new Map(),calls=[]; // 为参与 canvas 生成局部回执,移除的 S 仍列入 removedCanvasIds 和版本向量。 const model=(participants,complete=false)=>({documentFormatVersion:1,projectId:original.projectId,complete,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))}); // Foundry 编译器负责实际请求格式;内存 transport 检查每个参与版本,并在执行后故意丢一次响应。 const persistence=createFoundryPersistence({registry,workspaceId:workspace,rootId:ids.P,transport:async input=>{ calls.push(input);if(input.method==='GET')return{status:200,body:receipts.get(input.path.split('/').at(-1))}; const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],versions.get(id)??0); server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);for(const id of participants)versions.set(id,(versions.get(id)??0)+1); const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(participants)};receipts.set(input.requestId,receipt); if(lose){lose=false;throw Error('lost dissolve ACK');}return{status:200,body:receipt}; }}); // 保存完整恢复镜像和独占 owner;引擎重开时仍能取到原冻结请求与历史。 const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}}; // 假时钟不自动执行定时器,重开前主动把 now 前移以触发未知请求的恢复查询。 const options={scope:{accountId:'account',workspaceId:workspace,projectId:ids.P},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage,persistence,clock:{now:()=>now,setTimer:()=>0,clearTimer(){}}};let engine=await openProjectEngine(options); try{ assert.equal(engine.editor.dispatch({...command,canvasId:ids.S,entry:{id:ids.F,incarnation:1}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'unresolved');assert.deepEqual([...request.canvasIds].map(id=>inverse[id]).sort(),['P','S','T']);assert.equal(calls.filter(call=>call.method==='POST').length,1);checkPromoted(symbolic,rename(server,inverse)); await engine.close();now=10000;engine=await openProjectEngine(options);await engine.settle();assert.equal(calls.filter(call=>call.method==='POST').length,1);const query=calls.find(call=>call.method==='GET');assert.ok(query.path.endsWith('/'+request.id));assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved'); assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(new Map(server.canvases),new Map(original.canvases));assert.deepEqual(new Map(server.elements),new Map(original.elements));await engine.close();engine=await openProjectEngine(options);assert.equal(engine.getSnapshot().history.redo.length,1); assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();checkPromoted(symbolic,rename(server,inverse));assert.equal(calls.filter(call=>call.method==='POST').length,3); }finally{await engine.close();} });