Why: Fresh documents had no first-submit action, reading active duties opened confirmation dialogs, and delayed detail or status queries could overwrite another selection and send its document ID with the wrong duty draft. Evidence: SELF real-browser regressions observed the missing action, render-time dialogs, detail/status races, and a follow-up query after panel unmount. Five focused Playwright scenarios passed after the fix, together with the full npm test, npm run build, and git diff --check commands. Receipts: /tmp/project-member-duties-browser-green-20261013.json and /tmp/project-member-duties-full-build-green-20261013.json. Impact: Offer first submission independently of an existing review round. Ask for invalidation only on a dirty save click. Guard queries and continuations with project lifetime, selection, and request sequences; keep the existing Picker, canonical project routing, controls, and UI tokens. Refs: Dev project-member-access Task 4; independent synchronized Frontend P2 review
161 lines
9.3 KiB
TypeScript
161 lines
9.3 KiB
TypeScript
// Real React/Picker interaction with deterministic HTTP responses; no live account or database.
|
|
import { test, expect, type Page } from '@playwright/test'
|
|
|
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
|
|
|
function deferred() {
|
|
let resolve!: () => void
|
|
const promise = new Promise<void>(ok => { resolve = ok })
|
|
return { promise, resolve }
|
|
}
|
|
|
|
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' },
|
|
]
|
|
await page.route('**/api/**', async route => {
|
|
const request = route.request()
|
|
const path = new URL(request.url()).pathname
|
|
let body: unknown = []
|
|
if (path === '/api/health') body = { ok: true }
|
|
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
|
|
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
|
|
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
|
|
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
|
|
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
|
else if (path.startsWith('/api/commands/')) {
|
|
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 ?? ''}`)
|
|
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 }
|
|
} 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
|
|
} }
|
|
}
|
|
|
|
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 }) => {
|
|
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' } }])
|
|
})
|
|
|
|
test('viewing active duties is passive; only a dirty save asks for confirmation', 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',
|
|
} }])
|
|
})
|
|
|
|
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([])
|
|
})
|