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

95 lines
12 KiB
JavaScript
Raw Permalink 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.
/**
* 核心深复制产品规则测试:父子选择去重、分区树与引用映射、上级入边重接到副本,后代世界偏移只应用一次。
* 图片引用复用已有资源,执行任务元数据按 schema 省略;普通字面量和未知可保留字段不能被当 ID 随意替换。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,defineComponent,planDuplicate,applyChanges,validateDocument,canvasTransform,toWorld} from '../../src/lib/canvas-core/index.ts';
// 第三代文本节点工厂,副本应分配全新 ID/第一代身份;字面量 A 用来检查是否误当引用重写。
const node=(id,owner='P',componentId='text')=>({id,incarnation:3,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal A',presentation:{contentScale:1},future:{keep:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
// 带端点控制量和结点的贝塞尔路径,跨缩放坐标系复制时需分别核对位置及局部控制量。
const path={type:'bezier-chain',startControl:{x:5,y:6},endControl:{x:-7,y:8},knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]};
// 第二代连线引用 source/target 元素及局部 portId;复制时元素引用重映射,portId 不改。
const edge=(id,source,target,owner='P',curve=null)=>({id,incarnation:2,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},path:curve,data:{title:id}});
// 两层 frame 的 contentScale 与图片任务状态样本,分别验证坐标和资源/执行字段策略。
const registry=createRegistry(builtinComponents()),F={...node('F','P','frame'),geometry:{...node().geometry,rotation:30},data:{...node().data,title:'F',presentation:{contentScale:2}}},G={...node('G','S','frame'),data:{...node().data,title:'G',presentation:{contentScale:.75}}},A={...node('A','S','image'),data:{title:'A',text:'A',assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task',progress:.5,runStatus:'running',custom:'A'}};
// 三层分区同时含入边、深层入边、内部边、跨子区边及出边,明确区分哪些默认跟随被复制子树。
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),F,A,G,node('B','T'),node('D','T'),edge('incoming','C','F','P',path),edge('deep','C','B'),edge('internal','B','D','T',path),edge('across','A','B','S'),edge('outgoing','F','C')]);
// 从原文档取 incarnation,防止测试用过期身份绕过核心校验。
const target=id=>({id,incarnation:document.elements.get(id).incarnation});
// 默认同时选择 F 与已包含的 A,使用确定 ID 和世界偏移;extra 可替换映射及分配器测试恢复一致性。
const fixture=(selection=['F','A'],extra={})=>{let next=0;return planDuplicate(document,registry,selection.map(target),{createId:()=>`copy-${++next}`,worldOffset:{x:32,y:48},...extra});};
// 以 1e-7 容差比较旋转/缩放后的浮点世界坐标。
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
// F/A 重复选择只复制一棵深树;内部引用和 canvas owner 映射,上级 C 保留并接入新分区,默认不复制出边,逐记录前缀都合法。
test('deep copy deduplicates ancestor selection, maps the entire tree and preserves upper incoming sources',()=>{
const before=JSON.stringify([...document.elements]),plan=fixture(),after=applyChanges(document,plan.changes);
assert.deepEqual(plan.roots,[plan.elementIds.F]);assert.equal(Object.keys(plan.canvasIds).length,2);assert.equal(Object.keys(plan.elementIds).length,9);
for(const id of ['F','A','G','B','D']){const copy=after.elements.get(plan.elementIds[id]);assert.notEqual(copy.id,id);assert.equal(copy.incarnation,1);assert.ok(document.elements.has(id));}
assert.equal(after.canvases.get(plan.canvasIds.S).parent.entryElementId,plan.elementIds.F);assert.equal(after.canvases.get(plan.canvasIds.T).parent.canvasId,plan.canvasIds.S);
const incoming=after.elements.get(plan.elementIds.incoming);assert.equal(incoming.source.elementId,'C');assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(incoming.ownerCanvasId,'P');assert.equal(incoming.source.portId,'right');
assert.equal(after.elements.get(plan.elementIds.internal).ownerCanvasId,plan.canvasIds.T);assert.equal(after.elements.get(plan.elementIds.across).ownerCanvasId,plan.canvasIds.S);assert.equal(after.elements.get(plan.elementIds.deep).target.elementId,plan.elementIds.B);assert.equal(plan.elementIds.outgoing,undefined);
assert.equal(JSON.stringify([...document.elements]),before);validateDocument(after,registry);
// Each record boundary is legal for later incremental commits.
let partial=document;for(const change of plan.changes){partial=applyChanges(partial,[change]);validateDocument(partial,registry);}
});
// 旋转/缩放嵌套下各节点和内部曲线只得到一次世界偏移,后代不重复平移;上级入边控制量按对应坐标规则处理。
test('geometry uses one world offset across rotated/scaled ownership and does not offset descendants twice',()=>{
const plan=fixture(),after=applyChanges(document,plan.changes);
for(const id of ['F','A','G','B','D']){
const a=document.elements.get(id),b=after.elements.get(plan.elementIds[id]),from=toWorld(canvasTransform(document,a.ownerCanvasId),a.geometry),to=toWorld(canvasTransform(after,b.ownerCanvasId),b.geometry);
near(to.x-from.x,32);near(to.y-from.y,48);assert.equal(b.geometry.width,a.geometry.width);assert.equal(b.geometry.rotation,a.geometry.rotation);
}
const original=toWorld(canvasTransform(document,'T'),path.knots[0].position),copied=toWorld(canvasTransform(after,plan.canvasIds.T),after.elements.get(plan.elementIds.internal).path.knots[0].position);near(copied.x-original.x,32);near(copied.y-original.y,48);
const incoming=after.elements.get(plan.elementIds.incoming).path;near(incoming.knots[0].position.x,path.knots[0].position.x+16);near(incoming.knots[0].position.y,path.knots[0].position.y+24);assert.deepEqual(incoming.startControl,path.startControl);
});
// 图片副本保留 assetId/src 和普通字面量,省略 runId/taskId/progress/runStatus,避免把已执行任务当新执行工作。
test('image copies share resources and omit execution metadata while preserving ordinary literal values',()=>{
const plan=fixture(),data=applyChanges(document,plan.changes).elements.get(plan.elementIds.A).data;
assert.equal(data.assetId,'shared');assert.equal(data.src,'asset:shared');assert.equal(data.custom,'A');for(const key of ['runId','taskId','progress','runStatus'])assert.equal(Object.hasOwn(data,key),false);
});
// 完整稳定映射生成相同计划且不调用 createId;碰撞 ID、多余映射范围及过期源 incarnation 都拒绝。
test('a supplied stable mapping reproduces the same plan without allocating another identity',()=>{
const first=fixture();const second=fixture(['F','A'],{mapping:{elements:first.elementIds,canvases:first.canvasIds},createId:()=>{throw Error('new identity allocated');}});assert.deepEqual(second,first);
assert.throws(()=>fixture(['F'],{createId:()=> 'C'}),/duplicate_identity/);
assert.throws(()=>fixture(['F'],{mapping:{elements:{...first.elementIds,other:'unused'},canvases:first.canvasIds}}),/copy_mapping_scope_mismatch/);
assert.throws(()=>planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=> 'new'}),/stale_incarnation/);
});
// 同时选择上级 C 和入边时,C 也生成副本,入边指向新 C/新 F,且只创建一次连线。
test('selecting the upper source too maps one new source and does not duplicate the incoming edge',()=>{
const plan=fixture(['F','A','C','incoming']),after=applyChanges(document,plan.changes),incoming=after.elements.get(plan.elementIds.incoming);
assert.equal(incoming.source.elementId,plan.elementIds.C);assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(plan.changes.filter(change=>change.kind==='element'&&change.after?.id===incoming.id).length,1);
});
// 自定义声明的列表元素引用和 canvas 引用映射到新 ID;execution 按 omit 删除,literal 不变,未选中的 reject 引用报错。
test('declared nested/list references are remapped, resources remain shared and only declared transient fields disappear',()=>{
const definition=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['links'],target:'element',many:true,external:'reject'},{path:['view'],target:'canvas',external:'reject'}],omit:[['execution']]}}),r=createRegistry([...builtinComponents(),definition]),linked={...node('L'),componentId:'linked',data:{title:'L',text:'A',links:['A','B'],view:'S',execution:{taskId:'task'},literal:'A'}},source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),linked]);let next=0;
const plan=planDuplicate(source,r,[target('F'),{id:'L',incarnation:3}],{createId:()=>`ref-${++next}`}),copy=applyChanges(source,plan.changes).elements.get(plan.elementIds.L);
assert.deepEqual(copy.data.links,[plan.elementIds.A,plan.elementIds.B]);assert.equal(copy.data.view,plan.canvasIds.S);assert.equal(copy.data.literal,'A');assert.equal(copy.data.execution,undefined);
assert.throws(()=>planDuplicate(source,r,[{id:'L',incarnation:3}],{createId:()=>`external-${++next}`}),/copy_external_reference/);
});
// 缺复制 schema、未来数据版本、重叠/危险路径及未知曲线都明确拒绝,不能静默省略或猜测转换。
test('missing copy schema, unknown versions, ambiguous paths and opaque curves are explicit failures',()=>{
const known=defineComponent({...builtinComponents()[0],id:'opaque',copy:undefined}),r=createRegistry([...builtinComponents(),known]),source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),{...node('U'),componentId:'opaque'}]);
assert.throws(()=>planDuplicate(source,r,[{id:'U',incarnation:3}],{createId:()=> 'new'}),/copy_schema_unavailable/);
assert.throws(()=>defineComponent({...known,copy:{references:[{path:['nested'],target:'element',external:'preserve'}],omit:[['nested','id']]}}),/conflicting_copy_paths/);
assert.throws(()=>defineComponent({...known,copy:{references:[],omit:[['__proto__']]}}),/invalid_copy_schema/);
const future=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='A'?{...element,dataVersion:9}:element));assert.throws(()=>planDuplicate(future,registry,[target('F')],{createId:(()=>{let n=0;return()=>`future-${++n}`;})()}),/copy_schema_unavailable/);
const invalid=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='incoming'?{...element,path:{type:'future'}}:element));assert.throws(()=>planDuplicate(invalid,registry,[target('F')],{createId:(()=>{let n=0;return()=>`unknown-${++n}`;})()}),/unsupported_copy_path/);
});
// frame.ports 对象数组只映射 nodeId,局部端口 ID/handle 及原数据不变;错误数组形状和重叠通配路径都拒绝。
test('frame port object arrays map child identities but retain local port IDs, handles and the source data',()=>{
const ports=[{id:'in',direction:'input',nodeId:'A',handle:'left'},{id:'out',direction:'output',nodeId:'B',handle:'port:nested'}],source=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='F'?{...record,data:{...record.data,ports}}:record));let next=0;
const plan=planDuplicate(source,registry,[target('F')],{createId:()=>`port-${++next}`}),data=applyChanges(source,plan.changes).elements.get(plan.elementIds.F).data;
assert.deepEqual(data.ports,ports.map(port=>({...port,nodeId:plan.elementIds[port.nodeId]})));assert.deepEqual(source.elements.get('F').data.ports,ports);
const bad=createDocument('P',[...source.canvases.values()],[...source.elements.values()].map(record=>record.id==='F'?{...record,data:{...record.data,ports:{bad:'A'}}}:record));assert.throws(()=>planDuplicate(bad,registry,[target('F')],{createId:()=>`bad-${++next}`}),/invalid_copy_reference/);
assert.throws(()=>defineComponent({...builtinComponents()[0],copy:{references:[{path:['rows','*','id'],target:'element',external:'reject'},{path:['rows','0','id'],target:'element',external:'reject'}],omit:[]}}),/conflicting_copy_paths/);
});