/** * 旧 linked-board 成员迁移的 hook 与 DOM 场景,由 canvas-ui-check 提供运行环境。 * 内存 records/receipt 模拟版本、丢响应和能力探测;它验证客户端协调,不能替代后端数据库原子性或持久恢复测试。 */ import assert from 'node:assert/strict'; // 先直接驱动 useCanvasTree 精确控制竞态,再以 GraphCanvas 的拖放事件检查局部状态和其他节点可编辑。 export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasNode,emptyCanvas,tick,mock,GraphCanvas,event,window}) { // 父画布拥有分区入口和其他文本,子画布拥有待迁移图标;投影 ID 与服务端本地 ID 区分。 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; // 统一生成 JSON 响应,HTTP 错误也由客户端常规解析路径处理。 const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); // 只挂载 hook 并暴露最新 tree,测试直接调用命令而不依赖 DOM 手势。 function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);return null;} // 卸载后重建父子快照与请求记录;模式选择控制能力慢响应、失败、版本冲突、丢 ACK 或挂起提交。 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';} // 内存 API 按路径分流:完整快照保存、版本命令、分区创建与成员迁移,各自记录参与者版本。 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}}); } // 保存原 body/key;命中上一次回执必须逐字节相同,返回既有结果而不再移动成员。 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); // 先核对双方期望版本,412/409 模式在迁移前返回;成功路径转换 owner 局部坐标并同时更新两个记录。 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; // 本夹具中分区内容原点相对父画布为 (124,136),进出分区据此增减坐标。 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)}))}; // lost 在记录已更新后回 502;delayed 则挂起成功 ACK,分别覆盖不确定重试与并行本地编辑。 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();}); }; // 有限轮询并推进 React effect,超时描述具体等待条件,不把请求已发出当作已确认。 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);}; // 选中投影图标并模拟拖到父坐标,再提交跨 owner 迁移,等待请求实际进入替身。 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'); }; // 迁移携带落点,不先 PATCH;挂起期间只保护待移动成员,其他文本/拖动仍可继续。 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'); // 放行 ACK 后检查坐标、选中和唯一归属,同时保留并最终保存无关编辑;其 undo 不能复活旧 owner。 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'); // 丢 ACK 的重试沿用完全相同的 body/key,并保留响应丢失之后的新编辑。 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'); // 两个迁移共享参与者,第二个等待首个 ACK 后的新版本;不额外发送坐标 PATCH。 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(); // 未上传图片尚无服务端节点:本地切换归属可撤销;上传到当前 owner 后,后续 undo/redo 才使用服务端迁移。 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'); // 版本刷新和分区创建响应都不能覆盖本地 pending/已完成图片;重复父快照也只能采用一次。 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'); // 明确不支持 positions 时才走旧的坐标保存加迁移两请求路径。 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'); // 切到真实 GraphCanvas DOM 事件:拖出子图显示局部同步徽标,另一个节点仍可拖动且 ACK 不复位它。 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();}); // 每次按投影 ID 重新查子图图标;成功迁移后应以父画布本地 ID 出现。 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'); // 记录无关节点变换,确认同步中实际拖动生效,随后与 ACK 后 DOM 比较。 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'); // 正常结束卸载 hook/画布;整个 JSDOM 与全局资源由外层 canvas-ui-check 清理。 await act(async()=>{root.render(null);await tick();}); }