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..40c7cec7 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-duty-cell-assertion.md @@ -0,0 +1,48 @@ +--- +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 = 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 only a disabled non-assignable reviewer select', () => { + const verdict = evaluateReviewerDuty({ + 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 = evaluateReviewerDuty({ + source: 'select', + value: 'reviewer', + disabled: false, + canAssignLead: false, + }); + assert.equal(verdict.ok, false); + assert.match(verdict.reason, /disabled/); + }); + + it('rejects canAssignLead=true even if the reviewer select is disabled', () => { + const verdict = evaluateReviewerDuty({ + source: 'select', + value: 'reviewer', + disabled: true, + canAssignLead: true, + }); + assert.equal(verdict.ok, false); + 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 new file mode 100644 index 00000000..18c0c926 --- /dev/null +++ b/web/src/lib/dutyCellAssertion.ts @@ -0,0 +1,73 @@ +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 evaluateExpectedDuty(input: DutyCellInput, expected: DocDutyRole): 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 (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; +}