Files
Endoleap/tests/headless/duplicate-checkpoint.test.mjs
T

67 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 可恢复深复制规划器的语义检查点测试。检查点保存遍历游标、阶段及稳定映射,不保存完整节点正文。
* 任一阶段序列化/恢复后结果需与同步 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;}
});