From c8318e8551f01e6fc043dc5787d7bddb2e8f5e06 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 21:26:23 +0000 Subject: [PATCH 1/3] 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 --- ...3-document-revision-duty-cell-assertion.md | 47 ++++++++++++++ .doc/audit/INDEX.md | 8 +++ .doc/ops/CURRENT.md | 6 +- ...3-document-revision-duty-cell-assertion.md | 53 ++++++++++++++++ ...3-document-revision-duty-cell-assertion.md | 44 +++++++++++++ .doc/remediation/INDEX.md | 1 + .doc/status/FEATURE-MATRIX.md | 2 +- web/e2e/document-revision-runtime.spec.ts | 51 ++++++++++++--- .../user/UserProjectRightsModal.tsx | 15 +++++ web/src/lib/dutyCellAssertion.test.ts | 63 +++++++++++++++++++ web/src/lib/dutyCellAssertion.ts | 61 ++++++++++++++++++ 11 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 .doc/audit/2026-09-13-document-revision-duty-cell-assertion.md create mode 100644 .doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md create mode 100644 .doc/remediation/2026-09-13-document-revision-duty-cell-assertion.md create mode 100644 web/src/lib/dutyCellAssertion.test.ts create mode 100644 web/src/lib/dutyCellAssertion.ts diff --git a/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md b/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md new file mode 100644 index 00000000..24c430af --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md @@ -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` 存在时渲染 ` onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)} @@ -494,6 +506,9 @@ export const UserProjectRightsModal: FC = ({ ) : ( { + 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'); + }); +}); diff --git a/web/src/lib/dutyCellAssertion.ts b/web/src/lib/dutyCellAssertion.ts new file mode 100644 index 00000000..9893b4a6 --- /dev/null +++ b/web/src/lib/dutyCellAssertion.ts @@ -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' }; +} From c86e229303708cfcd6b53ab58d17a61fa4ecccc2 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 21:28:15 +0000 Subject: [PATCH 2/3] fix(e2e): require exact reviewer duty, reject viewer fallback USER_B is assigned reviewer in UI-01. Accept only role=reviewer on the duty control, and reject assignable or enabled selects. Co-authored-by: Cursor --- ...3-document-revision-duty-cell-assertion.md | 3 +- ...3-document-revision-duty-cell-assertion.md | 8 ++-- ...3-document-revision-duty-cell-assertion.md | 17 ++++---- web/e2e/document-revision-runtime.spec.ts | 18 +++++---- web/src/lib/dutyCellAssertion.test.ts | 39 ++++++++++++++----- web/src/lib/dutyCellAssertion.ts | 34 ++++++++++------ 6 files changed, 79 insertions(+), 40 deletions(-) diff --git a/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md b/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md index 24c430af..40c7cec7 100644 --- a/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md +++ b/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md @@ -24,7 +24,7 @@ severity: high | 验收 | `/编制负责人/.test(整行文本)` | 审核人行也被判成编制负责人 | | 控件语义 | select/badge 无稳定 `data-testid` / `data-doc-role` | 脚本只能刮行文案 | -select 的 `value` 才是当前职责;reviewer 无 `project:assign_lead` 时应 `disabled`。 +select 的 `value` 才是当前职责。USER_B 在 UI-01 被明确分配 reviewer:验收必须证明 `role=reviewer`,不得把职责同步丢失降级成的 `viewer` 判通过。select 还须 `data-can-assign=false` 且 `disabled`;`canAssignLead=true` 一律拒绝。 ## 严重度 @@ -45,3 +45,4 @@ select 的 `value` 才是当前职责;reviewer 无 `project:assign_lead` 时 ## 开放问题 - 本轮不重跑真实 UI-13a;不得标 E2E 通过或任务 `done` +- 独立审查:不得用 `reviewer|viewer` 宽判;`evaluateReviewerDuty` 只接受精确 reviewer diff --git a/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md b/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md index f18c2616..f6607bb5 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md @@ -13,7 +13,7 @@ mutex: document-revision-duty-cell-assertion ## 目标 -堵住第16轮 UI-13a:权限矩阵已打开,整行 `innerText` 含全部 select option,误命中「编制负责人」。改为读控件 value/badge。 +堵住第16轮 UI-13a:权限矩阵已打开,整行 `innerText` 含全部 select option,误命中「编制负责人」。改为读控件 value/badge,且 USER_B 必须精确 `reviewer`,不得接受 viewer 降级。 ## 范围 @@ -33,11 +33,13 @@ mutex: document-revision-duty-cell-assertion 2. [x] 精确角色解析 + option 文案不得误判测试 3. [x] 产品控件 data-* + UI-13a 接线 4. [x] `npm test` + `npm run build` 并原子提交 +5. [x] 独立审查收紧:只接受 reviewer;viewer 与 canAssignLead=true 拒绝 ## 验收标准 -- [x] select/badge 精确解析 author、reviewer、viewer -- [x] 整行 option 文案不得判为编制负责人 +- [x] select/badge 必须精确 `reviewer`;viewer/author/approver 拒绝 +- [x] 整行 option 文案不得推断职责 +- [x] select 必须不可配置且 disabled - [ ] 现场 UI-13a(未跑) ## 相关链接 diff --git a/.doc/remediation/2026-09-13-document-revision-duty-cell-assertion.md b/.doc/remediation/2026-09-13-document-revision-duty-cell-assertion.md index d0f59ef8..adfe7f38 100644 --- a/.doc/remediation/2026-09-13-document-revision-duty-cell-assertion.md +++ b/.doc/remediation/2026-09-13-document-revision-duty-cell-assertion.md @@ -14,28 +14,29 @@ updated: 2026-09-13 ## 目标 - 精确定位该文档行的职责控件 -- select:校验 `value`(reviewer B 期望 `reviewer` 或只读 `viewer`),不可配置时 `disabled` -- badge:读 `data-doc-role`,不用整行 option 文案 +- select:`value` 必须是 `reviewer`,且 `data-can-assign=false`、`disabled`;`canAssignLead=true` 拒绝 +- badge:`data-doc-role` 必须是 `reviewer`;`viewer` / `author` / `approver` / 未知值拒绝 - 保留无「双击任意段落就地编辑」、双击不出现编辑器 ## 分波次计划 | 波次 | 内容 | 验收 | |------|------|------| -| Wave 0 | 控件 `data-testid`/`data-doc-id`/`data-doc-role` + 解析谓词 + UI-13a | option 文案不得误判;author/reviewer/viewer 精确解析;`npm test`/`build` | +| Wave 0 | 控件 `data-testid`/`data-doc-id`/`data-doc-role` + 精确 reviewer 谓词 + UI-13a | option 文案不得误判;viewer/可配置 select 拒绝;`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`。 +2. `evaluateExpectedDuty(input, expected)` / `evaluateReviewerDuty`:UI-13a 只接受 `reviewer`。 +3. 产品 select/badge/行补 `data-testid`、`data-doc-id`、`data-doc-role`、`data-can-assign`。 +4. UI-13a 按 docId 定位控件,不再 `intakeRow.innerText`。 ## 验收标准 -- [x] 整行含全部 option 时不得判为 author -- [x] select `reviewer` + 不可配置 disabled 通过 -- [x] author/reviewer/viewer 精确解析 +- [x] 整行含全部 option 时不得推断职责 +- [x] select/badge 必须 `reviewer`;viewer/author/approver/未知拒绝 +- [x] select 必须 `canAssignLead=false` 且 disabled;`canAssignLead=true` 拒绝 - [ ] 真实 UI-13a(本轮不做) ## 相关链接 diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index 3cb9cf17..a17862e4 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -17,7 +17,7 @@ import { import { DOC_DUTY_BADGE_TESTID, DOC_DUTY_SELECT_TESTID, - evaluateReviewerOrViewerDuty, + evaluateReviewerDuty, } from '../src/lib/dutyCellAssertion'; /** @@ -387,14 +387,16 @@ async function readReviewerIntakeDuty(page: Page, docId?: string): Promise 0) { await expect(select).toBeVisible({ timeout: 10_000 }); - const verdict = evaluateReviewerOrViewerDuty({ + const verdict = evaluateReviewerDuty({ 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()); + if (!verdict.ok || verdict.role !== 'reviewer') { + fail(`reviewer B intake duty select must be reviewer: ${verdict.reason}`); + } + return 'reviewer'; } const badge = docId ? page.locator(`[data-testid="${DOC_DUTY_BADGE_TESTID}"][data-doc-id="${docId}"]`) @@ -406,12 +408,14 @@ async function readReviewerIntakeDuty(page: Page, docId?: string): Promise { () => 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 }); + const verdict = evaluateReviewerDuty({ 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({ + it('accepts only a disabled non-assignable reviewer select', () => { + const verdict = evaluateReviewerDuty({ source: 'select', value: 'reviewer', disabled: true, @@ -40,7 +41,7 @@ describe('dutyCellAssertion', () => { }); it('rejects an enabled reviewer select when the reviewer cannot assign leads', () => { - const verdict = evaluateReviewerOrViewerDuty({ + const verdict = evaluateReviewerDuty({ source: 'select', value: 'reviewer', disabled: false, @@ -50,14 +51,32 @@ describe('dutyCellAssertion', () => { assert.match(verdict.reason, /disabled/); }); - it('rejects author even if the caller only looked at option text before', () => { - const verdict = evaluateReviewerOrViewerDuty({ + it('rejects canAssignLead=true even if the reviewer select is disabled', () => { + const verdict = evaluateReviewerDuty({ source: 'select', - value: 'author', + value: 'reviewer', disabled: true, - canAssignLead: false, + canAssignLead: true, }); assert.equal(verdict.ok, false); - assert.equal(verdict.role, 'author'); + assert.match(verdict.reason, /assign_lead/); + }); + + it('rejects viewer, author, approver, and unknown values for USER_B', () => { + assert.equal( + evaluateReviewerDuty({ source: 'select', value: 'viewer', disabled: true, canAssignLead: false }).ok, + false, + ); + assert.equal(evaluateReviewerDuty({ source: 'badge', role: 'viewer' }).ok, false); + assert.equal( + evaluateReviewerDuty({ source: 'select', value: 'author', disabled: true, canAssignLead: false }).ok, + false, + ); + assert.equal(evaluateReviewerDuty({ source: 'badge', role: 'approver' }).ok, false); + assert.equal(evaluateReviewerDuty({ source: 'badge', role: 'unknown' }).ok, false); + assert.match( + evaluateExpectedDuty({ source: 'badge', role: 'viewer' }, 'reviewer').reason, + /expected_duty:reviewer;got:viewer/, + ); }); }); diff --git a/web/src/lib/dutyCellAssertion.ts b/web/src/lib/dutyCellAssertion.ts index 9893b4a6..18c0c926 100644 --- a/web/src/lib/dutyCellAssertion.ts +++ b/web/src/lib/dutyCellAssertion.ts @@ -36,12 +36,7 @@ export function resolveDutyCell(input: DutyCellInput): { role: DocDutyRole; disa 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 { +export function evaluateExpectedDuty(input: DutyCellInput, expected: DocDutyRole): DutyCellVerdict { if (input.source === 'row-text') { return { ok: false, reason: 'must_not_infer_duty_from_row_option_text' }; } @@ -51,11 +46,28 @@ export function evaluateReviewerOrViewerDuty(input: DutyCellInput): DutyCellVerd } 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' }; + if (role !== expected) { + return { ok: false, role, reason: `expected_duty:${expected};got:${role}` }; } return { ok: true, role, reason: 'ok' }; } + +/** + * USER_B was assigned reviewer in UI-01. Accept only role=reviewer. + * A select must be non-assignable and disabled; viewer/author/approver fail closed. + */ +export function evaluateReviewerDuty(input: DutyCellInput): DutyCellVerdict { + const verdict = evaluateExpectedDuty(input, 'reviewer'); + if (!verdict.ok) { + return verdict; + } + if (input.source === 'select') { + if (input.canAssignLead) { + return { ok: false, role: 'reviewer', reason: 'reviewer_must_not_assign_lead' }; + } + if (!input.disabled) { + return { ok: false, role: 'reviewer', reason: 'reviewer_select_must_be_disabled' }; + } + } + return verdict; +} From 5183d2866d72ba377316292f914b77aa35d8a235 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 21:28:55 +0000 Subject: [PATCH 3/3] docs(ops): record tightened duty-cell test count UI-13a reviewer contract now has 6 unit cases; keep the short-term note aligned without rewriting the already-reviewed fix commit. Co-authored-by: Cursor --- .../2026-09-13-document-revision-duty-cell-assertion.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md b/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md index f6607bb5..b754afc0 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-duty-cell-assertion.md @@ -51,5 +51,5 @@ mutex: document-revision-duty-cell-assertion - 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 +- 验证(收紧后):`npx tsx --test src/lib/dutyCellAssertion.test.ts` PASS(6);`cd web && npm test` PASS;`npm run build` PASS - **未跑真实 UI-13a,不得标 done**;**不得 merge / 启停容器 / E2E / push**