/** * 复制规划的协作调度与检查点保存节奏测试。逐步扫描、分配及映射可以让出执行权,但最终计划仍需完全一致。 * 检查点保存比任务让出更稀疏,不能让慢 checkpoint I/O 本身强迫下一片立即再保存;取消或错误不发布半份计划。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,planDuplicate,planDuplicateSteps,applyChanges,validateDocument,validateDocumentSteps,validJson} from '../../src/lib/canvas-core/index.ts'; import {planDuplicateAsync,prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts'; // 使用内置复制定义,调度差异不会改变类型解释。 const registry=createRegistry(builtinComponents()); // 生成 frame/文本节点;完整数据较简单,测试聚焦大量扫描及映射步骤。 const node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'copy'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); // 上级 C 指向 frame 的入边,验证协作式规划仍遵守上级入边复制规则。 const edge={id:'edge',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}}; // 2000 个子节点加入口、上级节点和入边构成 2003 项源,用于推动跨多个调度切片。 const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('C'),...Array.from({length:2000},(_,i)=>node(`n${i}`,'S')),edge]); // 每轮重新生成确定性的 ID 分配器,使异步与同步计划能深比较,不受全局随机 ID 干扰。 const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:()=>`new-${++id}`};}; // 固定时钟仍按每片 64 步让出两百余次;完整结果与同步路径相同,含上级入边、冻结副本及不变源文档。 test('cooperative deep planning yields throughout scanning, allocation and remapping and matches the synchronous plan',async()=>{ let slices=0;const plan=await planDuplicateAsync(document,registry,selection,options(),{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{slices++;}}); assert.ok(slices>200);assert.deepEqual(plan,planDuplicate(document,registry,selection,options()));assert.equal(validJson(plan),true); // 规划只生成 changes;显式 applyChanges 后再验证文档,并检查复制入边仍从原上级 C 出发。 const after=applyChanges(document,plan.changes);validateDocument(after,registry); assert.equal(after.elements.get(plan.elementIds.edge).source.elementId,'C');assert.equal(after.elements.get(plan.elementIds.edge).target.elementId,plan.elementIds.F); assert.equal(Object.isFrozen(plan.changes[0].after.data),true);assert.equal(document.elements.size,2003); }); // 默认 task yield 允许真实零延时定时器取消大规划;取消发生在分配前,没有 ID 或内容被发布。 test('default task yielding lets actual timers cancel a large copy without publishing content',async()=>{ const controller=new AbortController();let ids=0,ran=false; const timer=setTimeout(()=>{ran=true;controller.abort();},0); try{await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`unused-${++ids}`},{signal:controller.signal,maxStepsPerSlice:16}),/copy_aborted/);assert.equal(ran,true);assert.equal(ids,0);assert.equal(document.elements.size,2003);}finally{clearTimeout(timer);} }); // 已分配部分 ID 后在 yield 中取消,明确拒绝而非返回部分计划,原文档没有任何 new-* 对象。 test('cancellation during allocation never returns a partial plan or changes the source',async()=>{ const controller=new AbortController();let allocated=0,slices=0; await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`new-${++allocated}`},{signal:controller.signal,now:()=>0,maxStepsPerSlice:32,yieldTask:async()=>{slices++;if(allocated>20)controller.abort();}}),/copy_aborted/); assert.ok(allocated>20&&allocated<2003);assert.ok(slices>1);assert.equal(document.elements.has('new-1'),false); }); // 规划开始后调用者修改 selection、worldOffset 和实时根,仍使用已捕获的源与选项,不能中途混入新状态。 test('planning captures caller inputs and retains the original document when the live root changes',async()=>{ const small=createDocument('project',[{id:'P',parent:null}],[node('A')]);let live=small,changed=false; const targets=[{id:'A',incarnation:1}],settings={...options(),worldOffset:{x:40,y:50}}; const result=await planDuplicateAsync(live,registry,targets,settings,{now:()=>0,maxStepsPerSlice:1,yieldTask:async()=>{if(changed)return;changed=true;targets[0].id='missing';settings.worldOffset.x=999;live=applyChanges(small,[{kind:'element',id:'A',before:small.elements.get('A'),after:null}]);}}); assert.equal(live.elements.size,0);assert.equal(result.changes[0].after.geometry.x,40);assert.equal(result.changes[0].after.geometry.y,50);assert.equal(small.elements.size,1); }); // 即使步数上限很高,时间预算也会让出;宿主调度错误、非法预算和预先取消均保留各自错误。 test('time budget forces a task yield even when the step limit is high and scheduler errors propagate',async()=>{ let tick=0,yields=0; await planDuplicateAsync(createDocument('p',[{id:'P',parent:null}],[node('A')]),registry,[{id:'A',incarnation:1}],options(),{now:()=>++tick,frameBudgetMs:1,maxStepsPerSlice:99999,yieldTask:async()=>{yields++;}});assert.ok(yields>10); await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{maxStepsPerSlice:1,yieldTask:async()=>{throw Error('host stopped');}}),/host stopped/); for(const value of [0,-1,NaN,Infinity])await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{frameBudgetMs:value}),/invalid_copy_budget/); const controller=new AbortController();controller.abort();await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{signal:controller.signal}),/copy_aborted/); }); // 协作式验证和规划不能过滤掉错误记录:多根文档和旧 incarnation 仍以与同步路径一致的错误拒绝。 test('cooperative validation and planning retain invalid-input errors rather than skipping failed records',async()=>{ const invalid={...document,canvases:new Map([...document.canvases,['bad',{id:'bad',parent:null}]])}; assert.throws(()=>validateDocument(invalid,registry),/invalid_roots/);assert.throws(()=>[...validateDocumentSteps(invalid,registry)],/invalid_roots/); await assert.rejects(planDuplicateAsync(invalid,registry,selection,options(),{maxStepsPerSlice:10,yieldTask:async()=>{}}),/invalid_roots/); assert.throws(()=>[...planDuplicateSteps(document,registry,[{id:'F',incarnation:9}],options())],/stale_incarnation/); }); // 源 ID 为 __proto__/constructor 时仍编码为 own JSON 字段,映射对象原型不受影响且序列化往返稳定。 test('mapping output preserves special source IDs as own JSON fields without prototype mutation',async()=>{ const source=createDocument('special',[{id:'P',parent:null}],[node('__proto__'),node('constructor')]); const plan=await planDuplicateAsync(source,registry,[{id:'__proto__',incarnation:1},{id:'constructor',incarnation:1}],options()); assert.equal(Object.getPrototypeOf(plan.elementIds),Object.prototype);assert.equal(Object.hasOwn(plan.elementIds,'__proto__'),true);assert.equal(validJson(plan),true);assert.deepEqual(JSON.parse(JSON.stringify(plan.elementIds)),{['__proto__']:'new-1',constructor:'new-2'}); }); // 对比每次保存与间隔保存:最终映射、所有 yield 和阶段覆盖一致,而全量检查点显著减少,最大步距仍受限。 test('stream checkpoint cadence reduces full snapshots while preserving every task yield, phase and final mapping',async()=>{ // now 固定为 0,所以 50ms 间隔不会自行到期;阶段切换和步数上限仍会保存,yield 次数必须相同。 const binding={sourceToken:'captured',schemaToken:'schema'},run=async interval=>{let yields=0;const checkpoints=[],state=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:interval,maxStepsPerCheckpoint:2048,yieldTask:async()=>{yields++;},checkpoint:async state=>{checkpoints.push(state);}});return{state,yields,checkpoints};},every=await run(0),paced=await run(50); assert.deepEqual(paced.state,every.state);assert.equal(paced.yields,every.yields);assert.ok(paced.checkpoints.lengthstate.phase));for(const state of every.checkpoints)assert.ok(phases.has(state.phase));assert.equal(paced.checkpoints.at(-1).phase,'emit');assert.ok(paced.checkpoints.every(Object.isFrozen));for(let i=1;i{ const binding={sourceToken:'captured',schemaToken:'schema'},controller=new AbortController(),allocation=options();let saved; await assert.rejects(prepareDuplicateStream(document,registry,selection,allocation,binding,{signal:controller.signal,now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:50,maxStepsPerCheckpoint:512,yieldTask:async()=>{},checkpoint:async state=>{if(state.phase==='allocate-elements'&&state.elementIds.length){saved=state;controller.abort();}}}),/copy_aborted/);assert.ok(saved.elementIds.length>0); const resumed=await prepareDuplicateStream(document,registry,selection,allocation,{...binding,checkpoint:saved},{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}}),complete=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}});assert.deepEqual(resumed,complete);assert.deepEqual(resumed.elementIds.slice(0,saved.elementIds.length),saved.elementIds); let time=0,yields=0;const saves=[];await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>time,maxStepsPerSlice:64,checkpointIntervalMs:10,yieldTask:async()=>{time++;yields++;},checkpoint:async state=>{const start=time;time+=1000;saves.push({phase:state.phase,start,end:time});}});assert.ok(saves.length=10); }); // 非法 checkpoint 间隔或步数上限在任何 ID 分配/保存回调前拒绝,不能先产生部分准备成果。 test('invalid stream checkpoint cadence rejects before allocation or publication',async()=>{ for(const setting of [{checkpointIntervalMs:-1},{checkpointIntervalMs:NaN},{checkpointIntervalMs:Infinity},{maxStepsPerCheckpoint:0},{maxStepsPerCheckpoint:1.5}]){let allocated=0,published=0;await assert.rejects(prepareDuplicateStream(document,registry,selection,{createId:()=>{allocated++;return'new';}},{sourceToken:'captured',schemaToken:'schema'},{...setting,checkpoint:async()=>{published++;}}),/invalid_copy_budget/);assert.equal(allocated,0);assert.equal(published,0);} });