From c8318e8551f01e6fc043dc5787d7bddb2e8f5e06 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 21:26:23 +0000 Subject: [PATCH] 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' }; +}