141 lines
16 KiB
JavaScript
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();});
|
|
}
|