修复移动验收门禁漂移(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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);
|
||||
```
|
||||
|
||||
@@ -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。本步骤未部署、未推送、未修改生产配置或真实业务数据。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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=<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 目录 |
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户