/** * 可恢复深复制规划器的语义检查点测试。检查点保存遍历游标、阶段及稳定映射,不保存完整节点正文。 * 任一阶段序列化/恢复后结果需与同步 planDuplicate 一致,已分配身份不能再分配,旧检查点也不能随后续 step 改变。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts'; import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts'; import {planDuplicateResumable} from '../../src/lib/canvas-runtime/duplicate.ts'; // 节点及曲线工厂保留独立身份、owner 和端口,文本标记用于检查检查点没有复制完整正文。 const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:2,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal-data-not-in-checkpoint'},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]}}); // 三层分区含内部/跨分区边与上级入边,触发 DFS、分配、映射、节点/边输出等全部阶段。 const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),{...node('F','P','frame'),geometry:{...node('F').geometry,rotation:30},data:{title:'F',text:'',presentation:{contentScale:2},ports:[{id:'in',nodeId:'A',direction:'input',handle:'left'}]}},{...node('A','S','image'),data:{title:'A',text:'',assetId:'shared',runId:'not-copied'}},{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75}}},node('B','T'),node('D','T'),edge('incoming','C','F'),edge('deep','C','B'),edge('internal','B','D','T'),edge('across','A','B','S'),edge('outgoing','F','C')]); // 绑定来源/schema token;targets 取真实身份,drain 有界步进到 result,未结束则报错而不返回部分计划。 const binding={sourceToken:'source-sha',schemaToken:'schema-v1'},targets=(ids=['F','A'])=>ids.map(id=>({id,incarnation:document.elements.get(id).incarnation})),drain=planner=>{for(let i=0;i<100000;i++)if(planner.step())return planner.result();throw Error('planner did not terminate');}; // 逐阶段捕获并 JSON 往返检查点,以新文档对象恢复后对照完整同步计划;分配计数一致,原检查点不变且不含标记正文。 test('each durable planning phase resumes with the same deep-copy plan and no repeated identity allocation',()=>{ // 遍历每个可持久化阶段;emit 与 emit-edges 还要求已输出至少一项,避免只覆盖空阶段入口。 const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done']; for(const phase of phases){let next=0,planner=createDuplicatePlanner(document,registry,targets(),{createId:()=>`new-${++next}`},binding),checkpoint; for(let i=0;i<10000;i++){const done=planner.step(),state=planner.checkpoint();if(state.phase===phase&&(phase!=='emit'||state.emitted>0)&&(phase!=='emit-edges'||state.emittedEdges>0)){checkpoint=state;break;}if(done)break;} // 从 JSON 重建检查点及新的来源包络,沿用分配计数器;重复分配会让最终映射或总计数偏离同步对照。 assert.ok(checkpoint,`missing ${phase} checkpoint`);validateDuplicateCheckpoint(JSON.parse(JSON.stringify(checkpoint)));const before=JSON.stringify(checkpoint),allocated=next,source=createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()]),resumed=createDuplicatePlanner(source,registry,targets(),{createId:()=>`new-${++next}`},{...binding,checkpoint:JSON.parse(before)}),actual=drain(resumed);let reference=0;const expected=planDuplicate(document,registry,targets(),{createId:()=>`new-${++reference}`});assert.deepEqual(actual,expected,phase);assert.equal(next,reference,`identity replay in ${phase}`);assert.equal(JSON.stringify(checkpoint),before);assert.ok(next>=allocated);assert.ok(!before.includes('literal-data-not-in-checkpoint')); } }); // 在嵌套 DFS 展开中停下,恢复后保留待访问兄弟项及显式选中边;给定完整映射时任何新 ID 分配都会使测试失败。 test('nested DFS expansion checkpoints preserve pending siblings, selected edges and explicit stable mappings',()=>{ for(const selection of [['F','C','incoming'],['A','internal'],['incoming']]){let reference=0;const selected=targets(selection),expected=planDuplicate(document,registry,selected,{createId:()=>`copy-${++reference}`}),mapping={elements:expected.elementIds,canvases:expected.canvasIds};let planner=createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('allocated despite complete mapping');}},binding),state; for(let i=0;i<10000;i++){const done=planner.step();state=planner.checkpoint();if(state.expanding?.index>=0||done)break;} assert.deepEqual(drain(createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('reallocated');}},{...binding,checkpoint:state})),expected); } }); // 来源/schema 绑定不同、未来版本/阶段、负游标、非法映射或缺失工作项都拒绝;未完成时 result 也不可用。 test('checkpoint bindings, shape and source identities reject incompatible state before producing a plan',()=>{ const planner=createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},binding),state=planner.checkpoint();for(const patch of [{sourceToken:'different'},{schemaToken:'different'}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,...patch,checkpoint:state}),/binding_mismatch/); for(const patch of [{version:2},{phase:'future'},{cursor:-2},{elementIds:[['F','C']]},{work:[{kind:'element',id:'missing'}]}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,checkpoint:{...state,...patch}}));assert.throws(()=>planner.result(),/copy_plan_not_ready/); }); // 每片一步并持续保存,在已有至少两个元素 ID 后取消;用保存状态恢复,最终计划及 ID 分配总量仍与同步路径一致。 test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{ const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old); }); // 首次大检查点按预算协作式编码,并发推进 planner 不影响旧检查点/image;字符串编码分片且 UTF-8 计数准确,finally 恢复全局探针。 test('a first planning checkpoint encodes cooperatively and is isolated from later planner steps',async()=>{ const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.ts'); const rows=Array.from({length:1000},(_,i)=>({...document.elements.get('A'),id:'checkpoint-'+i,ownerCanvasId:'root'})); const source=createDocument('large',[{id:'root',parent:null}],rows),planner=createDuplicatePlanner(source,registry,rows.map(row=>({id:row.id,incarnation:row.incarnation})),{createId:()=>{throw Error('not allocating yet');}},binding); // 捕获后先保留原 JSON 字节,再探测封存期间的字符串编码次数;探针只改变计数,仍委托原 stringify。 const checkpoint=planner.checkpoint(),before=JSON.stringify(checkpoint),stringify=JSON.stringify;let encoded=0,atFirst,tick=0,yields=0; assert.equal(hasDetachedJsonData(checkpoint),true); JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('checkpoint-'))encoded++;return Reflect.apply(stringify,this,[value,...args]);}; try{ // 封存检查点尚未完成时继续推进 planner,模拟存储异步执行和后续准备并行;已捕获检查点必须保持旧值。 const pending=sealRecoveryImageAsync(17,1,1,checkpoint,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirst??=encoded;yields++;}}); for(let i=0;i<20000&&planner.progress().steps<=10;i++)planner.step(); assert.ok(planner.progress().steps>checkpoint.steps); const image=await pending;assert.ok(yields>0);assert.ok(atFirst<128,'checkpoint strings were normalized before the first yield'); assert.equal(stringify(image.payload),before);assert.equal(stringify(checkpoint),before); assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength); }finally{JSON.stringify=stringify;} });