Files
Endoleap/tests/headless/duplicate-scheduling.test.mjs

73 lines
8.9 KiB
JavaScript

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.length<every.checkpoints.length/4);const phases=new Set(paced.checkpoints.map(state=>state.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<paced.checkpoints.length;i++)assert.ok(paced.checkpoints[i].steps-paced.checkpoints[i-1].steps<=2048+64);assert.equal(document.elements.size,2003);
});
test('paced preparation resumes allocated IDs exactly and checkpoint I/O time does not force the next snapshot',async()=>{
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<yields/3);for(let i=1;i<saves.length;i++)if(saves[i].phase===saves[i-1].phase)assert.ok(saves[i].start-saves[i-1].end>=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);}
});