Files
Endoleap/tests/canvas-v2-scenario.mjs
T

187 lines
22 KiB
JavaScript

/**
* 旧 schema_version=2 linked-board 画布的 JSDOM 集成场景,不是 headless 增量协议版本号。
* 真实组件消费内存 API 替身:快照、事务回执、AI 模型/事件和图片均由此文件生成;不调用模型供应商或实际 Foundry 服务。
*/
import assert from 'node:assert/strict';
// 依次检查只读加载、子图编辑/AI、折叠展开、分区 undo/redo、跨 owner 连线与图片处理。
export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvasNode,emptyCanvas,event,tick,mock}) {
// 构造父入口、子节点、外部 AI 文本与图片;父边引用入口 port,子内容只存在子快照中。
const parent='22222222-2222-4222-8222-222222222222', child='11111111-1111-4111-8111-111111111111';
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='linked-frame';frame.data={...frame.data,title:'真实子图',subgraph:{boardId:child},ports:[{id:'out',nodeId:'child-text',handle:'right',direction:'output'}],presentation:{collapsed:false,contentScale:1,expandedSize:{width:640,height:400},collapsedSize:{width:240,height:140}}};
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='child-text';inside.data.title='子节点';inside.data.text='原始输入';
const target=makeCanvasNode('text',{x:950,y:100});target.id='outside';target.data.ai={enabled:true,modelId:'test-text',prompt:'继续整理',parameters:{}};
const image=makeCanvasNode('image',{x:1100,y:450},'https://example.com/reference.png');image.id='image-target';image.data.title='图片目标';
const legacy=makeCanvasNode('plain-image',{x:1600,y:100},'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB');legacy.id='legacy-image';
// Map 是模拟服务端快照事实;版本随每个写分支递增,不能与投影画面中的 :: ID 混用。
const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image,legacy],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]);
let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0;
const receipts=new Map();let createCount=0;
let assetUploads=0;
const saves=[];let job=null,poll=0,seq=0,imageRuns=0;const events=[];
// Response 包装走应用解码;emit 用递增 seq 支持 after_seq 事件过滤。
const json=(data,status=200)=>new Response(JSON.stringify(data),{status,headers:{'content-type':'application/json'}});
const emit=(type,data)=>events.push({seq:++seq,type,...data});
// 按 assets、快照、能力、模型、AI、连线和事务路径分流;未列出的请求立即失败。
mock(async(url,options={})=>{
if(new URL(url,'http://localhost').pathname.endsWith('/assets') && options.method==='POST') {
assetUploads++;assert.ok(options.headers['Idempotency-Key']);assert.ok(options.body.get('image').size>0);
return json({id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777',width:1,height:1,bytes:24,mime_type:'image/png'},201);
}
assert.match(options.headers?.Accept||'',/version=2/);
const parsed=new URL(url,'http://localhost'), parts=parsed.pathname.split('/');const board=parts[6], suffix=parts.slice(8).join('/'), method=options.method||'GET';
const snapshot=records.get(board);assert.ok(snapshot,`Unknown canvas ${board} ${url}`);
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
// 快照保存检查 If-Match,禁止投影 ID 持久化和把子节点重复写入父画布。
if(!suffix&&method==='PATCH'){
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
const payload=JSON.parse(options.body).document;
assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted');
if(board===parent)assert.ok(!payload.nodes.some(node=>node.id==='child-text'),'Child content cannot be duplicated into parent');
saves.push({board,document:payload});snapshot.document=structuredClone(payload);snapshot.version++;return json({version:snapshot.version});
}
if(suffix==='capabilities')return json({supported_schema_versions:[1,2],subgraphs:{connections:true}});
if(suffix==='ai/models')return json({models:[{id:'test-text',label:'测试文本模型',available:true,output_modalities:['text'],input_modalities:['text'],parameters_schema:{type:'object',properties:{},additionalProperties:false}},{id:'test-image',label:'测试图片模型',available:true,output_modalities:['image'],input_modalities:['text','image'],parameters_schema:{type:'object',properties:{},additionalProperties:false}}]});
if(suffix==='ai/runs'&&method==='GET')return json({runs:job&&['queued','running'].includes(job.status)?[{run_id:'test-run',board_id:child,status:job.status}]:[],next_cursor:null});
// 根据目标区分文本/图片模型及参与画布,校验幂等键与版本,再重置这次模拟 run 的事件序列。
if(suffix==='ai/runs'&&method==='POST'){
const input=JSON.parse(options.body);const isImage=input.node_id==='image-target';assert.equal(board,isImage?parent:child);assert.equal(input.node_id,isImage?'image-target':'child-text');assert.equal(input.model_id,isImage?'test-image':'test-text');assert.equal(input.propagate,!isImage);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);assert.ok(options.headers['Idempotency-Key']);
snapshot.document.nodes.find(node=>node.id===input.node_id).data.ai={enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:true};snapshot.version++;
poll=0;seq=0;events.length=0;job={status:'queued',id:isImage?'image-run-'+(++imageRuns):'test-run'};return json({run_id:job.id,status:'queued',canvas_version:snapshot.version,plan:{steps:[],edges:[]}},202);
}
if(suffix.startsWith('ai/runs/image-run-')&&suffix.endsWith('/cancel')&&method==='POST'){
job.status='cancelled';emit('step.cancelled',{board_id:parent,node_id:'image-target'});return json({run_id:job.id,status:'cancelled'});
}
// 图片轮询第二次生成 src/assetId 并发出 output/applied;取消状态不走成功写回分支。
if(suffix.startsWith('ai/runs/image-run-')&&method==='GET'){
poll++;if(job.status!=='cancelled')job.status=poll<2?'running':'succeeded';
if(poll===2&&job.status==='succeeded'){
const target=records.get(parent).document.nodes.find(node=>node.id==='image-target');target.data.src='https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/generated-image';target.data.assetId='44444444-4444-4444-8444-444444444444';records.get(parent).version++;
emit('step.output',{board_id:parent,node_id:'image-target',output:{output_id:'image-output',src:target.data.src}});emit('step.applied',{board_id:parent,node_id:'image-target',canvas_version:records.get(parent).version,patch:{src:target.data.src,assetId:target.data.assetId}});
}
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:job.id,status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'image-step',board_id:parent,node_id:'image-target',status:job.status}],outputs:[],applied_versions:{}});
}
// 三轮推进子文本生成、跨边活跃及父文本传播;应用靠 seq 游标读取新增事件。
if(suffix==='ai/runs/test-run'&&method==='GET'){
poll++;
if(poll===1){job.status='running';emit('step.running',{board_id:child,node_id:'child-text'});}
if(poll===2){records.get(child).document.nodes[0].data.text='生成后的子图内容';records.get(child).version++;emit('step.applied',{board_id:child,node_id:'child-text',canvas_version:records.get(child).version,patch:{text:'生成后的子图内容'}});emit('edge.active',{logical_edge_id:parent+'/boundary'});}
if(poll>=3&&job.status!=='succeeded'){job.status='succeeded';records.get(parent).document.nodes.find(node=>node.id==='outside').data.text='向下传播结果';records.get(parent).version++;emit('step.applied',{board_id:parent,node_id:'outside',canvas_version:records.get(parent).version,patch:{text:'向下传播结果'}});emit('edge.completed',{logical_edge_id:parent+'/boundary'});}
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:'test-run',status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'one',board_id:child,node_id:'child-text',status:poll>=2?'succeeded':'running'},{step_id:'two',board_id:parent,node_id:'outside',status:poll>=3?'succeeded':poll>=2?'running':'queued'}],outputs:[],applied_versions:{}});
}
// 首次连线在父入口增 port 与父边并更新双方版本,故意回 502;重试核对原 body/key 后返回同一回执。
if(suffix==='connections'&&method==='POST'){
connectionRequests++;
if(connectionReceipt){assert.equal(options.headers['Idempotency-Key'],connectionKey);assert.equal(options.body,connectionBody);return json(connectionReceipt,201);}
connectionKey=options.headers['Idempotency-Key'];connectionBody=options.body;
const input=JSON.parse(options.body);assert.equal(board,parent);assert.equal(input.source.board_id,child);assert.equal(input.source.node_id,'child-text');assert.equal(input.source.handle,'bottom');assert.equal(input.target.board_id,parent);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,child].sort());
for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version);
const linked=records.get(parent).document.nodes.find(node=>node.id==='linked-frame');linked.data.ports.push({id:'generated-port',direction:'output',nodeId:'child-text',handle:'bottom'});
records.get(parent).document.edges.push({id:input.edge.id,source:'linked-frame',sourceHandle:'port:generated-port',target:'outside',targetHandle:'left'});
records.get(parent).version++;records.get(child).version++;
connectionReceipt={transaction_id:'tx-connect',parent:{board_id:parent,...structuredClone(records.get(parent))},snapshots:[{board_id:parent,...structuredClone(records.get(parent))},{board_id:child,...structuredClone(records.get(child))}],undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}};
return json({error:'upstream_unavailable'},502); // 内存记录已更新后丢失响应,客户端须以原 key/body 取回回执。
}
// 创建的撤销检查当前父/子版本,以新父版本恢复原内容并返回归档子 ID;回执由内存生成。
if(suffix.startsWith('transactions/tx-create-')&&suffix.endsWith('/undo')&&method==='POST'){
const id=suffix.split('/')[1],receipt=receipts.get(id),input=JSON.parse(options.body);assert.ok(receipt);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,receipt.child].sort());assert.equal(input.expected_versions[parent],records.get(parent).version);assert.equal(input.expected_versions[receipt.child],records.get(receipt.child).version);
const next={...receipt.before,version:records.get(parent).version+1};records.set(parent,next);records.get(receipt.child).version++;
return json({transaction_id:'undo-'+id,parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],archived_board_ids:[receipt.child],applied_versions:{[parent]:next.version,[receipt.child]:records.get(receipt.child).version}});
}
// 每次创建新子 ID 和事务回执,记录创建前父快照,供后续撤销与重做参与者检查。
if(suffix==='subgraphs'&&method==='POST'){
const input=JSON.parse(options.body).frame;const newChild=crypto.randomUUID();const tx='tx-create-'+(++createCount);const beforeCreate=structuredClone(snapshot);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);
const node=makeCanvasNode('frame',input.position);node.id=input.id;node.data.title=input.title;node.data.subgraph={boardId:newChild};snapshot.document.nodes.push(node);snapshot.version++;
const empty={board_id:newChild,version:1,document:{...emptyCanvas(),schema_version:2}};records.set(newChild,empty);
receipts.set(tx,{before:beforeCreate,child:newChild});
return json({transaction_id:tx,parent:{board_id:parent,...structuredClone(snapshot)},child:empty,child_board:{id:newChild,workspace_id:'w'},undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}},201);
}
throw new Error(`Unhandled V2 API ${method} ${url}`);
});
// 用非整数 viewport 检查加载、测量与缩放都不写内容;视图状态仅记本地 viewport key。
const readOnly=structuredClone(records.get(parent));readOnly.document.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
await act(async()=>{root.render(h(GraphCanvas,{key:'read-only',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 read only',version:1},snapshot:readOnly}));await tick();});
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
assert.equal(saves.length,0,'Non-default viewport restoration, measurement and child loading must not PATCH');
await event(document.querySelector('[aria-label="缩小"]'),'click');
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
assert.equal(saves.length,0,'Changing the view without content edits must not PATCH');
const localView=JSON.parse(localStorage.getItem('endoleap_canvas_v1:w:'+parent+':viewport'));assert.ok(localView&&Number.isFinite(localView.zoom));
localStorage.removeItem('endoleap_canvas_v1:w:'+parent+':viewport');
console.log('PASS read-only: root/child loading, viewport restoration and zoom cause zero PATCH requests');
await act(async()=>{root.render(null);await tick();});
await act(async()=>{root.render(h(GraphCanvas,{key:'v2',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 test',version:1},snapshot:structuredClone(records.get(parent))}));await tick();});
await act(async()=>{await tick();});
// 通过 child::localId 查展开投影;只读挂载不得 PATCH,子节点必须只出现一次。
const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`);
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
assert.equal(saves.length,0,'Opening a root and its child without edits must not PATCH');
assert.ok(node(),'Child is expanded from its own snapshot');assert.equal(document.querySelectorAll(`.react-flow__node[data-id="${child}::child-text"]`).length,1);
// 选择子节点进入 AI 模式,加载文本模型并开启下游传播;拖动与事件写回交错。
await event(node(),'click');await event(document.querySelector('[aria-label="AI 模式"]'),'click');await act(async()=>{await tick();});
assert.ok(document.querySelector('[aria-label="元素 AI 工具"]'));assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
await event(document.querySelector('[aria-label="继续向下传播"]'),'click');
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
assert.ok(job);assert.equal(document.querySelector('[aria-label="AI 模式"]').getAttribute('aria-pressed'),'true');
// 生成期间移动子节点,后续检查实际 PATCH 发给 child;父回执不能把子节点另存成父成员。
await event(node(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:240,clientY:215,buttons:1});await event(window,'mouseup',{clientX:240,clientY:215,button:0});
// 有限轮询真实组件消费替身事件的结果;通过 act 推进 effect,而非直接替组件改 DOM。
const waitUntil=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,80));});}throw new Error('Timed out: '+label);};
await waitUntil(()=>node()?.querySelector('textarea')?.value==='生成后的子图内容','child AI result');
assert.ok(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),'Actual edge.active event starts execution-only animation');
assert.ok(records.get(child).document.nodes[0].position.x>10,'AI writeback preserves the concurrent child drag');
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="outside"] textarea')?.value==='向下传播结果','downstream AI result');
assert.equal(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),null,'Completed run stops execution-only animation');
assert.ok(saves.some(save=>save.board===child),'Child edits save to child canvas');
// 折叠隐藏投影、展开恢复,子局部位置应保持;随后新建分区并反复 undo/redo,检查新子参与者。
const childPosition=structuredClone(records.get(child).document.nodes[0].position);
await event(document.querySelector('.react-flow__node[data-id="linked-frame"]'),'click');
await event(document.querySelector('[aria-label="收起子画布"]'),'click');assert.equal(node(),null);assert.ok(document.querySelector('[aria-label="展开子画布"]'));
await event(document.querySelector('[aria-label="展开子画布"]'),'click');assert.ok(node());assert.deepEqual(records.get(child).document.nodes[0].position,childPosition);
const before=records.get(parent).document.nodes.length;await event(document.querySelector('[aria-label="添加框选区"]'),'click');
assert.equal(records.get(parent).document.nodes.length,before+1);assert.equal(records.size,3);
await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(records.get(parent).document.nodes.length,before);
await event(document.querySelector('[aria-label="重做"]'),'click');assert.equal(records.get(parent).document.nodes.length,before+1);
await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(records.get(parent).document.nodes.length,before,'Undo after recreate uses the new child participant');
await event(document.querySelector('[aria-label="重做"]'),'click');assert.equal(records.get(parent).document.nodes.length,before+1);
console.log('PASS transactions: create undo/redo and fresh child version participants');
await event(node(),'click');
const fromHandle=node().querySelector('[data-handleid="bottom"]');
const toHandle=document.querySelector('.react-flow__node[data-id="outside"] [data-handleid="left"]');
// JSDOM 没有真实命中布局,临时将 elementFromPoint 指向目标 handle,驱动连线事件后恢复。
const originalHitTest=document.elementFromPoint;document.elementFromPoint=()=>toHandle;
await event(fromHandle,'mousedown',{clientX:250,clientY:250,button:0,buttons:1});
await event(document,'mousemove',{clientX:500,clientY:250,buttons:1});
await event(document,'mouseup',{clientX:500,clientY:250,button:0});
document.elementFromPoint=originalHitTest;
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='linked-frame').data.ports.some(port=>port.id==='generated-port'),'cross-canvas connection transaction');
// 等待不确定结果提示再点击重试;两次请求最终只产生一条额外边。
// 本夹具只测试这一层父/子的共同上级连线事务,不据此证明任意深度 canvas 树的 LCA 计算。
await waitUntil(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='重试同步'),'uncertain transaction recovery');
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='重试同步'),'click');
assert.equal(connectionRequests,2);assert.equal(document.querySelector('.react-flow__edge[data-id="'+connectionReceipt.parent.document.edges.at(-1).id+'"]')!==null,true);
assert.equal(records.get(parent).document.edges.length,2);
console.log('PASS connections: real handle gesture, capability gate, LCA transaction and exact participant versions');
// AI 模式保持开启但按新图片目标筛选模型;生成只更新资源,尺寸/标题保持,下一次 run 可取消。
await event(document.querySelector('.react-flow__node[data-id="image-target"]'),'click');
await act(async()=>{await tick();});
assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试图片模型'),'Sticky AI mode selects models for the newly selected image');
assert.ok(!document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="image-target"] img')?.getAttribute('src')==='/api/foundry/workspaces/w/assets/generated-image?variant=preview','managed AI image result');
const generated=records.get(parent).document.nodes.find(node=>node.id==='image-target');assert.equal(generated.width,image.width);assert.equal(generated.height,image.height);assert.equal(generated.data.title,'图片目标');
await waitUntil(()=>document.querySelector('[aria-label="执行 AI"]'),'image run terminal UI');
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
await event(document.querySelector('[aria-label="取消生成"]'),'click');
await waitUntil(()=>document.querySelector('[aria-label="元素 AI 工具"]')?.textContent.includes('已取消生成'),'cancelled image run');
assert.equal(records.get(parent).document.nodes.find(node=>node.id==='image-target').data.src,generated.data.src);
console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation');
// 现有 inline 图片不触发隐式后台上传或引用改写;受管图片继续使用 preview 渲染。
assert.equal([...document.querySelectorAll('button')].some(button=>button.textContent==='优化图片加载'),false,'Temporary migration controls are absent even with an inline image');
assert.equal(assetUploads,0,'Existing inline images do not trigger background uploads');
assert.ok(records.get(parent).document.nodes.find(node=>node.id==='legacy-image').data.src.startsWith('data:image/'),'Existing image references are not rewritten');
assert.ok(document.querySelector('img[src$="?variant=preview"]'),'Managed images still render previews');
console.log('PASS images: temporary migration removed, no background uploads and managed previews retained');
console.log('PASS V2: child ownership and saves, live model catalog, AI propagation/polling, geometry preservation, collapse/expand and transactional child creation');
}