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()); 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}}); const edge={id:'edge',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}}; 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]); const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:()=>`new-${++id}`};}; 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); 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); }); 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);} }); 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); }); 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/); }); 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/); }); 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'}); }); test('stream checkpoint cadence reduces full snapshots while preserving every task yield, phase and final mapping',async()=>{ 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); }); 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);} });