172 lines
20 KiB
JavaScript
172 lines
20 KiB
JavaScript
/**
|
||
* 旧 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();});
|
||
}
|