Show document duties in three shared views
Why: the settings page was mixing duty assignment with whole-document review and chapter objects. The three views need one draft and one save check. Evidence: SELF vitest run of the frontend suite, 278 tests passed, including duty-draft and design-guard. Impact: the duties tab assigns existing documents only. Submit, approve, reject, and final stay out of this page. Commands that are not ready keep the tab hidden. Refs: feature/rdm-duty-matrix Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+57
-118
@@ -1,4 +1,4 @@
|
||||
// Real React/Picker interaction with deterministic HTTP responses; no live account or database.
|
||||
// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||
@@ -11,17 +11,10 @@ function deferred() {
|
||||
|
||||
async function dutiesPage(page: Page) {
|
||||
const writes: { command: string; params: Record<string, unknown> }[] = []
|
||||
const calls: { command: string; params: Record<string, unknown> }[] = []
|
||||
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
|
||||
const active = new Set<string>()
|
||||
const submissions: Record<string, unknown> = { 'doc-A': null, 'doc-B': null }
|
||||
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove',
|
||||
'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch',
|
||||
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status']
|
||||
const docs = [
|
||||
{ docId: 'doc-A', title: '文档A', typeKey: 'design', sections: 1, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' },
|
||||
{ docId: 'doc-B', title: '文档B', typeKey: 'design', sections: 1, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' },
|
||||
]
|
||||
let generation = 0
|
||||
const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
|
||||
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
@@ -36,125 +29,71 @@ async function dutiesPage(page: Page) {
|
||||
const command = decodeURIComponent(path.substring('/api/commands/'.length))
|
||||
const params = request.postDataJSON().params as Record<string, unknown>
|
||||
expect(params.projectId).toBe('domain-7')
|
||||
calls.push({ command, params })
|
||||
const hold = holds.get(`${command}:${params.docId ?? ''}`)
|
||||
const hold = holds.get(command)
|
||||
if (hold) { hold.started.resolve(); await hold.release.promise }
|
||||
let payload: unknown = {}
|
||||
if (command === 'rdm.duty.catalog') payload = { docs }
|
||||
else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) }
|
||||
else if (command === 'rdm.duty.get') {
|
||||
const doc = docs.find(d => d.docId === params.docId)!
|
||||
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned,
|
||||
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [],
|
||||
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null }
|
||||
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] }
|
||||
else { writes.push({ command, params }) }
|
||||
body = { payload }
|
||||
const author = generation === 0 ? 'alice' : 'bob'
|
||||
if (command === 'rdm.duty.matrix') {
|
||||
body = { payload: {
|
||||
canAssign: true,
|
||||
docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
|
||||
members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
|
||||
cells: [
|
||||
{ docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
|
||||
{ docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
|
||||
{ docId: 'doc-A', owner: 'carol', duty: 'approver' },
|
||||
],
|
||||
} }
|
||||
} else if (command === 'rdm.duty.preview') {
|
||||
body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } }
|
||||
} else if (command === 'rdm.duty.legacy.scan') {
|
||||
body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } }
|
||||
} else {
|
||||
writes.push({ command, params })
|
||||
generation += 1
|
||||
body = { payload: { applied: 1 } }
|
||||
}
|
||||
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '项目设置', exact: true }).click()
|
||||
await page.getByRole('tab', { name: /文档职责/ }).click()
|
||||
await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible()
|
||||
return { writes, calls, active, submissions,
|
||||
hold(command: string, docId: string) {
|
||||
const pending = { started: deferred(), release: deferred() }
|
||||
holds.set(`${command}:${docId}`, pending)
|
||||
return pending
|
||||
} }
|
||||
await page.getByRole('tab', { name: '文档职责', exact: true }).click()
|
||||
await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
|
||||
return { writes, hold(command: string) {
|
||||
const pending = { started: deferred(), release: deferred() }
|
||||
holds.set(command, pending)
|
||||
return pending
|
||||
} }
|
||||
}
|
||||
|
||||
async function chooseMember(page: Page, label: string, owner: string) {
|
||||
await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner)
|
||||
await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click()
|
||||
}
|
||||
|
||||
async function settleResponse(page: Page, command: string, release: () => void) {
|
||||
const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`))
|
||||
release()
|
||||
await response
|
||||
await page.evaluate(() => new Promise<void>(done => requestAnimationFrame(() => requestAnimationFrame(() => done()))))
|
||||
}
|
||||
|
||||
test('a newly assigned document can submit its first whole-document review', async ({ page }) => {
|
||||
test('three views share one draft and do not offer review actions', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible()
|
||||
const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true })
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }])
|
||||
await page.getByLabel('选择成员').selectOption('bob')
|
||||
await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
|
||||
await page.getByRole('tab', { name: '按文档' }).click()
|
||||
await page.getByLabel('选择文档').selectOption('doc-A')
|
||||
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
|
||||
await page.getByRole('tab', { name: '矩阵' }).click()
|
||||
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
|
||||
await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '保存职责' }).click()
|
||||
await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch'])
|
||||
})
|
||||
|
||||
test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => {
|
||||
test('a stale matrix response does not replace the current draft', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
fixture.active.add('doc-A')
|
||||
const dialogs: string[] = []
|
||||
let accept = false
|
||||
page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() })
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible()
|
||||
const save = page.getByRole('button', { name: '保存当前职责', exact: true })
|
||||
await expect(save).toBeDisabled()
|
||||
expect(dialogs).toEqual([])
|
||||
await chooseMember(page, '编制人', 'bob')
|
||||
await expect(save).toBeEnabled()
|
||||
expect(dialogs).toEqual([])
|
||||
await save.click()
|
||||
expect(dialogs).toHaveLength(1)
|
||||
expect(fixture.writes).toEqual([])
|
||||
accept = true
|
||||
await save.click()
|
||||
await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible()
|
||||
expect(dialogs).toHaveLength(2)
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
|
||||
projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave',
|
||||
} }])
|
||||
await page.getByLabel('选择成员').selectOption('alice')
|
||||
const held = fixture.hold('rdm.duty.matrix')
|
||||
await page.getByLabel('筛选文档名称或编号').fill('文档A')
|
||||
await held.started.promise
|
||||
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
|
||||
held.release.resolve()
|
||||
await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
|
||||
})
|
||||
|
||||
test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
const old = fixture.hold('rdm.duty.get', 'doc-A')
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await old.started.promise
|
||||
await page.getByRole('button', { name: '文档B', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
|
||||
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
|
||||
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
|
||||
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
|
||||
await chooseMember(page, '批准人', 'alice')
|
||||
await page.getByRole('button', { name: '保存当前职责', exact: true }).click()
|
||||
await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
|
||||
projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice',
|
||||
} }])
|
||||
})
|
||||
|
||||
test('a late review status cannot replace the selected document frozen review', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author,
|
||||
createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} })
|
||||
fixture.submissions['doc-A'] = submission(1, 'alice')
|
||||
fixture.submissions['doc-B'] = submission(2, 'bob')
|
||||
const old = fixture.hold('rdm.review.doc.status', 'doc-A')
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await old.started.promise
|
||||
await page.getByRole('button', { name: '文档B', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
|
||||
await settleResponse(page, 'rdm.review.doc.status', old.release.resolve)
|
||||
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
const old = fixture.hold('rdm.duty.get', 'doc-A')
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await old.started.promise
|
||||
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0)
|
||||
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
|
||||
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
|
||||
test('a narrow viewport points away from the full matrix', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 })
|
||||
await dutiesPage(page)
|
||||
await page.getByRole('tab', { name: '矩阵' }).click()
|
||||
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user