99 lines
10 KiB
JavaScript
99 lines
10 KiB
JavaScript
/**
|
|
* 旧 schema_version=2 linked-board 快照的兼容与投影测试。schema_version、headless 文档格式和增量协议版本不是同一编号。
|
|
* 检查展开分区、曲线/样式/执行配置、owner 投影与最低共同祖先;不把任意远程 AI 请求实际执行。
|
|
*/
|
|
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';
|
|
// 固定子 board 身份,投影时与符号节点 ID 组合,避免把 renderer ID 当作持久节点身份。
|
|
const childId = '11111111-1111-4111-8111-111111111111';
|
|
// 大尺寸 frame 引用子 board 和输出 port,父文字含 AI 配置,边包含完整曲线/动画/marker 数据。
|
|
function fixture() {
|
|
// frame 的局部入口与 child 中 inside 的身份通过 port 绑定;缩放 .2 影响投影,不改 child 原坐标。
|
|
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'}]};
|
|
// AI 字段只是序列化配置,fixture 不创建任务、调用模型或产生计费。
|
|
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';
|
|
// knots.position 是绝对点,in/out 是控制向量;其余字段承载线条展示、动画和执行配置。
|
|
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]}};
|
|
}
|
|
// V2 JSON 往返保留 subgraph、AI 与全部边 data,且已解码对象再次解码结果稳定。
|
|
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);
|
|
});
|
|
// 逐项破坏平滑控制向量、动画、线宽、端口方向、普通元素尺寸或 AI 参数容器,解码均失败。
|
|
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)));
|
|
}
|
|
});
|
|
// 展开时子节点只投影一次且边解析到真实内部端点;折叠改画到 frame,不改父子原文档或子局部坐标。
|
|
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});
|
|
});
|
|
// 边的点与向量经 owner→projected→owner 转换应精确返回原 data。
|
|
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);
|
|
});
|
|
// 插入中点把一段 cubic 拆为两段;101 个参数样本与原曲线距离小于 1e-8,验证形状而非仅端点。
|
|
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}};
|
|
// 在 t=.5 处分段;左/右段参数为 2t 与 2(t-.5),才能比较原曲线的相同位置。
|
|
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);}
|
|
});
|
|
// 远程生成文字可与本地几何三方合并,但同字段本地文字冲突必须抛 version_conflict。
|
|
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/);
|
|
});
|
|
// 五类节点乘八方向在显式比例锁定时保持原比例;自由模式仅右侧拉伸不改图高,frame 可大于普通卡片上限。
|
|
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);
|
|
});
|
|
// 受管资产显示转认证同源代理,普通外部 URL 保留;这里只检查地址转换,不验证实际认证请求。
|
|
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');
|
|
});
|
|
|
|
// 连线持久 owner 是最低共同祖先,事务参与者恰为两条 owner 路径的并集;祖先本身不需要重复登记。
|
|
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:[]}]]);
|
|
// a/b 是规划用符号端点,没有对应节点;此例只测 owner 路径,不证明端点存在或连接可提交。
|
|
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]));
|
|
});
|
|
// 旧 schema 或 subgraph owner 绑定不同的草稿不能恢复到已迁移 V2;等价深拷贝才可作为候选。
|
|
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);
|
|
});
|
|
// 同时拖选父 frame 与 child 的投影结果,只应用共同位移一次,反推 owner 局部坐标保持原值。
|
|
// 直接提供移动表来验证坐标换算,没有模拟浏览器指针事件。
|
|
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);
|
|
});
|