diff --git a/e2e/rdm-duties.playwright.ts b/e2e/rdm-duties.playwright.ts new file mode 100644 index 0000000..388c882 --- /dev/null +++ b/e2e/rdm-duties.playwright.ts @@ -0,0 +1,160 @@ +// 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(ok => { resolve = ok }) + return { promise, resolve } +} + +async function dutiesPage(page: Page) { + const writes: { command: string; params: Record }[] = [] + const calls: { command: string; params: Record }[] = [] + const holds = new Map; release: ReturnType }>() + const active = new Set() + const submissions: Record = { '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 + 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(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([]) +}) diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index a399345..b8abad1 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -323,44 +323,93 @@ export function RdmDutiesSection({ project }: { project: Project }) { const cmd = (id: string, params: Record) => api.rdmProjectCommand(project, id, params) const memberPage = useMemberPickerPage(members) + const requests = useRef({ project, epoch: 0, selected: '', selection: 0, catalog: 0, status: 0, action: 0 }) + requests.current.project = project + const currentProject = (epoch: number) => requests.current.project === project && requests.current.epoch === epoch + const currentDocument = (epoch: number, selection: number, id: string) => + currentProject(epoch) && requests.current.selection === selection && requests.current.selected === id - const refreshCatalog = async () => { + const refreshCatalog = async (epoch = requests.current.epoch) => { + const request = ++requests.current.catalog try { const out = await cmd<{ docs: DutyCatalogDoc[] }>('rdm.duty.catalog', { projectId }) + if (!currentProject(epoch) || request !== requests.current.catalog) return setDocs(out.docs ?? []) - } catch (e) { setErr(String(e)); setDocs([]) } + } catch (e) { + if (!currentProject(epoch) || request !== requests.current.catalog) return + setErr(String(e)); setDocs([]) + } } - const refreshStatus = async (id: string) => { - if (!id) { setSub(undefined); return } + const refreshStatus = async (id: string, epoch: number, selection: number) => { + if (!id || !currentDocument(epoch, selection, id)) return + const request = ++requests.current.status try { const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id)) + if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return setSub(out.submission) - } catch (e) { setSub(undefined) } + } catch (e) { + if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return + setSub(undefined); setErr(`整文状态读取失败:${e}`) + } } /** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */ - const selectDoc = async (id: string) => { - setErr(''); setNotice(''); setDocId(id); setDetail(undefined); setBatchSel(new Set()) + const selectDoc = async (id: string, keepNotice = false) => { + const epoch = requests.current.epoch + const selection = ++requests.current.selection + requests.current.selected = id + requests.current.status++ + setErr(''); if (!keepNotice) setNotice('') + setDocId(id); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) + setAuthor([]); setReviewers([]); setApprover([]) if (!id) return try { const out = await cmd('rdm.duty.get', { projectId, docId: id }) + if (!currentDocument(epoch, selection, id)) return + if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档') setDetail(out) setAuthor(out.assigned && out.roles.author ? [out.roles.author] : []) setReviewers(out.assigned ? out.roles.reviewers ?? [] : []) setApprover(out.assigned && out.roles.approver ? [out.roles.approver] : []) - } catch (e) { setErr(String(e)) } - await refreshStatus(id) + } catch (e) { + if (!currentDocument(epoch, selection, id)) return + setDetail(null); setErr(String(e)) + } + await refreshStatus(id, epoch, selection) } useEffect(() => { + const epoch = ++requests.current.epoch + requests.current.selected = ''; requests.current.selection++; requests.current.status++ + requests.current.action++ setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) - refreshCatalog() + setDocs(null); setMembers(null); setAuthor([]); setReviewers([]); setApprover([]) + setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) + setErr(''); setNotice(''); setBusy(false) + void refreshCatalog(epoch) cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId }) - .then(out => setMembers(out.members ?? [])).catch(() => setMembers([])) + .then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) }) + .catch(() => { if (currentProject(epoch)) setMembers([]) }) + return () => { + requests.current.epoch++; requests.current.selection++; requests.current.status++ + requests.current.catalog++; requests.current.action++; requests.current.selected = '' + } }, [project]) const run = async (label: string, fn: () => Promise) => { + const epoch = requests.current.epoch + const selection = requests.current.selection + const selected = requests.current.selected + const action = ++requests.current.action + const currentAction = () => currentProject(epoch) && action === requests.current.action setErr(''); setNotice(''); setBusy(true) - try { await fn(); setNotice(label); await refreshCatalog(); if (docId) await selectDoc(docId) } - catch (e) { setErr(String(e)) } finally { setBusy(false) } + try { + await fn() + if (!currentAction() || !currentDocument(epoch, selection, selected)) return + setNotice(label) + await refreshCatalog(epoch) + if (currentAction() && currentDocument(epoch, selection, selected) && selected) await selectDoc(selected, true) + } catch (e) { + if (currentAction() && currentDocument(epoch, selection, selected)) setErr(String(e)) + } finally { if (currentAction()) setBusy(false) } } const dirty = detail != null && ( @@ -368,12 +417,15 @@ export function RdmDutiesSection({ project }: { project: Project }) { || JSON.stringify(reviewers) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : []) || (approver[0] ?? '') !== (detail.assigned ? detail.roles.approver : '')) - const saveDisabled = busy || !!writeError || !docId || !author[0] || !approver[0] || reviewers.length === 0 - || (detail !== null && detail !== undefined && detail.active != null && !window.confirm( - `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) + const saveDisabled = busy || !!writeError || !docId || detail == null || !author[0] || !approver[0] || reviewers.length === 0 - const save = () => run('已保存当前职责', () => - cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + const save = () => { + if (saveDisabled || !dirty || requests.current.selected !== docId || detail?.docId !== docId) return + if (detail.active && !window.confirm( + `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) return + return run('已保存当前职责', () => + cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + } const batchApply = () => run(`已批量分配 ${batchSel.size} 份文档`, () => cmd('rdm.review.assign.doc.batch', { projectId, docs: [...batchSel].map(d => ({ @@ -424,15 +476,19 @@ export function RdmDutiesSection({ project }: { project: Project }) { )} + {detail && docId && ( +
+ +
+ )} {sub && (

整文审签 · 第 {sub.round} 轮 · {sub.state === 'submitted' ? 在途(全文锁定) : sub.state === 'final' ? 已终批 : 已驳回}

编 {sub.duties.author} · 审 {(sub.duties.reviewers ?? []).join('、')} · 批 {sub.duties.approver}
{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}
- @@ -682,4 +738,4 @@ export function RdmPermMatrixSection({ project }: { project: Project }) { {err &&
{err}
}
) -} \ No newline at end of file +}