51 lines
7.4 KiB
JavaScript
51 lines
7.4 KiB
JavaScript
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';
|
|
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}}]}});
|
|
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')]);
|
|
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');};
|
|
|
|
test('each durable planning phase resumes with the same deep-copy plan and no repeated identity allocation',()=>{
|
|
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;}
|
|
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'));
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
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/);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
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{
|
|
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;}
|
|
});
|