Files
Endoleap/tests/canvas-v2.test.mjs

76 lines
8.0 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { decodeCanvas, emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts';
import { curveSegments, cubicPoint, insertCurveKnot } from '../src/lib/canvas-curves.ts';
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData } from '../src/lib/canvas-tree.ts';
import { resizeFromPointer } from '../src/lib/canvas-geometry.ts';
import { managedImageSource } from '../src/lib/canvas-v2.ts';
const childId = '11111111-1111-4111-8111-111111111111';
function fixture() {
const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000;
frame.data={...frame.data,subgraph:{boardId:childId},presentation:{collapsed:false,contentScale:.2,expandedSize:{width:5000,height:400},collapsedSize:{width:240,height:140}},ports:[{id:'output',direction:'output',nodeId:'inside',handle:'right'}]};
const text=makeCanvasNode('text',{x:900,y:100});text.id='outside';text.data.ai={enabled:true,modelId:'dynamic-model',prompt:'hello',propagate:false,parameters:{}};
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='inside';
const edge={id:'edge',source:'frame',target:'outside',sourceHandle:'port:output',targetHandle:'left',data:{appearance:{color:'#123456',width:2,line:'dashed',head:{type:'triangle',filled:false,size:12},tail:{type:'circle',size:8}},animation:{mode:'executing',effect:'flow',direction:'forward',durationMs:1200,color:'#ffffff'},path:{type:'bezier-chain',knots:[{id:'k',position:{x:700,y:150},in:{x:-30,y:0},out:{x:30,y:0},mode:'smooth'}]},execution:{enabled:true}}};
return {frame,text,inside,root:{...emptyCanvas(),schema_version:2,nodes:[frame,text],edges:[edge]},child:{...emptyCanvas(),schema_version:2,nodes:[inside]}};
}
test('V2 roundtrip preserves linked frames, all curve/animation/marker data and AI config',()=>{
const {root}=fixture(); const decoded=decodeCanvas(JSON.stringify(root));
assert.deepEqual(decoded.edges[0].data,root.edges[0].data); assert.deepEqual(decoded.nodes[0].data.subgraph,root.nodes[0].data.subgraph); assert.deepEqual(decoded.nodes[1].data.ai,root.nodes[1].data.ai);assert.equal(decoded.nodes[0].width,5000);
assert.deepEqual(decodeCanvas(JSON.stringify(decoded)),decoded);
});
test('V2 rejects bad control data, wrong port directions and oversized ordinary elements',()=>{
for(const mutate of [d=>d.edges[0].data.path.knots[0].out.x=-30,d=>d.edges[0].data.animation.mode='random',d=>d.edges[0].data.appearance.width=100,d=>d.nodes[0].data.ports[0].direction='input',d=>d.nodes[1].width=5000,d=>d.nodes[1].data.ai.parameters=[]]){
const {root}=fixture();mutate(root);assert.throws(()=>decodeCanvas(JSON.stringify(root)));
}
});
test('expanded child nodes are projected once and boundary edges resolve to their actual nodes',()=>{
const {root,child}=fixture(), documents=new Map([['root',root],[childId,child]]);const view=projectCanvasTree('root',documents);
assert.equal(view.nodes.length,3);const projected=view.nodes.find(n=>n.id===childId+'::inside');assert.deepEqual(projected.position,{x:126,y:140});assert.equal(projected.width,52);assert.equal(view.nodeRefs.get(projected.id).boardId,childId);assert.equal(view.edges[0].source,projected.id);assert.equal(root.nodes.length,2);assert.equal(child.nodes.length,1);
root.nodes[0].data.presentation.collapsed=true;const collapsed=projectCanvasTree('root',documents);assert.equal(collapsed.nodes.length,2);assert.equal(collapsed.edges[0].source,'frame');assert.deepEqual(child.nodes[0].position,{x:10,y:20});
});
test('curve coordinates and vectors invert exactly between owner and projected canvases',()=>{
const {root}=fixture();const ref={boardId:'b',id:'e',origin:{x:100,y:-40},scale:.2};assert.deepEqual(transformEdgeData(transformEdgeData(root.edges[0].data,ref),ref,true),root.edges[0].data);
});
test('adding a curve point preserves both halves of the original cubic',()=>{
const from={point:{x:0,y:0},normal:{x:1,y:0}},to={point:{x:500,y:200},normal:{x:-1,y:0}};
const old=curveSegments(from,to)[0], split=insertCurveKnot(from,to,undefined), next=curveSegments(from,to,split.path);
assert.equal(next.length,2);for(let i=0;i<=100;i++){const t=i/100,a=cubicPoint(old,t),b=cubicPoint(next[t<=.5?0:1],t<=.5?t*2:(t-.5)*2);assert.ok(Math.hypot(a.x-b.x,a.y-b.y)<1e-8);}
});
test('AI remote output merges with local geometry but conflicts with locally edited text',()=>{
const base=fixture().child,local=structuredClone(base),remote=structuredClone(base);local.nodes[0].position={x:200,y:300};remote.nodes[0].data.text='generated';
const merged=mergeCanvasDocuments(base,local,remote);assert.deepEqual(merged.nodes[0].position,{x:200,y:300});assert.equal(merged.nodes[0].data.text,'generated');local.nodes[0].data.text='user typing';assert.throws(()=>mergeCanvasDocuments(base,local,remote),/version_conflict/);
});
test('eight-direction resize is free by default in resize mode and Shift locks the aspect',()=>{
for(const kind of ['plain-text','plain-image','text','image','frame']){
const node=makeCanvasNode(kind,{x:10,y:20});const oldRatio=node.width/node.height;
for(const direction of ['top','right','bottom','left','top-left','top-right','bottom-left','bottom-right']){
const patch=resizeFromPointer(node,direction,{x:80,y:40},true);assert.ok(Math.abs(patch.width/patch.height-oldRatio)<1e-9);
}
}
const image=makeCanvasNode('plain-image',{x:0,y:0});assert.equal(resizeFromPointer(image,'right',{x:100,y:0},false).height,image.height);
const frame=makeCanvasNode('frame',{x:0,y:0});assert.ok(resizeFromPointer(frame,'right',{x:10000,y:0},false).width>3000);
});
test('managed assets render through the authenticated same-origin proxy',()=>{
assert.equal(managedImageSource('https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/a'),'/api/foundry/workspaces/w/assets/a');assert.equal(managedImageSource('https://example.com/a.png'),'https://example.com/a.png');
});
test('connection plan uses the lowest common ancestor and exactly both ownership paths',async()=>{
const {connectionTransactionPlan}=await import('../src/lib/canvas-subgraphs.ts');const {root,child}=fixture();
const nested='33333333-3333-4333-8333-333333333333';const frame=makeCanvasNode('frame',{x:0,y:0});frame.data.subgraph={boardId:nested};child.nodes.push(frame);
const docs=new Map([['root',root],[childId,child],[nested,{...emptyCanvas(),schema_version:2,nodes:[]}]]);
const result=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'right'},{boardId:'root',id:'b',handle:'left'});
assert.equal(result.boardId,'root');assert.deepEqual(new Set(result.participants),new Set(['root',childId,nested]));assert.equal(result.source.node_id,'a');
const inner=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'bottom'},{boardId:childId,id:'b',handle:'top'});assert.equal(inner.boardId,childId);assert.deepEqual(new Set(inner.participants),new Set([childId,nested]));
});
test('incompatible drafts cannot overwrite migrated V2 ownership',async()=>{
const {canRestoreCanvasDraft}=await import('../src/lib/canvas-sync.ts');const {root}=fixture();const old={...structuredClone(root),schema_version:1};assert.equal(canRestoreCanvasDraft(old,root),false);
const changed=structuredClone(root);changed.nodes[0].data.subgraph.boardId='55555555-5555-4555-8555-555555555555';assert.equal(canRestoreCanvasDraft(changed,root),false);assert.equal(canRestoreCanvasDraft(structuredClone(root),root),true);
});
test('selecting both parent frame and child applies a shared drag once',async()=>{
const {referenceAfterMoves}=await import('../src/lib/canvas-tree.ts');const {root,child}=fixture();const docs=new Map([['root',root],[childId,child]]),view=projectCanvasTree('root',docs);
const projected=view.nodes.find(node=>node.id===childId+'::inside');const delta={x:50,y:80};const moved={x:projected.position.x+delta.x,y:projected.position.y+delta.y};
const ref=referenceAfterMoves('root',docs,view.boards,childId,new Map([['frame',{x:root.nodes[0].position.x+delta.x,y:root.nodes[0].position.y+delta.y}],[projected.id,moved]]));
assert.deepEqual({x:(moved.x-ref.origin.x)/ref.scale,y:(moved.y-ref.origin.y)/ref.scale},child.nodes[0].position);
});