diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md index 225d40379..13a74a16f 100644 --- a/DEVELOPMENT_GUIDE.md +++ b/DEVELOPMENT_GUIDE.md @@ -311,6 +311,21 @@ service 不负责: 如果无法执行,需要在提交说明里写清原因。 +### 单元测试里没有 antd 的样式 + +`src/test/setup.ts` 会清空 antd 注入的样式,只保留一条规则:带 antd 隐藏状态类 +(如 `ant-dropdown-hidden`,完整列表见该文件)的元素为 `display: none`。原因是 jsdom 每次计算样式都要把 +全部 CSS 规则逐条匹配一遍,带着 antd 的样式时场景测试会慢一个数量级。 + +写用例时注意: + +- 已关闭的下拉、气泡、抽屉和未激活的标签页仍然查不到,`toBeVisible()` 可以照常 + 使用。 +- 其它依赖 antd 样式的断言不成立,包括颜色、尺寸,以及任何靠样式而不是状态类 + 实现的显隐。 +- 相邻的行内元素在可访问名称里不再有空格。例如 `Descriptions` 的一行在浏览器里 + 读作“标签 值”,测试里是“标签值”,按名称查询时用能容忍空白的正则。 + ### Eval 模块的单元测试与覆盖率 修改质量评测模块后,运行 `pnpm test:eval`;需要覆盖率时运行 diff --git a/src/modules/knowledge-network/components/object-metric/ObjectMetricTrialDrawer.test.tsx b/src/modules/knowledge-network/components/object-metric/ObjectMetricTrialDrawer.test.tsx index c54f70011..7aa435d38 100644 --- a/src/modules/knowledge-network/components/object-metric/ObjectMetricTrialDrawer.test.tsx +++ b/src/modules/knowledge-network/components/object-metric/ObjectMetricTrialDrawer.test.tsx @@ -1284,7 +1284,7 @@ describe("ObjectMetricTrialDrawer", () => { ); expect( - screen.getByRole("dialog", { name: "试算 · 订单支付额 已发布 · v5" }), + screen.getByRole("dialog", { name: /^试算 · 订单支付额\s*已发布 · v5$/ }), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "试算" })); expect(await screen.findByText("无数据")).toBeInTheDocument(); diff --git a/src/modules/knowledge-network/components/object-type/ObjectTypeResourceGraph.test.tsx b/src/modules/knowledge-network/components/object-type/ObjectTypeResourceGraph.test.tsx index 9b661097f..1142ddcb9 100644 --- a/src/modules/knowledge-network/components/object-type/ObjectTypeResourceGraph.test.tsx +++ b/src/modules/knowledge-network/components/object-type/ObjectTypeResourceGraph.test.tsx @@ -82,6 +82,14 @@ vi.mock("@/modules/knowledge-network/components/shared/resource-permission-reque import { ObjectTypeResourceGraph } from "./ObjectTypeResourceGraph"; +// A detail row's accessible name is "label value" in a browser, where antd lays +// the two out as separate boxes. Tests run without antd's CSS, so the same two +// inline elements join with no space; accept either. +function detailRow(name: string) { + const parts = name.split(" ").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return new RegExp(`^${parts.join("\\s*")}$`); +} + const originalMatchMedia = window.matchMedia; beforeAll(() => { @@ -414,13 +422,17 @@ describe("ObjectTypeResourceGraph", () => { expect(within(graph).queryByText(/适用:|附加影响:/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "查看详情" })); const drawer = await screen.findByRole("dialog", { name: "行动类详情" }); - expect(within(drawer).getByRole("row", { name: "行动类别 对象关系操作" })).toBeInTheDocument(); - expect(within(drawer).getByRole("row", { name: "具体操作 建立关系" })).toBeInTheDocument(); - expect(await within(drawer).findByRole("row", { name: "关系类 客户关系" })).toBeInTheDocument(); expect( - within(drawer).getByRole("row", { - name: "作用范围 销售订单 → 客户关系 → 客户", - }), + within(drawer).getByRole("row", { name: detailRow("行动类别 对象关系操作") }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("具体操作 建立关系") }), + ).toBeInTheDocument(); + expect( + await within(drawer).findByRole("row", { name: detailRow("关系类 客户关系") }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("作用范围 销售订单 → 客户关系 → 客户") }), ).toBeInTheDocument(); }); it("keeps the current object and shows lightweight category-specific empty hints", async () => { @@ -795,22 +807,32 @@ describe("ObjectTypeResourceGraph", () => { expect(actions).not.toBeNull(); expect(descriptions).not.toBeNull(); expect(description).not.toBeNull(); - expect(within(drawer).getByRole("row", { name: "资源类型 行动类" })).toBeInTheDocument(); - expect(within(drawer).getByRole("row", { name: "行动类别 对象实例操作" })).toBeInTheDocument(); - expect(within(drawer).getByRole("row", { name: "具体操作 修改对象实例" })).toBeInTheDocument(); expect( - within(drawer).getByRole("row", { name: "触发条件 订单金额 大于 100" }), + within(drawer).getByRole("row", { name: detailRow("资源类型 行动类") }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("行动类别 对象实例操作") }), ).toBeInTheDocument(); - expect(within(drawer).getByRole("row", { name: "执行来源类型 工具" })).toBeInTheDocument(); expect( - within(drawer).getByRole("row", { name: "执行来源名称 订单核对工具" }), + within(drawer).getByRole("row", { name: detailRow("具体操作 修改对象实例") }), ).toBeInTheDocument(); expect( - within(drawer).getByRole("row", { name: /执行参数 2 个执行参数 · 1 个动态参数/ }), + within(drawer).getByRole("row", { name: detailRow("触发条件 订单金额 大于 100") }), ).toBeInTheDocument(); - expect(within(drawer).getByRole("row", { name: "影响对象类 销售订单" })).toBeInTheDocument(); expect( - within(drawer).getByRole("row", { name: "影响说明 同步订单核对结果" }), + within(drawer).getByRole("row", { name: detailRow("执行来源类型 工具") }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("执行来源名称 订单核对工具") }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: /执行参数\s*2 个执行参数 · 1 个动态参数/ }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("影响对象类 销售订单") }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("影响说明 同步订单核对结果") }), ).toBeInTheDocument(); expect( actions!.compareDocumentPosition(descriptions!) & Node.DOCUMENT_POSITION_FOLLOWING, @@ -1299,8 +1321,12 @@ describe("ObjectTypeResourceGraph", () => { fireEvent.click(await screen.findByRole("button", { name: "关系类 客户关系" })); const drawer = screen.getByRole("dialog", { name: "关系类详情" }); - expect(within(drawer).getByRole("row", { name: "起点对象类 销售订单" })).toBeInTheDocument(); - expect(within(drawer).getByRole("row", { name: "终点对象类 客户" })).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("起点对象类 销售订单") }), + ).toBeInTheDocument(); + expect( + within(drawer).getByRole("row", { name: detailRow("终点对象类 客户") }), + ).toBeInTheDocument(); expect(within(drawer).getByText("描述")).toBeInTheDocument(); expect(within(drawer).queryByText("业务定义")).not.toBeInTheDocument(); expect(within(drawer).getByRole("button", { name: "申请权限" })).toHaveAttribute( @@ -1330,10 +1356,14 @@ describe("ObjectTypeResourceGraph", () => { fireEvent.click(await screen.findByRole("button", { name: "对象指标 订单金额" })); expect(await screen.findByText("v1")).toBeInTheDocument(); expect(screen.getByText("草稿")).toBeInTheDocument(); - expect(screen.queryByRole("row", { name: "资源类型 对象指标" })).not.toBeInTheDocument(); - expect(screen.getByRole("row", { name: /对象指标编码 order_amount/ })).toBeInTheDocument(); - expect(screen.getByRole("row", { name: "对象指标类型 原子对象指标" })).toBeInTheDocument(); - expect(screen.getByRole("row", { name: "所属对象类 销售订单" })).toBeInTheDocument(); + expect( + screen.queryByRole("row", { name: detailRow("资源类型 对象指标") }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("row", { name: /对象指标编码\s*order_amount/ })).toBeInTheDocument(); + expect( + screen.getByRole("row", { name: detailRow("对象指标类型 原子对象指标") }), + ).toBeInTheDocument(); + expect(screen.getByRole("row", { name: detailRow("所属对象类 销售订单") })).toBeInTheDocument(); expect(screen.queryByText("计算范围")).not.toBeInTheDocument(); expect(screen.getByText("业务定义")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "详情" })).toBeInTheDocument(); diff --git a/src/test/setup.test.ts b/src/test/setup.test.ts index 0dd3057df..8dff84f69 100644 --- a/src/test/setup.test.ts +++ b/src/test/setup.test.ts @@ -5,6 +5,9 @@ * Conditions. See LICENSE for the full text. */ +import { Button } from "antd"; +import { render } from "@testing-library/react"; +import { createElement } from "react"; import { expect, it } from "vitest"; it("reads the scrollbar pseudo-element without a jsdom not-implemented warning", () => { @@ -33,3 +36,25 @@ it("reads the scrollbar pseudo-element without a jsdom not-implemented warning", virtualConsole.off("jsdomError", captureWarning); } }); + +it("keeps the styles antd injects empty so getComputedStyle stays cheap", () => { + render(createElement(Button, null, "OK")); + + const injected = [...document.head.querySelectorAll("style[data-rc-order]")]; + // If antd stops tagging its style nodes this way, setup.ts no longer empties + // them and every scene test silently slows down by an order of magnitude. + expect(injected.length).toBeGreaterThan(0); + expect(injected.map((style) => style.innerHTML)).toEqual(injected.map(() => "")); +}); + +it("still hides what antd marks with a hidden state class", () => { + const closedDropdown = document.createElement("div"); + closedDropdown.className = "ant-dropdown ant-dropdown-hidden"; + document.body.append(closedDropdown); + + try { + expect(closedDropdown).not.toBeVisible(); + } finally { + closedDropdown.remove(); + } +}); diff --git a/src/test/setup.ts b/src/test/setup.ts index 167ffd68b..eea3ab4be 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -19,6 +19,63 @@ const getComputedStyleWithScrollbarFallback: typeof window.getComputedStyle = ( window.getComputedStyle = getComputedStyleWithScrollbarFallback; globalThis.getComputedStyle = getComputedStyleWithScrollbarFallback; +// antd's CSS-in-JS injects thousands of rules into . jsdom answers every +// getComputedStyle() by matching each of those rules against the element, and +// Testing Library asks for it on every node when a *ByRole query resolves an +// accessible name, so scene tests spent ~90% of their time in selector +// matching. Tests assert behaviour, not antd's look, so the style nodes that +// rc-util injects (always tagged with data-rc-order before the CSS is written) +// are kept empty. Styles a test writes itself are left alone. +const styleInnerHtml = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML"); +// The native accessors are re-invoked below with the style element as `this`. +// eslint-disable-next-line @typescript-eslint/unbound-method +const { get: readInnerHtml, set: writeInnerHtml } = styleInnerHtml ?? {}; +if (readInnerHtml && writeInnerHtml) { + Object.defineProperty(HTMLStyleElement.prototype, "innerHTML", { + configurable: true, + enumerable: true, + get(this: HTMLStyleElement) { + return readInnerHtml.call(this) as string; + }, + set(this: HTMLStyleElement, value: string) { + writeInnerHtml.call(this, this.hasAttribute("data-rc-order") ? "" : value); + }, + }); +} + +// The one thing tests do rely on from antd's CSS: a closed dropdown, tooltip, +// popover, drawer or inactive tab pane stays in the DOM and is only hidden by +// a state class. Keep those rules so closed overlays stay out of *ByRole +// queries and toBeVisible() keeps its meaning. The list mirrors the +// `display: none` state classes in antd's component styles. +const antdHiddenStateClasses = [ + "ant-collapse-content-hidden", + "ant-drawer-content-wrapper-hidden", + "ant-dropdown-hidden", + "ant-dropdown-menu-hidden", + "ant-dropdown-menu-submenu-hidden", + "ant-form-item-hidden", + "ant-mentions-dropdown-hidden", + "ant-menu-hidden", + "ant-menu-submenu-hidden", + "ant-modal-mask-hidden", + "ant-picker-dropdown-hidden", + "ant-popover-hidden", + "ant-select-dropdown-hidden", + "ant-tabs-dropdown-hidden", + "ant-tabs-tabpane-hidden", + "ant-tag-hidden", + "ant-tooltip-hidden", + "ant-tour-hidden", + "ant-upload-hidden", +]; +const antdHiddenState = document.createElement("style"); +antdHiddenState.dataset.testAntdHiddenState = ""; +antdHiddenState.textContent = `${antdHiddenStateClasses + .map((name) => `.${name}`) + .join(", ")} { display: none; }`; +document.head.append(antdHiddenState); + afterEach(() => { cleanup(); });