97 lines
13 KiB
JavaScript
97 lines
13 KiB
JavaScript
/**
|
||
* 核心深复制产品规则测试:父子选择去重、分区树与引用映射、上级入边重接到副本,后代世界偏移只应用一次。
|
||
* 图片引用复用已有资源,执行任务元数据按 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);
|
||
// 按单条 change 依次应用并校验,证明这份计划的输出顺序允许逐项加入;不是对所有自定义循环引用的通用保证。
|
||
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);
|
||
// 入边一端留在 C、另一端跟随副本,结点取两端位移的中间量;起点控制向量不随整体位置平移。
|
||
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/);
|
||
});
|