fix(e2e): read duty from select value, not row option text
UI-13a treated concatenated select options as the current role. Assert the control value or badge role for that doc instead. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: "UI-13a 用整行 option 文案误判编制负责人"
|
||||||
|
status: in_progress
|
||||||
|
created: 2026-09-13
|
||||||
|
updated: 2026-09-13
|
||||||
|
severity: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI-13a 用整行 option 文案误判编制负责人
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
第16轮真实 UI(schema 30,`dev@dd5671e`):UI-00 至 UI-12 PASS;分类导航修复已实证,UI-13a 已能从「我的私密」切回「流程受控」并打开 reviewer B 权限矩阵。
|
||||||
|
|
||||||
|
失败在职责断言:`intakeRow.innerText` 被整行匹配,报 `reviewer B must not be 编制负责人`。实际 `innerText` 为:
|
||||||
|
|
||||||
|
`V0.5 用户初始需求收集表当前打开 状态: drafting ✍️ 编制负责人 🔍 审核负责人 👑 批准负责人 👁️ 查阅者 (只读) 前往`
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
| 层 | 问题 | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 职责单元格 | `onRoleChangeForDoc` 存在时渲染 `<select>`,四个 option 文案都在 DOM | `element.innerText` 含全部 option |
|
||||||
|
| 验收 | `/编制负责人/.test(整行文本)` | 审核人行也被判成编制负责人 |
|
||||||
|
| 控件语义 | select/badge 无稳定 `data-testid` / `data-doc-role` | 脚本只能刮行文案 |
|
||||||
|
|
||||||
|
select 的 `value` 才是当前职责;reviewer 无 `project:assign_lead` 时应 `disabled`。
|
||||||
|
|
||||||
|
## 严重度
|
||||||
|
|
||||||
|
`high`
|
||||||
|
|
||||||
|
权限矩阵已打开,真实只读检查未跑到就被假阳性打断。
|
||||||
|
|
||||||
|
## 关联整改
|
||||||
|
|
||||||
|
- Remediation: [../remediation/2026-09-13-document-revision-duty-cell-assertion.md](../remediation/2026-09-13-document-revision-duty-cell-assertion.md)
|
||||||
|
- Short-term: [../ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md](../ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md)
|
||||||
|
- 分支: `fix/document-revision-duty-cell-assertion`
|
||||||
|
|
||||||
|
## 证据
|
||||||
|
|
||||||
|
- 失败证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui13a-duty-cell-assertion-failure/`
|
||||||
|
|
||||||
|
## 开放问题
|
||||||
|
|
||||||
|
- 本轮不重跑真实 UI-13a;不得标 E2E 通过或任务 `done`
|
||||||
@@ -31,6 +31,14 @@
|
|||||||
|
|
||||||
### 最新专项审计(2026-09-13)
|
### 最新专项审计(2026-09-13)
|
||||||
|
|
||||||
|
**文件**: [2026-09-13-document-revision-duty-cell-assertion.md](./2026-09-13-document-revision-duty-cell-assertion.md)
|
||||||
|
|
||||||
|
**结论**: 第16轮 UI-13a 已打开 reviewer B 权限矩阵;整行 `innerText` 含全部 select option,误命中「编制负责人」。须读 select value / badge role。
|
||||||
|
|
||||||
|
**整改**: [职责单元格精确断言整改](../remediation/2026-09-13-document-revision-duty-cell-assertion.md)
|
||||||
|
|
||||||
|
### 受控文档分类导航(2026-09-13)
|
||||||
|
|
||||||
**文件**: [2026-09-13-document-revision-workflow-category-navigation.md](./2026-09-13-document-revision-workflow-category-navigation.md)
|
**文件**: [2026-09-13-document-revision-workflow-category-navigation.md](./2026-09-13-document-revision-workflow-category-navigation.md)
|
||||||
|
|
||||||
**结论**: 第15轮 UI-00–12 PASS;UI-13a 在私密分类下直接展开文档菜单,找不到 `doc_proj*_intake`。须先用页面控件切到流程受控。
|
**结论**: 第15轮 UI-00–12 PASS;UI-13a 在私密分类下直接展开文档菜单,找不到 `doc_proj*_intake`。须先用页面控件切到流程受控。
|
||||||
|
|||||||
+4
-2
@@ -1,11 +1,11 @@
|
|||||||
# 当前焦点
|
# 当前焦点
|
||||||
|
|
||||||
**更新**: 2026-09-13
|
**更新**: 2026-09-13
|
||||||
**production_ready**: no(DOCREV:schema **30**;第15轮 UI-13a 分类导航契约;不执行 env-service 重建 / 不声称 E2E 通过)
|
**production_ready**: no(DOCREV:schema **30**;第16轮 UI-13a 职责单元格断言;不执行 env-service 重建 / 不声称 E2E 通过)
|
||||||
|
|
||||||
## 焦点
|
## 焦点
|
||||||
|
|
||||||
1. **DOCREV-RT**: [受控文档分类导航契约](short-term/2026-09-13-document-revision-workflow-category-navigation.md)(`fix/document-revision-workflow-category-navigation`;第15轮 UI-13a 停在私密分类找不到 intake;不声称 E2E 通过)
|
1. **DOCREV-RT**: [职责单元格精确断言](short-term/2026-09-13-document-revision-duty-cell-assertion.md)(`fix/document-revision-duty-cell-assertion`;第16轮整行 option 文案误判编制负责人;不声称 E2E 通过)
|
||||||
2. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(已合入 `dev@339e871`;仍 `in_progress`,待占槽重跑 UI-03)
|
2. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(已合入 `dev@339e871`;仍 `in_progress`,待占槽重跑 UI-03)
|
||||||
3. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06)
|
3. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06)
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
| Cursor-Auto | document-revision-quality-repair-workspace-contract | fix/document-revision-quality-repair-workspace-contract | - | document-revision-quality-repair-workspace-contract | in_progress |
|
| Cursor-Auto | document-revision-quality-repair-workspace-contract | fix/document-revision-quality-repair-workspace-contract | - | document-revision-quality-repair-workspace-contract | in_progress |
|
||||||
| Cursor-Auto | document-revision-fast-completion-observation | fix/document-revision-fast-completion-observation | - | document-revision-fast-completion-observation | in_progress |
|
| Cursor-Auto | document-revision-fast-completion-observation | fix/document-revision-fast-completion-observation | - | document-revision-fast-completion-observation | in_progress |
|
||||||
| Cursor-Auto | document-revision-workflow-category-navigation | fix/document-revision-workflow-category-navigation | - | document-revision-workflow-category-navigation | in_progress |
|
| Cursor-Auto | document-revision-workflow-category-navigation | fix/document-revision-workflow-category-navigation | - | document-revision-workflow-category-navigation | in_progress |
|
||||||
|
| Cursor-Auto | document-revision-duty-cell-assertion | fix/document-revision-duty-cell-assertion | - | document-revision-duty-cell-assertion | in_progress |
|
||||||
|
|
||||||
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
|
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
|
||||||
|
|
||||||
@@ -81,3 +82,4 @@
|
|||||||
| UI-03 质量修复子工作区契约审计 | [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md) |
|
| UI-03 质量修复子工作区契约审计 | [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md) |
|
||||||
| UI-11 快速完成观测误判审计 | [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md) |
|
| UI-11 快速完成观测误判审计 | [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md) |
|
||||||
| UI-13a 私密分类导航失败审计 | [../audit/2026-09-13-document-revision-workflow-category-navigation.md](../audit/2026-09-13-document-revision-workflow-category-navigation.md) |
|
| UI-13a 私密分类导航失败审计 | [../audit/2026-09-13-document-revision-workflow-category-navigation.md](../audit/2026-09-13-document-revision-workflow-category-navigation.md) |
|
||||||
|
| UI-13a 职责单元格误判审计 | [../audit/2026-09-13-document-revision-duty-cell-assertion.md](../audit/2026-09-13-document-revision-duty-cell-assertion.md) |
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
title: "职责单元格精确断言"
|
||||||
|
status: in_progress
|
||||||
|
created: 2026-09-13
|
||||||
|
updated: 2026-09-13
|
||||||
|
owner: Cursor-Auto
|
||||||
|
branch: fix/document-revision-duty-cell-assertion
|
||||||
|
worktree: "-"
|
||||||
|
mutex: document-revision-duty-cell-assertion
|
||||||
|
---
|
||||||
|
|
||||||
|
# 职责单元格精确断言
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
堵住第16轮 UI-13a:权限矩阵已打开,整行 `innerText` 含全部 select option,误命中「编制负责人」。改为读控件 value/badge。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- `web/src/lib/dutyCellAssertion.ts` 与单测
|
||||||
|
- `UserProjectRightsModal` 职责控件可访问语义
|
||||||
|
- `web/e2e/document-revision-runtime.spec.ts` UI-13a
|
||||||
|
- audit / remediation / CURRENT
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不放宽 reviewer 只读;不删双击/无编辑提示检查
|
||||||
|
- 不碰证据;不合 dest;不启停容器;不 E2E;不 push
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. [x] 从 `dev@dd5671e` 开分支并登记 mutex
|
||||||
|
2. [x] 精确角色解析 + option 文案不得误判测试
|
||||||
|
3. [x] 产品控件 data-* + UI-13a 接线
|
||||||
|
4. [x] `npm test` + `npm run build` 并原子提交
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [x] select/badge 精确解析 author、reviewer、viewer
|
||||||
|
- [x] 整行 option 文案不得判为编制负责人
|
||||||
|
- [ ] 现场 UI-13a(未跑)
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- Audit: [../../audit/2026-09-13-document-revision-duty-cell-assertion.md](../../audit/2026-09-13-document-revision-duty-cell-assertion.md)
|
||||||
|
- Remediation: [../../remediation/2026-09-13-document-revision-duty-cell-assertion.md](../../remediation/2026-09-13-document-revision-duty-cell-assertion.md)
|
||||||
|
|
||||||
|
## 证据 / 备注
|
||||||
|
|
||||||
|
- mutex:`document-revision-duty-cell-assertion`(新 mutex)
|
||||||
|
- 基线:`dev@dd5671e`
|
||||||
|
- 验证:`npx tsx --test src/lib/dutyCellAssertion.test.ts` PASS(5);`cd web && npm test` PASS;`npm run build` PASS
|
||||||
|
- **未跑真实 UI-13a,不得标 done**;**不得 merge / 启停容器 / E2E / push**
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: "职责单元格按 value/badge 精确断言"
|
||||||
|
status: in_progress
|
||||||
|
created: 2026-09-13
|
||||||
|
updated: 2026-09-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# 职责单元格按 value/badge 精确断言
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
链到 [../audit/2026-09-13-document-revision-duty-cell-assertion.md](../audit/2026-09-13-document-revision-duty-cell-assertion.md)。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 精确定位该文档行的职责控件
|
||||||
|
- select:校验 `value`(reviewer B 期望 `reviewer` 或只读 `viewer`),不可配置时 `disabled`
|
||||||
|
- badge:读 `data-doc-role`,不用整行 option 文案
|
||||||
|
- 保留无「双击任意段落就地编辑」、双击不出现编辑器
|
||||||
|
|
||||||
|
## 分波次计划
|
||||||
|
|
||||||
|
| 波次 | 内容 | 验收 |
|
||||||
|
|------|------|------|
|
||||||
|
| Wave 0 | 控件 `data-testid`/`data-doc-id`/`data-doc-role` + 解析谓词 + UI-13a | option 文案不得误判;author/reviewer/viewer 精确解析;`npm test`/`build` |
|
||||||
|
| Wave 1 | 占槽重跑真实 UI-13a(本轮不做) | 矩阵断言过,只读检查仍跑 |
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
1. `resolveDutyCell`:只接受 `select.value` 或 `badge.role`;`row-text` 一律 reject。
|
||||||
|
2. 产品 select/badge/行补 `data-testid`、`data-doc-id`、`data-doc-role`、`data-can-assign`。
|
||||||
|
3. UI-13a 按 docId 定位控件,不再 `intakeRow.innerText`。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [x] 整行含全部 option 时不得判为 author
|
||||||
|
- [x] select `reviewer` + 不可配置 disabled 通过
|
||||||
|
- [x] author/reviewer/viewer 精确解析
|
||||||
|
- [ ] 真实 UI-13a(本轮不做)
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- Audit / Short-term:同 slug `2026-09-13-document-revision-duty-cell-assertion`
|
||||||
|
- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui13a-duty-cell-assertion-failure/`
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
|
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
|
||||||
|
| [2026-09-13-document-revision-duty-cell-assertion.md](./2026-09-13-document-revision-duty-cell-assertion.md) | UI-13a 整行 option 文案误判编制负责人,须读 select value/badge |
|
||||||
| [2026-09-13-document-revision-workflow-category-navigation.md](./2026-09-13-document-revision-workflow-category-navigation.md) | UI-13a 停在私密分类,打开受控文档前必须先切流程受控 |
|
| [2026-09-13-document-revision-workflow-category-navigation.md](./2026-09-13-document-revision-workflow-category-navigation.md) | UI-13a 停在私密分类,打开受控文档前必须先切流程受控 |
|
||||||
| [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md) | UI-11 快完成被硬等 inflight 误杀,只改验收观测契约 |
|
| [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md) | UI-11 快完成被硬等 inflight 误杀,只改验收观测契约 |
|
||||||
| [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md) | 质量修复子工作区越界改根 output.md,本地目标缺失 |
|
| [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md) | 质量修复子工作区越界改根 output.md,本地目标缺失 |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` |
|
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` |
|
||||||
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
|
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
|
||||||
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
|
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
|
||||||
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-13a 分类导航](../audit/2026-09-13-document-revision-workflow-category-navigation.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录;打开受控文档前须先切流程受控分类 | `ci` |
|
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-13a 职责单元格](../audit/2026-09-13-document-revision-duty-cell-assertion.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录;职责断言不得用整行 option 文案 | `ci` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
CATEGORY_TRIGGER_LABEL,
|
CATEGORY_TRIGGER_LABEL,
|
||||||
requiredCategoryForDocOpen,
|
requiredCategoryForDocOpen,
|
||||||
} from '../src/lib/workflowCategoryNavigation';
|
} from '../src/lib/workflowCategoryNavigation';
|
||||||
|
import {
|
||||||
|
DOC_DUTY_BADGE_TESTID,
|
||||||
|
DOC_DUTY_SELECT_TESTID,
|
||||||
|
evaluateReviewerOrViewerDuty,
|
||||||
|
} from '../src/lib/dutyCellAssertion';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document revision runtime — real UI acceptance (UI-01..UI-16).
|
* Document revision runtime — real UI acceptance (UI-01..UI-16).
|
||||||
@@ -373,6 +378,42 @@ async function openWorkflowDocById(page: Page, docId: string): Promise<void> {
|
|||||||
await item.click();
|
await item.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readReviewerIntakeDuty(page: Page, docId?: string): Promise<string> {
|
||||||
|
const select = docId
|
||||||
|
? page.locator(`[data-testid="${DOC_DUTY_SELECT_TESTID}"][data-doc-id="${docId}"]`)
|
||||||
|
: page
|
||||||
|
.locator('tr')
|
||||||
|
.filter({ hasText: /用户初始需求|需求摸底/ })
|
||||||
|
.locator(`[data-testid="${DOC_DUTY_SELECT_TESTID}"]`);
|
||||||
|
if ((await select.count()) > 0) {
|
||||||
|
await expect(select).toBeVisible({ timeout: 10_000 });
|
||||||
|
const verdict = evaluateReviewerOrViewerDuty({
|
||||||
|
source: 'select',
|
||||||
|
value: await select.inputValue(),
|
||||||
|
disabled: await select.isDisabled(),
|
||||||
|
canAssignLead: (await select.getAttribute('data-can-assign')) === 'true',
|
||||||
|
});
|
||||||
|
if (!verdict.ok) fail(`reviewer B intake duty select rejected: ${verdict.reason}`);
|
||||||
|
return verdict.role || (await select.inputValue());
|
||||||
|
}
|
||||||
|
const badge = docId
|
||||||
|
? page.locator(`[data-testid="${DOC_DUTY_BADGE_TESTID}"][data-doc-id="${docId}"]`)
|
||||||
|
: page
|
||||||
|
.locator('tr')
|
||||||
|
.filter({ hasText: /用户初始需求|需求摸底/ })
|
||||||
|
.locator(`[data-testid="${DOC_DUTY_BADGE_TESTID}"]`);
|
||||||
|
if ((await badge.count()) === 0) {
|
||||||
|
fail('intake duty cell missing select and badge');
|
||||||
|
}
|
||||||
|
await expect(badge).toBeVisible({ timeout: 10_000 });
|
||||||
|
const verdict = evaluateReviewerOrViewerDuty({
|
||||||
|
source: 'badge',
|
||||||
|
role: (await badge.getAttribute('data-doc-role')) || '',
|
||||||
|
});
|
||||||
|
if (!verdict.ok) fail(`reviewer B intake duty badge rejected: ${verdict.reason}`);
|
||||||
|
return verdict.role || '';
|
||||||
|
}
|
||||||
|
|
||||||
async function sendChat(page: Page, text: string) {
|
async function sendChat(page: Page, text: string) {
|
||||||
const box = page.getByPlaceholder(/向项目助手提问/);
|
const box = page.getByPlaceholder(/向项目助手提问/);
|
||||||
await expect(box).toBeVisible({ timeout: 15_000 });
|
await expect(box).toBeVisible({ timeout: 15_000 });
|
||||||
@@ -1229,15 +1270,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => {
|
|||||||
|
|
||||||
await pageB.locator('[title*="法定权限矩阵"]').click();
|
await pageB.locator('[title*="法定权限矩阵"]').click();
|
||||||
await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toBeVisible({ timeout: 15_000 });
|
await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toBeVisible({ timeout: 15_000 });
|
||||||
const intakeRow = pageB.locator('tr').filter({ hasText: /用户初始需求|需求摸底/ }).first();
|
const duty = await readReviewerIntakeDuty(pageB, controlledDocId);
|
||||||
await expect(intakeRow).toBeVisible({ timeout: 10_000 });
|
|
||||||
const duty = (await intakeRow.innerText()).replace(/\s+/g, ' ');
|
|
||||||
if (/编制负责人/.test(duty)) {
|
|
||||||
fail(`reviewer B must not be 编制负责人 on intake; duty=${duty}`);
|
|
||||||
}
|
|
||||||
if (!/审核负责人|查阅者/.test(duty)) {
|
|
||||||
fail(`reviewer B intake duty must be 审核/只读, got ${duty}`);
|
|
||||||
}
|
|
||||||
await pageB.getByRole('button', { name: '关闭面板' }).click();
|
await pageB.getByRole('button', { name: '关闭面板' }).click();
|
||||||
await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toHaveCount(0, { timeout: 10_000 });
|
await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
import { UserProfile, DocPermissionRole } from '../../types/docgov';
|
import { UserProfile, DocPermissionRole } from '../../types/docgov';
|
||||||
import { DeliverableItem } from '../../types/workbench';
|
import { DeliverableItem } from '../../types/workbench';
|
||||||
import { resolveCurrentDocRole } from '../../lib/currentDocRole';
|
import { resolveCurrentDocRole } from '../../lib/currentDocRole';
|
||||||
|
import {
|
||||||
|
DOC_DUTY_BADGE_TESTID,
|
||||||
|
DOC_DUTY_ROW_TESTID,
|
||||||
|
DOC_DUTY_SELECT_TESTID,
|
||||||
|
} from '../../lib/dutyCellAssertion';
|
||||||
|
|
||||||
interface UserProjectRightsModalProps {
|
interface UserProjectRightsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -424,6 +429,9 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={item.docId}
|
key={item.docId}
|
||||||
|
data-testid={DOC_DUTY_ROW_TESTID}
|
||||||
|
data-doc-id={item.docId}
|
||||||
|
data-doc-role={role}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: idx === deliverables.length - 1 ? 'none' : '1px solid var(--border)',
|
borderBottom: idx === deliverables.length - 1 ? 'none' : '1px solid var(--border)',
|
||||||
backgroundColor: isCurrentActive ? 'rgba(79, 107, 255, 0.08)' : 'transparent',
|
backgroundColor: isCurrentActive ? 'rgba(79, 107, 255, 0.08)' : 'transparent',
|
||||||
@@ -467,6 +475,10 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
|||||||
<td style={{ padding: '10px 12px' }}>
|
<td style={{ padding: '10px 12px' }}>
|
||||||
{onRoleChangeForDoc ? (
|
{onRoleChangeForDoc ? (
|
||||||
<select
|
<select
|
||||||
|
data-testid={DOC_DUTY_SELECT_TESTID}
|
||||||
|
data-doc-id={item.docId}
|
||||||
|
data-doc-role={role}
|
||||||
|
data-can-assign={canAssignLead ? 'true' : 'false'}
|
||||||
value={role}
|
value={role}
|
||||||
disabled={!canAssignLead}
|
disabled={!canAssignLead}
|
||||||
onChange={(e) => onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)}
|
onChange={(e) => onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)}
|
||||||
@@ -494,6 +506,9 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
data-testid={DOC_DUTY_BADGE_TESTID}
|
||||||
|
data-doc-id={item.docId}
|
||||||
|
data-doc-role={role}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
padding: '3px 8px',
|
padding: '3px 8px',
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
evaluateReviewerOrViewerDuty,
|
||||||
|
resolveDutyCell,
|
||||||
|
} from './dutyCellAssertion';
|
||||||
|
|
||||||
|
const FIELD_FAILURE_ROW =
|
||||||
|
'V0.5 用户初始需求收集表当前打开 状态: drafting ✍️ 编制负责人 🔍 审核负责人 👑 批准负责人 👁️ 查阅者 (只读) 前往';
|
||||||
|
|
||||||
|
describe('dutyCellAssertion', () => {
|
||||||
|
it('parses author, reviewer, and viewer from select value or badge role', () => {
|
||||||
|
assert.equal(resolveDutyCell({ source: 'select', value: 'author', disabled: false, canAssignLead: true }).role, 'author');
|
||||||
|
assert.equal(
|
||||||
|
resolveDutyCell({ source: 'select', value: 'reviewer', disabled: true, canAssignLead: false }).role,
|
||||||
|
'reviewer',
|
||||||
|
);
|
||||||
|
assert.equal(resolveDutyCell({ source: 'badge', role: 'viewer' }).role, 'viewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('must not infer author from a row whose option labels include 编制负责人', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => resolveDutyCell({ source: 'row-text', text: FIELD_FAILURE_ROW }),
|
||||||
|
/must_not_infer_duty_from_row_option_text/,
|
||||||
|
);
|
||||||
|
const verdict = evaluateReviewerOrViewerDuty({ source: 'row-text', text: FIELD_FAILURE_ROW });
|
||||||
|
assert.equal(verdict.ok, false);
|
||||||
|
assert.match(verdict.reason, /must_not_infer_duty_from_row_option_text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a disabled reviewer select when the reviewer cannot assign leads', () => {
|
||||||
|
const verdict = evaluateReviewerOrViewerDuty({
|
||||||
|
source: 'select',
|
||||||
|
value: 'reviewer',
|
||||||
|
disabled: true,
|
||||||
|
canAssignLead: false,
|
||||||
|
});
|
||||||
|
assert.equal(verdict.ok, true);
|
||||||
|
assert.equal(verdict.role, 'reviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an enabled reviewer select when the reviewer cannot assign leads', () => {
|
||||||
|
const verdict = evaluateReviewerOrViewerDuty({
|
||||||
|
source: 'select',
|
||||||
|
value: 'reviewer',
|
||||||
|
disabled: false,
|
||||||
|
canAssignLead: false,
|
||||||
|
});
|
||||||
|
assert.equal(verdict.ok, false);
|
||||||
|
assert.match(verdict.reason, /disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects author even if the caller only looked at option text before', () => {
|
||||||
|
const verdict = evaluateReviewerOrViewerDuty({
|
||||||
|
source: 'select',
|
||||||
|
value: 'author',
|
||||||
|
disabled: true,
|
||||||
|
canAssignLead: false,
|
||||||
|
});
|
||||||
|
assert.equal(verdict.ok, false);
|
||||||
|
assert.equal(verdict.role, 'author');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export const DOC_DUTY_ROW_TESTID = 'doc-duty-row';
|
||||||
|
export const DOC_DUTY_SELECT_TESTID = 'doc-duty-select';
|
||||||
|
export const DOC_DUTY_BADGE_TESTID = 'doc-duty-badge';
|
||||||
|
|
||||||
|
export const DOC_DUTY_ROLES = ['author', 'reviewer', 'approver', 'viewer'] as const;
|
||||||
|
export type DocDutyRole = (typeof DOC_DUTY_ROLES)[number];
|
||||||
|
|
||||||
|
export type DutyCellInput =
|
||||||
|
| { source: 'select'; value: string; disabled: boolean; canAssignLead: boolean }
|
||||||
|
| { source: 'badge'; role: string }
|
||||||
|
| { source: 'row-text'; text: string };
|
||||||
|
|
||||||
|
export type DutyCellVerdict = {
|
||||||
|
ok: boolean;
|
||||||
|
role?: DocDutyRole;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isDocDutyRole(value: string): value is DocDutyRole {
|
||||||
|
return (DOC_DUTY_ROLES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDutyCell(input: DutyCellInput): { role: DocDutyRole; disabled?: boolean } {
|
||||||
|
if (input.source === 'row-text') {
|
||||||
|
throw new Error('must_not_infer_duty_from_row_option_text');
|
||||||
|
}
|
||||||
|
if (input.source === 'select') {
|
||||||
|
if (!isDocDutyRole(input.value)) {
|
||||||
|
throw new Error(`unknown_duty_select_value:${input.value}`);
|
||||||
|
}
|
||||||
|
return { role: input.value, disabled: input.disabled };
|
||||||
|
}
|
||||||
|
if (!isDocDutyRole(input.role)) {
|
||||||
|
throw new Error(`unknown_duty_badge_role:${input.role}`);
|
||||||
|
}
|
||||||
|
return { role: input.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAcceptableReviewerOrViewer(role: DocDutyRole): boolean {
|
||||||
|
return role === 'reviewer' || role === 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reviewer B intake: current control value, never the concatenated option labels. */
|
||||||
|
export function evaluateReviewerOrViewerDuty(input: DutyCellInput): DutyCellVerdict {
|
||||||
|
if (input.source === 'row-text') {
|
||||||
|
return { ok: false, reason: 'must_not_infer_duty_from_row_option_text' };
|
||||||
|
}
|
||||||
|
let role: DocDutyRole;
|
||||||
|
try {
|
||||||
|
role = resolveDutyCell(input).role;
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
if (!isAcceptableReviewerOrViewer(role)) {
|
||||||
|
return { ok: false, role, reason: `unexpected_duty:${role}` };
|
||||||
|
}
|
||||||
|
if (input.source === 'select' && !input.canAssignLead && !input.disabled) {
|
||||||
|
return { ok: false, role, reason: 'reviewer_select_must_be_disabled_when_cannot_assign' };
|
||||||
|
}
|
||||||
|
return { ok: true, role, reason: 'ok' };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user