From 5dac336fbf851a4196ee6607e5705ee840dce92f Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 1 Aug 2026 16:28:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A7=BB=E5=8A=A8=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E9=97=A8=E7=A6=81=E6=BC=82=E7=A7=BB=EF=BC=8807-27-yan?= =?UTF-8?q?g-jun-reimbursement-readiness=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 54 +++++++++++++++++++ .../implement.md | 15 +++++- .../prd.md | 3 ++ .../e2e/pc-detail-mobile-visual-wave3.spec.ts | 5 +- .../r5-real-device-feedback-mobile-ux.spec.ts | 24 ++++++--- docs/测试规约与回归门禁.md | 1 + scripts/qa/route-manifest.json | 3 +- .../qa/tests/check-minimal-profile.test.mjs | 2 +- 8 files changed, 94 insertions(+), 13 deletions(-) diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 494a445a..de65b9ac 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -2053,3 +2053,57 @@ hierarchy without turning every page into a bespoke Tailwind card system. - Focused Playwright coverage for changed finance/admin pages. The Wave4 baseline test is `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts`. - UI design rules check when changing these shared classes. + +## Scenario: Frontend Acceptance Spec Gate Registration + +### 1. Scope / Trigger + +- Trigger: a frontend E2E spec is treated as a current acceptance contract rather than historical evidence or a manual-only diagnostic. + +### 2. Signatures + +- Manifest entry: `scripts/qa/route-manifest.json` → `acceptanceSpecs[]`. +- Executable gate: `bash scripts/qa/run-local-candidate.sh list|focused|full`. + +### 3. Contracts + +- A current acceptance spec must be referenced by an executed gate; merely existing under `client/tests/e2e/` is not coverage. +- `list` must resolve the spec, while `focused` or `full` must execute it and reject failed or skipped acceptance results. +- DOM discovery may be dynamic to avoid duplicating selectors, but the expected business set, order or invariant must remain explicit. Never accept whatever the page happens to render. +- Historical or unowned specs must be deliberately classified for later connection or deletion; do not silently present them as active coverage. + +### 4. Validation & Error Matrix + +- Manifest entry missing or invalid → flow-coverage gate fails. +- Acceptance spec skipped, failed or timed out → local-candidate result gate fails. +- Dynamic collection with no fixed expectation → review failure even if the test is green. +- Gate fixture cannot execute a newly called child script → fixture test fails and the child script must be added through command discovery or explicit setup. + +### 5. Good/Base/Bad Cases + +- Good: discover current action tiles from the grid, then assert the fixed role-specific key/label set and layout invariants. +- Base: a stable single control uses a direct `data-testid` assertion and is listed in `acceptanceSpecs`. +- Bad: count whatever matching nodes exist, assert only their individual dimensions, and leave the spec outside every runner. + +### 6. Tests Required + +- `node --test scripts/qa/tests/check-flow-coverage.test.mjs scripts/qa/tests/check-minimal-profile.test.mjs scripts/qa/tests/verify-local-candidate-results.test.mjs`. +- `bash scripts/qa/run-local-candidate.sh list` must list the spec. +- `bash scripts/qa/run-local-candidate.sh focused` or `full` must finish with zero failed/skipped acceptance tests. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const actions = collectVisibleActions(); +for (const action of actions) expect(action.height).toBeGreaterThanOrEqual(44); +``` + +#### Correct + +```ts +const actions = collectVisibleActions(); +expect(actions.map(({ key, label }) => ({ key, label }))).toEqual(EXPECTED_ACTIONS); +for (const action of actions) expect(action.height).toBeGreaterThanOrEqual(44); +``` diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md index 65ba2bce..7f6d1792 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md @@ -8,6 +8,14 @@ - [x] 聚焦 Playwright 覆盖列表筛选返回、审批任务返回、直接打开降级、金额格式与移动单一入口;再运行 typecheck、build 和治理门禁。 - [x] 本轮只做本地实现与验证;未获部署批准前不发布、不修改生产配置或真实业务数据。 +## 0.2 R5 移动契约与门禁收口(本轮) + +- [x] 修正 R5 首页指标采集:从 `mobile-action-grid` 的直接入口动态取得 key 与布局,不再维护已经失效的旧 key 列表。 +- [x] 对经理测试角色明确断言有序入口集合为 `new-reimbursement / capture / leave / purchase`,保留 44px、单行、SVG、边界和无重叠检查。 +- [x] 将 R5 登记到 `route-manifest.json` 的 `acceptanceSpecs`,使 `run-local-candidate.sh` 的 list/focused/full 都能解析并执行该用例;不重复加入另一个门禁造成同轮双跑。 +- [x] 运行 R5 双设备、flow coverage/本地候选资产解析、前端 typecheck/build 与 Trellis 治理门禁;记录约 18 个其他孤儿 spec 为后续治理,不在本轮扩范围。 +- [x] 修正上一轮证据中的错误表述:页面实际仍有四个入口,只有旧采集器因 key 漂移看见两个;本轮未推送、未部署前不宣称线上已修复。 + ## 1. 生产写入前门禁 - [x] 只读复核活动 server 构建标识、Compose 项目、4 个 systemd service 当前 `BUILD_SHA`、timer enabled/active、最近源端备份与 ARM 新鲜度;输出仅限状态和计数。 @@ -352,5 +360,8 @@ - 只读 review:按 workflow task、PaymentBatchItem/batch、回单计数三源逐项复核映射与权限;修正“workflow 已通过但尚未建批次”原本无当前节点的问题,使其明确落在“待财务创建付款批次”。确认未来 checker 不预填、普通申请人无批次链接、共享批次不暴露其他单据/金额/材料;review 未修改状态机、权限矩阵或生产数据。 - 最终治理:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤未部署、未推送、未修改生产配置、真实报销状态、付款批次、回单或业务数据。 - PC/移动 UX 最小收口:PC 从带 `status/page/scope` 的报销列表进入审批任务后可返回同一查询,从审批任务进入报销详情后显示“返回审批详情”,直接打开详情显示“返回报销列表”;业务动作区不再重复列表入口。共享 `formatMoney` 统一总额、明细、发票和标准提示为人民币两位小数;移动底栏只保留“提交审批”,材料区继续保留“添加发票”。 -- UX 聚焦验证:Wave3 返回/金额/视觉相关 5 项通过,R111 PC/企微材料相关 2 项通过;typecheck 与生产 build 通过,截图确认 PC 返回位于标题上方、移动底栏为单一主按钮。旧 R5 综合移动用例 2 项在进入报销详情前因首页快捷入口既有期望“至少 4 个”而失败,实际为 2 个;该失败不由本轮改动引起,不作为本轮详情回归失败,也未修改其首页基线断言。 -- UX 治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。稳定的 PC 单层来源返回和共享人民币展示合同已写入 `frontend-react-vite-design` spec。本步骤未部署、未推送、未修改生产配置或真实业务数据。 +- UX 聚焦验证:Wave3 返回/金额/视觉相关 5 项通过,R111 PC/企微材料相关 2 项通过;typecheck 与生产 build 通过,截图确认 PC 返回位于标题上方、移动底栏为单一主按钮。旧 R5 综合移动用例 2 项在进入报销详情前因采集器仍查 `approvals/reimbursements/me` 等旧 key 而只识别出 2 个入口;页面对该经理角色实际仍渲染“新建报销、上传票据、请假记录、采购”4 项,上一轮“实际为 2 个”的表述已在本轮勘误。 +- R5 门禁收口:测试从常用动作宫格动态采集入口,但固定断言经理角色的 4 项 key/标签集合,并保留双设备 44px、单行、SVG、边界和无重叠检查;详情返回按当前统一详情壳断言为按钮。R5 单跑 2/2 通过,并登记到 `route-manifest.json` 的 `acceptanceSpecs`。 +- 候选门禁修复:`run-local-candidate.sh list` 首轮暴露隔离夹具漏建普通 `bash child-script`,修正为按命令行同时发现 `bash` 与 `bash -n` 子脚本;focused 首轮随后揭示 Wave3 合同页在 Radix 菜单仍打开时等待背景“筛选”按钮至 300 秒超时,改为先完成页面操作、最后验证“更多/导出 CSV”。最终本地候选 50 项、10 个核心 witness 全通过,0 failed、0 skipped、0 unexpected,状态为 `FOCUSED_BROWSER_PASS`。 +- 门禁治理复验:QA Node 测试 52/52 通过;flow coverage 将 acceptance spec 从 7 项增至 8 项,8/8 有效且 0 blocking gap;前端最终 `typecheck` 与生产 build 明确 `exit=0`,PWA 产物生成完成。 +- UX 治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。稳定的 PC 单层来源返回、共享人民币展示及 acceptance spec 必须接入真实门禁的合同已写入 `frontend-react-vite-design` spec。本步骤未部署、未推送、未修改生产配置或真实业务数据。 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md index 22f0ffae..c640797c 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md @@ -57,6 +57,8 @@ - PC 报销详情必须提供位于标题左上方、来源感知且可预测的返回入口:从带筛选的报销列表进入时返回原列表 URL,从审批任务进入时返回原审批任务;刷新、收藏或非法来源降级到报销列表。返回入口不得继续混在提交、催办、打印或“更多”等业务动作中,来源只接受受信任的 OA 内部报销列表或审批任务路径。 - PC 与移动报销详情的人民币金额必须复用 `client/src/lib/displayFormat.ts` 中的共享格式化函数,统一显示货币符号、千分位和两位小数;缺失或非法金额显示中性占位,不把缺失值伪装成 0 元。 - 移动草稿详情的“添加发票”只保留在“报销材料”区;底部固定动作栏只保留“提交审批”。不删除发票上传/选择能力,不改变提交前检查、材料权限或审批状态机。 +- 旧 R5 真机反馈用例必须跟随当前移动首页信息架构:从常用动作宫格动态采集实际入口,但对经理测试角色固定断言“新建报销、上传票据、请假记录、采购”四项完整集合,并继续验证触控尺寸、单行标签、SVG 图标和无重叠;不得把动态采集写成“采到什么就接受什么”的重言测试。 +- R5 不得继续作为无人执行的孤立 spec。将其登记到现有本地候选门禁的 acceptance spec 清单,使 `run-local-candidate.sh` 每次候选回归都执行;其余未被任何门禁引用的历史 E2E 仅记录为后续测试资产治理范围,本轮不批量接入、跳过或删除。 ## Scope @@ -109,6 +111,7 @@ - [x] PC 从报销列表或审批任务进入报销详情后,标题左上方显示明确的来源返回入口;返回可恢复列表查询参数或原审批任务,直接打开详情则安全回报销列表,业务动作区不再重复显示“报销列表”。 - [x] PC/移动报销详情的总额、明细和发票金额统一使用共享人民币格式,覆盖整数、小数、千分位、空值和非法值。 - [x] 移动草稿详情“报销材料”区保留“添加发票”,底部固定栏不再重复该动作且“提交审批”仍可见、可达并满足安全区/触控尺寸合同。 +- [x] R5 双设备用例按当前四项常用动作合同通过,且已进入本地候选门禁的 acceptance spec 清单;门禁资产解析能列出该 spec,不再依赖手工全目录执行才发现漂移。 ## Out of Scope diff --git a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts index 5dc15fb9..c92c3799 100644 --- a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts +++ b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts @@ -1188,9 +1188,6 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => { const contractRows = page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row"); await expect(contractRows).toHaveCount(2); await expect(contractRows.first()).toBeVisible(); - await page.locator(".ops-command-bar__actions").getByRole("button", { name: "更多" }).click(); - await expect(page.getByRole("menuitem", { name: "导出 CSV" })).toBeVisible(); - await page.keyboard.press("Escape"); await page.getByRole("button", { name: "筛选", exact: true }).click(); await expect(page.getByTestId("contracts-advanced-filters")).toBeVisible(); await page.getByRole("button", { name: "新增合同" }).click(); @@ -1202,6 +1199,8 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => { await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档"); await page.getByTestId("action-confirm-cancel").click(); await expect(page.getByTestId("action-confirm-dialog")).toHaveCount(0); + await page.locator(".ops-command-bar__actions").getByRole("button", { name: "更多" }).click(); + await expect(page.getByRole("menuitem", { name: "导出 CSV" })).toBeVisible(); await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true }); }); diff --git a/client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts b/client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts index 456a42be..af698d7f 100644 --- a/client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts +++ b/client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts @@ -21,6 +21,13 @@ const DEVICE_CASES = [ }, ] as const; +const EXPECTED_HOME_ACTIONS = [ + { key: "new-reimbursement", label: "新建报销" }, + { key: "capture", label: "上传票据" }, + { key: "leave", label: "请假记录" }, + { key: "purchase", label: "采购" }, +] as const; + const employee = { id: "11111111-1111-4111-8111-111111111111", employeeNo: "HYD000006", @@ -276,10 +283,13 @@ async function collectMobileMetrics(page: Page) { const stickyButtons = [ document.querySelector("[data-testid='reimbursement-submit']"), ].filter(Boolean).map((element) => element!.getBoundingClientRect()); - const actionItems = ["approvals", "capture", "reimbursements", "me", "leave"].map((key) => { - const element = document.querySelector(`[data-testid='mobile-action-${key}']`); - const label = document.querySelector(`[data-testid='mobile-action-${key}-label']`); - const icon = document.querySelector(`[data-testid='mobile-action-${key}-icon']`); + const actionItems = Array.from( + document.querySelectorAll("[data-testid='mobile-action-grid'] > a[data-testid^='mobile-action-']"), + ).map((element) => { + const testId = element.getAttribute("data-testid") ?? ""; + const key = testId.replace(/^mobile-action-/, ""); + const label = element.querySelector(`[data-testid='mobile-action-${key}-label']`); + const icon = element.querySelector(`[data-testid='mobile-action-${key}-icon']`); const rect = element?.getBoundingClientRect(); const labelRect = label?.getBoundingClientRect(); const iconRect = icon?.getBoundingClientRect(); @@ -301,6 +311,7 @@ async function collectMobileMetrics(page: Page) { labelWhiteSpace: style?.whiteSpace ?? "", iconWidth: iconRect?.width ?? 0, iconHeight: iconRect?.height ?? 0, + iconSvgTagName: icon?.querySelector("svg")?.tagName.toLowerCase() ?? "", overlaps, labelWithin: Boolean(rect && labelRect) && @@ -368,7 +379,7 @@ test.describe("R5 真机反馈移动交互", () => { expect(homeMetrics.bottomNavBottomGap).not.toBeNull(); expect(homeMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2); expect(homeMetrics.scrollWidth).toBeLessThanOrEqual(homeMetrics.width + 2); - expect(homeMetrics.actionItems.length).toBeGreaterThanOrEqual(4); + expect(homeMetrics.actionItems.map(({ key, text }) => ({ key, label: text }))).toEqual(EXPECTED_HOME_ACTIONS); for (const action of homeMetrics.actionItems) { expect(action.height, `${device.label} ${action.text} action touch height`).toBeGreaterThanOrEqual(44); expect(action.labelWhiteSpace, `${device.label} ${action.text} action label no wrap`).toBe("nowrap"); @@ -376,6 +387,7 @@ test.describe("R5 真机反馈移动交互", () => { expect(action.labelWidth, `${device.label} ${action.text} action label readable width`).toBeGreaterThanOrEqual(20); expect(action.iconWidth, `${device.label} ${action.text} action icon width`).toBeGreaterThanOrEqual(28); expect(action.iconHeight, `${device.label} ${action.text} action icon height`).toBeGreaterThanOrEqual(28); + expect(action.iconSvgTagName, `${device.label} ${action.text} action uses SVG icon`).toBe("svg"); expect(action.overlaps, `${device.label} ${action.text} action label/icon overlap`).toBe(false); expect(action.labelWithin, `${device.label} ${action.text} action label within tile`).toBe(true); expect(action.iconWithin, `${device.label} ${action.text} action icon within tile`).toBe(true); @@ -414,7 +426,7 @@ test.describe("R5 真机反馈移动交互", () => { await page.goto("/mobile/reimbursements/r5-reimbursement-1"); await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible(); await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0); - await expect(page.getByRole("link", { name: "返回" })).toBeVisible(); + await expect(page.getByRole("button", { name: "返回" })).toBeVisible(); await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible(); await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toHaveCount(0); await expect(page.getByTestId("reimbursement-invoice-add")).toBeVisible(); diff --git a/docs/测试规约与回归门禁.md b/docs/测试规约与回归门禁.md index 854ee9b0..e2da234c 100644 --- a/docs/测试规约与回归门禁.md +++ b/docs/测试规约与回归门禁.md @@ -115,6 +115,7 @@ R7E 前和后续非微小改动至少从以下组合中按风险选择;进入 | UI 规则脚本 | `node scripts/qa/check-ui-design-rules.mjs --strict` | 当前 UI 静态规则 0 findings;视觉与交互裁决以 `frontend-design` 为准 | | MIF7 移动矩阵 | `cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts --workers=1 --reporter=list` | 首页、进度/审批、通知、三类详情和财务首页通过;公网时设置 `E2E_BASE_URL=https://oa.hyinda.com` | | R6/MUD6/WCM6 移动回归 | `r6-mobile-responsive-system-hotfix`、`r6-mobile-unified-design-rollout`、`r6-wecom-mobile-rendering-hardening` | 旧移动矩阵用于防止信息架构整改破坏基础移动体验 | +| R5 真机反馈契约 | 已登记到 `route-manifest.json` 的 `acceptanceSpecs`,随 `bash scripts/qa/run-local-candidate.sh` 执行 | 固定验证经理角色首页四项常用动作和双设备触控/单行/SVG/无重叠合同;信息架构调整必须同步更新期望,不能降级为动态自证 | | 公网匿名 | `/ready`、`check-public-bundle-copy.mjs` | `/ready` 成功且 public bundle findings=0 | | 带凭据 live readiness | `LIVE_SMOKE_BASE_URL=https://oa.hyinda.com QA_ISSUE_ID= bash scripts/qa/run-live-smoke-readiness.sh` | live smoke 和真实 OCR 不含 `SKIP`;pending OCR 可按规则非阻断 `SKIP` | | 核心业务流 | `BUSINESS_FLOW_GROUPS=reimbursement,approval,leave,procurement,governance bash scripts/qa/run-business-flow-gates.sh local` | 工具会话无法稳定承载长组合时,可按相同 groups 分段执行并在报告中引用各分段 PASS 目录 | diff --git a/scripts/qa/route-manifest.json b/scripts/qa/route-manifest.json index f03f831c..1597ac2f 100644 --- a/scripts/qa/route-manifest.json +++ b/scripts/qa/route-manifest.json @@ -19,7 +19,8 @@ "client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts", "client/tests/e2e/internal-readiness-ui.spec.ts", "client/tests/e2e/system-action-confirmation.spec.ts", - "client/tests/e2e/payment-reviewer-permissions.spec.ts" + "client/tests/e2e/payment-reviewer-permissions.spec.ts", + "client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts" ], "localCandidateFlows": [ { diff --git a/scripts/qa/tests/check-minimal-profile.test.mjs b/scripts/qa/tests/check-minimal-profile.test.mjs index 6224d125..54882f06 100644 --- a/scripts/qa/tests/check-minimal-profile.test.mjs +++ b/scripts/qa/tests/check-minimal-profile.test.mjs @@ -32,7 +32,7 @@ test("local-candidate 基础 profile 在没有 issues 目录时不调用历史 C mkdirSync(path.join(fixtureRoot, "server"), { recursive: true }); mkdirSync(path.join(fixtureRoot, "client"), { recursive: true }); - for (const match of scriptSource.matchAll(/\bbash -n ([^\s]+)/gu)) { + for (const match of scriptSource.matchAll(/^\s*bash(?: -n)? ([^\s]+)/gmu)) { const relativeFile = match[1]; for (const base of [fixtureRoot, path.join(fixtureRoot, "server")]) { const file = path.join(base, relativeFile);