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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(未跑)
|
||||
|
||||
## 相关链接
|
||||
|
||||
@@ -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(本轮不做)
|
||||
|
||||
## 相关链接
|
||||
|
||||
@@ -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<strin
|
||||
.locator(`[data-testid="${DOC_DUTY_SELECT_TESTID}"]`);
|
||||
if ((await select.count()) > 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<strin
|
||||
fail('intake duty cell missing select and badge');
|
||||
}
|
||||
await expect(badge).toBeVisible({ timeout: 10_000 });
|
||||
const verdict = evaluateReviewerOrViewerDuty({
|
||||
const verdict = evaluateReviewerDuty({
|
||||
source: 'badge',
|
||||
role: (await badge.getAttribute('data-doc-role')) || '',
|
||||
});
|
||||
if (!verdict.ok) fail(`reviewer B intake duty badge rejected: ${verdict.reason}`);
|
||||
return verdict.role || '';
|
||||
if (!verdict.ok || verdict.role !== 'reviewer') {
|
||||
fail(`reviewer B intake duty badge must be reviewer: ${verdict.reason}`);
|
||||
}
|
||||
return 'reviewer';
|
||||
}
|
||||
|
||||
async function sendChat(page: Page, text: string) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
evaluateReviewerOrViewerDuty,
|
||||
evaluateExpectedDuty,
|
||||
evaluateReviewerDuty,
|
||||
resolveDutyCell,
|
||||
} from './dutyCellAssertion';
|
||||
|
||||
@@ -23,13 +24,13 @@ describe('dutyCellAssertion', () => {
|
||||
() => 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user