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

161 lines
19 KiB
JavaScript

import assert from 'node:assert/strict';
export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvasNode,emptyCanvas,event,tick,mock}) {
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';
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=[];
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});
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(!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});
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'});
}
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:{}});
}
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:{}});
}
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); // Simulate a lost response after the atomic commit.
}
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}});
}
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}`);
});
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();});
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);
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');
// Move a child while the run executes. Its original owner receives the save.
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});
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');
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"]');
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');
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');
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');
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');
}