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

75 lines
10 KiB
JavaScript

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';
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}}]};
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}});
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')]);
const target=id=>({id,incarnation:document.elements.get(id).incarnation});
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});};
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
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);
});
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);
});
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/);
});
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);
});
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/);
});
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/);
});
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/);
});