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

172 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧 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();});
}