/** * 持久复制源的引用依赖与批次选择测试。映射必须和声明的引用路径使用同一遍历规则,不能仍指向原来的子对象。 * 外部保留引用还需校验目标生命周期;循环依赖要作为完整强连通组加入,不能靠拆边绕过依赖或容量。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,planDuplicate,defineComponent} from '../../src/lib/canvas-core/index.ts'; import {encodeDuplicateSource,prepareDuplicateImport,assertDuplicateExternal} from '../../src/lib/canvas-runtime/import-duplicate.ts'; import {selectImportGroups} from '../../src/lib/canvas-runtime/import-groups.ts'; // 可指定类型/归属/数据的第一代节点样本,用于 frame 端口及自定义对象数组引用。 const node=(id,owner='P',componentId='text',data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'',...data},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); // 使用内置复制 schema,frame.ports 中的 nodeId 被识别为元素引用,端口局部 ID 仍是普通数据。 const registry=createRegistry(builtinComponents()); // 复制 frame 的持久源须声明子节点依赖并使用新 nodeId;篡改为旧引用或删掉依赖都在导入准备时拒绝。 test('durable copy source includes mapped port dependencies and refuses an old source still targeting original children',async()=>{ const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame',{ports:[{nodeId:'A',id:'in',direction:'input',handle:'left'}]}),node('A','S')]);let next=0; const plan=planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=>`new-${++next}`}),source=await encodeDuplicateSource(document,registry,plan,new AbortController().signal),rows=(await source.text()).trim().split('\n').map(row=>JSON.parse(row)),value=rows.find(row=>row.key==='element:F'); assert.ok(value.dependencies.includes('element:A'));assert.equal(value.change.after.data.ports[0].nodeId,plan.elementIds.A); // 稳定映射表与源行依赖分别校验:一个决定新身份,另一个决定这些身份何时可安全加入。 const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([id,mapped])=>[`element:${id}`,mapped]),...Object.entries(plan.canvasIds).map(([id,mapped])=>[`canvas:${id}`,mapped])]),job={source:{kind:'duplicate-ndjson'},stableIds}; const line={value};assert.equal(prepareDuplicateImport(line,job,key=>stableIds[key],registry).key,'element:F'); // 两次篡改各从合法行复制:stale 回指原节点,missing 保持新引用但删依赖,分别隔离两种拒绝原因。 const stale=structuredClone(value);stale.change.after.data.ports[0].nodeId='A';stale.dependencies=stale.dependencies.filter(key=>key!=='element:A');assert.throws(()=>prepareDuplicateImport({value:stale},job,key=>stableIds[key],registry),/copy_external_reference/); const missing=structuredClone(value);missing.dependencies=missing.dependencies.filter(key=>key!=='element:A');assert.throws(()=>prepareDuplicateImport({value:missing},job,key=>stableIds[key],registry),/invalid_duplicate_source/); }); // 自定义 rows.*.node 外部引用保持原目标并记录 incarnation;目标后来换代,晚期 guard 必须拒绝旧引用。 test('object-array external references share the same traversal for mapping and late lifecycle guards',async()=>{ const linked=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['rows','*','node'],target:'element',external:'preserve'}],omit:[]}}),r=createRegistry([...builtinComponents(),linked]),document=createDocument('P',[{id:'P',parent:null}],[node('C'),node('L','P','linked',{rows:[{node:'C',literal:'C'}]})]); const plan=planDuplicate(document,r,[{id:'L',incarnation:1}],{createId:()=> 'new-L'}),rows=(await(await encodeDuplicateSource(document,r,plan,new AbortController().signal)).text()).trim().split('\n').map(row=>JSON.parse(row)),job={source:{kind:'duplicate-ndjson'},stableIds:{'element:L':'new-L'}}; assert.equal(rows[0].externalElements.find(row=>row.id==='C').incarnation,1);assertDuplicateExternal({value:rows[0]},job,document,r); const changed=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='C'?{...row,incarnation:2}:row));assert.throws(()=>assertDuplicateExternal({value:rows[0]},job,changed,r),/copy_external_changed/); }); // 构造待加入记录的位置描述:依赖键、编码字节数及变化数供 SCC 分组和硬容量检查。 const locator=(key,dependencies,byteLength=100)=>({key,dependencies,byteLength,changeCount:1}); // A/B 循环还依赖 outside:外部未完成时不选,已完成时整组可超过软 units=1,但字节或 changes 硬上限不足应抛错。 test('SCC selection waits for missing external dependencies and enforces hard group capacity',()=>{ const pending=[locator('A',['B']),locator('B',['A','outside'])];assert.deepEqual(selectImportGroups(pending,new Set(),{units:1,bytes:1000,changes:100}),[]); assert.deepEqual(selectImportGroups(pending,new Set(['outside']),{units:1,bytes:1000,changes:100}).flat().map(row=>row.key),['A','B']); assert.throws(()=>selectImportGroups(pending,new Set(['outside']),{units:32,bytes:199,changes:100}),/import_dependency_group_capacity/);assert.throws(()=>selectImportGroups(pending,new Set(['outside']),{units:32,bytes:1000,changes:1}),/import_dependency_group_capacity/); });