Files
Endoleap/tests/canvas-member-scenario.mjs

141 lines
16 KiB
JavaScript

import assert from 'node:assert/strict';
export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasNode,emptyCanvas,tick,mock,GraphCanvas,event,window}) {
const parent='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',child='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='frame';frame.data.subgraph={boardId:child};
const icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving';
const other=makeCanvasNode('text',{x:950,y:100});other.id='other';
let records,tree,posts,saves,release,mode,receipt,key,body,capability=true,capMode="",capCalls=0;
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);return null;}
const reset=async(nextMode='success')=>{
await act(async()=>{root.render(null);await tick();});localStorage.clear();
records=new Map([[parent,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(frame),structuredClone(other)]}}],[child,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(icon)]}}]]);
posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;capMode=nextMode;capCalls=0;
if(mode==='queued'){const second=structuredClone(icon);second.id='moving-two';second.position={x:100,y:20};records.get(child).document.nodes.push(second);mode='delayed';}
mock(async(url,options={})=>{
const parts=new URL(url,'http://localhost').pathname.split('/'),id=parts[6],suffix=parts.slice(8).join('/'),method=options.method||'GET';
if(suffix==='ai/runs')return json({runs:[]});
if(suffix==='capabilities'){
capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');}
if(capMode==='cap-failure'&&capCalls===1)throw new TypeError('network unavailable');
return json({subgraphs:{member_positions:capability}});
}
if(!suffix&&method==='GET')return json(structuredClone(records.get(id)));
if(!suffix&&method==='PATCH'){
assert.equal(options.headers['If-Match'],`"${records.get(id).version}"`);
const document=JSON.parse(options.body).document;saves.push({id,document});records.set(id,{version:records.get(id).version+1,document});return json({version:records.get(id).version});
}
if(suffix==='ai/runs'&&method==='POST'){const next=structuredClone(records.get(id));next.version++;records.set(id,next);return json({canvas_version:next.version,board_id:id});}
if(suffix==='subgraphs'&&method==='POST'){
const input=JSON.parse(options.body).frame,next=structuredClone(records.get(parent)),childID='66666666-6666-4666-8666-666666666666';
const added=makeCanvasNode('frame',input.position);added.id=input.id;added.data.subgraph={boardId:childID};next.document.nodes.push(added);next.version++;records.set(parent,next);
const empty={version:1,document:{...emptyCanvas(),schema_version:2}};records.set(childID,empty);
if(mode==='create-delayed')await new Promise(resolve=>{release=resolve;});
return json({transaction_id:crypto.randomUUID(),parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],child:{board_id:childID,...empty}});
}
assert.equal(suffix,'subgraphs/frame/members');posts.push({body:options.body,key:options.headers['Idempotency-Key']});
if(receipt && options.headers['Idempotency-Key']===key){assert.equal(options.body,body);return json(receipt);}
receipt=null;
body=options.body;key=options.headers['Idempotency-Key'];const input=JSON.parse(body);
for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version);
if(mode==='conflict'){records.get(parent).version++;mode='success';return json({error:'version_conflict'},412);}
if(mode==='rejected'){mode='success';return json({error:'ownership_conflict'},409);}
const source=structuredClone(records.get(input.source_board_id)),target=structuredClone(records.get(input.target_board_id));
const moving=source.document.nodes.find(n=>n.id===input.node_ids[0]);const pos=input.positions?.[moving.id]||moving.position;
const converted=input.source_board_id===child?{x:pos.x+124,y:pos.y+136}:{x:pos.x-124,y:pos.y-136};
source.document.nodes=source.document.nodes.filter(n=>n.id!==moving.id);target.document.nodes.push({...moving,position:converted});source.version++;target.version++;records.set(input.source_board_id,source);records.set(input.target_board_id,target);
receipt={transaction_id:crypto.randomUUID(),snapshots:[...records].map(([board_id,value])=>({board_id,...structuredClone(value)}))};
if(mode==='lost')return json({error:'upstream_unavailable'},502);
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
return json(receipt);
});
await act(async()=>{root.render(h(Fixture));await tick();await tick();});
};
const wait=async(check,label)=>{for(let i=0;i<120;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
const start=async()=>{
await act(async()=>{tree.select(child+'::moving');tree.setDragging(true);tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:950,y:200},dragging:false}]);tree.setDragging(false);void tree.moveMembers(parent,'frame',child,parent,['moving'],[parent,child]);await tick();});
await wait(()=>posts.length,'move admission');
};
await reset('delayed');await start();
assert.equal(saves.length,0,'Drop-only atomic moves must not PATCH first');assert.equal(tree.busy,false,'Member moves must not lock all editing');assert.equal(tree.memberJobs.length,1);
assert.deepEqual(JSON.parse(posts[0].body).positions.moving,{x:826,y:64});
await act(async()=>{tree.dispatch({type:'checkpoint'});tree.nodeAction('other',id=>({type:'edit',id,text:'同步期间编辑'}));tree.dispatch({type:'checkpoint'});tree.nodesChanged([{type:'position',id:'other',position:{x:1100,y:130},dragging:false}]);await tick();});
assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑');
await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:1200,y:900},dragging:false}]);await tick();});
assert.deepEqual(tree.current.current.get(child).nodes[0].position,{x:826,y:64},'Pending member cannot move again');
await act(async()=>{release();await tick();await tick();});await wait(()=>!tree.memberJobs.length,'move acknowledged');
assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑','Remote snapshots preserve local text');
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130},'Remote snapshots preserve unrelated dragging');
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='moving').position,{x:950,y:200},'Owner conversion preserves visible drop position');assert.equal(tree.rootDocument.nodes.find(n=>n.id==='moving').selected,true,'Selection follows ownership');
await wait(()=>saves.length===1,'intervening edits eventually saved');
assert.equal(records.get(parent).document.nodes.filter(n=>n.id==='moving').length,1);assert.equal(records.get(child).document.nodes.length,0);
await act(async()=>{await tree.undo();await tick();});
assert.ok(tree.rootDocument.nodes.some(n=>n.id==='moving'),'Undo of concurrent edits retains the new owner');
assert.equal(tree.current.current.get(child).nodes.some(n=>n.id==='moving'),false,'Undo does not resurrect an old owner');
await act(async()=>{await tree.redo();await tick();});
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130});
console.log('PASS member sync: one POST, no preceding PATCH, local editing during delayed commit, geometry and draft merge');
await reset('lost');await start();await wait(()=>tree.memberJobs[0]?.error,'uncertain result');
assert.equal(tree.busy,false);await act(async()=>{tree.nodeAction('other',id=>({type:'edit',id,text:'响应丢失后的编辑'}));tree.retryMembers();await tick();});
await wait(()=>!tree.memberJobs.length,'idempotent retry');assert.equal(posts.length,2);assert.equal(posts[0].body,posts[1].body);assert.equal(posts[0].key,posts[1].key);assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'响应丢失后的编辑');
console.log('PASS member retry: lost acknowledgement reuses body/key, keeps edits and does not duplicate ownership');
await reset('conflict');await start();await wait(()=>tree.memberJobs[0]?.error,'version rejection');
await act(async()=>{tree.retryMembers();await tick();});await wait(()=>!tree.memberJobs.length,'fresh version retry');assert.equal(posts.length,2);assert.notEqual(posts[0].key,posts[1].key);assert.equal(JSON.parse(posts[1].body).expected_versions[parent],2);
console.log('PASS member conflict: definite zero-write rejection reloads versions before a fresh command');
await reset('queued');await start();
await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving-two',position:{x:1000,y:300},dragging:false}]);void tree.moveMembers(parent,'frame',child,parent,['moving-two'],[parent,child]);await tick();});
assert.equal(posts.length,1,'Second ownership change waits for participant versions');
await act(async()=>{release();await tick();});await wait(()=>posts.length===2,'queued ownership move');
await act(async()=>{release();await tick();});await wait(()=>!tree.memberJobs.length,'queue drained');
assert.equal(saves.length,0);assert.equal(tree.rootDocument.nodes.filter(n=>n.id.startsWith('moving')).length,2);assert.equal(tree.current.current.get(child).nodes.length,0);
console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH');
await reset();
const pendingImage=makeCanvasNode('image',{x:200,y:200},'blob:editable-upload');pendingImage.id='pending-image';pendingImage.pendingUpload=pendingImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:pendingImage}]});tree.movePendingImage(parent,child,pendingImage.id);await tick();});
assert.equal(posts.length,0);assert.equal(saves.length,0);assert.deepEqual(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).position,{x:76,y:64});
await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));
await act(async()=>{await tree.redo();await tick();});
await act(async()=>{tree.nodeAction(child+'::'+pendingImage.id,id=>({type:'rename-node',id,title:'上传期间编辑'}));tree.resolveImageUpload(pendingImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});await tick();});
await wait(()=>records.get(child).document.nodes.some(n=>n.id===pendingImage.id),'upload completion at current owner');assert.equal(posts.length,0,'Local ownership before upload does not require a server move');
await act(async()=>{await tree.undo();await tick();});assert.equal(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).data.title,pendingImage.data.title,'Metadata acknowledgement does not consume an undo step');
await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));
await act(async()=>{await tree.redo();await tick();});assert.ok(tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));assert.equal(posts.length,2,'Undo/redo after persistence use real ownership transactions');
console.log('PASS pending image ownership: editable local moves, atomic local undo, current-owner upload resolution and transactional undo after persistence');
await reset('create-delayed');
const asyncImage=makeCanvasNode('image',{x:800,y:500},'blob:async-upload');asyncImage.id='async-image';asyncImage.pendingUpload=asyncImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:asyncImage}]});await tick();});
await act(async()=>{await tree.command(parent,'ai/runs',{node_id:'other',model_id:'test',prompt:'x',parameters:{}},undefined,false);await tick();});
assert.ok(tree.rootDocument.nodes.some(n=>n.pendingUpload===asyncImage.id),'Version-only command refresh retains local images');
let creation;await act(async()=>{creation=tree.command(parent,'subgraphs',{frame:{id:'new-frame',title:'Frame',position:{x:500,y:500},width:640,height:400,labelMode:'fixed'}});await tick();});
await wait(()=>release,'delayed frame command');
await act(async()=>{tree.resolveImageUpload(asyncImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});release();await creation;await tick();});
assert.ok(tree.rootDocument.nodes.some(n=>n.id===asyncImage.id&&!n.pendingUpload),'Concurrent command response cannot overwrite the resolved image');assert.equal(tree.rootDocument.nodes.filter(n=>n.id==='new-frame').length,1,'Repeated parent snapshots are adopted only once');
console.log('PASS upload command races: version-only refresh and duplicate transaction snapshots retain pending/ready local images');
await reset('slowcap');await start();await wait(()=>!tree.memberJobs.length,'slow capability move');assert.equal(saves.length,0,'A slow capability response must not trigger legacy PATCH fallback');
await reset('cap-failure');await start();await wait(()=>!tree.memberJobs.length,'capability retry');assert.equal(capCalls,2);assert.equal(saves.length,0,'A failed prefetch is retried rather than cached as unsupported');
console.log('PASS member capabilities: responses slower than two seconds and transient prefetch errors retain atomic moves');
capability=false;await reset();await start();await wait(()=>!tree.memberJobs.length,'legacy move');assert.equal(saves.length,1,'Old backend retains the coordinate save');assert.equal(JSON.parse(posts[0].body).positions,undefined);
console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback');
capability=true;await reset('delayed');
await act(async()=>{root.render(null);await tick();root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Member gestures',version:1},snapshot:structuredClone(records.get(parent))}));await tick();await tick();});
const member=()=>document.querySelector(`.react-flow__node[data-id="${child}::moving"]`);
await wait(()=>member(),'expanded icon');
await event(member(),'mousedown',{clientX:150,clientY:160,button:0,buttons:1});
await event(window,'mousemove',{clientX:160,clientY:160,buttons:1});await event(window,'mousemove',{clientX:950,clientY:210,buttons:1});await event(window,'mouseup',{clientX:950,clientY:210,button:0});
await wait(()=>release,'real member gesture admission');
assert.ok(document.querySelector('[aria-label="元素同步状态"]'),'Moving node has a local sync badge');
assert.equal([...document.querySelectorAll('[role="status"]')].some(n=>n.textContent==='正在同步画布…'),false,'No full-canvas synchronization overlay');
assert.equal(member().classList.contains('draggable'),false,'Only pending nodes and frame geometry are protected');
const otherNode=()=>document.querySelector('.react-flow__node[data-id="other"]');assert.equal(otherNode().classList.contains('draggable'),true);
const originalTransform=otherNode().style.transform;
await event(otherNode(),'mousedown',{clientX:1000,clientY:150,button:0,buttons:1});await event(window,'mousemove',{clientX:1010,clientY:150,buttons:1});await event(window,'mousemove',{clientX:1100,clientY:180,buttons:1});await event(window,'mouseup',{clientX:1100,clientY:180,button:0});
assert.notEqual(otherNode().style.transform,originalTransform,'Other elements can actually drag during synchronization');
const changedTransform=otherNode().style.transform;
await act(async()=>{release();await tick();});await wait(()=>!document.querySelector('[aria-label="元素同步状态"]'),'local badge removed on acknowledgement');
assert.equal(otherNode().style.transform,changedTransform,'Acknowledgement does not reset concurrent dragging');
assert.ok(document.querySelector('.react-flow__node[data-id="moving"]'),'Moved node has exactly one new owner');
console.log('PASS member UI: real cross-frame icon gesture, local status, no overlay and concurrent unrelated drag');
await act(async()=>{root.render(null);await tick();});
}