Why: the three views, the blocked empty-reviewer save, and the narrow-screen matrix need a browser check that does not depend on a live account. Evidence: SELF playwright e2e/rdm-duties.playwright.ts, 3 passed against the local Vite server. Impact: the duties end-to-end file now drives assignment, stale responses, and the narrow-screen guidance. Refs: feature/rdm-duty-matrix Co-authored-by: Cursor <cursoragent@cursor.com>
102 lines
5.6 KiB
TypeScript
102 lines
5.6 KiB
TypeScript
// 文档职责三视图:确定性 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')
|
||
|
||
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 holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
|
||
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
|
||
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')
|
||
const hold = holds.get(command)
|
||
if (hold) { hold.started.resolve(); await hold.release.promise }
|
||
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') {
|
||
writes.push({ command, params })
|
||
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: '文档职责', 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
|
||
} }
|
||
}
|
||
|
||
test('three views share one draft and do not offer review actions', async ({ page }) => {
|
||
const fixture = await dutiesPage(page)
|
||
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 page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
|
||
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('a stale matrix response does not replace the current draft', async ({ page }) => {
|
||
const fixture = await dutiesPage(page)
|
||
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 narrow viewport points away from the full matrix', async ({ page }) => {
|
||
await dutiesPage(page)
|
||
await page.setViewportSize({ width: 390, height: 800 })
|
||
await page.getByRole('tab', { name: '矩阵' }).click()
|
||
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
|
||
})
|