/** * 应用反馈组件的 JSDOM 场景:原生 title 回退、provider tooltip、通知更新及画布成功/错误提示。 * 只断言 DOM 属性、事件和状态,不证明真实屏幕阅读器播报或浏览器浮层排版。 */ import assert from "node:assert/strict"; // 复用外层 React root,先验证独立控件,再验证带 AppFeedback 的画布交互。 export async function feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick }) { await act(async () => { root.render(null); await tick(); }); let button, clicks = 0; // 最小 SVG 图标替身只满足 IconButton 接口,不依赖真实图标资源。 const icon = () => h("svg", { "aria-hidden": true }); // ref 必须直接指向业务 button;点击计数用于检查 tooltip 不吞掉原操作。 const control = () => h(IconButton, { icon, label: "旋转工具", title: "旋转选中元素", ref: (value) => { button = value; }, onClick: () => { clicks++; } }); // 没有 provider 时保留原生 title;加入 provider 后不增加包裹层,改用关联 tooltip。 await act(async () => { root.render(control()); await tick(); }); assert.equal(button.title, "旋转选中元素", "Standalone controls retain native explanations"); await act(async () => { root.render(h(AppFeedback, null, h("div", { id: "controls" }, control()))); await tick(); }); assert.ok(document.querySelector("#controls").firstElementChild === button, "Tooltip does not wrap the button or alter toolbar geometry"); assert.equal(button.tagName, "BUTTON", "Forwarded ref still points at the actual button"); assert.equal(button.title, "", "A provider avoids duplicate native and Radix tooltips"); // 键盘聚焦应出现说明并建立 aria-describedby,Escape 隐藏说明后点击仍能触发业务回调。 await act(async () => { button.focus(); await tick(); }); const tooltip = document.querySelector('[role="tooltip"]'); assert.ok(tooltip?.textContent.includes("旋转选中元素"), "Keyboard focus exposes the explanation"); assert.ok(button.getAttribute("aria-describedby"), "Explanation is associated with its trigger"); await event(document, "keydown", { key: "Escape" }); assert.ok(!document.querySelector('[role="tooltip"]'), "Escape dismisses the tooltip"); await event(button, "click"); assert.equal(clicks, 1, "Tooltips preserve business clicks"); // 同一通知 ID 更新内容,只保留一个 toast;随后显式 dismiss 并等待退出清理。 await act(async () => { notify({ id: "feedback-check", title: "正在处理" }); await tick(); notify({ id: "feedback-check", title: "已完成", level: "success" }); await tick(); }); assert.equal(document.querySelectorAll('[data-sonner-toast]').length, 1, "One logical notice updates instead of duplicating"); assert.ok(document.querySelector('[data-sonner-toast]').textContent.includes("已完成")); assert.ok(document.querySelector('[aria-label="关闭通知"]'), "Notification close control is labelled"); await act(async () => { dismissNotification("feedback-check"); await tick(); }); for (let i = 0; i < 20 && document.querySelector('[data-sonner-toast]'); i++) await act(async () => { await tick(); }); assert.ok(!document.querySelector('[data-sonner-toast]'), "Dismissed notifications are removed"); console.log("PASS feedback: native fallback, forwarded ref, keyboard tooltip/Escape, clicks and stable notification IDs"); // 播种旧版 demo 本地草稿,包含文本与分区;这不是服务端成员迁移测试。 const id = "feedback-board"; const node = { ...makeCanvasNode("text", { x: 100, y: 100 }), id: "feedback-text" }; const frame = { ...makeCanvasNode("frame", { x: 500, y: 100 }), id: "feedback-frame" }; frame.data.title = "提示分区"; localStorage.setItem(`endoleap_canvas_v1:w:${id}`, JSON.stringify({ ...emptyCanvas(), nodes: [node, frame] })); await act(async () => { root.render(h(AppFeedback, null, h(GraphCanvas, { board: { id, workspace_id: "w", title: "反馈测试", kind: "board", version: 1 } }))); await tick(); }); // 拖入分区后同时核对成功提示和实际归属徽标,不能只看到 toast 就算操作生效。 const textNode = document.querySelector('.react-flow__node[data-id="feedback-text"]'); await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 }); await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 }); await event(window, "mousemove", { clientX: 700, clientY: 200, buttons: 1 }); await event(window, "mouseup", { clientX: 700, clientY: 200, button: 0 }); assert.ok(document.querySelector('[data-sonner-toast][data-type="success"]')?.textContent.includes("已加入「提示分区」")); assert.ok(document.querySelector('[title="属于:提示分区"]'), "Notifications do not replace actual membership state"); // 用 PDF MIME 模拟不支持的图片选择,错误通知出现后继续创建文本,检查错误不阻塞编辑。 const fileInput = document.querySelector('input[type="file"][accept^="image/"]'); Object.defineProperty(fileInput, "files", { configurable: true, value: [new File(["test"], "test.pdf", { type: "application/pdf" })] }); await act(async () => { fileInput.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); }); assert.ok(document.querySelector('[data-sonner-toast][data-type="error"]')?.textContent.includes("演示模式请选择")); await event(document.querySelector('[aria-label="添加文本卡片"]'), "click"); assert.equal(document.querySelectorAll('.react-flow__node').length, 3, "An error notification never blocks editing"); // 关闭此画布错误通知并卸载组件,避免影响后续共享 root 的场景。 await act(async () => { dismissNotification(`canvas:${id}:error`); root.render(null); await tick(); }); console.log("PASS canvas feedback: membership success, input errors and continued local editing through the application provider"); }