docs(tests): clarify legacy canvas DOM scenario coverage

This commit is contained in:
2026-11-12 18:23:15 +08:00
parent 7ac78e1191
commit 357c92d749
4 changed files with 14 additions and 7 deletions
+3 -1
View File
@@ -25,6 +25,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
// 内存 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';
// 此分支未区分 method,POST 也直接得到空列表;下方同路径 POST 分支不会到达,不模拟 canvas_version 推进。
if(suffix==='ai/runs')return json({runs:[]});
// 慢响应延迟超过两秒;一次网络失败只影响首个请求,允许检查重新探测而非永久判为不支持。
if(suffix==='capabilities'){
@@ -126,7 +127,8 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
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/已完成图片;重复父快照也只能采用一次。
// 空 AI 响应与分区创建响应不能覆盖本地 pending/已完成图片;重复父快照也只能采用一次。
// 当前 mock 的 ai/runs POST 被前面的通用分支截获,因此该调用不能单独证明版本推进后的 refresh 合并行为。
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();});
+5 -3
View File
@@ -19,7 +19,7 @@ const { build, stop } = tools("esbuild");
// 独立 DOM 和 localhost origin 提供 localStorage 等环境,不请求 localhost 服务。
const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', { url: "http://localhost", pretendToBeVisual: true });
const { window } = dom;
// React's async act creates MessageChannels; track and close test-owned ports.
// 记录此进程后来创建的 MessageChannel;React 异步 act 可持有这些端口,最终关闭以免测试进程悬挂。
const NativeMessageChannel = globalThis.MessageChannel;
const channels = [];
globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } };
@@ -109,7 +109,8 @@ try {
let calls = 0, controls, renders = 0;
apiMock = async () => { calls++; return Response.json({ runs: [] }); };
const tree = { busy: false, writers: { current: new Map() } };
// 挂载 AI hook 并计数渲染,检查空结果轮询既不重复请求也不造成额外渲染。
// 挂载 AI hook 并计数 Probe 函数调用,检查截止时间前触发的空闲回调不请求也不增加调用次数。
// 这是函数执行计数,不是 React DOM commit 次数;未推进墙上时间便手动触发 timer,验证的是 deadline 门禁。
function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; }
try {
await act(async () => { root.render(h(Probe)); await tick(); });
@@ -257,7 +258,8 @@ try {
const { feedbackScenario } = await import("./feedback-scenario.mjs");
await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick });
await act(async () => { root.unmount(); await tick(); });
// 清理临时打包文件、JSDOM、esbuild 及 act 创建的 MessageChannel 端口,恢复原通道构造器。
// 断言失败也会进入以下清理;root.unmount 只在正常路径执行,finally 仍关闭 JSDOM、esbuild 和记录到的通道。
// 清理临时打包文件并恢复原通道构造器,不逐项恢复仅供此独立进程使用的 DOM 全局替身。
} finally {
await rm(folder, { recursive: true, force: true }); window.close(); stop();
for (const channel of channels) { channel.port1.close(); channel.port2.close(); }
+3 -1
View File
@@ -75,11 +75,13 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC
assert.ok(document.querySelector(`img[src="${localURL}"]`),'Local image remains visible while remote preview loads');
const remote=document.querySelector('.react-flow__node img[src$="?variant=preview"]');await act(async()=>{remote.dispatchEvent(new window.Event('load'));await tick();});
assert.ok(!document.querySelector(`img[src="${localURL}"]`));assert.ok(revoked.includes(localURL));await wait(()=>snapshot.document.nodes.some(n=>n.data.src===asset.url),'original reference saved');
// 确认资源引用只保存一次,并保留上传期间的标题、位置和尺寸修改。
// 确认最终快照只有一个该资源节点,并保留标题;位置/尺寸比较的是当前 DOM,不逐字段检查持久化坐标。
// saves 可以含多次 PATCH,本断言不等于“资源引用只保存一次”。
const persisted=snapshot.document.nodes.find(n=>n.id===imageNodeID);assert.equal(persisted.data.title,'上传前改名');assert.equal(editable().style.transform,editedPosition);assert.equal(editable().style.width,editedWidth);assert.equal(editable().style.height,editedHeight);
assert.equal(snapshot.document.nodes.filter(n=>n.data.src===asset.url).length,1);assert.equal(snapshot.document.nodes.find(n=>n.data.src===asset.url).data.assetId,assetID);
console.log('PASS uploads: immediate local preview, unrelated editing, original-byte attachment and no blob URL persistence');
// 403 后预览和文件仍保留;手动重试沿用第一次幂等键,最终只落一个节点。
// 这是旧上传交互的重试场景;重试由用户点击,不是 headless 未知执行结果的自动回执查询流程。
await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'visible upload failure');
assert.ok(document.querySelector('img[src^="blob:"]'));assert.equal(saves.length,0);const key=uploads[0].key;mode='delayed';
await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='重试'),'click');assert.equal(uploads.length,2);assert.equal(uploads[1].key,key,'Manual retry retains the original idempotency key');
+3 -2
View File
@@ -79,7 +79,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
records.get(parent).document.edges.push({id:input.edge.id,source:'linked-frame',sourceHandle:'port:generated-port',target:'outside',targetHandle:'left'});
records.get(parent).version++;records.get(child).version++;
connectionReceipt={transaction_id:'tx-connect',parent:{board_id:parent,...structuredClone(records.get(parent))},snapshots:[{board_id:parent,...structuredClone(records.get(parent))},{board_id:child,...structuredClone(records.get(child))}],undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}};
return json({error:'upstream_unavailable'},502); // Simulate a lost response after the atomic commit.
return json({error:'upstream_unavailable'},502); // 内存记录已更新后丢失响应,客户端须以原 key/body 取回回执。
}
// 创建的撤销检查当前父/子版本,以新父版本恢复原内容并返回归档子 ID;回执由内存生成。
if(suffix.startsWith('transactions/tx-create-')&&suffix.endsWith('/undo')&&method==='POST'){
@@ -122,7 +122,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
await event(document.querySelector('[aria-label="继续向下传播"]'),'click');
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
assert.ok(job);assert.equal(document.querySelector('[aria-label="AI 模式"]').getAttribute('aria-pressed'),'true');
// Move a child while the run executes. Its original owner receives the save.
// 生成期间移动子节点,后续检查实际 PATCH 发给 child;父回执不能把子节点另存成父成员。
await event(node(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:240,clientY:215,buttons:1});await event(window,'mouseup',{clientX:240,clientY:215,button:0});
// 有限轮询真实组件消费替身事件的结果;通过 act 推进 effect,而非直接替组件改 DOM。
const waitUntil=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,80));});}throw new Error('Timed out: '+label);};
@@ -156,6 +156,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
document.elementFromPoint=originalHitTest;
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='linked-frame').data.ports.some(port=>port.id==='generated-port'),'cross-canvas connection transaction');
// 等待不确定结果提示再点击重试;两次请求最终只产生一条额外边。
// 本夹具只测试这一层父/子的共同上级连线事务,不据此证明任意深度 canvas 树的 LCA 计算。
await waitUntil(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='重试同步'),'uncertain transaction recovery');
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='重试同步'),'click');
assert.equal(connectionRequests,2);assert.equal(document.querySelector('.react-flow__edge[data-id="'+connectionReceipt.parent.document.edges.at(-1).id+'"]')!==null,true);