Add the unified document render workspace with review and range annotations #106
@@ -0,0 +1,775 @@
|
||||
// 统一渲染 §11 浏览器验收:真实产品(RDM 二进制 auth on + dist preview)走通九项。
|
||||
// 驱动方:/tmp/renderer-browser-acceptance/browser-acceptance.sh(种子写 $RBA_OUT/seed.env);
|
||||
// 未提供 PLAYWRIGHT_BASE_URL 或 RBA_OUT 时整组跳过。
|
||||
// 产品缺能力处用 expect.soft('GAP[仓] …') 记失败并继续取证;浏览器做不到、只能以本人会话
|
||||
// 直调命令的步骤记为 API_ASSISTED。跨用例状态写 $RBA_OUT/state.json(失败会重启 worker)。
|
||||
import { test, expect, type Browser, type Locator, type Page } from '@playwright/test'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const env = (k: string) => process.env[k] ?? ''
|
||||
const OUT = env('RBA_OUT')
|
||||
const EVID = OUT ? path.join(OUT, 'evidence') : ''
|
||||
const RUN = path.basename(OUT || 'run')
|
||||
const PW = env('RBA_PASSWORD')
|
||||
const U = { admin: env('RBA_ADMIN'), author: env('RBA_AUTHOR'), reviewer: env('RBA_REVIEWER'), approver: env('RBA_APPROVER'), outsider: env('RBA_OUTSIDER') }
|
||||
const A = { name: env('RBA_PROJECT_A'), pid: env('RBA_PID_A'), host: env('RBA_HOST_A'), doc: env('RBA_DOC_A'), title: env('RBA_DOC_A_TITLE') }
|
||||
const B = { name: env('RBA_PROJECT_B'), pid: env('RBA_PID_B'), host: env('RBA_HOST_B'), doc: env('RBA_DOC_B') }
|
||||
const UNCTRL = { doc: env('RBA_UNCTRL_DOC'), title: env('RBA_UNCTRL_TITLE') }
|
||||
const GENERAL = { id: env('RBA_GENERAL_PROFILE'), version: env('RBA_GENERAL_VERSION') }
|
||||
const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}`
|
||||
const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标`
|
||||
const RANGE_ANNOTATION = `§11 范围批注 ${RUN}:「目标」需可量化`
|
||||
const REVISION_AFTER = `§11 修订 ${RUN}:目标改为三项可量化指标`
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !OUT, 'set PLAYWRIGHT_BASE_URL and RBA_OUT (browser-acceptance.sh drives this suite)')
|
||||
test.describe.configure({ mode: 'default', timeout: 240_000 })
|
||||
|
||||
// ---- 证据与跨用例状态 ----
|
||||
|
||||
const STATE = () => path.join(OUT, 'state.json')
|
||||
const readState = (): Record<string, any> => { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')) } catch { return {} } }
|
||||
const writeState = (patch: Record<string, unknown>) => fs.writeFileSync(STATE(), JSON.stringify({ ...readState(), ...patch }, null, 2))
|
||||
|
||||
let evidence: string[] = []
|
||||
let notes: string[] = []
|
||||
let openSessions = new Set<() => Promise<void>>()
|
||||
const note = (s: string) => { notes.push(s) }
|
||||
|
||||
test.beforeEach(() => { evidence = []; notes = []; openSessions = new Set() })
|
||||
test.afterEach(async ({}, info) => {
|
||||
for (const close of [...openSessions]) await close().catch(() => {})
|
||||
const items = info.annotations.filter(a => a.type === 'item').map(a => a.description)
|
||||
const dir = path.join(EVID, 'results')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const errors = info.errors.map(e => (e.message ?? String(e)).replace(/\u001b\[[0-9;]*m/g, '').slice(0, 1500))
|
||||
fs.writeFileSync(path.join(dir, `${info.testId}.json`), JSON.stringify({
|
||||
title: info.title, items, status: info.status, errors, evidence, notes,
|
||||
apiAssisted: info.annotations.filter(a => a.type === 'API_ASSISTED').map(a => a.description),
|
||||
durationMs: info.duration,
|
||||
}, null, 2))
|
||||
})
|
||||
|
||||
const items = (...ids: number[]) => { for (const id of ids) test.info().annotations.push({ type: 'item', description: String(id) }) }
|
||||
const apiAssisted = (what: string) => test.info().annotations.push({ type: 'API_ASSISTED', description: what })
|
||||
|
||||
async function shot(page: Page, name: string, target?: Locator) {
|
||||
const file = path.join(EVID, 'screens', `${name}.png`)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
if (target) await target.screenshot({ path: file })
|
||||
else await page.screenshot({ path: file, fullPage: false })
|
||||
evidence.push(file)
|
||||
}
|
||||
|
||||
interface NetEntry { url: string; method: string; status: number; request: string; body: string }
|
||||
|
||||
/** 每个会话一个上下文:主题经真实持久化入口 localStorage('as.ui') 注入;记录全部 /api 响应体。 */
|
||||
async function session(browser: Browser, owner: string, tag: string, opts: { theme?: 'light' | 'dark'; width?: number; height?: number } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: opts.width ?? 1440, height: opts.height ?? 900 }, acceptDownloads: true, locale: 'zh-CN' })
|
||||
if (opts.theme) {
|
||||
await ctx.addInitScript(theme => {
|
||||
const prev = (() => { try { return JSON.parse(localStorage.getItem('as.ui') || '{}') } catch { return {} } })()
|
||||
localStorage.setItem('as.ui', JSON.stringify({ ...prev, theme }))
|
||||
}, opts.theme)
|
||||
}
|
||||
const page = await ctx.newPage()
|
||||
const net: NetEntry[] = []
|
||||
const pending: Promise<void>[] = []
|
||||
page.on('response', res => {
|
||||
const url = res.url()
|
||||
if (!url.includes('/api/') || (res.headers()['content-type'] ?? '').includes('event-stream')) return
|
||||
pending.push(Promise.race([res.text(), new Promise<string>(r => setTimeout(() => r('<<body timeout>>'), 5_000))])
|
||||
.catch(() => '<<no body>>')
|
||||
.then(body => { net.push({ url, method: res.request().method(), status: res.status(), request: res.request().postData() ?? '', body }) }))
|
||||
})
|
||||
const close = async () => {
|
||||
openSessions.delete(close)
|
||||
await Promise.all(pending)
|
||||
const file = path.join(EVID, 'network', `${tag}.jsonl`)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, net.map(n => JSON.stringify(n)).join('\n') + '\n')
|
||||
evidence.push(file)
|
||||
await ctx.close()
|
||||
}
|
||||
openSessions.add(close)
|
||||
await login(page, owner)
|
||||
return { page, net, pending, close }
|
||||
}
|
||||
|
||||
async function login(page: Page, owner: string) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(owner)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PW)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await page.locator('.tsb-tool', { hasText: '追溯审计' }).waitFor({ state: 'attached', timeout: 20_000 })
|
||||
}
|
||||
|
||||
/** 窄屏下侧栏收进「上下文」面板:先经工作台导航打开,再点审计入口。 */
|
||||
async function clickAuditEntry(page: Page) {
|
||||
const entry = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||
if (!(await entry.isVisible())) await page.getByRole('button', { name: '打开上下文' }).click()
|
||||
await entry.click()
|
||||
}
|
||||
|
||||
async function openAudit(page: Page, projectName: string) {
|
||||
await clickAuditEntry(page)
|
||||
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
|
||||
await page.locator('.rdm-audit-project').selectOption({ label: projectName })
|
||||
}
|
||||
|
||||
async function openDoc(page: Page, title: string) {
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
const open = page.getByRole('button', { name: `阅读与交付:${title}`, exact: true })
|
||||
for (let i = 0; i < 5 && !(await open.isVisible().catch(() => false)); i++) {
|
||||
const more = page.locator('.rdm-audit-more')
|
||||
if (await more.isVisible().catch(() => false)) await more.click()
|
||||
else await page.waitForTimeout(500)
|
||||
}
|
||||
await open.click()
|
||||
await expect(page.locator('.drw')).toBeVisible()
|
||||
await expect(page.locator('.drw-canvas-node').first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
|
||||
const panel = (page: Page, label: string) =>
|
||||
page.locator('.drw-panel').filter({ has: page.locator('.ui-seclabel', { hasText: new RegExp(`^${label}$`) }) })
|
||||
|
||||
const deliveryOf = (page: Page, mode: '预览' | '审阅' | '批准') =>
|
||||
page.locator('.drw-delivery').filter({ has: page.locator('.ui-chip', { hasText: new RegExp(`^${mode}$`) }) }).first()
|
||||
|
||||
/** 以本人浏览器会话直调命令(与前端同一 /api/commands 通道、同一 cookie)。 */
|
||||
async function command(page: Page, id: string, params: Record<string, unknown>, source?: string) {
|
||||
const [conversationId, anchorMessageId] = (source ?? '').split(' ')
|
||||
const res = await page.request.post(`/api/commands/${id}`, {
|
||||
data: { params, ...(source ? { entrance: 'api', conversationId, anchorMessageId } : {}) },
|
||||
})
|
||||
const text = await res.text()
|
||||
let body: any = null
|
||||
try { body = JSON.parse(text) } catch { /* 非 JSON 原样保留 */ }
|
||||
const file = path.join(EVID, 'api', `${test.info().testId}-${id}.json`)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, JSON.stringify({ id, params, status: res.status(), body: body ?? text }, null, 2))
|
||||
evidence.push(file)
|
||||
return { status: res.status(), text, body }
|
||||
}
|
||||
|
||||
async function download(page: Page, delivery: Locator, label: string) {
|
||||
const button = delivery.getByRole('button', { name: label, exact: true })
|
||||
await expect(button, `下载入口「${label}」`).toBeVisible()
|
||||
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 30_000 }), button.click()])
|
||||
const name = dl.suggestedFilename()
|
||||
const file = path.join(EVID, 'downloads', name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
await dl.saveAs(file)
|
||||
evidence.push(file)
|
||||
return { name, file, size: fs.statSync(file).size }
|
||||
}
|
||||
|
||||
async function expectReadableFailures(page: Page) {
|
||||
for (const msg of await page.locator('.drw-failure-msg').allTextContents()) {
|
||||
expect(msg.trim().startsWith('{'), `失败文案不得是原始 JSON:${msg}`).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
const parseJson = (text: string): any => { try { return JSON.parse(text) } catch { return null } }
|
||||
|
||||
/**
|
||||
* 工作区整文决定的取证:先有同会话的已提交来源消息(POST /messages 响应),命令再以它为顶层锚点分发,
|
||||
* params 不含来源字段,入口为页面动作;回执必须 committed。
|
||||
*/
|
||||
async function expectCommittedDecision(s: { net: NetEntry[] }, command: string, label: string) {
|
||||
const cmdUrl = `/api/commands/${command}`
|
||||
await expect.poll(() => s.net.some(n => n.url.includes(cmdUrl)), { message: `应分发 ${command}`, timeout: 15_000 }).toBe(true)
|
||||
const dispatch = s.net.filter(n => n.url.includes(cmdUrl)).at(-1)!
|
||||
const body = parseJson(dispatch.request)
|
||||
expect(body.params, '命令参数不携带来源字段').not.toHaveProperty('anchorMessageId')
|
||||
expect(body.entrance, '工作区动作入口').toBe('button')
|
||||
const findSource = () => s.net.find(n => n.method === 'POST'
|
||||
&& n.url.includes(`/api/conversations/${body.conversationId}/messages`)
|
||||
&& parseJson(n.body)?.message?.id === body.anchorMessageId)
|
||||
await expect.poll(() => findSource() !== undefined, { message: '锚点必须是同会话已提交的来源消息', timeout: 15_000 }).toBe(true)
|
||||
expect(parseJson(findSource()!.request)?.text, '来源消息写明中文审签决定').toContain(`审签决定:${label}`)
|
||||
expect(parseJson(dispatch.body)?.receipt?.state, `${command} 回执 committed`).toBe('committed')
|
||||
}
|
||||
|
||||
// ---- §11 ----
|
||||
|
||||
test('§11-1 文档树 → 结构画布与阅读视图(共用选中与过滤)', async ({ browser }) => {
|
||||
items(1)
|
||||
const s = await session(browser, U.author, 'item1-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
const docRow = page.locator('.rdm-audit-tree-row').filter({ hasText: A.title })
|
||||
await expect(docRow).toBeVisible({ timeout: 15_000 })
|
||||
await docRow.locator('.rdm-audit-tree-node').click()
|
||||
await expect(page.locator('.rdm-audit-tree-children').first()).toBeVisible({ timeout: 15_000 })
|
||||
await shot(page, 'item1-doc-tree')
|
||||
await openDoc(page, A.title)
|
||||
|
||||
const nodes = page.locator('.drw-canvas-node')
|
||||
const total = await nodes.count()
|
||||
note(`结构画布节点数 ${total}`)
|
||||
expect(total, '受控文档应展开为多节点结构').toBeGreaterThan(10)
|
||||
const blank = (await page.locator('.drw-canvas-title').allTextContents()).filter(t => !t.trim()).length
|
||||
expect(blank, '画布不得出现无标题的空白节点').toBe(0)
|
||||
await shot(page, 'item1-canvas')
|
||||
const target = page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first()
|
||||
await target.click()
|
||||
await expect(target).toHaveAttribute('aria-selected', 'true')
|
||||
await page.getByLabel('过滤节点').fill('目标')
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
await expect(page.locator('.drw-reader')).toBeVisible()
|
||||
await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText('项目内容、目标及意义')
|
||||
await expect(page.getByLabel('过滤节点')).toHaveValue('目标')
|
||||
await shot(page, 'item1-reader-filtered')
|
||||
await page.getByLabel('过滤节点').fill('')
|
||||
await page.getByRole('tab', { name: '结构画布' }).click()
|
||||
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', '项目内容、目标及意义')
|
||||
// 键盘导航
|
||||
await page.locator('.drw-canvas-node[aria-selected="true"]').focus()
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toBeFocused()
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-2b 规则绑定:项目设置权限可绑定与解绑(非受控文档)', async ({ browser }) => {
|
||||
items(2)
|
||||
const s = await session(browser, U.admin, 'item2b-admin')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, UNCTRL.title)
|
||||
const binding = panel(page, '规则绑定')
|
||||
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
|
||||
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
|
||||
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
|
||||
await expect(panel(page, '规则绑定').getByText('绑定规则已完成')).toBeVisible({ timeout: 30_000 })
|
||||
await expectReadableFailures(page)
|
||||
await expect(panel(page, '渲染规则').locator('.drw-profile-source', { hasText: '文档规则' }), '绑定后规则列表刷新出文档规则').toBeVisible({ timeout: 15_000 })
|
||||
await shot(page, 'item2b-bound', binding)
|
||||
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '解绑', exact: true }).click()
|
||||
await expect(panel(page, '规则绑定').getByText('解绑规则已完成')).toBeVisible({ timeout: 30_000 })
|
||||
await expectReadableFailures(page)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-3b 受控文档拒绝绑定通用规则,失败以可读文案显示', async ({ browser }) => {
|
||||
items(3)
|
||||
const s = await session(browser, U.admin, 'item3b-admin')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const binding = panel(page, '规则绑定')
|
||||
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
|
||||
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
|
||||
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
|
||||
await expect(binding.locator('.drw-failure-msg')).toContainText('受控文档', { timeout: 30_000 })
|
||||
await expect(binding.locator('.drw-failure-msg')).not.toContainText('controlled_requires_exact_profile')
|
||||
await shot(page, 'item3b-controlled-refusal', binding)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-3 受控文档:规则来源、版本与不回退说明', async ({ browser }) => {
|
||||
items(3)
|
||||
const s = await session(browser, U.author, 'item3-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const rules = panel(page, '渲染规则')
|
||||
const option = rules.locator('.drw-profile').first()
|
||||
await expect(option).toBeVisible({ timeout: 15_000 })
|
||||
await expect(option.locator('.drw-profile-source')).toHaveText('模板规则')
|
||||
await expect(option.locator('.drw-profile-id')).toHaveText(TEMPLATE_PROFILE)
|
||||
await expect(option.locator('.drw-profile-ver')).toHaveText(`v${env('RBA_TEMPLATE_VERSION')}`)
|
||||
// 44 个 DHF 专用规则均已发布(state=published、publicationAllowed=true):状态为已发布,可正式交付,无未发布原因。
|
||||
await expect(option.locator('.ui-chip').first()).toHaveText('已发布')
|
||||
await expect(option.locator('.ui-chip', { hasText: '可正式交付' })).toBeVisible()
|
||||
await expect(option.locator('input[type="radio"]')).toBeEnabled()
|
||||
await expect(option.locator('.drw-profile-reason')).toHaveCount(0)
|
||||
await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则')
|
||||
await expect(rules.locator('.drw-failure-msg')).toContainText('不得回退到项目或公司默认规则')
|
||||
await expectReadableFailures(page)
|
||||
await shot(page, 'item3-rules-controlled', rules)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-2 专用规则与公司通用规则可选', async ({ browser }) => {
|
||||
items(2)
|
||||
const s = await session(browser, U.author, 'item2-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const rules = panel(page, '渲染规则')
|
||||
await expect(rules.locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
|
||||
const sources = await rules.locator('.drw-profile-source').allTextContents()
|
||||
note(`受控文档可选规则来源:${sources.join('、')}`)
|
||||
expect(sources.some(x => x === '模板规则' || x === '文档类型规则' || x === '文档规则'), '应提供专用(模板/文档类型)规则').toBe(true)
|
||||
await shot(page, 'item2-controlled-rules', rules)
|
||||
|
||||
await page.getByRole('button', { name: '← 返回文档树' }).click()
|
||||
await openDoc(page, UNCTRL.title)
|
||||
const rulesU = panel(page, '渲染规则')
|
||||
await expect(rulesU.locator('.drw-profile').or(rulesU.locator('.drw-failure')).or(rulesU.getByText('没有可用的渲染规则')).first()).toBeVisible({ timeout: 15_000 })
|
||||
const uSources = await rulesU.locator('.drw-profile-source').allTextContents()
|
||||
note(`非受控文档可选规则来源:${uSources.join('、') || '(无)'};拒绝:${(await rulesU.locator('.drw-failure-msg').allTextContents()).join(' | ')}`)
|
||||
await shot(page, 'item2-uncontrolled-rules', rulesU)
|
||||
expect.soft(uSources.includes('公司默认规则'),
|
||||
`GAP[RDM] 已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version} 不出现在非受控文档的规则列表:rdm.render.profiles 只读 rdm_render_profile_bindings,而产品无任何写绑定命令(organization-default/project-default/document-type 绑定不可达)`).toBe(true)
|
||||
expect.soft(sources.length + uSources.length >= 2 && uSources.length >= 1,
|
||||
'GAP[RDM] 浏览器内无法在同一文档上于专用规则与公司通用规则之间选择(专用规则只到 validated;通用规则无绑定)').toBe(true)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-4 生成预览并把诊断定位到节点', async ({ browser }) => {
|
||||
items(4)
|
||||
const s = await session(browser, U.author, 'item4-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const generate = page.getByRole('button', { name: '生成预览', exact: true })
|
||||
await expect(generate).toBeEnabled({ timeout: 15_000 })
|
||||
await generate.click()
|
||||
const preview = panel(page, '预览与提交')
|
||||
await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 90_000 })
|
||||
await expectReadableFailures(page)
|
||||
await shot(page, 'item4-preview-receipt')
|
||||
await expect(preview.locator('.drw-op .drw-failure'), '预览不应失败').toHaveCount(0)
|
||||
const receipt = preview.locator('.drw-receipt')
|
||||
await expect(receipt.locator('.ui-chip').first()).toHaveText('已生成', { timeout: 60_000 })
|
||||
await expect(receipt.getByText('诊断读取中')).toHaveCount(0, { timeout: 60_000 })
|
||||
await expect(receipt.locator('.drw-diags').or(receipt.getByText('没有诊断问题')).or(receipt.locator('.drw-failure')).first()).toBeVisible()
|
||||
await expectReadableFailures(page)
|
||||
const summary = await receipt.locator('.drw-muted').first().textContent()
|
||||
const located = receipt.locator('button.drw-diag-node')
|
||||
const locatedCount = await located.count()
|
||||
const unlocated = await receipt.locator('.drw-diag-node.unlocated').count()
|
||||
note(`诊断摘要:${summary};可定位 ${locatedCount} 条(首屏),未定位 ${unlocated} 条`)
|
||||
await shot(page, 'item4-diagnostics', preview)
|
||||
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
|
||||
expect.soft(locatedCount,
|
||||
'GAP[Renderer] 渲染器生产代码从不产出 warning/info 诊断、也不填 Diagnostic.NodeID(仅失败时产出无节点的 error);GAP[RDM] 受控 DHF 实例 172 个节点全为空/未开始,预览 diagnostics 为 null——无可定位到节点的诊断').toBeGreaterThan(0)
|
||||
if (!locatedCount) { await s.close(); return }
|
||||
const first = located.first()
|
||||
const title = (await first.getAttribute('title')) ?? ''
|
||||
await first.click()
|
||||
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', title)
|
||||
const counted = receipt.locator('.drw-diag.sev-error button.drw-diag-node, .drw-diag.sev-warning button.drw-diag-node')
|
||||
if (await counted.count()) {
|
||||
const t = (await counted.first().getAttribute('title')) ?? ''
|
||||
await expect(page.locator(`.drw-canvas-node[title="${t.replace(/"/g, '\\"')}"] .drw-badge`).first()).toBeVisible()
|
||||
}
|
||||
await shot(page, 'item4-diagnostic-located-canvas')
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
const section = page.locator('section.drw-reader-node.sel')
|
||||
await expect(section.locator('.drw-reader-title')).toHaveText(title)
|
||||
await expect(section.locator('a.drw-anchor')).toHaveAttribute('href', `#${await section.getAttribute('id')}`)
|
||||
await shot(page, 'item4-diagnostic-located-reader')
|
||||
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-5a 编制人提交审阅', async ({ browser }) => {
|
||||
items(5)
|
||||
const s = await session(browser, U.author, 'item5a-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const submit = page.getByRole('button', { name: '提交审阅', exact: true })
|
||||
await expect(submit).toBeVisible({ timeout: 15_000 })
|
||||
await shot(page, 'item5a-submit-state', panel(page, '预览与提交'))
|
||||
const uiSubmit = await submit.isEnabled()
|
||||
note(`浏览器「提交审阅」可用:${uiSubmit};原因:${await submit.getAttribute('title')}`)
|
||||
expect.soft(uiSubmit,
|
||||
'GAP[RDM] 受控文档的专用模板规则只能经 rdm.render.validate 到 validated,产品无发布路径(company.* 为保留前缀,candidate.publish 不绑定作用域),rdm.render.submit 要求 published → 浏览器无法提交审阅').toBe(true)
|
||||
let submissionId = ''
|
||||
let round = 0
|
||||
if (uiSubmit) {
|
||||
await submit.click()
|
||||
await expect(panel(page, '预览与提交').getByText('提交审阅已完成')).toBeVisible({ timeout: 60_000 })
|
||||
} else {
|
||||
apiAssisted(`rdm.render.submit 以编制人会话显式指定已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version}`)
|
||||
const r = await command(page, 'rdm.render.submit', {
|
||||
projectId: A.pid, docId: A.doc, operationId: `rba-submit-${RUN}`, profileId: GENERAL.id, profileVersion: GENERAL.version,
|
||||
})
|
||||
note(`API 提交回执:${r.text.slice(0, 300)}`)
|
||||
expect.soft(r.body?.receipt?.state,
|
||||
`BLOCKER[RDM] rdm.render.submit 被拒(${r.body?.receipt?.reason ?? r.status}):trustedRenderActor → LookupAccountDirectoryOwner(purpose=document-duties) 需要 RDM 注册 AccountDirectoryScopeResolver,而 RDM d63f1db 从未调用 RegisterAccountDirectoryScopeResolver,Backend 恒返回 ErrAccountForbidden;submit/annotate/revise 在真实产品中全部不可用`).toBe('committed')
|
||||
}
|
||||
const status = await command(page, 'rdm.review.doc.status', { projectId: A.pid, docId: A.doc })
|
||||
submissionId = status.body?.payload?.submission?.submissionId ?? ''
|
||||
round = status.body?.payload?.submission?.round ?? 0
|
||||
writeState({ submissionId, round })
|
||||
expect(submissionId, '提交后 rdm.review.doc.status 返回提交编号').not.toBe('')
|
||||
await page.reload()
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
await expect(panel(page, '审阅').locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 15_000 })
|
||||
await shot(page, 'item5a-submitted')
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-5b 审核人添加批注与修订', async ({ browser }) => {
|
||||
items(5)
|
||||
const { submissionId } = readState()
|
||||
const s = await session(browser, U.reviewer, 'item5b-reviewer')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const review = panel(page, '审阅')
|
||||
if (!submissionId) {
|
||||
await expect(review.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
|
||||
await shot(page, 'item5b-reviewer-no-submission')
|
||||
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核人无法添加批注与修订').not.toBe('')
|
||||
}
|
||||
await expect(review.locator('.drw-mono'), '审核人在新会话中看到编制人的提交').toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
await page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first().click()
|
||||
await expect(review.locator('.drw-composer-node')).toHaveText('项目内容、目标及意义')
|
||||
await review.getByLabel('批注内容').fill(ANNOTATION)
|
||||
await review.getByRole('button', { name: '添加批注' }).click()
|
||||
await expect(review.locator('.drw-anno-text', { hasText: ANNOTATION })).toBeVisible({ timeout: 30_000 })
|
||||
await review.getByLabel('审阅动作').selectOption('revise')
|
||||
await review.getByLabel('修订类型').selectOption('replace')
|
||||
await review.getByLabel('修订前').fill('项目目标')
|
||||
await review.getByLabel('修订后').fill(REVISION_AFTER)
|
||||
await review.getByRole('button', { name: '提交修订' }).click()
|
||||
await expect(review.locator('.drw-diff-add', { hasText: REVISION_AFTER })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(review.locator('.drw-anno-group .drw-anno-node').first()).toHaveText('项目内容、目标及意义')
|
||||
await expect(page.locator('.drw-canvas-node[title="项目内容、目标及意义"] .drw-badge.review').first()).toBeVisible()
|
||||
|
||||
// 范围批注(06 §2):阅读视图选中节点正文「目标」(码点 5..7),载荷带偏移与摘录,不带 textHash/range。
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
const nodeText = page.locator('section.drw-reader-node.sel [data-anchor-node]')
|
||||
await expect(nodeText).toHaveText('项目内容、目标及意义')
|
||||
await nodeText.evaluate(el => {
|
||||
const range = document.createRange()
|
||||
range.setStart(el.firstChild!, 5)
|
||||
range.setEnd(el.firstChild!, 7)
|
||||
const sel = window.getSelection()!
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
||||
})
|
||||
await expect(review.locator('.drw-composer-quote')).toHaveText('「目标」')
|
||||
await review.getByLabel('批注内容').fill(RANGE_ANNOTATION)
|
||||
await review.getByRole('button', { name: '添加批注' }).click()
|
||||
const annotateCalls = () => s.net.filter(n => n.url.includes('/api/commands/rdm.render.review.annotate') && parseJson(n.request)?.params?.text === RANGE_ANNOTATION)
|
||||
await expect.poll(() => annotateCalls().length, { message: '应分发范围批注', timeout: 30_000 }).toBeGreaterThan(0)
|
||||
const rangeCall = annotateCalls().at(-1)!
|
||||
const rangeParams = parseJson(rangeCall.request)?.params
|
||||
expect(rangeParams, '范围批注载荷').toMatchObject({ textStart: 5, textEnd: 7, quote: '目标' })
|
||||
expect(rangeParams).not.toHaveProperty('textHash')
|
||||
expect(rangeParams).not.toHaveProperty('range')
|
||||
note(`范围批注回执:${rangeCall.body.slice(0, 300)}`)
|
||||
const rangeItem = review.locator('.drw-anno', { hasText: RANGE_ANNOTATION })
|
||||
const anchored = await rangeItem.locator('.drw-anno-quote').waitFor({ timeout: 30_000 }).then(() => true, () => false)
|
||||
if (anchored) await expect(rangeItem.locator('.drw-anno-quote')).toHaveText('目标')
|
||||
expect.soft(anchored, `GAP[RDM] 范围批注未按所选文字保存(回执:${rangeCall.body.slice(0, 200)})`).toBe(true)
|
||||
await page.getByRole('tab', { name: '结构画布' }).click()
|
||||
|
||||
await expectReadableFailures(page)
|
||||
await shot(page, 'item5b-review-annotations')
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
|
||||
items(5)
|
||||
const { submissionId } = readState()
|
||||
const r = await session(browser, U.reviewer, 'item5c-reviewer')
|
||||
await openAudit(r.page, A.name)
|
||||
await openDoc(r.page, A.title)
|
||||
const reviewPanel = panel(r.page, '审阅')
|
||||
if (!submissionId) {
|
||||
await expect(reviewPanel.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
|
||||
expect.soft(await r.page.getByRole('button', { name: /审核通过|驳回|批准|签署|定稿/ }).count(),
|
||||
'GAP[Frontend] 文档工作区没有审核通过/驳回/批准入口(rdm.review.doc.approve/final 需会话来源锚点,只能经对话代理)').toBeGreaterThan(0)
|
||||
await shot(r.page, 'item5c-reviewer-no-submission', reviewPanel)
|
||||
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核与批准签署无从进行').not.toBe('')
|
||||
}
|
||||
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
// 整文审签只走工作区入口:审核人待审时必须出现「审核通过」;成功与可读失败都必须可见。
|
||||
const decisionPanel = panel(r.page, '整文审签')
|
||||
const uiApprove = decisionPanel.getByRole('button', { name: '审核通过', exact: true })
|
||||
await expect(uiApprove, 'GAP[Frontend] 审核人在待审状态下工作区没有「审核通过」入口').toBeVisible({ timeout: 20_000 })
|
||||
await uiApprove.click()
|
||||
const done = decisionPanel.getByText('整文审核通过已完成')
|
||||
const failed = decisionPanel.locator('.drw-failure-msg')
|
||||
await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 })
|
||||
expect.soft(await done.count(), `审核通过应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1)
|
||||
await expectReadableFailures(r.page)
|
||||
await expectCommittedDecision(r, 'rdm.review.doc.approve', '审核通过')
|
||||
await shot(r.page, 'item5c-reviewer-approved', decisionPanel)
|
||||
await r.close()
|
||||
|
||||
const a = await session(browser, U.approver, 'item5c-approver')
|
||||
await openAudit(a.page, A.name)
|
||||
await openDoc(a.page, A.title)
|
||||
const approverPanel = panel(a.page, '审阅')
|
||||
await expect(approverPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
// 终批入口只在批准人且全员已审核通过时出现;载荷绑定界面所示轮次。
|
||||
const finalPanel = panel(a.page, '整文审签')
|
||||
const uiFinal = finalPanel.getByRole('button', { name: '终批', exact: true })
|
||||
await expect(uiFinal, 'GAP[Frontend] 批准人在全员审核通过后工作区没有「终批」入口').toBeVisible({ timeout: 20_000 })
|
||||
await uiFinal.click()
|
||||
const finalDone = finalPanel.getByText('整文终批已完成')
|
||||
const finalFailed = finalPanel.locator('.drw-failure-msg')
|
||||
await expect(finalDone.or(finalFailed).first()).toBeVisible({ timeout: 60_000 })
|
||||
expect.soft(await finalDone.count(), `终批应成功:${(await finalFailed.allTextContents()).join(' | ')}`).toBe(1)
|
||||
await expectReadableFailures(a.page)
|
||||
await expectCommittedDecision(a, 'rdm.review.doc.final', '终批')
|
||||
await a.page.reload()
|
||||
await openAudit(a.page, A.name)
|
||||
await openDoc(a.page, A.title)
|
||||
const decisions = panel(a.page, '审阅').locator('.drw-decision')
|
||||
await expect(decisions.first()).toBeVisible({ timeout: 20_000 })
|
||||
const decisionTexts = await decisions.allTextContents()
|
||||
note(`签署记录:${decisionTexts.join(' | ')}`)
|
||||
expect(decisionTexts.length, '审阅面板显示审核与批准两条签署').toBeGreaterThanOrEqual(2)
|
||||
await shot(a.page, 'item5c-decisions', panel(a.page, '审阅'))
|
||||
await a.close()
|
||||
})
|
||||
|
||||
test('§11-6 下载 Markdown、批准稿、审阅留痕稿与完整包', async ({ browser }) => {
|
||||
items(6)
|
||||
const { submissionId } = readState()
|
||||
if (!submissionId) {
|
||||
// 无渲染提交:只能取证编制人预览交付的 Markdown/DOCX/清单下载,批准稿与留痕稿记为阻断
|
||||
const s = await session(browser, U.author, 'item6-author-preview')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const preview = deliveryOf(page, '预览')
|
||||
await expect(preview).toBeVisible({ timeout: 20_000 })
|
||||
const md = await download(page, preview, 'Markdown')
|
||||
expect(md.name).toMatch(/\.md$/)
|
||||
const docx = await download(page, preview, '预览 DOCX')
|
||||
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
|
||||
const manifest = await download(page, preview, '清单(manifest)')
|
||||
note(`预览交付下载:${[md, docx, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
|
||||
writeState({ downloads: [md, docx, manifest].map(f => f.name) })
|
||||
await shot(page, 'item6-preview-downloads', panel(page, '交付记录'))
|
||||
expect.soft(await deliveryOf(page, '批准').count(), 'BLOCKER[RDM] 无渲染提交 → 无批准交付,批准稿不可下载').toBeGreaterThan(0)
|
||||
expect.soft(await page.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count(), 'BLOCKER[RDM] 无渲染提交 → 无审阅交付,审阅留痕稿不可下载').toBeGreaterThan(0)
|
||||
const pkg = await download(page, preview, '完整交付包(ZIP)')
|
||||
expect(pkg.name).toMatch(/\.zip$/)
|
||||
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
|
||||
await s.close()
|
||||
return
|
||||
}
|
||||
const s = await session(browser, U.approver, 'item6-approver')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const reviewPanel = panel(page, '审阅')
|
||||
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
const ok = panel(page, '预览与提交')
|
||||
await reviewPanel.getByRole('button', { name: '生成批准稿' }).click()
|
||||
await expect(ok.getByText('生成批准稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
|
||||
await expectReadableFailures(page)
|
||||
await expect(ok.locator('.drw-op .drw-failure'), '生成批准稿不应失败').toHaveCount(0)
|
||||
await reviewPanel.getByRole('button', { name: '生成留痕稿' }).click()
|
||||
await expect(ok.getByText('生成留痕稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
|
||||
await expect(ok.locator('.drw-op .drw-failure'), '生成留痕稿不应失败').toHaveCount(0)
|
||||
const approved = deliveryOf(page, '批准')
|
||||
const reviewed = deliveryOf(page, '审阅')
|
||||
await expect(approved).toBeVisible({ timeout: 20_000 })
|
||||
await expect(reviewed).toBeVisible({ timeout: 20_000 })
|
||||
await shot(page, 'item6-deliveries', panel(page, '交付记录'))
|
||||
const kinds = { approved: await approved.locator('.drw-download').allTextContents(), review: await reviewed.locator('.drw-download').allTextContents() }
|
||||
note(`批准交付可下载:${kinds.approved.join('、')};审阅交付可下载:${kinds.review.join('、')}`)
|
||||
writeState({ deliveryKinds: kinds })
|
||||
|
||||
const md = await download(page, approved, 'Markdown')
|
||||
expect(md.name).toMatch(/\.md$/)
|
||||
expect(fs.readFileSync(md.file, 'utf8').length).toBeGreaterThan(50)
|
||||
const docx = await download(page, approved, '批准稿 DOCX')
|
||||
expect(docx.name).toMatch(/\.docx$/)
|
||||
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
|
||||
const traceHolder = (await reviewed.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count()) ? reviewed : approved
|
||||
const trace = await download(page, traceHolder, '审阅留痕 DOCX')
|
||||
expect(trace.name).toMatch(/\.docx$/)
|
||||
expect(fs.readFileSync(trace.file).subarray(0, 2).toString()).toBe('PK')
|
||||
const manifest = await download(page, approved, '清单(manifest)')
|
||||
note(`下载:${[md, docx, trace, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
|
||||
// 长文件名:显示截断,title 保留完整文件名(§11-9 一并取证)
|
||||
const lastName = panel(page, '交付记录').locator('.drw-filename').first()
|
||||
await expect(lastName).toHaveAttribute('title', manifest.name)
|
||||
const shown = (await lastName.textContent()) ?? ''
|
||||
note(`文件名显示「${shown}」,完整「${manifest.name}」`)
|
||||
if (manifest.name.length > 36) expect(shown).toContain('…')
|
||||
await shot(page, 'item6-downloaded-longname', panel(page, '交付记录'))
|
||||
writeState({ downloads: [md, docx, trace, manifest].map(f => f.name) })
|
||||
// 完整包:rdm.render.download kind=package,服务端确定性 ZIP;与单件下载同一鉴权路径。
|
||||
const pkg = await download(page, approved, '完整交付包(ZIP)')
|
||||
expect(pkg.name).toMatch(/\.zip$/)
|
||||
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
|
||||
note(`完整交付包:${pkg.name}(${pkg.size} B)`)
|
||||
writeState({ downloads: [md, docx, trace, manifest, pkg].map(f => f.name) })
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-7 追溯网显示来源、规则、修订、签署与交付物', async ({ browser }) => {
|
||||
items(7)
|
||||
const s = await session(browser, U.admin, 'item7-admin')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await page.getByRole('tab', { name: '关联追溯' }).click()
|
||||
const pick = page.locator('.rdm-audit-pick button', { hasText: /^项目内容、目标及意义$/ }).first()
|
||||
await expect(pick).toBeVisible({ timeout: 60_000 })
|
||||
await pick.click()
|
||||
await expect(page.locator('.rdm-audit-trace-head')).toBeVisible()
|
||||
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 20_000 })
|
||||
await shot(page, 'item7-trace-section')
|
||||
await Promise.all(s.pending)
|
||||
const relationBodies = s.net.filter(n => n.url.includes('rdm.inspect.relations')).map(n => n.body).join('\n')
|
||||
const text = (await page.locator('.rdm-audit-body').textContent()) ?? ''
|
||||
note(`关联追溯页面文本(截断):${text.slice(0, 300)}`)
|
||||
const has = (re: RegExp) => re.test(text) || re.test(relationBodies)
|
||||
expect.soft(has(/source|来源依据([1-9]/), 'GAP[RDM] 追溯网未给出该章节的来源依据(rdm.inspect.relations 只含通用网/合规边)').toBe(true)
|
||||
expect.soft(has(/profile|渲染规则|org\.general|company\.dhf/), 'GAP[RDM] 追溯网不含渲染规则(profile)节点或边').toBe(true)
|
||||
expect.soft(has(/revision|修订/), 'GAP[RDM] 追溯网不含审阅修订').toBe(true)
|
||||
expect.soft(has(/decision|approve|final|签署|批准/), 'GAP[RDM] 追溯网不含审核/批准签署').toBe(true)
|
||||
expect.soft(has(/delivery|交付|deliveryId/), 'GAP[RDM] 追溯网不含渲染交付物(preview/review/approved delivery)').toBe(true)
|
||||
// 补充取证:操作记录是否留下渲染与签署事件(不计入追溯网判定)
|
||||
await page.getByRole('button', { name: '← 返回' }).click()
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
await page.waitForTimeout(2_000)
|
||||
await shot(page, 'item7-timeline')
|
||||
const timeline = (await page.locator('.rdm-audit-body').textContent()) ?? ''
|
||||
note(`操作记录含 render:${/render/.test(timeline)};含 review:${/review/.test(timeline)}`)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-8 无权用户的页面与网络响应不含受限内容', async ({ browser }) => {
|
||||
items(8)
|
||||
const st = readState()
|
||||
const s = await session(browser, U.outsider, 'item8-outsider')
|
||||
const { page } = s
|
||||
await clickAuditEntry(page)
|
||||
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
|
||||
const options = await page.locator('.rdm-audit-project option').allTextContents()
|
||||
note(`无权用户项目列表:${options.join('、')}`)
|
||||
expect(options).not.toContain(A.name)
|
||||
expect(options).toContain(B.name)
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-row').first()).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByRole('tab', { name: '关联追溯' }).click()
|
||||
await page.waitForTimeout(1_500)
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
await page.waitForTimeout(1_500)
|
||||
await shot(page, 'item8-outsider-audit')
|
||||
const pageText = (await page.locator('body').textContent()) ?? ''
|
||||
for (const m of [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER]) expect(pageText, `页面不得出现受限内容「${m}」`).not.toContain(m)
|
||||
const passiveCount = s.net.length
|
||||
|
||||
// 主动探测:以无权用户会话直调 A 的读取与下载命令(请求中自带的 id 不算泄露)
|
||||
const probes: [string, Record<string, unknown>][] = [
|
||||
['rdm.inspect.summary', { projectId: A.pid }],
|
||||
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs' }],
|
||||
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs', parent: A.doc }],
|
||||
['rdm.inspect.relations', { projectId: A.pid, kind: 'doc', id: A.doc, depth: 1 }],
|
||||
['rdm.render.profiles', { projectId: A.pid, docId: A.doc }],
|
||||
['rdm.render.deliveries', { projectId: A.pid, docId: A.doc, limit: 20 }],
|
||||
['rdm.review.doc.status', { projectId: A.pid, docId: A.doc }],
|
||||
...(st.submissionId ? [['rdm.render.review.view', { projectId: A.pid, submissionId: st.submissionId }] as [string, Record<string, unknown>]] : []),
|
||||
]
|
||||
const probeResults: { id: string; status: number; state: string; hasPayload: boolean }[] = []
|
||||
const probeBodies: { url: string; body: string }[] = []
|
||||
for (const [id, params] of probes) {
|
||||
const r = await command(page, id, params)
|
||||
probeBodies.push({ url: `probe:${id}`, body: r.text })
|
||||
const state = r.body?.receipt?.state ?? ''
|
||||
const payload = r.body?.payload
|
||||
const hasPayload = payload !== undefined && payload !== null && !(typeof payload === 'object' && Object.keys(payload).length === 0)
|
||||
probeResults.push({ id, status: r.status, state, hasPayload })
|
||||
expect.soft(r.status >= 400 || state === 'rejected', `${id} 对无权用户应被拒绝:HTTP ${r.status} ${r.text.slice(0, 200)}`).toBe(true)
|
||||
expect.soft(hasPayload, `${id} 不得返回受限载荷`).toBe(false)
|
||||
}
|
||||
for (const kind of ['markdown', 'docx', 'manifest']) {
|
||||
const r = await command(page, 'rdm.render.download', { projectId: A.pid, deliveryId: 'probe', kind })
|
||||
probeBodies.push({ url: `probe:rdm.render.download:${kind}`, body: r.text })
|
||||
probeResults.push({ id: `rdm.render.download:${kind}`, status: r.status, state: r.body?.receipt?.state ?? '', hasPayload: !!r.body?.payload })
|
||||
expect.soft(!r.body?.payload?.dataBase64, 'rdm.render.download 不得返回文件内容').toBe(true)
|
||||
}
|
||||
const hostProjects = await page.request.get('/api/projects')
|
||||
const hostText = await hostProjects.text()
|
||||
expect.soft(hostText.includes(A.host), '宿主项目列表不得含项目 A').toBe(false)
|
||||
const hostConv = await page.request.get(`/api/projects/${A.host}/conversations`)
|
||||
const hostConvText = await hostConv.text()
|
||||
probeBodies.push({ url: '/api/projects', body: hostText }, { url: `/api/projects/${A.host}/conversations`, body: hostConvText })
|
||||
note(`宿主 A 会话列表 HTTP ${hostConv.status()}`)
|
||||
expect.soft(hostConv.status() >= 400 || !hostConvText.includes('§11'), '宿主 A 会话不得可见').toBe(true)
|
||||
note(`主动探测:${JSON.stringify(probeResults)}`)
|
||||
await Promise.all(s.pending)
|
||||
|
||||
// 全部网络响应体扫描:内容标记任何响应都不得出现;A 的标识只在被动浏览响应中检查
|
||||
const contentMarkers = [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER, ...(st.downloads ?? [])]
|
||||
const idMarkers = [A.pid, A.host, st.submissionId].filter(Boolean)
|
||||
const leaks: string[] = []
|
||||
s.net.forEach((n, i) => {
|
||||
for (const m of contentMarkers) if (m && n.body.includes(m)) leaks.push(`${n.url} 含「${m}」`)
|
||||
if (i < passiveCount) for (const m of idMarkers) if (n.body.includes(m)) leaks.push(`${n.url} 含标识 ${m}`)
|
||||
})
|
||||
for (const p of probeBodies) for (const m of contentMarkers) if (m && p.body.includes(m)) leaks.push(`${p.url} 含「${m}」`)
|
||||
const scanned = path.join(EVID, 'network', 'item8-scan.json')
|
||||
fs.mkdirSync(path.dirname(scanned), { recursive: true })
|
||||
fs.writeFileSync(scanned, JSON.stringify({ pageResponses: s.net.length, passive: passiveCount, probeResponses: probeBodies.length, contentMarkers, idMarkers, leaks, probeResults }, null, 2))
|
||||
evidence.push(scanned)
|
||||
expect(leaks, `受限内容泄露:${leaks.join(';')}`).toEqual([])
|
||||
await s.close()
|
||||
|
||||
// 反向:A 的审核人不是 B 成员,看不到 B
|
||||
const r = await session(browser, U.reviewer, 'item8-reviewer-vs-B')
|
||||
await clickAuditEntry(r.page)
|
||||
await expect(r.page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
|
||||
expect(await r.page.locator('.rdm-audit-project option').allTextContents()).not.toContain(B.name)
|
||||
const bProbe = await command(r.page, 'rdm.render.profiles', { projectId: B.pid, docId: B.doc })
|
||||
expect.soft(bProbe.status >= 400 || bProbe.body?.receipt?.state === 'rejected', `审核人读 B 应被拒绝:${bProbe.text.slice(0, 200)}`).toBe(true)
|
||||
await r.close()
|
||||
})
|
||||
|
||||
test('§11-9 浅色/深色/窄屏/长文档/长文件名可用', async ({ browser }) => {
|
||||
items(9)
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
for (const [w, h, tag] of [[1440, 900, 'wide'], [390, 844, 'narrow']] as const) {
|
||||
const s = await session(browser, U.author, `item9-${theme}-${tag}`, { theme, width: w, height: h })
|
||||
const { page } = s
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe(theme)
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
await expect(panel(page, '渲染规则').locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
|
||||
const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth)
|
||||
note(`${theme}/${tag}:.drw 横向溢出 ${overflow}px`)
|
||||
expect.soft(overflow, `${theme}/${tag} 工作区不得横向溢出`).toBeLessThanOrEqual(1)
|
||||
const title = page.locator('.drw-title')
|
||||
await expect(title).toHaveAttribute('title', A.title)
|
||||
const clipped = await title.evaluate(el => el.scrollWidth > el.clientWidth)
|
||||
note(`${theme}/${tag}:${A.title.length} 字长标题截断显示 ${clipped}`)
|
||||
expect(clipped, '长标题在页头截断显示(完整标题保留在 title)').toBe(true)
|
||||
expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
|
||||
const bodyOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect.soft(bodyOverflow, `${theme}/${tag} 页面不得横向滚动`).toBeLessThanOrEqual(1)
|
||||
await shot(page, `item9-${theme}-${tag}-canvas`)
|
||||
if (tag === 'wide') {
|
||||
const delivery = page.locator('.drw-delivery').first()
|
||||
await expect(delivery, '长文件名取证需要至少一条交付记录').toBeVisible({ timeout: 15_000 })
|
||||
const file = await download(page, delivery, 'Markdown')
|
||||
const name = panel(page, '交付记录').locator('.drw-filename').first()
|
||||
await expect(name).toHaveAttribute('title', file.name)
|
||||
const shown = (await name.textContent()) ?? ''
|
||||
note(`${theme}:下载文件名「${file.name}」(${file.name.length} 字)显示为「${shown}」——RDM 交付文件名恒为 <docId>.<ext>,产品不产生长文件名;长名称以 ${A.title.length} 字文档标题检验`)
|
||||
expect(shown.length).toBeLessThanOrEqual(36)
|
||||
await shot(page, `item9-${theme}-filename`, panel(page, '交付记录'))
|
||||
}
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
const sections = await page.locator('section.drw-reader-node').count()
|
||||
expect(sections, '长文档阅读视图完整列出节点').toBeGreaterThan(10)
|
||||
await page.locator('section.drw-reader-node').last().scrollIntoViewIfNeeded()
|
||||
await expect(page.locator('section.drw-reader-node').last()).toBeInViewport()
|
||||
await shot(page, `item9-${theme}-${tag}-reader-end`)
|
||||
await page.locator('.drw-side').scrollIntoViewIfNeeded()
|
||||
await shot(page, `item9-${theme}-${tag}-side`, page.locator('.drw-side'))
|
||||
await page.getByRole('button', { name: '← 返回文档树' }).click()
|
||||
await expect(page.getByRole('button', { name: `阅读与交付:${A.title}`, exact: true })).toBeVisible()
|
||||
await s.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -71,7 +71,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').first().click()
|
||||
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
|
||||
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
|
||||
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: '章节' }) }).first()
|
||||
await expect(sectionNode).toBeVisible()
|
||||
await sectionNode.click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||||
import { auth } from './auth'
|
||||
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
|
||||
import { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||
@@ -233,10 +233,10 @@ export const api = {
|
||||
if (error) throw new Error(error)
|
||||
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
|
||||
},
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
|
||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params }),
|
||||
body: JSON.stringify(commandRequestBody(params, source)),
|
||||
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||||
.then(out => {
|
||||
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 统一渲染 Task 11:整文审签与规则绑定面板。纯展示:入口、载荷与校验来自 documentRenderPresentation,
|
||||
// 异步操作状态由工作区唯一持有并经 props 注入;入口只在服务端允许(能力 ∧ 冻结职责/项目设置权限)时出现。
|
||||
import { useState } from 'react'
|
||||
import { Btn, Chip, SectionLabel } from '@ui'
|
||||
import {
|
||||
BIND_SOURCE_LABELS, validateReviewText, ruleLabel,
|
||||
type BindSource, type BindableRule, type DecisionEntries, type DecisionKind, type DocSubmissionView, type RuleRef,
|
||||
} from '../documentRenderPresentation'
|
||||
import { OperationStatus, type OperationState } from './renderFeedback'
|
||||
|
||||
export const ACTION_OP_LABELS: Record<string, string> = {
|
||||
approve: '整文审核通过', reject: '整文驳回', final: '整文终批', bind: '绑定规则', unbind: '解绑规则',
|
||||
}
|
||||
|
||||
const DECISION_LABELS: Record<DecisionKind, string> = { approve: '审核通过', reject: '驳回', final: '终批' }
|
||||
const SUBMISSION_STATE: Record<string, { label: string; tone: 'ok' | 'info' | 'danger' | 'plain' }> = {
|
||||
submitted: { label: '审签中', tone: 'info' },
|
||||
rejected: { label: '已驳回', tone: 'danger' },
|
||||
final: { label: '已终批', tone: 'ok' },
|
||||
}
|
||||
|
||||
export function ReviewDecisionPanel({ sub, entries, busy, status, onRetry, onDecide }: {
|
||||
sub: DocSubmissionView
|
||||
entries: DecisionEntries
|
||||
busy: boolean
|
||||
status: OperationState
|
||||
onRetry: () => void
|
||||
onDecide: (kind: DecisionKind, text: string) => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const [hint, setHint] = useState('')
|
||||
const reviewerIds = Object.keys(sub.reviews)
|
||||
const approvedCount = reviewerIds.filter(id => sub.reviews[id] === true).length
|
||||
const stateView = SUBMISSION_STATE[sub.state] ?? { label: '状态未知', tone: 'plain' as const }
|
||||
const anyEntry = entries.approve || entries.reject || entries.final
|
||||
|
||||
const decide = (kind: DecisionKind) => {
|
||||
// 驳回必须给出原因;审核通过与终批的说明可选(服务端同样可选)。
|
||||
if (kind === 'reject') {
|
||||
const bad = validateReviewText(text)
|
||||
if (bad) { setHint(bad); return }
|
||||
}
|
||||
setHint('')
|
||||
onDecide(kind, text)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drw-row drw-row-between">
|
||||
<SectionLabel>整文审签</SectionLabel>
|
||||
<span className="drw-row">
|
||||
<Chip tone={stateView.tone}>{stateView.label}</Chip>
|
||||
<span className="drw-muted">第 {sub.round} 轮 · 审核 {approvedCount}/{reviewerIds.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
{anyEntry && (
|
||||
<>
|
||||
<textarea className="ui-input drw-textarea" aria-label="审签说明" value={text}
|
||||
onChange={e => setText(e.target.value)} placeholder="说明可留空;驳回时必须填写原因" />
|
||||
{hint && <div className="drw-inline-err">{hint}</div>}
|
||||
<div className="drw-row">
|
||||
{entries.approve && <Btn variant="ghost" disabled={busy} onClick={() => decide('approve')}>{DECISION_LABELS.approve}</Btn>}
|
||||
{entries.reject && <Btn variant="danger" disabled={busy} onClick={() => decide('reject')}>{DECISION_LABELS.reject}</Btn>}
|
||||
{entries.final && <Btn variant="primary" disabled={busy} onClick={() => decide('final')}>{DECISION_LABELS.final}</Btn>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SCOPES: BindSource[] = ['document', 'document-type', 'project-default']
|
||||
const ruleKey = (r: RuleRef) => `${r.profileId}@${r.version}`
|
||||
|
||||
export function ProfileBindingPanel({ rules, bindings, typeKey, busy, status, onRetry, onBind, onUnbind }: {
|
||||
rules: BindableRule[]
|
||||
bindings: Partial<Record<BindSource, RuleRef>>
|
||||
typeKey: string | undefined
|
||||
busy: boolean
|
||||
status: OperationState
|
||||
onRetry: () => void
|
||||
onBind: (source: BindSource, rule: RuleRef) => void
|
||||
onUnbind: (source: BindSource) => void
|
||||
}) {
|
||||
const [choice, setChoice] = useState('')
|
||||
const selected = rules.find(r => ruleKey(r) === choice)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionLabel>规则绑定</SectionLabel>
|
||||
{rules.length === 0 ? (
|
||||
<div className="empty-note">没有可绑定的已发布规则</div>
|
||||
) : (
|
||||
<div className="drw-bind-rules" role="radiogroup" aria-label="可绑定规则">
|
||||
{rules.map(r => (
|
||||
<label key={ruleKey(r)} className={`drw-bind-rule${choice === ruleKey(r) ? ' sel' : ''}`}>
|
||||
<input type="radio" name="drw-bind-rule" value={ruleKey(r)} checked={choice === ruleKey(r)} onChange={() => setChoice(ruleKey(r))} />
|
||||
<span className="drw-mono">{ruleLabel(r)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{SCOPES.map(source => {
|
||||
const bound = bindings[source]
|
||||
const needsType = source === 'document-type'
|
||||
const bindDisabled = busy || !selected || (needsType && !typeKey)
|
||||
return (
|
||||
<div key={source} className="drw-bind-row">
|
||||
<div className="drw-row drw-row-between">
|
||||
<span className="drw-profile-source">{BIND_SOURCE_LABELS[source]}</span>
|
||||
<span className="drw-mono">{bound ? ruleLabel(bound) : '未绑定'}</span>
|
||||
</div>
|
||||
<div className="drw-row">
|
||||
<Btn variant="ghost" disabled={bindDisabled} onClick={() => selected && onBind(source, selected)}>绑定所选规则</Btn>
|
||||
{bound && <Btn variant="ghost" disabled={busy} onClick={() => onUnbind(source)}>解绑</Btn>}
|
||||
</div>
|
||||
{needsType && !typeKey && <div className="drw-muted">文档类型未就绪,暂不能绑定文档类型规则</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const flush = () => new Promise(r => setTimeout(r, 0))
|
||||
|
||||
describe('startLatestFetch(历史请求单一所有者)', () => {
|
||||
it('较新的请求已发起时丢弃过期响应', async () => {
|
||||
const ref = { current: 0 }
|
||||
const onData = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const older = deferred<string>()
|
||||
const newer = deferred<string>()
|
||||
startLatestFetch(ref, () => older.promise, onData, onError)
|
||||
startLatestFetch(ref, () => newer.promise, onData, onError)
|
||||
newer.resolve('newer')
|
||||
await flush()
|
||||
older.resolve('older')
|
||||
await flush()
|
||||
expect(onData.mock.calls).toEqual([['newer']])
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('过期请求的失败不写回错误', async () => {
|
||||
const ref = { current: 0 }
|
||||
const onError = vi.fn()
|
||||
const older = deferred<string>()
|
||||
startLatestFetch(ref, () => older.promise, vi.fn(), onError)
|
||||
startLatestFetch(ref, () => new Promise<string>(() => {}), vi.fn(), onError)
|
||||
older.reject(new Error('stale'))
|
||||
await flush()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('清理(卸载或参数变化)后在途响应被丢弃', async () => {
|
||||
const ref = { current: 0 }
|
||||
const onData = vi.fn()
|
||||
const pending = deferred<string>()
|
||||
const cleanup = startLatestFetch(ref, () => pending.promise, onData, vi.fn())
|
||||
cleanup()
|
||||
pending.resolve('late')
|
||||
await flush()
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('只推进自己的 ref,不影响其他 seq 所有者', () => {
|
||||
const historyRef = { current: 0 }
|
||||
const eventsRef = { current: 7 }
|
||||
startLatestFetch(historyRef, () => new Promise<string>(() => {}), vi.fn(), vi.fn())
|
||||
expect(historyRef.current).toBe(1)
|
||||
expect(eventsRef.current).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TreeNodeRow 无障碍属性', () => {
|
||||
const noop = () => {}
|
||||
const render = (item: { kind: string; id: string; title: string; hasChildren?: boolean }, children = {}) =>
|
||||
renderToStaticMarkup(<TreeNodeRow item={item} children={children} onSelect={noop} onLoadMore={noop} onOpenDoc={noop} />)
|
||||
|
||||
it('可展开行:折叠时 aria-expanded=false,箭头 aria-hidden', () => {
|
||||
const html = render({ kind: 'spine', id: 's1', title: '骨架' })
|
||||
expect(html).toContain('aria-expanded="false"')
|
||||
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▸<\/span>/)
|
||||
})
|
||||
|
||||
it('可展开行:已加载下级时 aria-expanded=true', () => {
|
||||
const html = render({ kind: 'spine', id: 's1', title: '骨架' }, { s1: { items: [], hasMore: false, truncated: false } })
|
||||
expect(html).toContain('aria-expanded="true"')
|
||||
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▾<\/span>/)
|
||||
})
|
||||
|
||||
it('不可展开行省略 aria-expanded 与箭头', () => {
|
||||
const html = render({ kind: 'doc', id: 'd1', title: '文档' })
|
||||
expect(html).not.toContain('aria-expanded')
|
||||
expect(html).not.toContain('rdm-audit-tree-caret')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,15 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
import {
|
||||
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable,
|
||||
relationOtherEndpoint,
|
||||
type AuditEventViewInput,
|
||||
} from '../rdmAuditPresentation'
|
||||
import {
|
||||
collectRecursiveTree, isTreeItemInspectable, mergeTreePage,
|
||||
type AuditTreeItem, type AuditTreePage,
|
||||
} from '../rdmAuditTree'
|
||||
import { store, useStore } from '../store'
|
||||
import { exportNeedsDraft } from '../workspaces'
|
||||
import {
|
||||
@@ -12,6 +21,7 @@ import {
|
||||
regsTreeParams, sealStateLabel,
|
||||
type RegBatchDetail, type RegCitationDetail,
|
||||
} from '../regCitations'
|
||||
import { DocumentRenderWorkspace, saveBase64File } from './DocumentRenderWorkspace'
|
||||
import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
@@ -19,11 +29,11 @@ import { CitationCard, CitationListItem, CitationStateChips } from './RegCitatio
|
||||
interface InspectProject { projectId: string; name: string; canAudit: boolean }
|
||||
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
|
||||
interface CoverageItem { face: string; reason: string }
|
||||
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||
interface ListShell<T = any> { items: T[]; businessAudit?: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
|
||||
interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||
type TreeItem = AuditTreeItem
|
||||
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||
interface EventDTO extends AuditEventViewInput {}
|
||||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||
@@ -38,6 +48,23 @@ const TABS = [
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
const OPERATION_FILTERS = [
|
||||
['', '全部操作'],
|
||||
['doc.instantiate', '创建文档实例'],
|
||||
['section.add', '新增文档章节'],
|
||||
['slice.ingest', '新增项目资料'],
|
||||
['edge.candidate', '提出追溯候选'],
|
||||
['edge.confirm', '确认追溯关系'],
|
||||
['change.requested', '提出变更申请'],
|
||||
['change.applied', '应用变更'],
|
||||
['open-item.add', '登记项目问题'],
|
||||
['open-item.close', '关闭项目问题'],
|
||||
['rdm.work.observe', '拆解工作来源'],
|
||||
['rdm.work.risk', '发现项目风险'],
|
||||
['rdm.work.risk.answer', '回答风险澄清'],
|
||||
['rdm.structure.decide', '决定结构变更'],
|
||||
] as const
|
||||
|
||||
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||||
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||||
function RdmErrorState({ raw }: { raw: string }) {
|
||||
@@ -99,7 +126,7 @@ export function RdmAuditWorkspace() {
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
</div>
|
||||
@@ -175,19 +202,6 @@ interface ExportDownloaded {
|
||||
sha256: string
|
||||
}
|
||||
|
||||
function saveExportFile(fileName: string, mime: string, dataB64: string) {
|
||||
const bin = atob(dataB64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function ExportSection({ pid }: { pid: string }) {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const generation = useRef(0)
|
||||
@@ -237,7 +251,7 @@ function ExportSection({ pid }: { pid: string }) {
|
||||
try {
|
||||
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
|
||||
if (!current()) return
|
||||
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
|
||||
saveBase64File(dl.fileName, dl.mime, dl.dataB64)
|
||||
} catch (e) {
|
||||
if (current()) setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
@@ -282,15 +296,20 @@ function ExportSection({ pid }: { pid: string }) {
|
||||
|
||||
function MaterialsTab({ pid }: { pid: string }) {
|
||||
const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs')
|
||||
// 打开文档时树列表保持挂载(只隐藏),返回后展开状态不丢。
|
||||
const [openDoc, setOpenDoc] = useState<TreeItem | null>(null)
|
||||
return (
|
||||
<div className="rdm-audit-materials">
|
||||
<div className="rdm-audit-materials-side">
|
||||
{openDoc && (
|
||||
<DocumentRenderWorkspace projectId={pid} docId={openDoc.id} docTitle={openDoc.title} onBack={() => setOpenDoc(null)} />
|
||||
)}
|
||||
<div className="rdm-audit-materials-side" hidden={!!openDoc}>
|
||||
<div className="tabs">
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
<button className={`tab${kind === 'regs' ? ' active' : ''}`} onClick={() => setKind('regs')}>法规核验</button>
|
||||
</div>
|
||||
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} />}
|
||||
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -477,19 +496,20 @@ function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitation
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs'; onOpenDoc: (doc: TreeItem) => void }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||
const [detailErr, setDetailErr] = useState('')
|
||||
const [children, setChildren] = useState<Record<string, AuditTreePage>>({})
|
||||
const genRef = useRef(0)
|
||||
const detailGenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||
detailGenRef.current++
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
@@ -503,24 +523,42 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleDoc = (item: TreeItem) => {
|
||||
const toggleNode = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s })) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const loadMoreChildren = (item: TreeItem) => {
|
||||
const current = children[item.id]
|
||||
if (!current?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', {
|
||||
projectId: pid, kind, parent: item.id, cursor: current.nextCursor,
|
||||
})
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setChildren(prev => ({ ...prev, [item.id]: mergeTreePage(prev[item.id], s) }))
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||
setSelected(item); setDetail(null)
|
||||
const gen = genRef.current
|
||||
if (isTreeItemExpandable(item)) toggleNode(item)
|
||||
const detailGen = ++detailGenRef.current
|
||||
if (!isTreeItemInspectable(item)) {
|
||||
setSelected(null); setDetail(null); setDetailErr('')
|
||||
return
|
||||
}
|
||||
setSelected(item); setDetail(null); setDetailErr('')
|
||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
.then(d => { if (detailGen === detailGenRef.current) setDetail(d) })
|
||||
.catch(e => { if (detailGen === detailGenRef.current) setDetailErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
@@ -530,37 +568,53 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<div key={item.id}>
|
||||
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && children[item.id] && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||
{children[item.id].map(sub => (
|
||||
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(sub)}>
|
||||
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{shell.items.map(item => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} onLoadMore={loadMoreChildren} onOpenDoc={onOpenDoc} />)}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>对象详情</SectionLabel>
|
||||
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
{detailErr ? <RdmErrorState raw={detailErr} /> : !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
|
||||
item: TreeItem
|
||||
selectedId?: string
|
||||
children: Record<string, AuditTreePage>
|
||||
onSelect: (item: TreeItem) => void
|
||||
onLoadMore: (item: TreeItem) => void
|
||||
onOpenDoc: (doc: TreeItem) => void
|
||||
}) {
|
||||
const expandable = isTreeItemExpandable(item)
|
||||
const nestedPage = children[item.id]
|
||||
const nested = nestedPage?.items
|
||||
const readable = describeTreeItem(item)
|
||||
return (
|
||||
<div>
|
||||
<div className="rdm-audit-tree-row">
|
||||
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}
|
||||
aria-expanded={expandable ? Boolean(nested) : undefined}>
|
||||
{expandable && <span className="rdm-audit-tree-caret" aria-hidden="true">{nested ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{readable.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{readable.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && (
|
||||
<button type="button" className="btn v-ghost rdm-audit-tree-open" onClick={() => onOpenDoc(item)}
|
||||
aria-label={`阅读与交付:${readable.title}`}>阅读与交付</button>
|
||||
)}
|
||||
</div>
|
||||
{nested && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{nested.length === 0 && <div className="empty-note">无下级节点</div>}
|
||||
{nested.map(child => <TreeNodeRow key={child.id} item={child} selectedId={selectedId} children={children} onSelect={onSelect} onLoadMore={onLoadMore} onOpenDoc={onOpenDoc} />)}
|
||||
{nestedPage.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => onLoadMore(item)}>加载更多下级节点</button>}
|
||||
{nestedPage.truncated && <div className="rdm-audit-truncated">该节点的下级内容已截断</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -585,21 +639,17 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, sel])
|
||||
|
||||
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||||
// (parent=<docId>),按文档聚合后合并展示。
|
||||
const [sections, setSections] = useState<TreeItem[]>([])
|
||||
const [treeItems, setTreeItems] = useState<TreeItem[]>([])
|
||||
const [pickErr, setPickErr] = useState('')
|
||||
useEffect(() => {
|
||||
if (sel) return
|
||||
let c = false
|
||||
;(async () => {
|
||||
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
||||
const secs: TreeItem[] = []
|
||||
for (const d of top.items.filter(i => i.kind === 'doc')) {
|
||||
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
|
||||
secs.push(...sub.items.filter(i => i.kind === 'section'))
|
||||
}
|
||||
if (!c) setSections(secs)
|
||||
})().catch(() => {})
|
||||
setTreeItems([]); setPickErr('')
|
||||
const items = await collectRecursiveTree((parent, cursor) =>
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent, cursor }))
|
||||
if (!c) setTreeItems(items.filter(item => item.kind !== 'doc'))
|
||||
})().catch(e => { if (!c) setPickErr(e instanceof Error ? e.message : String(e)) })
|
||||
return () => { c = true }
|
||||
}, [pid, sel])
|
||||
|
||||
@@ -607,13 +657,14 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||||
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
|
||||
{pickErr ? <RdmErrorState raw={pickErr} /> : treeItems.length === 0 ? <EmptyState icon="folder">暂无可追溯对象</EmptyState> : (
|
||||
<div className="rdm-audit-pick">
|
||||
{sections.map(s => (
|
||||
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
|
||||
{s.title}
|
||||
{treeItems.map(item => {
|
||||
const readable = describeTreeItem(item)
|
||||
return <button key={item.id} className="btn v-ghost" onClick={() => setSel({ kind: item.kind, id: item.id, title: readable.title })}>
|
||||
{readable.kind} · {readable.title}
|
||||
</button>
|
||||
))}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -628,7 +679,7 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
<div className="rdm-audit-trace-head">
|
||||
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
|
||||
<strong>{sel.title}</strong>
|
||||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||
<Chip tone="plain">{humanizeKind(sel.kind)} · {sel.id}</Chip>
|
||||
</div>
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||
@@ -637,11 +688,11 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
<div className="rdm-audit-trace-cols">
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>来源依据({sources.length})</SectionLabel>
|
||||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
|
||||
</div>
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>影响对象({impacts.length})</SectionLabel>
|
||||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -650,20 +701,35 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RelationRow({ r }: { r: RelationEntry }) {
|
||||
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
|
||||
function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: string; id: string } }) {
|
||||
const other = relationOtherEndpoint(r, selected)
|
||||
const readable = humanizeRelation(r)
|
||||
return (
|
||||
<div className="rdm-audit-rel">
|
||||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
|
||||
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
|
||||
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
|
||||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
|
||||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{readable.layer}</Chip>
|
||||
<span className="rdm-audit-rel-type">{readable.type}</span>
|
||||
<span className="rdm-audit-rel-endpoint">{humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''}</span>
|
||||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' || r.state === 'stale' ? 'warn' : 'plain'}>{readable.state}</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 操作记录(事件时间线) ----
|
||||
|
||||
// 单一所有者:仅最近一次发起的请求可写回;返回的清理函数作废在途请求。
|
||||
export function startLatestFetch<T>(
|
||||
genRef: { current: number },
|
||||
request: () => Promise<T>,
|
||||
onData: (data: T) => void,
|
||||
onError: (message: string) => void,
|
||||
): () => void {
|
||||
const gen = ++genRef.current
|
||||
request()
|
||||
.then(d => { if (gen === genRef.current) onData(d) })
|
||||
.catch(e => { if (gen === genRef.current) onError(e instanceof Error ? e.message : String(e)) })
|
||||
return () => { if (gen === genRef.current) genRef.current++ }
|
||||
}
|
||||
|
||||
function TimelineTab({ pid }: { pid: string }) {
|
||||
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
|
||||
const [cursor, setCursor] = useState('')
|
||||
@@ -675,6 +741,7 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
const [historySeq, setHistorySeq] = useState(0)
|
||||
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
|
||||
const genRef = useRef(0)
|
||||
const historyGenRef = useRef(0)
|
||||
|
||||
const load = useCallback((cur: string, replace: boolean) => {
|
||||
const gen = ++genRef.current
|
||||
@@ -685,7 +752,8 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setItems(prev => replace ? s.items : [...prev, ...s.items])
|
||||
const page = replace ? [...(s.businessAudit || []), ...s.items] : s.items
|
||||
setItems(prev => replace ? page : [...prev, ...page])
|
||||
setCursor(s.nextCursor || '')
|
||||
setHasMore(s.hasMore)
|
||||
})
|
||||
@@ -696,12 +764,9 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyMode) { setHistoryData(null); return }
|
||||
const gen = genRef.current
|
||||
setHistoryData(null)
|
||||
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||||
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
|
||||
.then(d => { if (gen === genRef.current) setHistoryData(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
return startLatestFetch(historyGenRef, () => api.rdmCommand<HistoryData>('rdm.inspect.history', params), setHistoryData, setErr)
|
||||
}, [pid, historyMode, historySeq])
|
||||
|
||||
return (
|
||||
@@ -709,8 +774,9 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
<div className="rdm-audit-timeline-filters">
|
||||
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
|
||||
onChange={e => setFilterActor(e.target.value)} />
|
||||
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
|
||||
onChange={e => setFilterOp(e.target.value)} />
|
||||
<select className="ui-input" aria-label="按操作筛选" value={filterOp} onChange={e => setFilterOp(e.target.value)}>
|
||||
{OPERATION_FILTERS.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
|
||||
</select>
|
||||
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
|
||||
{historyMode ? '← 返回当前' : '历史模式'}
|
||||
</button>
|
||||
@@ -761,27 +827,38 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
}
|
||||
|
||||
function OpGroupView({ group }: { group: OpGroup }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const first = group.events[0] ? describeAuditEvent(group.events[0]) : null
|
||||
return (
|
||||
<div className="rdm-audit-opgroup">
|
||||
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
|
||||
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
|
||||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||
<Chip tone="plain">{group.events.length} 个事件</Chip>
|
||||
</button>
|
||||
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
<Collapsible id={`rdm-audit-operation-${group.operationId}`} trigger={(
|
||||
<span className="rdm-audit-opgroup-head">
|
||||
<span className="rdm-audit-opgroup-actor">{first?.subject || group.actor}</span>
|
||||
<span className="rdm-audit-opgroup-action">{first?.action || '项目操作'}</span>
|
||||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||
<Chip tone="plain">包含 {group.events.length} 项记录</Chip>
|
||||
</span>
|
||||
)}>
|
||||
{group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ ev }: { ev: EventDTO }) {
|
||||
const view = describeAuditEvent(ev)
|
||||
return (
|
||||
<div className="rdm-audit-event">
|
||||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
|
||||
<span className="rdm-audit-event-op">{ev.op}</span>
|
||||
<span className="rdm-audit-event-actor">{ev.actor}</span>
|
||||
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
|
||||
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
|
||||
<div className="rdm-audit-event-head">
|
||||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleString('zh-CN')}</span>
|
||||
<strong className="rdm-audit-event-action">{view.action}</strong>
|
||||
<Chip tone={ev.disclosure === 'ok' && view.outcome === '已生效' ? 'ok' : 'warn'}>{view.outcome}</Chip>
|
||||
</div>
|
||||
<div className="rdm-audit-event-summary">{view.subject}处理了{view.object}</div>
|
||||
<dl className="rdm-audit-event-fields">
|
||||
<div><dt>原因</dt><dd>{view.reason}</dd></div>
|
||||
<div><dt>影响</dt><dd>{view.impact}</dd></div>
|
||||
<div><dt>依据</dt><dd>{view.source}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -792,8 +869,8 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || detail.id}</strong></div>
|
||||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||
<div>{humanizeKind(detail.kind)} · 对象编号 {detail.id}{detail.ver ? ` · 版本 ${detail.ver}` : ''}</div>
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : detail.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(detail.state)}</Chip></div>}
|
||||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||
{detail.versions && detail.versions.length > 0 && (
|
||||
<div>
|
||||
@@ -813,7 +890,7 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
{detail.relations && detail.relations.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
|
||||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} selected={{ kind: detail.kind, id: detail.id }} />)}
|
||||
</div>
|
||||
)}
|
||||
{detail.recentEvents && detail.recentEvents.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 统一渲染的失败与操作状态展示:工作区与各面板共用,只渲染,不持有异步状态。
|
||||
// 原始回执只进 Collapsible 折叠区;可读文案由 describeRenderFailure 统一给出。
|
||||
import { Btn, Collapsible } from '@ui'
|
||||
import { describeRenderFailure, type RenderOperation } from '../documentRenderPresentation'
|
||||
|
||||
export interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string }
|
||||
|
||||
export function RenderFailure({ error, id }: { error: unknown; id: string }) {
|
||||
const f = describeRenderFailure(error)
|
||||
return (
|
||||
<div className="drw-failure" role="alert">
|
||||
<div className="drw-failure-msg">{f.message}</div>
|
||||
<Collapsible id={`drw-failure-${id}`} trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{f.raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OperationStatus({ state, onRetry, labels }: { state: OperationState; onRetry: () => void; labels: Record<string, string> }) {
|
||||
if (state.status === 'idle' || !state.op) return null
|
||||
const label = labels[state.op.kind] ?? '操作'
|
||||
if (state.status === 'running') return <div className="drw-op"><span className="mk-sweep">{label}进行中</span></div>
|
||||
if (state.status === 'failed') {
|
||||
return (
|
||||
<div className="drw-op">
|
||||
<RenderFailure error={state.error} id={`op-${state.op.kind}`} />
|
||||
<Btn variant="ghost" onClick={onRetry}>重试</Btn>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 统一渲染 Task 11(审签来源):整文审签的正式命令必须锚定一条已提交的对话消息(基座来源门禁)。
|
||||
// 流程:宿主项目的审签记录对话 → 提交一条中文审签说明(用户消息,幂等编号 = 动作编号)→ 以该消息为锚点分发命令。
|
||||
// 任一步失败都可读地拒绝且绝不分发命令;身份只由服务端认证主体决定,载荷不含任何身份字段。
|
||||
import type { Conversation, Message, Project } from './contracts'
|
||||
import { describeRenderFailure, type DecisionKind } from './documentRenderPresentation'
|
||||
import { rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
|
||||
|
||||
export const DECISION_RECORD_TITLE = '文档审签记录'
|
||||
const DECISION_ENTRANCE: CommandSource['entrance'] = 'button'
|
||||
const CONVERSATION_STAGE = '审签记录对话'
|
||||
const SOURCE_STAGE = '审签来源消息'
|
||||
|
||||
/** 只需要的会话端口:调用方传入共享的 api 对象,不另起一层 API。 */
|
||||
export interface DecisionPort {
|
||||
listConversations: (projectId: string) => Promise<Conversation[]>
|
||||
createConversation: (projectId: string, title?: string) => Promise<Conversation>
|
||||
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
|
||||
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
|
||||
}
|
||||
|
||||
export interface DecisionRequest {
|
||||
host: Project | undefined
|
||||
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
|
||||
operationId: string
|
||||
decision: DecisionKind
|
||||
doc: { id: string; title: string }
|
||||
round: number
|
||||
note: string
|
||||
command: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
function decisionLabel(kind: DecisionKind): string {
|
||||
switch (kind) {
|
||||
case 'approve': return '审核通过'
|
||||
case 'reject': return '驳回'
|
||||
case 'final': return '终批'
|
||||
default: {
|
||||
const exhaustive: never = kind
|
||||
throw new Error(`unknown decision kind: ${String(exhaustive)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 审签来源正文(中文):决定、文档、轮次与说明/原因;不含任何身份字段,首字符不为 “/” 以免被当作命令。 */
|
||||
export function decisionSourceText(input: Pick<DecisionRequest, 'decision' | 'doc' | 'round' | 'note'>): string {
|
||||
const doc = input.doc.title ? `${input.doc.title}(${input.doc.id})` : input.doc.id
|
||||
const noteLabel = input.decision === 'reject' ? '驳回原因' : '说明'
|
||||
return [
|
||||
`审签决定:${decisionLabel(input.decision)}`,
|
||||
`文档:${doc}`,
|
||||
`轮次:第 ${input.round} 轮`,
|
||||
`${noteLabel}:${input.note.trim() || '无'}`,
|
||||
'来源:文档渲染工作区',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** 研发项目 → 其宿主对话项目(settings 中绑定的 rdm.projectId 相同者)。 */
|
||||
export function hostProjectFor(projects: Project[], rdmProjectId: string): Project | undefined {
|
||||
return projects.find(p => {
|
||||
try { return rdmCommandProjectId(p) === rdmProjectId } catch { return false }
|
||||
})
|
||||
}
|
||||
|
||||
function refusal(stage: string, detail: string): Error {
|
||||
return new Error(`${stage}未能提交,审签未执行:${detail}`)
|
||||
}
|
||||
|
||||
function stepFailure(stage: string, err: unknown): Error {
|
||||
const raw = err instanceof Error ? err.message : String(err ?? '')
|
||||
if (/\b403\b/.test(raw)) return refusal(stage, '当前账号无权在该对话中记录审签')
|
||||
if (/\b409\b/.test(raw)) return refusal(stage, '会话正在处理其他任务,请稍后重试')
|
||||
if (/\b429\b/.test(raw)) return refusal(stage, '会话排队已满,请稍后重试')
|
||||
return refusal(stage, describeRenderFailure(err).message)
|
||||
}
|
||||
|
||||
/** 记录对话:复用宿主项目中标题为审签记录、未归档的对话;没有则新建。 */
|
||||
async function recordConversation(port: DecisionPort, projectId: string): Promise<Conversation> {
|
||||
let list: Conversation[]
|
||||
try {
|
||||
list = await port.listConversations(projectId)
|
||||
} catch (e) {
|
||||
throw stepFailure(CONVERSATION_STAGE, e)
|
||||
}
|
||||
const existing = list.find(c => c.title === DECISION_RECORD_TITLE && !c.archived)
|
||||
if (existing) return existing
|
||||
try {
|
||||
return await port.createConversation(projectId, DECISION_RECORD_TITLE)
|
||||
} catch (e) {
|
||||
throw stepFailure(CONVERSATION_STAGE, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交来源消息:必须是已提交的同会话用户消息;被当作命令路由的说明不算来源。 */
|
||||
async function recordSource(port: DecisionPort, convId: string, req: DecisionRequest): Promise<string> {
|
||||
let out: { message?: Message; command?: string }
|
||||
try {
|
||||
out = await port.sendMessage(convId, decisionSourceText(req), [], `decision:${req.operationId}`)
|
||||
} catch (e) {
|
||||
throw stepFailure(SOURCE_STAGE, e)
|
||||
}
|
||||
if (out.command) throw refusal(SOURCE_STAGE, '审签说明被识别为命令,未作为来源记录')
|
||||
const msg = out.message
|
||||
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== convId) {
|
||||
throw refusal(SOURCE_STAGE, '服务端未返回已提交的对话消息')
|
||||
}
|
||||
return msg.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 整文审签的唯一写路径:先记录来源,再以来源消息为锚点分发命令。
|
||||
* 宿主项目缺失、项目未就绪、任一记录步骤失败时,命令一律不分发。
|
||||
*/
|
||||
export async function commitDecision(port: DecisionPort, req: DecisionRequest): Promise<unknown> {
|
||||
const host = req.host
|
||||
if (!host) throw new Error('未找到该研发项目对应的对话项目,请刷新项目列表后重试;审签未执行')
|
||||
const gate = rdmProjectCommandError(host, req.command)
|
||||
if (gate) throw new Error(`${gate};审签未执行`)
|
||||
const conversation = await recordConversation(port, host.id)
|
||||
const anchorMessageId = await recordSource(port, conversation.id, req)
|
||||
return port.rdmCommand(req.command, req.params, {
|
||||
conversationId: conversation.id, anchorMessageId, entrance: DECISION_ENTRANCE,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState,
|
||||
isTreeItemExpandable, relationOtherEndpoint,
|
||||
} from './rdmAuditPresentation'
|
||||
|
||||
describe('RDM audit presentation', () => {
|
||||
it('turns a structured document event into a readable action and impact', () => {
|
||||
expect(describeAuditEvent({
|
||||
seq: 18,
|
||||
at: '2026-10-15T08:00:00Z',
|
||||
op: 'doc.instantiate',
|
||||
actor: 'alice',
|
||||
source: 'template:dhf-proposal@v2',
|
||||
target: { kind: 'doc', id: 'doc-1', title: '项目建议书' },
|
||||
detail: '{"raw":"must not be shown"}',
|
||||
operationId: 'op-1',
|
||||
disclosure: 'ok',
|
||||
result: 'committed',
|
||||
impact: '新增 12 个递归节点',
|
||||
reason: '从已发布模板建立项目实例',
|
||||
})).toEqual({
|
||||
action: '创建文档实例',
|
||||
subject: 'Alice',
|
||||
object: '文档“项目建议书”',
|
||||
outcome: '已生效',
|
||||
reason: '从已发布模板建立项目实例',
|
||||
impact: '新增 12 个递归节点',
|
||||
source: '模板:DHF 项目建议书(版本 v2)',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose unknown operation codes or raw JSON as user-facing text', () => {
|
||||
const view = describeAuditEvent({
|
||||
seq: 19,
|
||||
at: '2026-10-15T08:01:00Z',
|
||||
op: 'internal.weird_opcode',
|
||||
actor: 'user-42',
|
||||
source: 'host',
|
||||
target: { kind: 'node', id: 'node-secret' },
|
||||
detail: '{"secret":"raw payload"}',
|
||||
operationId: 'op-2',
|
||||
disclosure: 'restricted',
|
||||
})
|
||||
expect(view.action).toBe('更新项目内容')
|
||||
expect(JSON.stringify(view)).not.toContain('internal.weird_opcode')
|
||||
expect(JSON.stringify(view)).not.toContain('raw payload')
|
||||
expect(view.outcome).toBe('部分信息受限')
|
||||
expect(view.object).toBe('节点(node-secret)')
|
||||
expect(view.source).toBe('系统受信操作记录')
|
||||
})
|
||||
|
||||
it('preserves domain provenance and object identity in readable audit rows', () => {
|
||||
expect(describeAuditEvent({
|
||||
seq: 20,
|
||||
at: '2026-10-15T08:02:00Z',
|
||||
op: 'edge.candidate',
|
||||
actor: 'model-runner',
|
||||
source: 'model',
|
||||
target: { kind: 'edge', id: 'edg-7' },
|
||||
operationId: 'op-3',
|
||||
disclosure: 'model-candidate',
|
||||
})).toMatchObject({
|
||||
action: '提出追溯关系候选',
|
||||
object: '追溯边(edg-7)',
|
||||
source: '模型分析候选',
|
||||
})
|
||||
expect(describeAuditEvent({
|
||||
seq: 21,
|
||||
at: '2026-10-15T08:03:00Z',
|
||||
op: 'section.add',
|
||||
actor: 'alice',
|
||||
source: 'human',
|
||||
target: { kind: 'section', id: 'sec-2' },
|
||||
operationId: 'op-4',
|
||||
disclosure: 'ok',
|
||||
}).source).toBe('项目成员操作')
|
||||
})
|
||||
|
||||
it('humanizes tree, relationship, and lifecycle vocabulary', () => {
|
||||
expect(humanizeKind('section')).toBe('章节')
|
||||
expect(humanizeState('candidate')).toBe('待确认')
|
||||
expect(humanizeState('retired')).toBe('已停用')
|
||||
expect(humanizeState('stale')).toBe('待复核')
|
||||
expect(humanizeRelation({ layer: 'compliance', type: 'verifies', state: 'active' })).toEqual({
|
||||
layer: '合规关系',
|
||||
type: '验证',
|
||||
state: '已生效',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows recursive sections to expand when the server reports children', () => {
|
||||
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true })).toBe(true)
|
||||
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-2', title: '空文档', hasChildren: false })).toBe(false)
|
||||
expect(isTreeItemExpandable({ kind: 'section', id: 'sec-1', title: '章节', hasChildren: true })).toBe(true)
|
||||
expect(isTreeItemExpandable({ kind: 'paragraph', id: 'p-1', title: '段落', hasChildren: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('gives untitled template nodes a distinct readable name', () => {
|
||||
expect(describeTreeItem({
|
||||
kind: 'paragraph',
|
||||
id: 'dn-1',
|
||||
title: '',
|
||||
order: 6,
|
||||
templateBlockId: 'dhf.dx.a7850d1881.3.placeholder',
|
||||
})).toEqual({ kind: '段落', title: '模板内容 7(3)' })
|
||||
expect(describeTreeItem({ kind: 'table', id: 'dn-2', title: '', order: 2 })).toEqual({
|
||||
kind: '表格',
|
||||
title: '模板内容 3',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the endpoint opposite the selected object in a trace relationship', () => {
|
||||
const relation = {
|
||||
from: { kind: 'slice', id: 'slc-6' },
|
||||
to: { kind: 'section', id: 'sec-2' },
|
||||
}
|
||||
expect(relationOtherEndpoint(relation, { kind: 'section', id: 'sec-2' })).toEqual(relation.from)
|
||||
expect(relationOtherEndpoint(relation, { kind: 'slice', id: 'slc-6' })).toEqual(relation.to)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
export interface AuditEventViewInput {
|
||||
seq: number
|
||||
at: string
|
||||
op: string
|
||||
actor: string
|
||||
source: string
|
||||
target: { kind: string; id: string; title?: string }
|
||||
detail?: string
|
||||
operationId: string
|
||||
disclosure: string
|
||||
summary?: string
|
||||
result?: string
|
||||
reason?: string
|
||||
impact?: string
|
||||
}
|
||||
|
||||
interface AuditEventPresentation {
|
||||
action: string
|
||||
subject: string
|
||||
object: string
|
||||
outcome: string
|
||||
reason: string
|
||||
impact: string
|
||||
source: string
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', spine: '知识主题', edge: '追溯边',
|
||||
paragraph: '段落', table: '表格', row: '表格行', cell: '表格单元格',
|
||||
field: '填写字段', attachment: '附件',
|
||||
requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系',
|
||||
template: '模板', project: '项目', conclusion: '结论', issue: '问题',
|
||||
}
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线',
|
||||
retired: '已停用', stale: '待复核',
|
||||
approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代',
|
||||
failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消',
|
||||
delivered: '已记录',
|
||||
unknown: '结果未知', restricted: '部分信息受限',
|
||||
}
|
||||
|
||||
const OP_LABELS: Record<string, string> = {
|
||||
'doc.instantiate': '创建文档实例',
|
||||
'section.add': '新增文档章节',
|
||||
'section.version': '更新文档内容',
|
||||
'section.state': '变更章节状态',
|
||||
'section.tombstone': '停用文档章节',
|
||||
'slice.ingest': '新增项目资料',
|
||||
'slice.retag': '更新资料标签',
|
||||
'slice.tombstone': '停用项目资料',
|
||||
'spine.add': '新增知识主题',
|
||||
'mount.add': '挂接项目资料',
|
||||
'mount.move': '移动项目资料',
|
||||
'mount.remove': '移除资料挂接',
|
||||
'edge.candidate': '提出追溯关系候选',
|
||||
'edge.confirm': '确认追溯关系',
|
||||
'edge.retire': '停用追溯关系',
|
||||
'change.requested': '提出变更申请',
|
||||
'change.approved': '批准变更申请',
|
||||
'change.applied': '应用变更',
|
||||
'open-item.add': '登记项目问题',
|
||||
'open-item.close': '关闭项目问题',
|
||||
'dictionary.revise': '修订项目字典',
|
||||
'req.change': '更新项目要求',
|
||||
}
|
||||
|
||||
const RELATION_LABELS: Record<string, string> = {
|
||||
verifies: '验证', supports: '支持', derives: '派生自', impacts: '影响',
|
||||
traces: '追溯到', replaces: '取代', conflicts: '存在冲突', references: '引用',
|
||||
}
|
||||
|
||||
const LAYER_LABELS: Record<string, string> = {
|
||||
compliance: '合规关系', knowledge: '知识关系', document: '文档关系', project: '项目关系',
|
||||
}
|
||||
|
||||
export function humanizeKind(kind: string): string {
|
||||
return KIND_LABELS[kind] || '项目对象'
|
||||
}
|
||||
|
||||
export function describeTreeItem(item: {
|
||||
kind: string
|
||||
id: string
|
||||
title?: string
|
||||
order?: number
|
||||
templateBlockId?: string
|
||||
}): { kind: string; title: string } {
|
||||
const title = item.title?.trim()
|
||||
if (title) return { kind: humanizeKind(item.kind), title }
|
||||
|
||||
const position = typeof item.order === 'number' ? item.order + 1 : undefined
|
||||
const parts = (item.templateBlockId || '').split('.').filter(Boolean)
|
||||
const semantic = new Set(['placeholder', 'example', 'fixed'])
|
||||
while (parts.length > 0 && semantic.has(parts[parts.length - 1])) parts.pop()
|
||||
const hint = parts.at(-1)
|
||||
const numbered = position ? `模板内容 ${position}` : '未命名模板内容'
|
||||
return {
|
||||
kind: humanizeKind(item.kind),
|
||||
title: hint ? `${numbered}(${hint})` : numbered,
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeState(state: string): string {
|
||||
return STATE_LABELS[state] || '状态已记录'
|
||||
}
|
||||
|
||||
function humanizeOperation(op: string): string {
|
||||
if (OP_LABELS[op]) return OP_LABELS[op]
|
||||
if (op.startsWith('doc.')) return '更新文档内容'
|
||||
if (op.startsWith('section.')) return '更新文档内容'
|
||||
if (op.startsWith('slice.') || op.startsWith('spine.') || op.startsWith('mount.') || op.startsWith('knowledge.')) return '更新项目知识'
|
||||
if (op.startsWith('edge.') || op.startsWith('relation.') || op.startsWith('trace.')) return '更新追溯关系'
|
||||
if (op.startsWith('change.')) return '处理项目变更'
|
||||
if (op.startsWith('open-item.')) return '处理项目问题'
|
||||
if (op.startsWith('risk.')) return '处理项目风险'
|
||||
if (op.startsWith('proposal.')) return '处理结构变更'
|
||||
return '更新项目内容'
|
||||
}
|
||||
|
||||
function humanizeActor(actor: string): string {
|
||||
if (!actor) return '未知成员'
|
||||
if (/^[a-z][a-z0-9._-]*$/i.test(actor) && !actor.includes('-') && !actor.includes('_')) {
|
||||
return actor.charAt(0).toUpperCase() + actor.slice(1)
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
function humanizeSource(source: string): string {
|
||||
if (!source) return '当时未记录来源'
|
||||
const template = /^template:([^@]+)@(.+)$/.exec(source)
|
||||
if (template) {
|
||||
const title = template[1] === 'dhf-proposal' ? 'DHF 项目建议书' : template[1]
|
||||
return `模板:${title}(版本 ${template[2]})`
|
||||
}
|
||||
if (source === 'host') return '系统受信操作记录'
|
||||
if (source === 'human') return '项目成员操作'
|
||||
if (source === 'rule') return '项目规则推导'
|
||||
if (source === 'model') return '模型分析候选'
|
||||
if (source.startsWith('message:')) return '项目对话'
|
||||
if (source.startsWith('artifact:')) return '项目文件'
|
||||
return '已登记项目来源'
|
||||
}
|
||||
|
||||
function humanizeOutcome(input: AuditEventViewInput): string {
|
||||
if (input.disclosure !== 'ok') return input.disclosure === 'model-candidate' ? '模型候选,尚未确认' : '部分信息受限'
|
||||
return humanizeState(input.result || 'committed')
|
||||
}
|
||||
|
||||
export function describeAuditEvent(input: AuditEventViewInput): AuditEventPresentation {
|
||||
const targetTitle = input.target?.title ? `“${input.target.title}”` : ''
|
||||
const targetIdentity = targetTitle || (input.target?.id ? `(${input.target.id})` : '')
|
||||
return {
|
||||
action: input.summary || humanizeOperation(input.op),
|
||||
subject: humanizeActor(input.actor),
|
||||
object: `${humanizeKind(input.target?.kind)}${targetIdentity}`,
|
||||
outcome: humanizeOutcome(input),
|
||||
reason: input.reason || '未记录具体原因',
|
||||
impact: input.impact || '影响范围尚未记录',
|
||||
source: humanizeSource(input.source),
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeRelation(input: { layer?: string; type?: string; kind?: string; state?: string }) {
|
||||
const type = input.type || input.kind || ''
|
||||
return {
|
||||
layer: LAYER_LABELS[input.layer || ''] || '项目关系',
|
||||
type: RELATION_LABELS[type] || '关联',
|
||||
state: humanizeState(input.state || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function relationOtherEndpoint<T extends { kind?: string; id?: string }>(
|
||||
relation: { from?: T; to?: T },
|
||||
selected: { kind: string; id: string },
|
||||
): T | undefined {
|
||||
if (relation.to?.kind === selected.kind && relation.to.id === selected.id) return relation.from
|
||||
if (relation.from?.kind === selected.kind && relation.from.id === selected.id) return relation.to
|
||||
return relation.from || relation.to
|
||||
}
|
||||
|
||||
export function isTreeItemExpandable(item: { kind: string; hasChildren?: boolean; id?: string; title?: string }): boolean {
|
||||
return item.kind === 'spine' || item.hasChildren === true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectRecursiveTree, isTreeItemInspectable, mergeTreePage, type AuditTreePage } from './rdmAuditTree'
|
||||
|
||||
describe('RDM audit tree traversal', () => {
|
||||
it('keeps child cursors and appends later pages', () => {
|
||||
const first: AuditTreePage = {
|
||||
items: [{ kind: 'paragraph', id: 'p-1', title: '一' }],
|
||||
nextCursor: 'next', hasMore: true, truncated: false,
|
||||
}
|
||||
const second: AuditTreePage = {
|
||||
items: [{ kind: 'paragraph', id: 'p-2', title: '二' }],
|
||||
hasMore: false, truncated: false,
|
||||
}
|
||||
expect(mergeTreePage(first, second)).toEqual({
|
||||
items: [...first.items, ...second.items],
|
||||
hasMore: false,
|
||||
truncated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('collects every page and every server-reported depth for trace selection', async () => {
|
||||
const pages = new Map<string, AuditTreePage>([
|
||||
['|', { items: [{ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true }], nextCursor: 'root-2', hasMore: true, truncated: false }],
|
||||
['|root-2', { items: [{ kind: 'doc', id: 'doc-2', title: '文档二', hasChildren: false }], hasMore: false, truncated: false }],
|
||||
['doc-1|', { items: [{ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec', hasChildren: true }], nextCursor: 'child-2', hasMore: true, truncated: false }],
|
||||
['doc-1|child-2', { items: [{ kind: 'section', id: 'sec-2', title: '正文', hasChildren: false }], hasMore: false, truncated: false }],
|
||||
['dn-sec|', { items: [{ kind: 'paragraph', id: 'p-deep', title: '深层内容', hasChildren: false }], hasMore: false, truncated: false }],
|
||||
])
|
||||
const items = await collectRecursiveTree(async (parent, cursor) => {
|
||||
const page = pages.get(`${parent}|${cursor}`)
|
||||
if (!page) throw new Error(`unexpected page ${parent}|${cursor}`)
|
||||
return page
|
||||
})
|
||||
expect(items.map(item => item.id)).toEqual(['doc-1', 'doc-2', 'dn-sec', 'sec-2', 'p-deep'])
|
||||
})
|
||||
|
||||
it('only requests object details for legacy sections supported by the server', () => {
|
||||
expect(isTreeItemInspectable({ kind: 'section', id: 'sec-2', title: '正文', templateBlockId: '' })).toBe(true)
|
||||
expect(isTreeItemInspectable({ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec' })).toBe(false)
|
||||
expect(isTreeItemInspectable({ kind: 'paragraph', id: 'p-1', title: '内容' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface AuditTreeItem {
|
||||
kind: string
|
||||
id: string
|
||||
title: string
|
||||
state?: string
|
||||
hasChildren?: boolean
|
||||
order?: number
|
||||
templateBlockId?: string
|
||||
}
|
||||
|
||||
export interface AuditTreePage {
|
||||
items: AuditTreeItem[]
|
||||
nextCursor?: string
|
||||
hasMore: boolean
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
type AuditTreePageFetcher = (parent: string, cursor: string) => Promise<AuditTreePage>
|
||||
|
||||
export function mergeTreePage(current: AuditTreePage, next: AuditTreePage): AuditTreePage {
|
||||
return { ...next, items: [...current.items, ...next.items] }
|
||||
}
|
||||
|
||||
async function collectParentPages(fetchPage: AuditTreePageFetcher, parent: string): Promise<AuditTreeItem[]> {
|
||||
const items: AuditTreeItem[] = []
|
||||
const seenCursors = new Set<string>()
|
||||
let cursor = ''
|
||||
for (let pageNo = 0; pageNo < 100; pageNo++) {
|
||||
if (seenCursors.has(cursor)) throw new Error('树分页游标重复,已停止加载')
|
||||
seenCursors.add(cursor)
|
||||
const page = await fetchPage(parent, cursor)
|
||||
items.push(...page.items)
|
||||
if (!page.hasMore) return items
|
||||
if (!page.nextCursor) throw new Error('树分页缺少后续游标')
|
||||
cursor = page.nextCursor
|
||||
}
|
||||
throw new Error('树分页超过安全上限')
|
||||
}
|
||||
|
||||
export async function collectRecursiveTree(fetchPage: AuditTreePageFetcher): Promise<AuditTreeItem[]> {
|
||||
const roots = await collectParentPages(fetchPage, '')
|
||||
const out = [...roots]
|
||||
const queue = roots.filter(item => item.hasChildren === true)
|
||||
const seenParents = new Set<string>()
|
||||
while (queue.length > 0) {
|
||||
const parent = queue.shift()!
|
||||
if (seenParents.has(parent.id)) continue
|
||||
seenParents.add(parent.id)
|
||||
const children = await collectParentPages(fetchPage, parent.id)
|
||||
out.push(...children)
|
||||
if (out.length > 5000) throw new Error('树节点超过安全上限')
|
||||
queue.push(...children.filter(item => item.hasChildren === true))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function isTreeItemInspectable(item: AuditTreeItem): boolean {
|
||||
return item.kind === 'section' && !item.templateBlockId && !item.id.startsWith('dn-')
|
||||
}
|
||||
@@ -153,6 +153,22 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
|
||||
|
||||
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
|
||||
/**
|
||||
* 命令来源(基座 cmdbus.RequiresProjectWorkDecision):正式写命令必须带已提交的对话来源,
|
||||
* 且位于请求体顶层(conversationId/anchorMessageId/entrance),不得放进 params。
|
||||
* entrance 取服务端 CanonicalEntrance 集合;页面动作为 button。
|
||||
*/
|
||||
export interface CommandSource {
|
||||
conversationId: string
|
||||
anchorMessageId: string
|
||||
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
|
||||
}
|
||||
|
||||
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
|
||||
source
|
||||
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
|
||||
: { params }
|
||||
|
||||
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
|
||||
|
||||
export type MemberMatchMode = 'fuzzy' | 'exact'
|
||||
|
||||
+7
-2
@@ -13,6 +13,7 @@ import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
|
||||
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
|
||||
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
|
||||
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
@@ -132,6 +133,8 @@ interface AppState {
|
||||
rdmMembersAvailable: boolean
|
||||
rdmDocReviewAvailable: boolean
|
||||
rdmPermMatrixAvailable: boolean
|
||||
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
|
||||
rdmRender: RenderCapabilities
|
||||
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||||
adminSections: AdminZoneSection[]
|
||||
ui: UIState
|
||||
@@ -203,6 +206,7 @@ let state: AppState = {
|
||||
rdmCockpitAvailable: false,
|
||||
rdmExportAvailable: false,
|
||||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
rdmRender: NO_RENDER_CAPABILITIES,
|
||||
adminSections: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
@@ -445,7 +449,7 @@ export const store = {
|
||||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||||
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
|
||||
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
|
||||
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
|
||||
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
|
||||
@@ -1205,11 +1209,12 @@ export const store = {
|
||||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||||
rdmDocReviewAvailable: rdmDutyReady(cmds),
|
||||
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
|
||||
rdmRender: renderCapabilities(cmds),
|
||||
})
|
||||
} catch {
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
|
||||
}
|
||||
try {
|
||||
const sections = await api.adminZoneSections()
|
||||
|
||||
+157
-5
@@ -625,12 +625,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
|
||||
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
|
||||
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
|
||||
.rdm-audit-opgroup-action { color: var(--text-dim); }
|
||||
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
|
||||
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
|
||||
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-event { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||
.rdm-audit-event-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-dim); }
|
||||
.rdm-audit-event-action { color: var(--text); }
|
||||
.rdm-audit-event-summary { color: var(--text); }
|
||||
.rdm-audit-event-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
|
||||
.rdm-audit-event-fields > div { min-width: 0; padding: 8px; background: var(--bg-raised); border-radius: var(--radius-sm); }
|
||||
.rdm-audit-event-fields dt { color: var(--text-dim); font-size: var(--fs-ui-xs); }
|
||||
.rdm-audit-event-fields dd { margin: 3px 0 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -644,6 +649,149 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
.rdm-audit-materials-side[hidden] { display: none; }
|
||||
.rdm-audit-materials > .drw { flex: 1; }
|
||||
.rdm-audit-tree-row { display: flex; align-items: center; gap: 4px; }
|
||||
.rdm-audit-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
|
||||
.rdm-audit-tree-open { flex: none; font-size: var(--fs-ui-sm); padding: 4px 8px; }
|
||||
/* 统一渲染 Task 11:文档阅读与交付工作区(结构画布 / 阅读视图 + 规则、预览、审阅、下载) */
|
||||
.drw { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.drw-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.drw-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-lg); color: var(--text); }
|
||||
.drw-view-tabs { flex: none; }
|
||||
.drw-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); gap: 16px; align-items: start; }
|
||||
.drw-main, .drw-side { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.drw-filters { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.drw-search { flex: 1; min-width: 140px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.drw-select { height: 32px; font-size: var(--fs-ui-sm); max-width: 100%; }
|
||||
.drw-canvas { position: relative; height: min(60vh, 520px); overflow: auto; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); cursor: grab; touch-action: none; }
|
||||
.drw-canvas.is-panning { cursor: grabbing; user-select: none; }
|
||||
.drw-canvas-stage { position: relative; }
|
||||
.drw-canvas-edges { position: absolute; inset: 0; pointer-events: none; }
|
||||
.drw-canvas-edge { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
|
||||
.drw-canvas-node { position: absolute; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-dim); font-size: var(--fs-ui-sm); text-align: left; cursor: pointer; }
|
||||
.drw-canvas-node:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.drw-canvas-node.depth-0 { color: var(--text); font-weight: 600; border-color: var(--border-strong); }
|
||||
.drw-canvas-node.sel { background: var(--bg-active); color: var(--text); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.drw-canvas-node:focus-visible, .drw-reader-title:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
.drw-canvas-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.drw-reader { max-height: min(60vh, 520px); overflow: auto; padding: 4px 16px 16px; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius); }
|
||||
.drw-reader-node { padding: 4px 8px; border-radius: var(--radius-sm); scroll-margin-top: 8px; }
|
||||
.drw-reader-node.sel { background: var(--bg-active); }
|
||||
.drw-reader-heading { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; font-weight: 600; color: var(--text); }
|
||||
h2.drw-reader-heading { font-size: var(--fs-ui-xl); }
|
||||
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
|
||||
h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
|
||||
.drw-reader-title { background: none; border: none; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: var(--radius-xs); overflow-wrap: anywhere; }
|
||||
.drw-reader-title[data-anchor-node] { cursor: text; }
|
||||
.drw-anchor { color: var(--text-faint); font-size: var(--fs-ui-sm); text-decoration: none; }
|
||||
.drw-anchor:hover { color: var(--accent-text); }
|
||||
.drw-badges { display: inline-flex; gap: 4px; flex: none; }
|
||||
.drw-badge { min-width: 18px; padding: 0 5px; border-radius: var(--radius-full); font-size: var(--fs-ui-xs); font-weight: 600; line-height: 16px; text-align: center; color: var(--text-on-semantic); }
|
||||
.drw-badge.sev-error { background: var(--err-text); }
|
||||
.drw-badge.sev-warning { background: var(--warn-text); }
|
||||
.drw-badge.review { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.drw-selected { padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); }
|
||||
.drw-selected-title { font-size: var(--fs-ui-base); color: var(--text); margin-bottom: 6px; overflow-wrap: anywhere; }
|
||||
.drw-facts { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); }
|
||||
.drw-fact { display: flex; gap: 8px; align-items: baseline; }
|
||||
.drw-fact-k { flex: none; min-width: 4.5em; color: var(--text-dim); }
|
||||
.drw-fact-v { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.drw-diff-del, .drw-diff-add { padding: 4px 8px; border-radius: var(--radius-xs); font-size: var(--fs-ui-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.drw-diff-del { background: var(--diff-remove); color: var(--diff-remove-text); text-decoration: line-through; }
|
||||
.drw-diff-add { background: var(--diff-add); color: var(--diff-add-text); }
|
||||
.drw-diff-del + .drw-diff-add { margin-top: 4px; }
|
||||
.drw-conflict { color: var(--warn-text); }
|
||||
.drw-byline { display: inline-flex; gap: 6px; align-items: baseline; min-width: 0; }
|
||||
.drw-byline-name { color: var(--text); font-weight: 550; }
|
||||
.drw-byline-time { color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.drw-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); min-width: 0; }
|
||||
.drw-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
|
||||
.drw-row-between { justify-content: space-between; }
|
||||
.drw-muted { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.drw-mono { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-ok { font-size: var(--fs-ui-sm); color: var(--ok-text); }
|
||||
.drw-op { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font-size: var(--fs-ui-sm); }
|
||||
.drw-receipt { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-filename { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-failure { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-failure-msg { font-size: var(--fs-ui-sm); color: var(--err-text); overflow-wrap: anywhere; }
|
||||
.drw-notice { padding: 8px 10px; background: var(--wait-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.drw-profiles { display: flex; flex-direction: column; gap: 8px; }
|
||||
.drw-profile-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-profile { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.drw-profile:hover { background: var(--bg-hover); }
|
||||
.drw-profile.sel { background: var(--bg-active); }
|
||||
.drw-profile.off { cursor: not-allowed; }
|
||||
.drw-profile input { margin-top: 3px; flex: none; }
|
||||
.drw-profile-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.drw-profile-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-profile-source { color: var(--text); font-weight: 600; }
|
||||
.drw-profile-id { max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-profile-ver { color: var(--text-dim); }
|
||||
.drw-profile-reason { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.drw-diags, .drw-annos { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-group-trigger { display: flex; width: 100%; padding: 4px 0; background: none; border: none; cursor: pointer; text-align: left; }
|
||||
.drw-group-label { font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text); }
|
||||
.drw-group-label.sev-error { color: var(--err-text); }
|
||||
.drw-group-label.sev-warning { color: var(--warn-text); }
|
||||
.drw-diag-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
|
||||
.drw-diag { display: flex; flex-wrap: wrap; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
|
||||
.drw-diag.sev-error { border-left-color: var(--err); }
|
||||
.drw-diag.sev-warning { border-left-color: var(--warn); }
|
||||
.drw-diag-node { max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.drw-diag-node.unlocated { color: var(--text-dim); cursor: default; }
|
||||
.drw-diag-msg { flex: 1 1 12em; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.drw-diag-code { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-diag-hint { flex-basis: 100%; color: var(--text-dim); }
|
||||
.drw-more { align-self: flex-start; font-size: var(--fs-ui-sm); }
|
||||
.drw-anno-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-anno-node { align-self: flex-start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; }
|
||||
.drw-anno, .drw-rev { padding: 6px 8px; background: var(--bg-raised); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); }
|
||||
.drw-anno.resolved { opacity: .8; }
|
||||
.drw-anno-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 2px; }
|
||||
.drw-anno-text { color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; }
|
||||
.drw-anno.needs-reanchor { border-color: var(--warn); }
|
||||
.drw-anno-quote { margin: 2px 0 4px; padding: 0 8px; border-left: 2px solid var(--border-strong); color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.drw-anno-reanchor { margin-bottom: 4px; font-size: var(--fs-ui-sm); color: var(--warn-text); }
|
||||
.drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
|
||||
.drw-traces { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-trace { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
|
||||
.drw-trace.masked { border-left-style: dashed; }
|
||||
.drw-trace-type { font-weight: 600; color: var(--text); }
|
||||
.drw-trace-subject { min-width: 0; color: var(--text-dim); overflow-wrap: anywhere; }
|
||||
.drw-trace-masked { padding: 0 6px; border: 1px dashed var(--border-strong); border-radius: var(--radius-xs); color: var(--text-dim); }
|
||||
.drw-trace-body { flex-basis: 100%; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.drw-decisions { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.drw-download { font-size: var(--fs-ui-sm); padding: 4px 8px; }
|
||||
.drw-bind-rules { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-bind-rule { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.drw-bind-rule:hover { background: var(--bg-hover); }
|
||||
.drw-bind-rule.sel { background: var(--bg-active); }
|
||||
.drw-bind-row { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); font-size: var(--fs-ui-sm); }
|
||||
.drw-composer { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
|
||||
.drw-composer-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-composer-node { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.drw-composer-reply { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.drw-composer-scope { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-composer-quote { min-width: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.drw-anchor-notice { font-size: var(--fs-ui-sm); color: var(--warn-text); }
|
||||
.drw-textarea { min-height: 64px; resize: vertical; font-size: var(--fs-ui-sm); }
|
||||
.drw-inline-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
|
||||
.drw-link { padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.drw-link:disabled { color: var(--text-dim); cursor: default; }
|
||||
.drw-deliveries { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-delivery { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); }
|
||||
.drw-delivery.sel { background: var(--bg-active); }
|
||||
@media (max-width: 860px) {
|
||||
.drw-body { grid-template-columns: minmax(0, 1fr); }
|
||||
.drw-canvas, .drw-reader { height: auto; max-height: 50vh; }
|
||||
.drw-canvas { min-height: 240px; }
|
||||
}
|
||||
.duty-matrix-wrap { overflow: auto; }
|
||||
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
|
||||
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
|
||||
@@ -721,3 +869,7 @@ button.cockpit-tree-more { cursor: pointer; text-decoration: underline; }
|
||||
.cockpit-panel-label { color: var(--text); }
|
||||
.cockpit-panel-conflict { flex-direction: column; }
|
||||
.cockpit-conflict-side { color: var(--text); }
|
||||
@media (max-width: 760px) {
|
||||
.rdm-audit-event-fields { grid-template-columns: 1fr; }
|
||||
.rdm-audit-trace-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// 统一渲染 Task 11(审签来源):整文审签先在项目对话写一条已提交的用户消息作为来源,
|
||||
// 再以该消息为锚点分发命令。覆盖:调用顺序、顶层锚点字段、复用/新建记录对话、
|
||||
// 每一步失败的可读拒绝(失败即不分发)、同一动作的重试幂等编号、双击只提交一次。
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Conversation, Message, Project } from '../src/contracts'
|
||||
import { commandRequestBody, rdmProjectCommandError } from '../src/rdmManagement'
|
||||
import { createSingleFlight, describeRenderFailure, decisionPayload } from '../src/documentRenderPresentation'
|
||||
import {
|
||||
DECISION_RECORD_TITLE, commitDecision, decisionSourceText, hostProjectFor,
|
||||
type DecisionPort, type DecisionRequest,
|
||||
} from '../src/documentDecision'
|
||||
|
||||
const conv = (id: string, over: Partial<Conversation> = {}): Conversation => ({
|
||||
id, projectId: 'host-1', title: DECISION_RECORD_TITLE, createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z', ...over,
|
||||
})
|
||||
const userMsg = (id: string, conversationId: string): Message => ({
|
||||
id, conversationId, role: 'user', parts: [], createdAt: '2026-10-17T00:00:01Z',
|
||||
})
|
||||
const hostProject = (over: Partial<Project> = {}): Project => ({
|
||||
id: 'host-1', name: '宿主项目', createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z',
|
||||
settings: { 'rdm.state': 'ready', 'rdm.projectId': 'rdm-1' }, ...over,
|
||||
})
|
||||
|
||||
function harness(over: Partial<DecisionPort> = {}) {
|
||||
const calls: string[] = []
|
||||
const port: DecisionPort = {
|
||||
listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }),
|
||||
createConversation: vi.fn(async (_pid: string, title?: string) => { calls.push('create'); return conv('c-new', { title: title ?? '' }) }),
|
||||
sendMessage: vi.fn(async (convId: string) => { calls.push('send'); return { message: userMsg('m-1', convId) } }),
|
||||
rdmCommand: vi.fn(async () => { calls.push('command'); return { receipt: { state: 'committed' } } }),
|
||||
...over,
|
||||
}
|
||||
return { port, calls }
|
||||
}
|
||||
|
||||
/** 断言并取出拒绝原因;若意外成功则失败。 */
|
||||
async function failureOf(p: Promise<unknown>): Promise<Error> {
|
||||
try { await p } catch (e) { return e as Error }
|
||||
throw new Error('expected the decision to be refused')
|
||||
}
|
||||
|
||||
function request(over: Partial<DecisionRequest> = {}): DecisionRequest {
|
||||
const payload = decisionPayload('approve', 'rdm-1', 'doc-9', '同意发布', 2)
|
||||
return {
|
||||
host: hostProject(), operationId: 'op_a', decision: 'approve',
|
||||
doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布',
|
||||
command: payload.command, params: payload.params, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('decisionSourceText', () => {
|
||||
it('states the decision, document, round and note in Chinese without identity fields', () => {
|
||||
const text = decisionSourceText({ decision: 'approve', doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布' })
|
||||
expect(text).toContain('审签决定:审核通过')
|
||||
expect(text).toContain('文档:采购流程说明(doc-9)')
|
||||
expect(text).toContain('轮次:第 2 轮')
|
||||
expect(text).toContain('说明:同意发布')
|
||||
expect(text.startsWith('/')).toBe(false)
|
||||
expect(text).not.toMatch(/owner|actor|author|identity/i)
|
||||
})
|
||||
|
||||
it('labels a rejection reason and writes 无 when the note is empty', () => {
|
||||
const reject = decisionSourceText({ decision: 'reject', doc: { id: 'd', title: '' }, round: 1, note: '依据不足' })
|
||||
expect(reject).toContain('审签决定:驳回')
|
||||
expect(reject).toContain('驳回原因:依据不足')
|
||||
expect(reject).toContain('文档:d')
|
||||
const final = decisionSourceText({ decision: 'final', doc: { id: 'd', title: 'T' }, round: 1, note: ' ' })
|
||||
expect(final).toContain('审签决定:终批')
|
||||
expect(final).toContain('说明:无')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitDecision', () => {
|
||||
it('posts the committed source message first, then dispatches with it as the top-level anchor', async () => {
|
||||
const { port, calls } = harness()
|
||||
const req = request()
|
||||
await commitDecision(port, req)
|
||||
expect(calls).toEqual(['list', 'send', 'command'])
|
||||
expect(port.sendMessage).toHaveBeenCalledWith('c-1', decisionSourceText(req), [], 'decision:op_a')
|
||||
expect(port.rdmCommand).toHaveBeenCalledWith(req.command, req.params, { conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' })
|
||||
})
|
||||
|
||||
it('keeps the command params free of conversation fields and identity', async () => {
|
||||
const { port } = harness()
|
||||
await commitDecision(port, request())
|
||||
const [, params] = vi.mocked(port.rdmCommand).mock.calls[0]
|
||||
expect(params).toEqual({ projectId: 'rdm-1', docId: 'doc-9', note: '同意发布' })
|
||||
expect(params).not.toHaveProperty('conversationId')
|
||||
expect(params).not.toHaveProperty('anchorMessageId')
|
||||
expect(params).not.toHaveProperty('entrance')
|
||||
})
|
||||
|
||||
it('reuses the titled, non-archived decision record conversation of the host project', async () => {
|
||||
const { port } = harness({
|
||||
listConversations: vi.fn(async () => [
|
||||
conv('c-other', { title: '闲聊' }),
|
||||
conv('c-archived', { archived: true }),
|
||||
conv('c-record'),
|
||||
]),
|
||||
})
|
||||
await commitDecision(port, request())
|
||||
expect(port.listConversations).toHaveBeenCalledWith('host-1')
|
||||
expect(port.createConversation).not.toHaveBeenCalled()
|
||||
expect(port.sendMessage).toHaveBeenCalledWith('c-record', expect.any(String), [], 'decision:op_a')
|
||||
})
|
||||
|
||||
it('creates the decision record conversation when the host project has none', async () => {
|
||||
const { port } = harness({ listConversations: vi.fn(async () => []) })
|
||||
await commitDecision(port, request())
|
||||
expect(port.createConversation).toHaveBeenCalledWith('host-1', DECISION_RECORD_TITLE)
|
||||
expect(port.sendMessage).toHaveBeenCalledWith('c-new', expect.any(String), [], 'decision:op_a')
|
||||
})
|
||||
|
||||
it('anchors on the message id returned by the committed post, never on a different conversation', async () => {
|
||||
const { port } = harness({
|
||||
sendMessage: vi.fn(async () => ({ message: userMsg('m-9', 'other-conv') })),
|
||||
})
|
||||
await expect(commitDecision(port, request())).rejects.toThrow('未能提交,审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when the response is not a committed user message', async () => {
|
||||
const assistant = { ...userMsg('m-2', 'c-1'), role: 'assistant' as const }
|
||||
const { port } = harness({ sendMessage: vi.fn(async () => ({ message: assistant })) })
|
||||
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when the post was treated as a command rather than recorded as source text', async () => {
|
||||
const { port } = harness({
|
||||
sendMessage: vi.fn(async (convId: string) => ({ message: userMsg('m-1', convId), command: 'system.intent' })),
|
||||
})
|
||||
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses without dispatching when the host project is not found', async () => {
|
||||
const { port } = harness()
|
||||
await expect(commitDecision(port, request({ host: undefined }))).rejects.toThrow('未找到该研发项目对应的对话项目')
|
||||
expect(port.listConversations).not.toHaveBeenCalled()
|
||||
expect(port.sendMessage).not.toHaveBeenCalled()
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses without any write when the host project is not ready', async () => {
|
||||
const { port } = harness()
|
||||
const host = hostProject({ settings: { 'rdm.state': 'provisioning' } })
|
||||
await expect(commitDecision(port, request({ host }))).rejects.toThrow('项目正在准备')
|
||||
expect(port.listConversations).not.toHaveBeenCalled()
|
||||
expect(port.sendMessage).not.toHaveBeenCalled()
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a readable refusal and does not dispatch when the session has lapsed', async () => {
|
||||
const { port } = harness({ listConversations: vi.fn(async () => { throw new Error('401: unauthorized') }) })
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
expect(err.message).toContain('登录已失效')
|
||||
expect(port.sendMessage).not.toHaveBeenCalled()
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a busy conversation as retryable and does not dispatch', async () => {
|
||||
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('409: conversation busy') }) })
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
expect(err.message).toContain('会话正在处理其他任务,请稍后重试')
|
||||
expect(err.message).toContain('审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a forbidden source post as a refusal and does not dispatch', async () => {
|
||||
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('403: forbidden') }) })
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
expect(err.message).toContain('当前账号无权在该对话中记录审签')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a source-preflight rejection from the command step as a readable refusal', async () => {
|
||||
const { port } = harness({
|
||||
rdmCommand: vi.fn(async () => { throw new Error('project source preflight: formal project command requires an authenticated conversation source') }),
|
||||
})
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
const view = describeRenderFailure(err)
|
||||
expect(view.message).toContain('审签来源尚未通过校验')
|
||||
expect(view.message.startsWith('{')).toBe(false)
|
||||
})
|
||||
|
||||
it('retries the same operation with the same request id so the server returns the same source message', async () => {
|
||||
let fail = true
|
||||
const { port } = harness({
|
||||
sendMessage: vi.fn(async (convId: string) => {
|
||||
if (fail) { fail = false; throw new Error('503: unavailable') }
|
||||
return { message: userMsg('m-1', convId) }
|
||||
}),
|
||||
})
|
||||
await expect(commitDecision(port, request())).rejects.toThrow()
|
||||
await commitDecision(port, request())
|
||||
const ids = vi.mocked(port.sendMessage).mock.calls.map(c => c[3])
|
||||
expect(ids).toEqual(['decision:op_a', 'decision:op_a'])
|
||||
expect(port.rdmCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('double click', () => {
|
||||
it('runs one source post and one command when the action fires twice before settling', async () => {
|
||||
const { port } = harness()
|
||||
const flight = createSingleFlight()
|
||||
const click = () => {
|
||||
if (flight.busy()) return
|
||||
void flight.run(() => commitDecision(port, request())).catch(() => undefined)
|
||||
}
|
||||
click()
|
||||
click()
|
||||
await vi.waitFor(() => expect(port.rdmCommand).toHaveBeenCalledTimes(1))
|
||||
expect(port.sendMessage).toHaveBeenCalledTimes(1)
|
||||
expect(flight.busy()).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the flight after a failure so the readable refusal can be retried', async () => {
|
||||
const flight = createSingleFlight()
|
||||
await expect(flight.run(() => Promise.reject(new Error('x')))).rejects.toThrow('x')
|
||||
expect(flight.busy()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostProjectFor', () => {
|
||||
it('finds the host project bound to the RDM project id and ignores unbound projects', () => {
|
||||
const bound = hostProject()
|
||||
const unbound = hostProject({ id: 'host-2', settings: {} })
|
||||
expect(hostProjectFor([unbound, bound], 'rdm-1')).toBe(bound)
|
||||
expect(hostProjectFor([unbound], 'rdm-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('command request body', () => {
|
||||
it('keeps the plain body without a source', () => {
|
||||
expect(commandRequestBody({ a: 1 })).toEqual({ params: { a: 1 } })
|
||||
})
|
||||
|
||||
it('places the source fields at the top level and leaves params untouched', () => {
|
||||
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }))
|
||||
.toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdmProjectCommandError for decisions', () => {
|
||||
it('gates decision commands on project work readiness', () => {
|
||||
expect(rdmProjectCommandError(hostProject(), 'rdm.review.doc.approve')).toBeNull()
|
||||
expect(rdmProjectCommandError(hostProject({ settings: { 'rdm.state': 'closed' } }), 'rdm.review.doc.approve')).toContain('项目已关闭')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
// 统一渲染 Task 11(续):整文审签入口、完整包下载、规则绑定与解绑、诊断代码分组。
|
||||
// 入口只在服务端冻结职责允许时出现;载荷只含 projectId/docId 与 note|reason|round;
|
||||
// 拒绝以可读文案显示,原始回执只进折叠区。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
RDM_RENDER_COMMANDS, NO_RENDER_CAPABILITIES, renderCapabilities,
|
||||
normalizeDocSubmission, decisionEntries, decisionPayload,
|
||||
normalizeDeliveries, downloadOptions,
|
||||
describeRenderFailure, diagnosticCodeLabel, normalizeDiagnostics,
|
||||
normalizeCandidateRules, bindableRules, currentBindings, profileBindPayload, profileUnbindPayload,
|
||||
projectSettingsAllowed, createRequestGuard, normalizeProfiles,
|
||||
type DecisionEntries,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import { ReviewDecisionPanel, ProfileBindingPanel } from '../src/components/DocumentRenderActions'
|
||||
import { DiagnosticsList } from '../src/components/DocumentRenderWorkspace'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
const idle = { op: null, status: 'idle' as const, error: '' }
|
||||
|
||||
const statusPayload = {
|
||||
docId: 'd1',
|
||||
submission: {
|
||||
submissionId: 'ds_d1_2', round: 2, state: 'submitted', createdBy: 'author-1',
|
||||
// Go 结构体 domain.Roles 无 json 标签:字段名原样输出为 Author/Reviewers/Approver。
|
||||
duties: { Author: 'author-1', Reviewers: ['rev-1', 'rev-2'], Approver: 'appr-1' },
|
||||
reviews: { 'rev-1': true, 'rev-2': false },
|
||||
},
|
||||
}
|
||||
const sub = normalizeDocSubmission(statusPayload)!
|
||||
const allowedCaps = { approve: true, reject: true, final: true }
|
||||
const none: DecisionEntries = { approve: false, reject: false, final: false }
|
||||
|
||||
describe('审签与绑定能力门控(命令清单逐项判定)', () => {
|
||||
it('每项审签/绑定命令独立门控,核心渲染能力不受影响', () => {
|
||||
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
|
||||
expect(renderCapabilities(all)).toMatchObject({ ready: true, approve: true, reject: true, final: true, bind: true, unbind: true })
|
||||
const noFinal = renderCapabilities(all.filter(c => c.id !== 'rdm.review.doc.final'))
|
||||
expect(noFinal).toMatchObject({ ready: true, approve: true, reject: true, final: false })
|
||||
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profile.unbind'))).toMatchObject({ bind: true, unbind: false })
|
||||
expect(NO_RENDER_CAPABILITIES).toMatchObject({ approve: false, reject: false, final: false, bind: false, unbind: false })
|
||||
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).final).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('整文提交归一化(rdm.review.doc.status)', () => {
|
||||
it('兼容服务端 Go 字段名 Author/Reviewers/Approver,并保留冻结的审核进度', () => {
|
||||
expect(sub).toEqual({
|
||||
submissionId: 'ds_d1_2', round: 2, state: 'submitted', author: 'author-1',
|
||||
reviewers: ['rev-1', 'rev-2'], approver: 'appr-1', reviews: { 'rev-1': true, 'rev-2': false },
|
||||
})
|
||||
})
|
||||
it('无提交或轮次非法即无记录,不补造', () => {
|
||||
expect(normalizeDocSubmission({ docId: 'd1', submission: null })).toBeNull()
|
||||
expect(normalizeDocSubmission({ submission: { round: 0, submissionId: 'x' } })).toBeNull()
|
||||
expect(normalizeDocSubmission({ submission: { round: 1 } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签入口:只在服务端冻结职责允许时出现', () => {
|
||||
it('审核人未审时可通过;已通过的审核人仅可驳回;编制人不可自审', () => {
|
||||
expect(decisionEntries(sub, 'rev-2', allowedCaps)).toEqual({ approve: true, reject: true, final: false })
|
||||
expect(decisionEntries(sub, 'rev-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
expect(decisionEntries(sub, 'author-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
})
|
||||
it('批准人仅在全员审核通过后可终批;批准人本身可驳回', () => {
|
||||
expect(decisionEntries(sub, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
const allDone = { ...sub, reviews: { 'rev-1': true, 'rev-2': true } }
|
||||
expect(decisionEntries(allDone, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: true })
|
||||
})
|
||||
it('非冻结名单成员、无身份、非在途状态、能力未开一律无入口', () => {
|
||||
expect(decisionEntries(sub, 'outsider', allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries(sub, undefined, allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries({ ...sub, state: 'final' }, 'rev-2', allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries(sub, 'rev-2', { approve: false, reject: true, final: true })).toEqual({ approve: false, reject: true, final: false })
|
||||
expect(decisionEntries(null, 'rev-2', allowedCaps)).toEqual(none)
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签载荷:只含 projectId/docId 与 note|reason|round', () => {
|
||||
it('approve 的 note、reject 的 reason、final 的 round/reason;空说明不携带', () => {
|
||||
expect(decisionPayload('approve', 'p1', 'd1', ' 通过 ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1', note: '通过' } })
|
||||
expect(decisionPayload('approve', 'p1', 'd1', ' ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1' } })
|
||||
expect(decisionPayload('reject', 'p1', 'd1', '依据不足', 2)).toEqual({ command: 'rdm.review.doc.reject', params: { projectId: 'p1', docId: 'd1', reason: '依据不足' } })
|
||||
expect(decisionPayload('final', 'p1', 'd1', '', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2 } })
|
||||
expect(decisionPayload('final', 'p1', 'd1', '终批', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2, reason: '终批' } })
|
||||
})
|
||||
it('任何审签载荷都不携带身份字段(身份只认会话)', () => {
|
||||
const identityKey = /actor|user|owner|identity|approvedBy|reviewer|approver|author|signature/i
|
||||
for (const kind of ['approve', 'reject', 'final'] as const) {
|
||||
const keys = Object.keys(decisionPayload(kind, 'p1', 'd1', 'x', 2).params)
|
||||
expect(keys.filter(k => identityKey.test(k))).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('完整交付包(rdm.render.download kind=package)', () => {
|
||||
const ready = normalizeDeliveries({
|
||||
items: [{ deliveryId: 'd9', mode: 'approved', status: 'ready', profileId: 'x', profileVersion: 1, createdAt: '', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown'] }],
|
||||
}).items[0]
|
||||
it('交付已生成(ready)时完整包位于单件下载之后', () => {
|
||||
expect(downloadOptions(ready).map(o => o.kind)).toEqual(['markdown', 'docx', 'review-docx', 'manifest', 'package'])
|
||||
expect(downloadOptions(ready).at(-1)?.label).toBe('完整交付包(ZIP)')
|
||||
})
|
||||
it('非 ready 的交付不提供完整包', () => {
|
||||
expect(downloadOptions({ ...ready, status: 'generation-failed' }).map(o => o.kind)).not.toContain('package')
|
||||
expect(downloadOptions({ ...ready, status: 'frozen' }).map(o => o.kind)).not.toContain('package')
|
||||
})
|
||||
})
|
||||
|
||||
describe('服务端拒绝码:翻译为可读文案,原始回执保留', () => {
|
||||
it('受控、未发布、绑定变化三类拒绝', () => {
|
||||
const controlled = describeRenderFailure(new Error('400: controlled_requires_exact_profile: controlled document d1 requires its exact template profile'))
|
||||
expect(controlled.message).toContain('受控文档')
|
||||
expect(controlled.raw).toContain('controlled_requires_exact_profile')
|
||||
expect(describeRenderFailure(new Error('400: profile_not_published: x@1 is not published (missing)')).message).toContain('尚未发布')
|
||||
expect(describeRenderFailure(new Error('400: binding_mismatch: document d1 is bound to a@1')).message).toContain('刷新')
|
||||
})
|
||||
it('审签拒绝保持服务端原文(去掉状态码前缀)', () => {
|
||||
expect(describeRenderFailure(new Error('400: 仅本轮(第 2 轮)冻结名单内的审核人可审批')))
|
||||
.toMatchObject({ message: '仅本轮(第 2 轮)冻结名单内的审核人可审批' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('诊断:八类代码的中文标签与严重度分组', () => {
|
||||
const codes = ['required_content_missing', 'intentionally_empty', 'template_placeholder_left', 'mapping_fallback',
|
||||
'signature_data_missing', 'mapping_unresolved', 'mapping_ambiguous', 'approval_invalid']
|
||||
it('已知代码显示中文标签,未知代码原样保留', () => {
|
||||
for (const code of codes) expect(diagnosticCodeLabel(code)).not.toBe(code)
|
||||
expect(diagnosticCodeLabel('custom_x')).toBe('custom_x')
|
||||
})
|
||||
it('错误在前、警告在后;节点按标题定位,代码标签可见', () => {
|
||||
const diags = normalizeDiagnostics([
|
||||
{ code: 'mapping_unresolved', severity: 'error', message: '映射未解析', nodeId: 'n1', suggestion: '补充映射' },
|
||||
{ code: 'template_placeholder_left', severity: 'warning', message: '占位符未替换', nodeId: 'n2' },
|
||||
])
|
||||
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
|
||||
diagnostics: diags, nodeTitle: id => `章节 ${id}`, onSelectNode: () => {}, idPrefix: 'p',
|
||||
}))
|
||||
expect(html.indexOf('错误(1)')).toBeGreaterThanOrEqual(0)
|
||||
expect(html.indexOf('错误(1)')).toBeLessThan(html.indexOf('警告(1)'))
|
||||
expect(html).toContain(diagnosticCodeLabel('mapping_unresolved'))
|
||||
expect(html).toContain('章节 n1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则绑定:候选与服务端选项合并、当前绑定、载荷', () => {
|
||||
const profiles = normalizeProfiles({
|
||||
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
|
||||
options: [
|
||||
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
|
||||
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
|
||||
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: false, fallbackAllowed: true, blockedReason: '资产校验未通过' },
|
||||
],
|
||||
})
|
||||
const candidates = normalizeCandidateRules({
|
||||
projectId: 'p1',
|
||||
candidates: [
|
||||
{ candidateId: 'c1', state: 'published', publication: { profileId: 'prj.std', version: '2', decision: 'ok' } },
|
||||
{ candidateId: 'c2', state: 'draft' },
|
||||
{ candidateId: 'c3', state: 'published', publication: { profileId: 'prj.std', version: '2' } },
|
||||
],
|
||||
})
|
||||
it('可绑定规则 = 服务端选项中已发布项 ∪ 项目已发布候选(去重,保序)', () => {
|
||||
expect(bindableRules(profiles, candidates).map(r => `${r.profileId}@${r.version}`)).toEqual(['org@3', 'doc@2', 'prj.std@2'])
|
||||
})
|
||||
it('当前绑定只取服务端选项中的绑定来源', () => {
|
||||
expect(currentBindings(profiles)).toEqual({
|
||||
document: { profileId: 'doc', version: '2' },
|
||||
'project-default': { profileId: 'prj', version: '5' },
|
||||
})
|
||||
})
|
||||
it('绑定载荷:key 按作用域取值;文档类型缺失则不生成载荷', () => {
|
||||
const rule = { profileId: 'prj.std', version: '2' }
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: 'SDD', rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'document-type', key: 'SDD', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: undefined, rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: undefined, rule })).toBeUndefined()
|
||||
})
|
||||
it('解绑带前置条件(profileId+version 成对);无绑定时不带前置条件', () => {
|
||||
expect(profileUnbindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', precondition: { profileId: 'doc', version: '2' } }))
|
||||
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'doc', version: '2' })
|
||||
expect(profileUnbindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: 'SDD' }))
|
||||
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('项目设置权限:以服务端 rdm.perm.check 的 project.settings 为准', () => {
|
||||
it('仅 allowed=true 的 project.settings 才放行', () => {
|
||||
expect(projectSettingsAllowed({ projectId: 'p1', member: 'u', role: 'member', profRole: null, actions: [{ action: 'project.settings', allowed: true, source: 'override' }] })).toBe(true)
|
||||
expect(projectSettingsAllowed({ actions: [{ action: 'project.settings', allowed: false, source: 'x' }] })).toBe(false)
|
||||
expect(projectSettingsAllowed({ actions: [{ action: 'doc.read', allowed: true, source: 'x' }] })).toBe(false)
|
||||
expect(projectSettingsAllowed(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('请求守卫:过期的审签/绑定结果被丢弃', () => {
|
||||
it('同一作用域的新请求使旧结果失效;取消后全部失效', () => {
|
||||
const g = createRequestGuard()
|
||||
const first = g.begin('scope')
|
||||
const second = g.begin('scope')
|
||||
expect(g.current(first, 'scope')).toBe(false)
|
||||
expect(g.current(second, 'scope')).toBe(true)
|
||||
g.cancel()
|
||||
expect(g.current(second, 'scope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签面板:入口门控与拒绝显示', () => {
|
||||
const noop = () => {}
|
||||
it('无可用入口时不出现任何动作按钮,只显示轮次与进度', () => {
|
||||
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
|
||||
sub, entries: none, busy: false, status: idle, onRetry: noop, onDecide: noop,
|
||||
}))
|
||||
expect(html).toContain('第 2 轮')
|
||||
expect(html).not.toContain('审核通过')
|
||||
expect(html).not.toContain('驳回')
|
||||
expect(html).not.toContain('终批')
|
||||
})
|
||||
it('只渲染服务端允许的入口;失败以可读文案显示并保留原始回执', () => {
|
||||
const entries: DecisionEntries = { approve: true, reject: true, final: false }
|
||||
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
|
||||
sub, entries, busy: false,
|
||||
status: { op: { kind: 'reject', operationId: 'op_1' }, status: 'failed', error: '400: 当前身份无权驳回' },
|
||||
onRetry: noop, onDecide: noop,
|
||||
}))
|
||||
expect(html).toContain('审核通过')
|
||||
expect(html).toContain('驳回')
|
||||
expect(html).not.toContain('终批')
|
||||
expect(html).toContain('当前身份无权驳回')
|
||||
expect(html).toContain('原始回执')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则绑定面板:当前绑定、解绑入口与文档类型禁用', () => {
|
||||
const noop = () => {}
|
||||
const rules = [{ profileId: 'prj.std', version: '2', label: 'prj.std v2' }]
|
||||
it('显示三个作用域的当前绑定;可解绑已绑定项;未选规则时绑定按钮禁用', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
|
||||
rules, bindings: { document: { profileId: 'doc', version: '2' } }, typeKey: undefined,
|
||||
busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
|
||||
}))
|
||||
expect(html).toContain('本文档')
|
||||
expect(html).toContain('文档类型')
|
||||
expect(html).toContain('项目默认')
|
||||
expect(html).toContain('doc@2')
|
||||
expect(html).toContain('解绑')
|
||||
expect(html).toContain('disabled')
|
||||
})
|
||||
it('文档类型未知时,文档类型行提示无法绑定', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
|
||||
rules, bindings: {}, typeKey: undefined, busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
|
||||
}))
|
||||
expect(html).toContain('文档类型未就绪')
|
||||
})
|
||||
})
|
||||
|
||||
describe('能力表与类型守卫', () => {
|
||||
it('未知 source 的绑定不进入当前绑定', () => {
|
||||
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.TPL', version: '1', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false }] })
|
||||
expect(currentBindings(p)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,916 @@
|
||||
// 统一渲染 Task 11:规则选择、预览诊断、双视图文档树、审阅与下载的纯展示逻辑。
|
||||
// 组件只消费这里的函数;服务端字段一律视为可能被裁剪,缺失即不显示,不补造。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
RDM_RENDER_COMMANDS, renderCapabilities,
|
||||
normalizeProfiles, profileOptionViews, controlledNotice, defaultProfileKey, profileKey,
|
||||
describeRenderFailure, displayText,
|
||||
normalizeDiagnostics, groupDiagnostics, diagnosticsSummaryText, diagnosticCountsByNode, locateDiagnostic,
|
||||
nextOperation, createRequestGuard, renderScope,
|
||||
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
|
||||
nodeFacts, hasFacts,
|
||||
normalizeReviewView, partitionAnnotations, partitionRevisions, annotatePayload, revisePayload, validateReviewText,
|
||||
locateNodeId, reviewTargetNode, normalizeRenderTrace, traceAnchor, traceTypeLabel, traceEndpointLabel,
|
||||
normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64,
|
||||
truncateMiddle, latestSubmissionId, formatRenderTime,
|
||||
submissionIdOf, currentSubmissionId, loadDeliveryDiagnostics, receiptStateView, walkDocTree, DOC_TREE_LIMITS,
|
||||
type RenderProfiles, type DocTree,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import {
|
||||
ProfileOptionList, DiagnosticsList, AnnotationGroups, RevisionGroups, RenderTraceList, DownloadButtons, RenderReceipt,
|
||||
} from '../src/components/DocumentRenderWorkspace'
|
||||
import { RenderFailure } from '../src/components/renderFeedback'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
|
||||
describe('渲染能力门控(fail-closed)', () => {
|
||||
it('核心命令齐备才 ready;审阅/批注各自独立门控', () => {
|
||||
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
|
||||
const caps = renderCapabilities(all)
|
||||
expect(caps).toEqual({
|
||||
ready: true, submit: true, review: true, annotate: true, revise: true, reviewExport: true, approvedExport: true,
|
||||
approve: true, reject: true, final: true, bind: true, unbind: true,
|
||||
})
|
||||
const noReview = renderCapabilities(all.filter(c => c.id !== 'rdm.render.review.view'))
|
||||
expect(noReview.ready).toBe(true)
|
||||
expect(noReview.review).toBe(false)
|
||||
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profiles')).ready).toBe(false)
|
||||
})
|
||||
it('null / 非数组 / disabled 一律未就绪', () => {
|
||||
expect(renderCapabilities(null).ready).toBe(false)
|
||||
expect(renderCapabilities('x' as never).ready).toBe(false)
|
||||
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).ready).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const profiles = (over: Partial<RenderProfiles> = {}): RenderProfiles => ({
|
||||
options: [
|
||||
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false },
|
||||
{ source: 'template', profileId: 'tpl', version: '1', state: 'draft', publicationAllowed: false, controlled: true, fallbackAllowed: false },
|
||||
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false, snapshotHash: 'h1' },
|
||||
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: true, fallbackAllowed: false, blockedReason: '资产校验未通过' },
|
||||
],
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('规则选择(五级来源)', () => {
|
||||
it('按 文档 > 模板 > 项目默认 > 公司默认 排序并给出来源/版本/状态', () => {
|
||||
const views = profileOptionViews(profiles())
|
||||
expect(views.map(v => v.option.source)).toEqual(['document', 'template', 'project-default', 'organization-default'])
|
||||
expect(views.map(v => v.sourceLabel)).toEqual(['文档规则', '模板规则', '项目默认规则', '公司默认规则'])
|
||||
expect(views[0]).toMatchObject({ stateLabel: '已发布', previewAllowed: true, formalAllowed: true })
|
||||
expect(views[0].reason).toBeUndefined()
|
||||
})
|
||||
it('停用规则不可用,给出服务端原因;草稿只能预览', () => {
|
||||
const [, draft, blocked] = profileOptionViews(profiles())
|
||||
expect(blocked).toMatchObject({ previewAllowed: false, formalAllowed: false, reason: '资产校验未通过' })
|
||||
expect(draft).toMatchObject({ previewAllowed: true, formalAllowed: false })
|
||||
expect(draft.reason).toContain('草稿')
|
||||
})
|
||||
it('受控文档不回退到项目或公司默认,并给出明确说明', () => {
|
||||
const org = profileOptionViews(profiles())[3]
|
||||
expect(org).toMatchObject({ previewAllowed: false, formalAllowed: false })
|
||||
expect(org.reason).toContain('不回退到项目或公司默认')
|
||||
expect(controlledNotice(profiles())).toContain('不会回退到项目或公司默认规则')
|
||||
const free = profiles({ options: [{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true }] })
|
||||
expect(controlledNotice(free)).toBeUndefined()
|
||||
expect(profileOptionViews(free)[0].previewAllowed).toBe(true)
|
||||
})
|
||||
it('未获发布许可的已发布规则不能用于正式交付', () => {
|
||||
const p = profiles({ options: [{ source: 'project-default', profileId: 'p', version: '1', state: 'published', publicationAllowed: false, controlled: false, fallbackAllowed: true }] })
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ previewAllowed: true, formalAllowed: false })
|
||||
})
|
||||
it('默认选中服务端 selected;否则第一个可预览项', () => {
|
||||
const p = profiles({ selected: { source: 'template', profileId: 'tpl', version: '1', hash: 'x' } })
|
||||
expect(defaultProfileKey(p)).toBe(profileKey({ source: 'template', profileId: 'tpl', version: '1' }))
|
||||
expect(defaultProfileKey(profiles())).toBe('document:doc@2')
|
||||
expect(defaultProfileKey(profiles({ options: [] }))).toBe('')
|
||||
})
|
||||
it('归一化丢弃不完整或未知来源的选项', () => {
|
||||
const p = normalizeProfiles({ options: [{ source: 'galaxy', profileId: 'x', version: '1' }, { source: 'project', profileId: 'x', version: '1' }, { source: 'document', profileId: 'd' }, { source: 'document', profileId: 'd', version: 2, state: 'published' }] })
|
||||
expect(p.options).toHaveLength(1)
|
||||
expect(p.options[0]).toMatchObject({ profileId: 'd', version: '2', publicationAllowed: false, controlled: false, fallbackAllowed: false })
|
||||
expect(p.refusal).toBeUndefined()
|
||||
expect(normalizeProfiles(null).options).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// 以下夹具逐字段对照服务端 JSON(Agent-System-RDM internal/rdmapp/render_workspace.go
|
||||
// RenderProfileOptions / render_delivery.go RenderDeliveryReceipt / docsubmission.go
|
||||
// DocSubmissionRec / plugins/rdm/plugin.go rdmRenderDownload / inspection.go InspectTree)。
|
||||
|
||||
describe('服务端 rdm.render.profiles 真实载荷', () => {
|
||||
const uncontrolled = {
|
||||
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
|
||||
options: [
|
||||
{ source: 'document-type', profileId: 'type.design', version: '2', state: 'published', publicationAllowed: true, snapshotHash: 'h2', controlled: false, fallbackAllowed: true },
|
||||
{ source: 'project-default', profileId: 'prj.std', version: '1', state: 'published', publicationAllowed: true, snapshotHash: 'h3', controlled: false, fallbackAllowed: true },
|
||||
{ source: 'organization-default', profileId: 'org.std', version: '4', state: 'published', publicationAllowed: true, snapshotHash: 'h4', controlled: false, fallbackAllowed: true },
|
||||
],
|
||||
selected: { source: 'document-type', profileId: 'type.design', version: '2', hash: 'h2' },
|
||||
}
|
||||
it('document-type / project-default 选项与 selected 都被保留,selected 为默认选择', () => {
|
||||
const p = normalizeProfiles(uncontrolled)
|
||||
expect(p.options.map(o => o.source)).toEqual(['document-type', 'project-default', 'organization-default'])
|
||||
expect(p.selected).toEqual({ source: 'document-type', profileId: 'type.design', version: '2', hash: 'h2' })
|
||||
expect(profileOptionViews(p).map(v => v.sourceLabel)).toEqual(['文档类型规则', '项目默认规则', '公司默认规则'])
|
||||
expect(defaultProfileKey(p)).toBe('document-type:type.design@2')
|
||||
})
|
||||
it('只有 project-default 命中时以它为默认选择', () => {
|
||||
const p = normalizeProfiles({ ...uncontrolled, options: uncontrolled.options.slice(1), selected: { source: 'project-default', profileId: 'prj.std', version: '1', hash: 'h3' } })
|
||||
expect(defaultProfileKey(p)).toBe('project-default:prj.std@1')
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ previewAllowed: true, formalAllowed: true, sourceLabel: '项目默认规则' })
|
||||
})
|
||||
it('五级来源按 文档 > 文档类型 > 模板 > 项目默认 > 公司默认 排序', () => {
|
||||
const opt = (source: string, profileId: string) => ({ source, profileId, version: '1', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true })
|
||||
const p = normalizeProfiles({ options: [opt('organization-default', 'o'), opt('project-default', 'p'), opt('template', 't'), opt('document-type', 'dt'), opt('document', 'd')] })
|
||||
expect(profileOptionViews(p).map(v => [v.option.source, v.sourceLabel])).toEqual([
|
||||
['document', '文档规则'], ['document-type', '文档类型规则'], ['template', '模板规则'], ['project-default', '项目默认规则'], ['organization-default', '公司默认规则'],
|
||||
])
|
||||
})
|
||||
it('受控文档:模板规则可选,项目默认与公司默认带服务端原因且不可用', () => {
|
||||
const reason = '受控文档必须使用精确绑定的规则,不得回退到项目或公司默认规则'
|
||||
const p = normalizeProfiles({
|
||||
projectId: 'p1', docId: 'd1', controlled: true, fallbackAllowed: false,
|
||||
options: [
|
||||
{ source: 'template', profileId: 'company.TPL-SDD', version: '3', state: 'published', publicationAllowed: true, snapshotHash: 'ht', controlled: true, fallbackAllowed: false },
|
||||
{ source: 'project-default', profileId: 'prj.std', version: '1', state: 'published', publicationAllowed: true, snapshotHash: 'h3', controlled: true, fallbackAllowed: false, blockedReason: reason },
|
||||
{ source: 'organization-default', profileId: 'org.std', version: '4', state: 'published', publicationAllowed: true, snapshotHash: 'h4', controlled: true, fallbackAllowed: false, blockedReason: reason },
|
||||
],
|
||||
refusal: { code: 'controlled_requires_exact_profile', message: '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则' },
|
||||
})
|
||||
const views = profileOptionViews(p)
|
||||
expect(views.map(v => [v.sourceLabel, v.previewAllowed])).toEqual([['模板规则', true], ['项目默认规则', false], ['公司默认规则', false]])
|
||||
expect(views[1].reason).toBe(reason)
|
||||
expect(defaultProfileKey(p)).toBe('template:company.TPL-SDD@3')
|
||||
})
|
||||
it('已验证未发布的模板规则:服务端 blockedReason 只挡正式交付,仍可预览且为默认选择', () => {
|
||||
const blockedReason = '规则 company.dhf.2750fbaf1f67d10c@1.0.0 处于 validated 状态,未发布,不能用于正式审阅或批准交付'
|
||||
const p = normalizeProfiles({
|
||||
projectId: 'p1', docId: 'doc-33', controlled: true, fallbackAllowed: false,
|
||||
options: [{ source: 'template', profileId: 'company.dhf.2750fbaf1f67d10c', version: '1.0.0', state: 'validated', publicationAllowed: false, snapshotHash: '5fba', controlled: true, fallbackAllowed: false, blockedReason }],
|
||||
refusal: { code: 'controlled_requires_exact_profile', message: '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则' },
|
||||
})
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ stateLabel: '已验证', previewAllowed: true, formalAllowed: false, reason: blockedReason })
|
||||
expect(defaultProfileKey(p)).toBe('template:company.dhf.2750fbaf1f67d10c@1.0.0')
|
||||
})
|
||||
it('没有已验证快照的规则(missing)不可预览也不可正式交付', () => {
|
||||
const blockedReason = '规则 company.dhf.x@2.0.0 尚无已验证的快照,不能用于正式审阅或批准交付'
|
||||
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.dhf.x', version: '2.0.0', state: 'missing', publicationAllowed: false, controlled: true, fallbackAllowed: false, blockedReason }] })
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ stateLabel: '无快照', previewAllowed: false, formalAllowed: false, reason: blockedReason })
|
||||
expect(defaultProfileKey(p)).toBe('')
|
||||
})
|
||||
it('refusal 对象 {code,message} 显示 message', () => {
|
||||
const message = '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则'
|
||||
const p = normalizeProfiles({ projectId: 'p1', docId: 'd1', controlled: true, fallbackAllowed: false, options: [], refusal: { code: 'controlled_requires_exact_profile', message } })
|
||||
expect(p.refusal).toBe(message)
|
||||
const html = renderToStaticMarkup(createElement(ProfileOptionList, { profiles: p, selectedKey: '', onSelect: () => {} }))
|
||||
expect(html).toContain(message)
|
||||
expect(html).toContain('drw-failure')
|
||||
expect(html).not.toContain('没有可用的渲染规则')
|
||||
})
|
||||
it('refusal 缺 message 时退到 code', () => {
|
||||
const p = normalizeProfiles({ options: [], refusal: { code: 'profile_not_found', message: '' } })
|
||||
expect(p.refusal).toBe('profile_not_found')
|
||||
expect(normalizeProfiles({ options: [], refusal: {} }).refusal).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('服务端 rdm.render.submit 真实回执', () => {
|
||||
it('从 DocSubmissionRec 取 submissionId', () => {
|
||||
const rec = {
|
||||
submissionId: 'sub-9', projectId: 'p1', docId: 'd1', round: 2, sections: { s1: 3 },
|
||||
duties: { author: 'amy', reviewer: 'bob', approver: 'carol' }, digest: 'dg', state: 'pending', reviews: {},
|
||||
createdBy: 'amy', createdAt: '2026-10-15T00:00:00Z',
|
||||
}
|
||||
expect(submissionIdOf(rec)).toBe('sub-9')
|
||||
expect(submissionIdOf({ id: 'legacy' })).toBeUndefined()
|
||||
expect(submissionIdOf(null)).toBeUndefined()
|
||||
})
|
||||
it('当前提交:本会话选择 > rdm.review.doc.status 最新提交 > 交付记录推断', () => {
|
||||
const delivered = [{ submissionId: 'sub-old', createdAt: '2026-10-15T00:00:00Z' }] as Parameters<typeof latestSubmissionId>[0]
|
||||
const status = { docId: 'd1', submission: { submissionId: 'sub-new', round: 2, state: 'pending' } }
|
||||
expect(currentSubmissionId('sub-mine', status, delivered)).toBe('sub-mine')
|
||||
expect(currentSubmissionId(null, status, delivered)).toBe('sub-new')
|
||||
expect(currentSubmissionId(null, { docId: 'd1', submission: null }, delivered)).toBe('sub-old')
|
||||
expect(currentSubmissionId(null, null, [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64')
|
||||
const readyReceipt = {
|
||||
jobId: 'rjob-1', deliveryId: 'rdlv-1', projectId: 'p1', docId: 'd1', operationId: 'op_1', mode: 'preview', state: 'ready',
|
||||
sourceVersionId: 'sv1', profileSnapshotHash: 'ph', reviewSnapshotHash: '', artifactSha256: 'a', manifestSha256: 'm',
|
||||
fileName: 'd1.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', createdAt: '2026-10-15T00:00:00Z',
|
||||
}
|
||||
|
||||
describe('预览/导出后读取诊断(rdm.render.download kind=diagnostics)', () => {
|
||||
const diagnosticsJSON = JSON.stringify([
|
||||
{ code: 'heading.skip', severity: 'warning', message: '标题层级跳跃', nodeId: 's2', rulePath: 'headings' },
|
||||
{ code: 'section.required', severity: 'error', message: '缺少必填章节', nodeId: 's1', suggestion: '补写范围' },
|
||||
])
|
||||
const download = (data: string) => ({
|
||||
deliveryId: 'rdlv-1', kind: 'diagnostics', fileName: 'd1-diagnostics.json', mime: 'application/json', sha256: 'x', dataBase64: b64(data),
|
||||
})
|
||||
it('按回执 deliveryId 请求诊断并解码 UTF-8 JSON', async () => {
|
||||
const calls: [string, Record<string, unknown>][] = []
|
||||
const out = await loadDeliveryDiagnostics(async (command, params) => { calls.push([command, params]); return download(diagnosticsJSON) }, 'p1', readyReceipt, () => true)
|
||||
expect(calls).toEqual([['rdm.render.download', { projectId: 'p1', deliveryId: 'rdlv-1', kind: 'diagnostics' }]])
|
||||
expect(out).toMatchObject({ deliveryId: 'rdlv-1' })
|
||||
const list = out && 'diagnostics' in out ? out.diagnostics : []
|
||||
expect(list.map(d => [d.code, d.severity, d.nodeId])).toEqual([['heading.skip', 'warning', 's2'], ['section.required', 'error', 's1']])
|
||||
expect(list[1].message).toBe('缺少必填章节')
|
||||
})
|
||||
it('服务端 nil 诊断(JSON null)视为无问题', async () => {
|
||||
const out = await loadDeliveryDiagnostics(async () => download('null'), 'p1', readyReceipt, () => true)
|
||||
expect(out).toEqual({ deliveryId: 'rdlv-1', diagnostics: [] })
|
||||
})
|
||||
it('请求失败或内容无法解析给出可读文案,不抛出', async () => {
|
||||
const failed = await loadDeliveryDiagnostics(async () => { throw new Error('400: delivery blob is not available: diagnostics') }, 'p1', readyReceipt, () => true)
|
||||
expect(failed).toEqual({ deliveryId: 'rdlv-1', error: 'delivery blob is not available: diagnostics' })
|
||||
const garbled = await loadDeliveryDiagnostics(async () => download('{not json'), 'p1', readyReceipt, () => true)
|
||||
expect(garbled).toEqual({ deliveryId: 'rdlv-1', error: '诊断报告无法解析' })
|
||||
const incomplete = await loadDeliveryDiagnostics(async () => ({ deliveryId: 'rdlv-1' }), 'p1', readyReceipt, () => true)
|
||||
expect(incomplete).toEqual({ deliveryId: 'rdlv-1', error: '下载内容不完整' })
|
||||
})
|
||||
it('回执缺 deliveryId 不发请求', async () => {
|
||||
let called = false
|
||||
const out = await loadDeliveryDiagnostics(async () => { called = true }, 'p1', { state: 'ready' }, () => true)
|
||||
expect(called).toBe(false)
|
||||
expect(out).toEqual({ deliveryId: '', error: '回执缺少交付编号,无法读取诊断' })
|
||||
})
|
||||
it('过期下载(守卫失效)被丢弃', async () => {
|
||||
let live = true
|
||||
const out = await loadDeliveryDiagnostics(async () => { live = false; return download(diagnosticsJSON) }, 'p1', readyReceipt, () => live)
|
||||
expect(out).toBeNull()
|
||||
const failedStale = await loadDeliveryDiagnostics(async () => { live = false; throw new Error('boom') }, 'p1', readyReceipt, () => live)
|
||||
expect(failedStale).toBeNull()
|
||||
})
|
||||
it('回执面板:中文状态、诊断加载中/失败可读/节点定位', () => {
|
||||
const render = (diagnostics: Parameters<typeof RenderReceipt>[0]['diagnostics']) => renderToStaticMarkup(createElement(RenderReceipt, {
|
||||
receipt: readyReceipt, diagnostics, nodeTitle: (id: string) => (id === 's1' ? '范围' : undefined), onSelectNode: () => {},
|
||||
}))
|
||||
const loading = render({ status: 'loading' })
|
||||
expect(loading).toContain('已生成')
|
||||
expect(loading).not.toContain('>ready<')
|
||||
expect(loading).toContain('诊断读取中')
|
||||
const failed = render({ status: 'error', error: '400: {"receipt":{"error":"诊断不可用"}}' })
|
||||
expect(failed).toContain('诊断不可用')
|
||||
expect(failed).toContain('drw-failure')
|
||||
const ready = render({ status: 'ready', items: normalizeDiagnostics([{ code: 'E1', severity: 'error', message: '缺少必填章节', nodeId: 's1' }]) })
|
||||
expect(ready).toContain('1 个错误')
|
||||
expect(ready).toContain('drw-diag-node')
|
||||
expect(ready).toContain('title="范围"')
|
||||
})
|
||||
it('回执 state 映射为中文标签', () => {
|
||||
expect(receiptStateView('ready')).toEqual({ label: '已生成', tone: 'ok' })
|
||||
expect(receiptStateView('running')).toEqual({ label: '生成中', tone: 'info' })
|
||||
expect(receiptStateView('frozen')).toEqual({ label: '排队中', tone: 'plain' })
|
||||
expect(receiptStateView('generation-failed')).toEqual({ label: '生成失败', tone: 'danger' })
|
||||
expect(receiptStateView('cancelled')).toEqual({ label: '已取消', tone: 'warn' })
|
||||
expect(receiptStateView('weird')).toEqual({ label: '状态未知', tone: 'plain' })
|
||||
expect(receiptStateView(undefined)).toEqual({ label: '状态未知', tone: 'plain' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('文档树逐层展开(rdm.inspect.tree 每次只返回一层)', () => {
|
||||
type Node = { id: string; title: string; children?: Node[] }
|
||||
const shellItem = (docId: string, parentId: string, n: Node, order: number) => ({
|
||||
kind: 'section', id: n.id, docId, parentId, order, title: n.title, templateBlockId: '', state: 'draft',
|
||||
hasChildren: (n.children?.length ?? 0) > 0, structureRevision: 1, contentRevision: 1,
|
||||
})
|
||||
/** 模拟 InspectTree:parent=docId 返回顶层(parentId 为空串),parent=nodeId 返回其直接子节点;按 limit 分页。 */
|
||||
function fakeServer(docId: string, roots: Node[], limit = 50) {
|
||||
const byParent = new Map<string, Node[]>([[docId, roots]])
|
||||
const visit = (list: Node[]) => { for (const n of list) { byParent.set(n.id, n.children ?? []); visit(n.children ?? []) } }
|
||||
visit(roots)
|
||||
const calls: string[] = []
|
||||
let inflight = 0
|
||||
let maxInflight = 0
|
||||
const fetchLevel = async (parent: string, cursor: string) => {
|
||||
calls.push(parent)
|
||||
inflight++
|
||||
maxInflight = Math.max(maxInflight, inflight)
|
||||
await new Promise(r => setTimeout(r, 1))
|
||||
inflight--
|
||||
const list = byParent.get(parent) ?? []
|
||||
const start = cursor ? Number(cursor) : 0
|
||||
const page = list.slice(start, start + limit)
|
||||
const hasMore = start + limit < list.length
|
||||
return {
|
||||
items: page.map((n, i) => shellItem(docId, parent === docId ? '' : parent, n, start + i)),
|
||||
hasMore, ...(hasMore ? { nextCursor: String(start + limit) } : {}),
|
||||
}
|
||||
}
|
||||
return { fetchLevel, calls, maxInflight: () => maxInflight }
|
||||
}
|
||||
const roots: Node[] = [
|
||||
{ id: 'n1', title: '范围', children: [{ id: 'n11', title: '目的', children: [{ id: 'n111', title: '背景' }] }, { id: 'n12', title: '适用' }] },
|
||||
{ id: 'n2', title: '设计', children: [{ id: 'n21', title: '架构' }] },
|
||||
]
|
||||
it('逐层请求子节点并按文档顺序构建完整嵌套树(非编号标题)', async () => {
|
||||
const s = fakeServer('doc1', roots)
|
||||
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
|
||||
expect(out?.truncated).toBe(false)
|
||||
const t = buildDocTree({ id: 'doc1', title: '设计说明书' }, out!.items)
|
||||
expect(t.order).toEqual(['doc1', 'n1', 'n11', 'n111', 'n12', 'n2', 'n21'])
|
||||
expect(t.nodes.n1.children).toEqual(['n11', 'n12'])
|
||||
expect(t.nodes.n11.children).toEqual(['n111'])
|
||||
expect(t.nodes.n111.depth).toBe(3)
|
||||
expect(t.nodes.n21.parentId).toBe('n2')
|
||||
expect(s.calls).not.toContain('n12')
|
||||
expect(new Set(s.calls)).toEqual(new Set(['doc1', 'n1', 'n2', 'n11']))
|
||||
})
|
||||
it('同层分页读完', async () => {
|
||||
const many: Node[] = Array.from({ length: 7 }, (_, i) => ({ id: `m${i}`, title: `节${i}` }))
|
||||
const s = fakeServer('doc1', [{ id: 'root', title: '根', children: many }], 3)
|
||||
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
|
||||
expect(out!.items.map(i => i.id)).toEqual(['root', ...many.map(n => n.id)])
|
||||
})
|
||||
it('并发不超过 4', async () => {
|
||||
const wide: Node[] = Array.from({ length: 12 }, (_, i) => ({ id: `w${i}`, title: `宽${i}`, children: [{ id: `w${i}c`, title: '子' }] }))
|
||||
const s = fakeServer('doc1', wide)
|
||||
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
|
||||
expect(out!.items).toHaveLength(24)
|
||||
expect(s.maxInflight()).toBeLessThanOrEqual(4)
|
||||
expect(s.maxInflight()).toBeGreaterThan(1)
|
||||
})
|
||||
it('深度超过 12 截断并标记', async () => {
|
||||
let chain: Node = { id: 'c20', title: '底' }
|
||||
for (let i = 19; i >= 1; i--) chain = { id: `c${i}`, title: `层${i}`, children: [chain] }
|
||||
const out = await walkDocTree(fakeServer('doc1', [chain]).fetchLevel, 'doc1', () => true)
|
||||
expect(out!.items).toHaveLength(12)
|
||||
expect(out!.truncated).toBe(true)
|
||||
expect(DOC_TREE_LIMITS).toEqual({ maxDepth: 12, maxNodes: 2000, concurrency: 4, maxPages: 20 })
|
||||
})
|
||||
it('节点数超过上限截断并标记', async () => {
|
||||
const wide: Node[] = Array.from({ length: 30 }, (_, i) => ({ id: `x${i}`, title: `节${i}` }))
|
||||
const out = await walkDocTree(fakeServer('doc1', wide).fetchLevel, 'doc1', () => true, { ...DOC_TREE_LIMITS, maxNodes: 10 })
|
||||
expect(out!.items).toHaveLength(10)
|
||||
expect(out!.truncated).toBe(true)
|
||||
})
|
||||
it('守卫失效后停止继续请求并丢弃结果', async () => {
|
||||
const s = fakeServer('doc1', roots)
|
||||
let live = true
|
||||
const out = await walkDocTree(async (parent, cursor) => { const r = await s.fetchLevel(parent, cursor); live = false; return r }, 'doc1', () => live)
|
||||
expect(out).toBeNull()
|
||||
expect(s.calls).toEqual(['doc1'])
|
||||
})
|
||||
it('环与重复 id 不重复展开', async () => {
|
||||
const out = await walkDocTree(async parent => ({
|
||||
items: parent === 'doc1'
|
||||
? [{ kind: 'section', id: 'a', parentId: '', title: '甲', hasChildren: true }]
|
||||
: [{ kind: 'section', id: 'a', parentId: 'a', title: '甲', hasChildren: true }, { kind: 'section', id: 'doc1', parentId: 'a', title: '根', hasChildren: true }],
|
||||
}), 'doc1', () => true)
|
||||
expect(out!.items.map(i => i.id)).toEqual(['a'])
|
||||
})
|
||||
it('模板实例中无标题的块(封面、签署表、字段)以块类型与模板块编号标识,不渲染空白节点', async () => {
|
||||
const out = await walkDocTree(async parent => ({
|
||||
items: parent === 'doc-33'
|
||||
? [
|
||||
{ kind: 'section', id: 'dn-cover', parentId: '', title: '', templateBlockId: 'pp.cover', hasChildren: false },
|
||||
{ kind: 'table', id: 'dn-sign', parentId: '', title: ' ', templateBlockId: 'pp.identity-signoff.table', hasChildren: false },
|
||||
{ kind: 'field', id: 'dn-x', parentId: '', title: '', hasChildren: false },
|
||||
{ kind: 'section', id: 'dn-ctx', parentId: '', title: '项目内容、目标及意义', templateBlockId: 'pp.context', hasChildren: false },
|
||||
]
|
||||
: [],
|
||||
}), 'doc-33', () => true)
|
||||
expect(out!.items.map(i => i.title)).toEqual(['章节(pp.cover)', '表格(pp.identity-signoff.table)', '字段(dn-x)', '项目内容、目标及意义'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('拒绝与失败转为可读文案(原始回执另行折叠)', () => {
|
||||
it('非 2xx JSON 回执取 error 文本,不直出 JSON', () => {
|
||||
const f = describeRenderFailure(new Error('400: {"receipt":{"state":"rejected","error":"规则未发布,不能用于正式交付"}}'))
|
||||
expect(f.message).toBe('规则未发布,不能用于正式交付')
|
||||
expect(f.raw).toContain('"receipt"')
|
||||
})
|
||||
it('登录失效 / 能力缺失 / 无法解析', () => {
|
||||
expect(describeRenderFailure('401: x').message).toContain('登录已失效')
|
||||
expect(describeRenderFailure('400: unknown or disabled command: rdm.render.profiles').message).toContain('未提供文档渲染能力')
|
||||
expect(describeRenderFailure('500: {"weird":1}').message).toBe('操作失败,请稍后重试')
|
||||
expect(describeRenderFailure('{"a":1}').message).toBe('操作失败,请稍后重试')
|
||||
})
|
||||
it('纯文本拒绝原样(截断)显示;OOXML 不显示', () => {
|
||||
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
|
||||
expect(describeRenderFailure('x'.repeat(500)).message.length).toBeLessThanOrEqual(241)
|
||||
expect(describeRenderFailure('<w:document><w:body/></w:document>').message).toBe('操作失败,请稍后重试')
|
||||
})
|
||||
it('displayText 屏蔽原始文档格式', () => {
|
||||
expect(displayText('<?xml version="1.0"?><w:p/>')).toBe('(原始文档格式内容不显示)')
|
||||
expect(displayText(' ')).toBeUndefined()
|
||||
expect(displayText(12)).toBeUndefined()
|
||||
expect(displayText('正文')).toBe('正文')
|
||||
})
|
||||
it('RenderFailure 只在 Collapsible 内出现原始回执', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderFailure, { error: '400: {"receipt":{"error":"拒绝"}}', id: 't' }))
|
||||
expect(html).toContain('拒绝')
|
||||
expect(html).toContain('原始回执')
|
||||
expect(html).toContain('mk-col')
|
||||
expect(html).not.toContain('<details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('预览诊断', () => {
|
||||
const raw = [
|
||||
{ code: 'W1', severity: 'warning', message: '标题层级跳跃', nodeId: 's2' },
|
||||
{ code: 'E1', severity: 'error', message: '缺少必填章节', nodeId: 's1', suggestion: '补写' },
|
||||
{ code: 'I1', severity: 'info', message: '提示' },
|
||||
{ severity: 'error' },
|
||||
{ code: 'E2', severity: 'fatal', message: '模板缺失', nodeId: 's1' },
|
||||
]
|
||||
it('归一化严重级并丢弃无消息条目', () => {
|
||||
const list = normalizeDiagnostics(raw)
|
||||
expect(list).toHaveLength(4)
|
||||
expect(list.find(d => d.code === 'E2')?.severity).toBe('error')
|
||||
expect(normalizeDiagnostics('nope')).toEqual([])
|
||||
})
|
||||
it('按 错误 > 警告 > 提示 分组,省略空组', () => {
|
||||
const groups = groupDiagnostics(normalizeDiagnostics(raw))
|
||||
expect(groups.map(g => [g.severity, g.items.length])).toEqual([['error', 2], ['warning', 1], ['info', 1]])
|
||||
expect(groups[0].label).toBe('错误')
|
||||
expect(groupDiagnostics([])).toEqual([])
|
||||
})
|
||||
it('摘要与节点计数', () => {
|
||||
expect(diagnosticsSummaryText({ errors: 2, warnings: 1 })).toBe('2 个错误 · 1 个警告')
|
||||
expect(diagnosticsSummaryText({ errors: 0, warnings: 0 })).toBe('无错误或警告')
|
||||
const counts = diagnosticCountsByNode(normalizeDiagnostics(raw))
|
||||
expect(counts.s1).toEqual({ errors: 2, warnings: 0 })
|
||||
expect(counts.s2).toEqual({ errors: 0, warnings: 1 })
|
||||
})
|
||||
it('诊断列表:可定位的可点击,未定位的标明且不可点;长列表分组折叠', () => {
|
||||
const list = normalizeDiagnostics(Array.from({ length: 120 }, (_, i) => ({ code: 'W', severity: 'warning', message: `问题 ${i}`, nodeId: i % 2 ? 's1' : 'ghost' })))
|
||||
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
|
||||
diagnostics: list, nodeTitle: (id: string) => (id === 's1' ? '第一章' : undefined), onSelectNode: () => {}, idPrefix: 't',
|
||||
}))
|
||||
expect(html).toContain('警告(120)')
|
||||
expect(html).toContain('第一章')
|
||||
expect(html).toContain('未定位节点')
|
||||
expect(html).toContain('mk-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('诊断定位:treeNodeId 优先,nodeId 回退', () => {
|
||||
// 渲染器 nodeId 是 canonical/映射节点 id;RDM 另给工作区树节点 id treeNodeId。
|
||||
const known = new Set(['dn-cover', 's1'])
|
||||
const titles: Record<string, string> = { 'dn-cover': '封面', s1: '第一章' }
|
||||
const nodeTitle = (id: string) => (known.has(id) ? titles[id] : undefined)
|
||||
const raw = [
|
||||
{ code: 'required_content_missing', severity: 'warning', message: '缺少文档编号', nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' },
|
||||
{ code: 'W', severity: 'warning', message: '旧载荷:只有树节点 nodeId', nodeId: 's1' },
|
||||
{ code: 'W', severity: 'error', message: 'treeNodeId 不在树中时回退 nodeId', nodeId: 's1', treeNodeId: 'dn-gone' },
|
||||
{ code: 'W', severity: 'warning', message: '只有映射 id', nodeId: 'pp.revision.entry.c01' },
|
||||
{ code: 'W', severity: 'warning', message: '两者都不在树中', nodeId: 'pp.x', treeNodeId: 'dn-gone' },
|
||||
{ code: 'W', severity: 'info', message: '无节点' },
|
||||
]
|
||||
const list = normalizeDiagnostics(raw)
|
||||
|
||||
it('归一化保留 treeNodeId,空串与非字符串丢弃', () => {
|
||||
expect(list[0]).toMatchObject({ nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' })
|
||||
expect(list[1].treeNodeId).toBeUndefined()
|
||||
const odd = normalizeDiagnostics([{ message: 'm', treeNodeId: '' }, { message: 'm', treeNodeId: 7 }])
|
||||
expect(odd.every(d => !('treeNodeId' in d))).toBe(true)
|
||||
})
|
||||
it('locateDiagnostic:树节点 id 优先,缺省或未知时回退 nodeId,都未知则未定位', () => {
|
||||
expect(list.map(d => locateDiagnostic(d, nodeTitle))).toEqual([
|
||||
{ id: 'dn-cover', title: '封面' },
|
||||
{ id: 's1', title: '第一章' },
|
||||
{ id: 's1', title: '第一章' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
})
|
||||
it('节点计数按定位到的树节点归档;不在树中的不计入任何节点', () => {
|
||||
const counts = diagnosticCountsByNode(list, id => known.has(id))
|
||||
expect(counts).toEqual({ 'dn-cover': { errors: 0, warnings: 1 }, s1: { errors: 1, warnings: 1 } })
|
||||
})
|
||||
it('诊断列表:两种 id 形式都可点击定位,未定位的明确标出', () => {
|
||||
// 只有首组默认展开(SSR 不渲染折叠组内容),因此只取警告组。
|
||||
const warnings = list.filter(d => d.severity === 'warning')
|
||||
const html = renderToStaticMarkup(createElement(DiagnosticsList, { diagnostics: warnings, nodeTitle, onSelectNode: () => {}, idPrefix: 'loc' }))
|
||||
expect(html).toContain('title="封面"')
|
||||
expect(html).toContain('title="第一章"')
|
||||
expect(html.match(/<button[^>]*class="drw-diag-node"/g)).toHaveLength(2)
|
||||
expect(html.match(/drw-diag-node unlocated/g)).toHaveLength(2)
|
||||
expect(html).not.toContain('pp.cover.document-number.value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('操作编号:每次动作新编号,重试复用', () => {
|
||||
it('新动作铸新 id;同类重试复用;不同类不复用', () => {
|
||||
let n = 0
|
||||
const mint = () => `op_${++n}`
|
||||
const a = nextOperation('preview', mint, null)
|
||||
expect(a).toEqual({ kind: 'preview', operationId: 'op_1' })
|
||||
expect(nextOperation('preview', mint, a, true)).toBe(a)
|
||||
expect(nextOperation('preview', mint, a).operationId).toBe('op_2')
|
||||
expect(nextOperation('submit', mint, a, true).operationId).toBe('op_3')
|
||||
})
|
||||
it('默认铸造器生成非空唯一编号', () => {
|
||||
const x = nextOperation('preview', undefined, null)
|
||||
const y = nextOperation('preview', undefined, null)
|
||||
expect(x.operationId).toMatch(/^op_/)
|
||||
expect(x.operationId).not.toBe(y.operationId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('请求守卫:过期响应丢弃', () => {
|
||||
it('更新的请求使旧票据失效', () => {
|
||||
const g = createRequestGuard()
|
||||
const scope = renderScope(1, 'p', 'd')
|
||||
const t1 = g.begin(scope)
|
||||
const t2 = g.begin(scope)
|
||||
expect(g.current(t1, scope)).toBe(false)
|
||||
expect(g.current(t2, scope)).toBe(true)
|
||||
})
|
||||
it('项目/文档/身份切换使票据失效;cancel 使全部失效', () => {
|
||||
const g = createRequestGuard()
|
||||
const t = g.begin(renderScope(1, 'p', 'd'))
|
||||
expect(g.current(t, renderScope(1, 'p', 'other'))).toBe(false)
|
||||
expect(g.current(t, renderScope(2, 'p', 'd'))).toBe(false)
|
||||
expect(g.current(t, renderScope(1, 'q', 'd'))).toBe(false)
|
||||
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(true)
|
||||
g.cancel()
|
||||
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(false)
|
||||
})
|
||||
it('作用域键不因分隔符拼接而碰撞', () => {
|
||||
expect(renderScope(1, 'a|b', 'c')).not.toBe(renderScope(1, 'a', 'b|c'))
|
||||
})
|
||||
})
|
||||
|
||||
const doc = { id: 'doc1', title: '设计说明书' }
|
||||
const items = [
|
||||
{ kind: 'section', id: 's1', title: '1 范围' },
|
||||
{ kind: 'section', id: 's11', title: '1.1 目的' },
|
||||
{ kind: 'section', id: 's12', title: '1.2 适用', state: 'baselined' },
|
||||
{ kind: 'section', id: 's2', title: '2 设计' },
|
||||
{ kind: 'section', id: 's21', title: '细节', level: 2 },
|
||||
{ kind: 'section', id: 's1', title: '重复' },
|
||||
]
|
||||
function tree(): DocTree { return buildDocTree(doc, items) }
|
||||
|
||||
describe('文档树:构建、过滤、布局与键盘导航', () => {
|
||||
it('按编号或 level 推导层级,忽略重复 id', () => {
|
||||
const t = tree()
|
||||
expect(t.rootId).toBe('doc1')
|
||||
expect(t.order).toEqual(['doc1', 's1', 's11', 's12', 's2', 's21'])
|
||||
expect(t.nodes.doc1.children).toEqual(['s1', 's2'])
|
||||
expect(t.nodes.s1.children).toEqual(['s11', 's12'])
|
||||
expect(t.nodes.s21.parentId).toBe('s2')
|
||||
expect(t.nodes.s12.depth).toBe(2)
|
||||
expect(t.nodes.s1.title).toBe('1 范围')
|
||||
})
|
||||
it('显式 parentId 优先', () => {
|
||||
const t = buildDocTree(doc, [{ kind: 'section', id: 'a', title: '甲' }, { kind: 'section', id: 'b', title: '乙', parentId: 'a' }])
|
||||
expect(t.nodes.a.children).toEqual(['b'])
|
||||
})
|
||||
it('过滤保留命中节点及其祖先;问题/审阅模式按标记过滤', () => {
|
||||
const t = tree()
|
||||
const marks = { issues: new Set(['s21']), review: new Set(['s11']) }
|
||||
expect([...visibleNodeIds(t, { text: '适用', mode: 'all' }, marks)].sort()).toEqual(['doc1', 's1', 's12'])
|
||||
expect([...visibleNodeIds(t, { text: '', mode: 'issues' }, marks)].sort()).toEqual(['doc1', 's2', 's21'])
|
||||
expect([...visibleNodeIds(t, { text: '', mode: 'review' }, marks)].sort()).toEqual(['doc1', 's1', 's11'])
|
||||
expect(visibleNodeIds(t, { text: '', mode: 'all' }, marks).size).toBe(6)
|
||||
expect(visibleOrder(t, new Set(['doc1', 's2', 's21']))).toEqual(['doc1', 's2', 's21'])
|
||||
})
|
||||
it('思维导图布局:父节点居中于子节点,边连接父子,尺寸覆盖全部节点', () => {
|
||||
const t = tree()
|
||||
const layout = layoutMindMap(t, visibleNodeIds(t, { text: '', mode: 'all' }, { issues: new Set(), review: new Set() }))
|
||||
const at = (id: string) => layout.nodes.find(n => n.id === id)!
|
||||
expect(at('s11').x).toBeGreaterThan(at('s1').x)
|
||||
expect(at('s1').y).toBe((at('s11').y + at('s12').y) / 2)
|
||||
expect(layout.edges).toHaveLength(5)
|
||||
expect(layout.edges[0].d).toMatch(/^M[\d.]+ [\d.]+ C/)
|
||||
for (const n of layout.nodes) {
|
||||
expect(n.x + 180).toBeLessThanOrEqual(layout.width)
|
||||
expect(n.y + 32).toBeLessThanOrEqual(layout.height)
|
||||
}
|
||||
})
|
||||
it('键盘:上下按可见顺序、左到父、右到首子、Home/End,未处理返回 null', () => {
|
||||
const t = tree()
|
||||
const order = t.order
|
||||
expect(moveSelection(t, order, 's1', 'ArrowDown')).toBe('s11')
|
||||
expect(moveSelection(t, order, 's1', 'ArrowUp')).toBe('doc1')
|
||||
expect(moveSelection(t, order, 's12', 'ArrowLeft')).toBe('s1')
|
||||
expect(moveSelection(t, order, 's1', 'ArrowRight')).toBe('s11')
|
||||
expect(moveSelection(t, order, 's1', 'End')).toBe('s21')
|
||||
expect(moveSelection(t, order, 's21', 'Home')).toBe('doc1')
|
||||
expect(moveSelection(t, order, null, 'ArrowDown')).toBe('doc1')
|
||||
expect(moveSelection(t, order, 's21', 'ArrowDown')).toBe('s21')
|
||||
expect(moveSelection(t, order, 's1', 'x')).toBeNull()
|
||||
expect(moveSelection(t, ['doc1', 's2'], 's2', 'ArrowRight')).toBe('s2')
|
||||
})
|
||||
it('锚点 id 只含安全字符', () => {
|
||||
expect(nodeAnchorId('sec/1 a')).toBe('drw-node-sec_1_a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('节点事实:只显示服务端给出的字段', () => {
|
||||
it('空详情不产出任何事实', () => {
|
||||
const f = nodeFacts({ kind: 'section', id: 's1', relations: [], recentEvents: [] })
|
||||
expect(hasFacts(f)).toBe(false)
|
||||
expect(f).toEqual({ sources: [], impacts: [] })
|
||||
expect(hasFacts(nodeFacts(null))).toBe(false)
|
||||
})
|
||||
it('来源/影响/置信/冲突/作者时间/变更前后', () => {
|
||||
const f = nodeFacts({
|
||||
kind: 'section', id: 's1', confidence: 0.82, conflict: '与需求 R3 表述冲突',
|
||||
versions: [{ seq: 1, actor: 'amy', at: '2026-10-01T00:00:00Z' }, { seq: 3, actor: 'bob', at: '2026-10-03T00:00:00Z' }],
|
||||
change: { before: '旧文', after: '新文', impact: '影响验证计划' },
|
||||
relations: [
|
||||
{ layer: 'trace', state: 'active', from: { kind: 'req', id: 'r1' }, to: { kind: 'section', id: 's1' }, conf: 0.9 },
|
||||
{ layer: 'trace', state: 'active', from: { kind: 'note', id: 'secret', disclosure: 'private' }, to: { kind: 'section', id: 's1' } },
|
||||
{ layer: 'trace', state: 'active', from: { kind: 'section', id: 's1' }, to: { kind: 'test', id: 't1' } },
|
||||
],
|
||||
})
|
||||
expect(f.sources).toEqual(['req:r1(置信 90%)'])
|
||||
expect(f.impacts).toEqual(['test:t1'])
|
||||
expect(f.confidence).toBe('82%')
|
||||
expect(f.conflict).toBe('与需求 R3 表述冲突')
|
||||
expect(f.author).toBe('bob')
|
||||
expect(f.at).toBe('2026-10-03T00:00:00Z')
|
||||
expect(f.change).toEqual({ before: '旧文', after: '新文', impact: '影响验证计划' })
|
||||
expect(hasFacts(f)).toBe(true)
|
||||
})
|
||||
it('无版本时退到正式版作者;OOXML 变更不显示原文', () => {
|
||||
const f = nodeFacts({ id: 's1', formal: { actor: 'carol', at: '2026-09-01T00:00:00Z' }, change: { after: '<w:p>x</w:p>' } })
|
||||
expect(f.author).toBe('carol')
|
||||
expect(f.change?.after).toBe('(原始文档格式内容不显示)')
|
||||
expect(f.change?.before).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('审阅:批注、修订与载荷', () => {
|
||||
const view = normalizeReviewView({
|
||||
annotations: [
|
||||
{ id: 'a1', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '请补充', replies: [{ id: 'a1r', nodeId: 's1', author: { id: 'u2', displayName: '李四' }, createdAt: '2026-10-02T00:00:00Z', text: '已补', resolved: false, orphaned: false }], resolved: false, orphaned: false },
|
||||
{ id: 'a2', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '原位置已删除', replies: [], resolved: false, orphaned: true },
|
||||
{ id: 'a3', nodeId: 'gone', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '找不到节点', replies: [], resolved: true, orphaned: false },
|
||||
{ id: 'bad' },
|
||||
],
|
||||
revisions: [{ id: 'v1', nodeId: 's1', kind: 'replace', before: '旧', after: '新', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z' }, { id: 'v2', kind: 'explode' }],
|
||||
decisions: [{ actor: { id: 'u3', displayName: '王五' }, duty: 'reviewer', decision: 'approve', at: '2026-10-03T00:00:00Z' }],
|
||||
})
|
||||
it('归一化丢弃不完整条目', () => {
|
||||
expect(view.annotations.map(a => a.id)).toEqual(['a1', 'a2', 'a3'])
|
||||
expect(view.annotations[0].replies).toHaveLength(1)
|
||||
expect(view.revisions.map(r => r.id)).toEqual(['v1'])
|
||||
expect(view.decisions).toHaveLength(1)
|
||||
expect(normalizeReviewView(undefined)).toEqual({ annotations: [], revisions: [], decisions: [] })
|
||||
})
|
||||
it('孤立或节点不存在的批注进入「无法定位」,绝不挂到其他节点', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1', 's2']))
|
||||
expect(byNode.get('s1')?.map(a => a.id)).toEqual(['a1'])
|
||||
expect(orphaned.map(a => a.id)).toEqual(['a2', 'a3'])
|
||||
})
|
||||
it('批注分组渲染:回复缩进、无法定位单独成组', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1']))
|
||||
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '第一章', onSelectNode: () => {} }))
|
||||
expect(html).toContain('无法定位(2)')
|
||||
expect(html).toContain('已补')
|
||||
expect(html).toContain('李四')
|
||||
expect(html.indexOf('第一章')).toBeLessThan(html.indexOf('无法定位'))
|
||||
})
|
||||
it('载荷只含业务字段,身份与时间由服务端填写', () => {
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: ' 好 ', operationId: 'op_1' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '好', operationId: 'op_1' })
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' })
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' })
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' })
|
||||
})
|
||||
it('文本校验', () => {
|
||||
expect(validateReviewText(' ')).toBe('请输入内容')
|
||||
expect(validateReviewText('x'.repeat(4001))).toContain('4000')
|
||||
expect(validateReviewText('好')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('审阅按工作区树节点分组与定位(treeNodeId 优先,nodeId 回退)', () => {
|
||||
// RDM 契约:nodeId 恒为渲染器规范节点(pp.…),treeNodeId 为审阅人所选树节点(dn-/sec-/legacy-)。
|
||||
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
|
||||
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
|
||||
{ kind: 'field', id: 'dn-leaf', title: '目标', parentId: 'dn-head' },
|
||||
{ kind: 'section', id: 'sec-2', title: '第二章' },
|
||||
])
|
||||
const known = new Set(wsTree.order)
|
||||
const person = { id: 'u1', displayName: '审核人' }
|
||||
const view = normalizeReviewView({
|
||||
annotations: [
|
||||
{ id: 'a1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', author: person, createdAt: '', text: '按所选标题', replies: [] },
|
||||
{ id: 'a2', nodeId: 'pp.goal.leaf', treeNodeId: 'dn-head', author: person, createdAt: '', text: '同一树节点', replies: [] },
|
||||
{ id: 'a3', nodeId: 'sec-2', author: person, createdAt: '', text: '旧条目只有 nodeId', replies: [] },
|
||||
{ id: 'a4', nodeId: 'dn-leaf', treeNodeId: 'dn-gone', author: person, createdAt: '', text: 'treeNodeId 不在树中回退 nodeId', replies: [] },
|
||||
{ id: 'a5', nodeId: 'pp.x', treeNodeId: 'dn-gone', author: person, createdAt: '', text: '都不在树中', replies: [] },
|
||||
{ id: 'a6', nodeId: 'pp.y', treeNodeId: 'dn-head', author: person, createdAt: '', text: '服务端标孤立', replies: [], orphaned: true },
|
||||
{ id: 'a7', nodeId: 'pp.z', treeNodeId: '', author: person, createdAt: '', text: '空 treeNodeId 不保留', replies: [] },
|
||||
],
|
||||
revisions: [
|
||||
{ id: 'v1', nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf', kind: 'replace', before: '旧', after: '新', author: person, createdAt: '' },
|
||||
{ id: 'v2', nodeId: 'pp.unknown', kind: 'insert', after: '无处可挂', author: person, createdAt: '' },
|
||||
],
|
||||
})
|
||||
|
||||
it('归一化保留 treeNodeId,nodeId 仍为规范节点', () => {
|
||||
expect(view.annotations[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-head' })
|
||||
expect(view.annotations.find(a => a.id === 'a7')).not.toHaveProperty('treeNodeId')
|
||||
expect(view.revisions[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf' })
|
||||
})
|
||||
it('批注按 treeNodeId 分组,缺省或不在树中回退 nodeId,都不在或孤立进入无法定位', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, known)
|
||||
expect([...byNode.keys()]).toEqual(['dn-head', 'sec-2', 'dn-leaf'])
|
||||
expect(byNode.get('dn-head')?.map(a => a.id)).toEqual(['a1', 'a2'])
|
||||
expect(byNode.get('dn-leaf')?.map(a => a.id)).toEqual(['a4'])
|
||||
expect(orphaned.map(a => a.id)).toEqual(['a5', 'a6', 'a7'])
|
||||
})
|
||||
it('修订同规则分组', () => {
|
||||
const { byNode, orphaned } = partitionRevisions(view.revisions, known)
|
||||
expect([...byNode.keys()]).toEqual(['dn-leaf'])
|
||||
expect(orphaned.map(r => r.id)).toEqual(['v2'])
|
||||
})
|
||||
it('分组标题是树节点名并带定位目标(点击由工作区滚动到该节点)', () => {
|
||||
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
|
||||
const anno = partitionAnnotations(view.annotations, known)
|
||||
const html = renderToStaticMarkup(createElement(AnnotationGroups, { ...anno, nodeTitle, onSelectNode: () => {}, onReply: () => {} }))
|
||||
expect(html).toContain('data-locate="dn-head"')
|
||||
expect(html).toContain('项目内容、目标及意义')
|
||||
expect(html).not.toContain('pp.goal.value')
|
||||
const rev = partitionRevisions(view.revisions, known)
|
||||
const revHtml = renderToStaticMarkup(createElement(RevisionGroups, { ...rev, total: view.revisions.length, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(revHtml).toContain('data-locate="dn-leaf"')
|
||||
expect(revHtml).toContain('修订(2)')
|
||||
expect(revHtml).toContain('无法定位(1)')
|
||||
})
|
||||
it('从树上批注/修订发送所选树节点 id;文档根不是审阅目标', () => {
|
||||
const target = reviewTargetNode(wsTree, 'dn-leaf')
|
||||
expect(target?.id).toBe('dn-leaf')
|
||||
expect(reviewTargetNode(wsTree, 'doc1')).toBeUndefined()
|
||||
expect(reviewTargetNode(wsTree, null)).toBeUndefined()
|
||||
expect(reviewTargetNode(null, 'dn-leaf')).toBeUndefined()
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: target!.id, text: '批', operationId: 'op' }).nodeId).toBe('dn-leaf')
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 'sec-2', kind: 'delete', before: '删', operationId: 'op' }).nodeId).toBe('sec-2')
|
||||
})
|
||||
it('locateNodeId 与诊断定位同规则', () => {
|
||||
expect(locateNodeId({ treeNodeId: 'dn-head', nodeId: 'sec-2' }, id => known.has(id))).toBe('dn-head')
|
||||
expect(locateNodeId({ treeNodeId: 'dn-gone', nodeId: 'sec-2' }, id => known.has(id))).toBe('sec-2')
|
||||
expect(locateNodeId({ nodeId: 'pp.x' }, id => known.has(id))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('渲染链条(rdm.inspect.relations layer=render)', () => {
|
||||
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
|
||||
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
|
||||
{ kind: 'section', id: 'sec-2', title: '第二章' },
|
||||
])
|
||||
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
|
||||
const sub = { kind: 'render-submission', id: 'sub-1' }
|
||||
const full = normalizeRenderTrace({
|
||||
items: [
|
||||
{ layer: 'general', type: 'derives', from: { kind: 'section', id: 'a' }, to: { kind: 'section', id: 'b' } },
|
||||
{ layer: 'render', type: 'selected-profile', from: { kind: 'doc', id: 'doc1' }, to: { kind: 'render-profile', id: 'company.dhf@1', source: 'template' } },
|
||||
{ layer: 'render', type: 'annotated', from: sub, to: { kind: 'review-annotation', id: 'ann-1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', displayName: '审核人', at: '2026-10-17T00:00:00Z', text: '请补充' } },
|
||||
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
|
||||
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-2' }, to: { kind: 'document-node', id: 'dn-gone' } },
|
||||
{ layer: 'render', type: 'revised', from: sub, to: { kind: 'review-revision', id: 'rev-1', nodeId: 'sec-2', revisionKind: 'replace' } },
|
||||
{ layer: 'render', type: 'restricted-basis', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'restricted-source', id: 'restricted' } },
|
||||
{ layer: 'render', type: 'future-edge', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'x', id: 'y' } },
|
||||
{ layer: 'render', type: 'delivered', from: { kind: 'doc', id: 'doc1' } },
|
||||
],
|
||||
truncated: true,
|
||||
})
|
||||
|
||||
it('只取 layer=render 且端点完整的条目,未知类型保留原名', () => {
|
||||
expect(full.entries.map(e => e.rawType)).toEqual(['selected-profile', 'annotated', 'anchored-to', 'anchored-to', 'revised', 'restricted-basis', 'future-edge'])
|
||||
expect(full.entries[6].type).toBe('other')
|
||||
expect(traceTypeLabel(full.entries[6])).toBe('future-edge')
|
||||
expect(full.truncated).toBe(true)
|
||||
expect(full.masked).toBe(false)
|
||||
expect(normalizeRenderTrace(null)).toEqual({ entries: [], masked: false, truncated: false })
|
||||
})
|
||||
it('anchored-to 的目标是树节点 id(不回退规范 id);批注/修订 treeNodeId 优先、nodeId 回退', () => {
|
||||
const [, annotated, anchored, anchoredGone, revised, restricted] = full.entries
|
||||
expect(traceAnchor(anchored)).toMatchObject({ subject: { kind: 'review-annotation', id: 'ann-1' }, target: { treeNodeId: 'dn-head' } })
|
||||
expect(traceAnchor(anchored).target).not.toHaveProperty('nodeId')
|
||||
expect(traceAnchor(anchoredGone).target).toEqual({ treeNodeId: 'dn-gone' })
|
||||
expect(traceAnchor(annotated).target).toEqual({ treeNodeId: 'dn-head', nodeId: 'pp.goal.value' })
|
||||
expect(traceAnchor(revised).target).toEqual({ treeNodeId: undefined, nodeId: 'sec-2' })
|
||||
expect(traceAnchor(restricted).target).toBeUndefined()
|
||||
expect(traceEndpointLabel(restricted.to)).toBe('存在受限依据')
|
||||
})
|
||||
it('渲染:已定位的树节点可点击定位,未知树节点显示未定位', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: full, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html).toContain('批注锚定')
|
||||
expect(html.match(/data-locate="dn-head"/g)).toHaveLength(2)
|
||||
expect(html).toContain('data-locate="sec-2"')
|
||||
expect(html).toContain('未定位节点')
|
||||
expect(html).not.toContain('dn-gone"')
|
||||
expect(html).toContain('模板规则')
|
||||
expect(html).toContain('替换')
|
||||
expect(html).toContain('请补充')
|
||||
expect(html).toContain('已截断')
|
||||
expect(html).not.toContain('内容已遮蔽')
|
||||
})
|
||||
it('遮蔽条目只显示元数据:标「内容已遮蔽」,正文字段即使误发也不读取', () => {
|
||||
const masked = normalizeRenderTrace({
|
||||
items: [
|
||||
{ layer: 'render', type: 'annotated', contentMasked: true, from: sub, to: { kind: 'review-annotation', id: 'ann-1', treeNodeId: 'dn-head', displayName: '审核人', text: 'SECRET-TEXT', excerpt: 'SECRET-EXCERPT' } },
|
||||
{ layer: 'render', type: 'revised', contentMasked: true, from: sub, to: { kind: 'review-revision', id: 'rev-1', treeNodeId: 'sec-2', revisionKind: 'replace', before: 'SECRET-BEFORE', after: 'SECRET-AFTER' } },
|
||||
{ layer: 'render', type: 'anchored-to', contentMasked: true, from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
|
||||
],
|
||||
})
|
||||
expect(masked.masked).toBe(true)
|
||||
expect(masked.entries.every(e => e.masked && e.body === undefined)).toBe(true)
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: masked, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html.match(/内容已遮蔽/g)).toHaveLength(3)
|
||||
expect(html).toContain('正文、批注与修订文字已遮蔽')
|
||||
expect(html).toContain('审核人')
|
||||
expect(html).toContain('data-locate="dn-head"')
|
||||
expect(html).not.toContain('SECRET')
|
||||
})
|
||||
it('空链条有明确空态', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: normalizeRenderTrace({ items: [] }), nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html).toContain('暂无渲染链条记录')
|
||||
})
|
||||
})
|
||||
|
||||
describe('交付记录与下载', () => {
|
||||
const page = normalizeDeliveries({
|
||||
items: [
|
||||
{ deliveryId: 'd2', mode: 'approved', submissionId: 'sub1', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-03T00:00:00Z', requestedBy: { id: 'u1', displayName: '张三' }, status: 'succeeded', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown', 'evil'], diagnosticsSummary: { errors: 0, warnings: 1 } },
|
||||
{ deliveryId: 'd1', mode: 'preview', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-01T00:00:00Z', status: 'succeeded', availableKinds: ['docx', 'diagnostics'] },
|
||||
{ mode: 'preview' },
|
||||
],
|
||||
nextCursor: 'c2',
|
||||
})
|
||||
it('归一化与分页合并去重', () => {
|
||||
expect(page.items.map(d => d.deliveryId)).toEqual(['d2', 'd1'])
|
||||
expect(page.nextCursor).toBe('c2')
|
||||
expect(page.items[1].diagnosticsSummary).toEqual({ errors: 0, warnings: 0 })
|
||||
const merged = mergeDeliveries(page.items, [{ ...page.items[1] }, { ...page.items[0], deliveryId: 'd0' }])
|
||||
expect(merged.map(d => d.deliveryId)).toEqual(['d2', 'd1', 'd0'])
|
||||
expect(latestSubmissionId(page.items)).toBe('sub1')
|
||||
expect(latestSubmissionId([page.items[1]])).toBeUndefined()
|
||||
})
|
||||
it('下载选项按固定顺序,批准模式标注批准稿,忽略未知类型', () => {
|
||||
expect(downloadOptions(page.items[0]).map(o => [o.kind, o.label])).toEqual([
|
||||
['markdown', 'Markdown'], ['docx', '批准稿 DOCX'], ['review-docx', '审阅留痕 DOCX'], ['manifest', '清单(manifest)'],
|
||||
])
|
||||
expect(downloadOptions(page.items[1]).map(o => o.label)).toEqual(['预览 DOCX', '诊断报告'])
|
||||
})
|
||||
it('下载载荷接受 contentType/filename 或 mime/fileName;缺数据报错', () => {
|
||||
expect(normalizeDownload({ filename: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' }))
|
||||
.toEqual({ fileName: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' })
|
||||
expect(normalizeDownload({ fileName: 'b.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', dataBase64: 'QQ==', sha256: 'y' }).contentType)
|
||||
.toContain('wordprocessingml')
|
||||
expect(() => normalizeDownload({ fileName: 'c', sha256: 'z' })).toThrow('下载内容不完整')
|
||||
expect(() => normalizeDownload({ dataBase64: 'QQ==', sha256: 'z' })).toThrow('下载内容不完整')
|
||||
expect(normalizeDownload({ fileName: 'c', dataBase64: 'QQ==', sha256: 'z' }).contentType).toBe('application/octet-stream')
|
||||
})
|
||||
it('base64 解码', () => {
|
||||
expect([...decodeBase64('QUJD')]).toEqual([65, 66, 67])
|
||||
})
|
||||
it('长文件名中间截断并保留扩展名', () => {
|
||||
const long = '研发项目设计说明书-第三轮审阅留痕-包含全部批注与修订记录-最终版本.docx'
|
||||
const short = truncateMiddle(long, 24)
|
||||
expect(short.length).toBeLessThanOrEqual(24)
|
||||
expect(short.endsWith('.docx')).toBe(true)
|
||||
expect(short).toContain('…')
|
||||
expect(truncateMiddle('a.md', 24)).toBe('a.md')
|
||||
})
|
||||
it('下载按钮:截断名 + title 全名', () => {
|
||||
const html = renderToStaticMarkup(createElement(DownloadButtons, {
|
||||
delivery: { ...page.items[0], deliveryId: 'd2' }, busyKind: null, onDownload: () => {},
|
||||
}))
|
||||
expect(html).toContain('批准稿 DOCX')
|
||||
expect(html).toContain('审阅留痕 DOCX')
|
||||
expect(html).not.toContain('evil')
|
||||
})
|
||||
it('时间格式化:非法时间为空串', () => {
|
||||
expect(formatRenderTime('not-a-date')).toBe('')
|
||||
expect(formatRenderTime('2026-10-01T00:00:00Z')).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则列表渲染', () => {
|
||||
it('显示来源、版本、状态与不可用原因;受控说明可见', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
|
||||
profiles: profiles(), selectedKey: 'document:doc@2', onSelect: () => {},
|
||||
}))
|
||||
for (const s of ['文档规则', '项目默认规则', '模板规则', '公司默认规则', 'v2', '已发布', '草稿', '资产校验未通过', '不会回退到项目或公司默认规则']) {
|
||||
expect(html).toContain(s)
|
||||
}
|
||||
expect(html).toContain('disabled')
|
||||
})
|
||||
it('refusal 以可读失败显示', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
|
||||
profiles: profiles({ options: [], refusal: '{"weird":true}' }), selectedKey: '', onSelect: () => {},
|
||||
}))
|
||||
expect(html).toContain('操作失败,请稍后重试')
|
||||
expect(html).toContain('原始回执')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
// 批注范围锚定(06 §2:nodeId + textStart + textEnd + textHash;10 §5「批注范围准确」)。
|
||||
// RDM 契约:偏移是冻结节点文本上的 Unicode 码点(Go rune = JS Array.from 下标)半开区间;
|
||||
// quote 与范围同发时须等于范围原文;回复不带范围;textHash/range 永不由前端发送。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
annotatePayload, codePointRange, describeRenderFailure, normalizeReviewView, partitionAnnotations, selectionAnchor,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import { AnnotationGroups, AnnotationScope } from '../src/components/DocumentRenderWorkspace'
|
||||
|
||||
describe('码点偏移(UTF-16 选区 → RDM rune 偏移)', () => {
|
||||
it('ASCII:半开区间,参数顺序无关,越界裁剪', () => {
|
||||
expect(codePointRange('Hello world', 6, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
expect(codePointRange('Hello world', 11, 6)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
expect(codePointRange('Hello world', 6, 99)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
})
|
||||
it('首尾空白不计入,纯空白或空选区不记录范围', () => {
|
||||
expect(codePointRange('Hello world', 5, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
expect(codePointRange('Hello world', 5, 6)).toBeUndefined()
|
||||
expect(codePointRange('Hello world', 3, 3)).toBeUndefined()
|
||||
})
|
||||
it('CJK:与契约示例一致("审阅正文 Review" 中 Review 为 5..11)', () => {
|
||||
expect(codePointRange('审阅正文 Review', 5, 11)).toEqual({ textStart: 5, textEnd: 11, quote: 'Review' })
|
||||
expect(codePointRange('项目内容、目标及意义', 5, 7)).toEqual({ textStart: 5, textEnd: 7, quote: '目标' })
|
||||
})
|
||||
it('emoji 代理对:UTF-16 下标换算为码点,后续字符不错位', () => {
|
||||
const text = '审阅👍正文 Review'
|
||||
expect(text.indexOf('Review')).toBe(7)
|
||||
expect(codePointRange(text, 7, 13)).toEqual({ textStart: 6, textEnd: 12, quote: 'Review' })
|
||||
expect(Array.from(text).slice(6, 12).join('')).toBe('Review')
|
||||
expect(codePointRange(text, 2, 4)).toEqual({ textStart: 2, textEnd: 3, quote: '👍' })
|
||||
})
|
||||
it('端点落在代理对中间时外扩为完整字符,绝不发出半个字符', () => {
|
||||
const text = '审阅👍正文'
|
||||
expect(codePointRange(text, 3, 5)).toEqual({ textStart: 2, textEnd: 4, quote: '👍正' })
|
||||
expect(codePointRange(text, 0, 3)).toEqual({ textStart: 0, textEnd: 3, quote: '审阅👍' })
|
||||
})
|
||||
it('ZWJ 组合 emoji 按码点(rune)计数而非字形', () => {
|
||||
const family = '👨\u200d👩\u200d👧'
|
||||
const text = `a${family}b`
|
||||
expect(codePointRange(text, 1, 1 + family.length)).toEqual({ textStart: 1, textEnd: 6, quote: family })
|
||||
})
|
||||
})
|
||||
|
||||
describe('阅读视图选区判定', () => {
|
||||
const hit = (nodeId: string, text: string, start: number, end: number) => ({ nodeId, text, start, end })
|
||||
it('无选区或折叠:不记录', () => {
|
||||
expect(selectionAnchor(null)).toEqual({ kind: 'none' })
|
||||
})
|
||||
it('落在单个节点正文内:记录该节点的码点范围', () => {
|
||||
expect(selectionAnchor([hit('dn-head', '项目内容、目标及意义', 5, 7)]))
|
||||
.toEqual({ kind: 'range', anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
|
||||
})
|
||||
it('只擦到下一节点起点(零长度交集)仍算单节点', () => {
|
||||
expect(selectionAnchor([hit('a', 'Alpha', 0, 5), hit('b', 'Beta', 0, 0)]))
|
||||
.toEqual({ kind: 'range', anchor: { nodeId: 'a', textStart: 0, textEnd: 5, quote: 'Alpha' } })
|
||||
})
|
||||
it('跨越多个节点:说明原因,不记录范围(提交批注整个节点)', () => {
|
||||
const r = selectionAnchor([hit('a', 'Alpha', 2, 5), hit('b', 'Beta', 0, 2)])
|
||||
expect(r.kind).toBe('multi-node')
|
||||
expect(r.kind === 'multi-node' && r.notice).toContain('跨越多个节点')
|
||||
expect(r.kind === 'multi-node' && r.notice).toContain('批注整个节点')
|
||||
})
|
||||
it('选区不含任何节点正文(或只有空白):说明原因,不记录范围', () => {
|
||||
expect(selectionAnchor([]).kind).toBe('outside-text')
|
||||
expect(selectionAnchor([hit('a', 'A b', 1, 2)]).kind).toBe('outside-text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdm.render.review.annotate 载荷', () => {
|
||||
const base = { projectId: 'p', submissionId: 'sub', treeNodeId: 'dn-head', text: ' 批 ', operationId: 'op' }
|
||||
const range = { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' }
|
||||
it('有选区:发送 textStart/textEnd/quote,绝不发送 textHash 或 range', () => {
|
||||
const p = annotatePayload({ ...base, range })
|
||||
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', textStart: 5, textEnd: 7, quote: '目标' })
|
||||
expect(p).not.toHaveProperty('textHash')
|
||||
expect(p).not.toHaveProperty('range')
|
||||
})
|
||||
it('无选区:整节点批注,不带任何范围参数', () => {
|
||||
for (const p of [annotatePayload(base), annotatePayload({ ...base, range: null })]) {
|
||||
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op' })
|
||||
}
|
||||
})
|
||||
it('回复:即使持有选区也不带范围(继承父批注锚点)', () => {
|
||||
const p = annotatePayload({ ...base, replyTo: 'a1', range })
|
||||
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', replyTo: 'a1' })
|
||||
for (const k of ['textStart', 'textEnd', 'quote', 'textHash', 'range']) expect(p).not.toHaveProperty(k)
|
||||
})
|
||||
it('范围不属于目标节点时不发送(绝不把 A 节点的偏移挂到 B 节点)', () => {
|
||||
expect(annotatePayload({ ...base, range: { ...range, nodeId: 'dn-other' } })).not.toHaveProperty('textStart')
|
||||
})
|
||||
})
|
||||
|
||||
describe('审阅视图:摘录与需重新锚定', () => {
|
||||
const person = { id: 'u1', displayName: '审核人' }
|
||||
const view = normalizeReviewView({
|
||||
annotations: [
|
||||
{ id: 'a1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '精确锚定', quote: '目标', needsReanchor: false,
|
||||
range: { start: 5, end: 7, textHash: 'h' }, replies: [{ id: 'r1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '回复', quote: '目标' }] },
|
||||
{ id: 'a2', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '漂移了', quote: '旧目标', needsReanchor: true, replies: [] },
|
||||
{ id: 'a3', nodeId: 'pp.sig', treeNodeId: 'dn-head', author: person, createdAt: '', text: '节点级', replies: [] },
|
||||
],
|
||||
})
|
||||
it('归一化保留 quote 与 needsReanchor;缺省不补造', () => {
|
||||
expect(view.annotations[0]).toMatchObject({ quote: '目标' })
|
||||
expect(view.annotations[0]).not.toHaveProperty('needsReanchor')
|
||||
expect(view.annotations[1]).toMatchObject({ quote: '旧目标', needsReanchor: true })
|
||||
expect(view.annotations[2]).not.toHaveProperty('quote')
|
||||
})
|
||||
it('批注项显示摘录;需重新锚定时有可见状态与说明', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['dn-head']))
|
||||
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '项目内容、目标及意义', onSelectNode: () => {} }))
|
||||
expect(html).toContain('<blockquote class="drw-anno-quote" title="目标">目标</blockquote>')
|
||||
expect(html).toContain('<blockquote class="drw-anno-quote" title="旧目标">旧目标</blockquote>')
|
||||
expect(html.match(/需重新锚定/g)).toHaveLength(1)
|
||||
expect(html).toContain('drw-anno needs-reanchor')
|
||||
expect(html).toContain('上方为旧摘录')
|
||||
expect(html.match(/drw-anno-quote/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('撰写区的批注范围', () => {
|
||||
const render = (props: Partial<Parameters<typeof AnnotationScope>[0]>) =>
|
||||
renderToStaticMarkup(createElement(AnnotationScope, { anchor: null, notice: '', replying: false, onClear: () => {}, ...props }))
|
||||
it('有选区:显示摘录、码点位置与改为整个节点', () => {
|
||||
const html = render({ anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
|
||||
expect(html).toContain('「目标」')
|
||||
expect(html).toContain('第 6–7 字')
|
||||
expect(html).toContain('改为整个节点')
|
||||
})
|
||||
it('无选区:明示整个节点', () => {
|
||||
expect(render({})).toContain('整个节点(在阅读视图中选中该节点的文字')
|
||||
})
|
||||
it('选区无效:显示说明', () => {
|
||||
expect(render({ notice: '所选文字跨越多个节点' })).toContain('role="status">所选文字跨越多个节点')
|
||||
})
|
||||
it('回复:沿用原批注范围,不显示选区', () => {
|
||||
const html = render({ replying: true, anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
|
||||
expect(html).toContain('回复沿用原批注的范围')
|
||||
expect(html).not.toContain('目标')
|
||||
})
|
||||
})
|
||||
|
||||
describe('review_anchor_invalid: 转为中文可读文案(原始回执保留)', () => {
|
||||
const cases: [string, string][] = [
|
||||
['review_anchor_invalid: node pp.sig (image) has no frozen text a range can anchor; address a paragraph, heading, list item or table cell', '没有可按范围批注的正文'],
|
||||
['review_anchor_invalid: quote does not match the frozen text of node pp.goal at 5..7', '与提交审阅时冻结的正文不一致'],
|
||||
['review_anchor_invalid: quote occurs more than once in node pp.goal; supply textStart and textEnd', '出现多次'],
|
||||
['review_anchor_invalid: quote is not in the frozen text of node pp.goal', '不在该节点冻结的正文中'],
|
||||
['review_anchor_invalid: textStart/textEnd 5..99 out of range for node pp.goal (10 characters)', '超出该节点冻结正文的长度'],
|
||||
['review_anchor_invalid: reply must address the parent\'s node pp.goal; replies inherit the parent\'s anchor', '回复只能针对原批注所在的节点'],
|
||||
['textStart and textEnd must be supplied together', '起止位置须成对提供'],
|
||||
['review_anchor_invalid: something new', '批注范围无效'],
|
||||
]
|
||||
it.each(cases)('%s', (msg, expected) => {
|
||||
for (const raw of [msg, `400: ${JSON.stringify({ error: msg })}`]) {
|
||||
const f = describeRenderFailure(new Error(raw))
|
||||
expect(f.message).toContain(expected)
|
||||
expect(f.message).not.toContain('review_anchor_invalid')
|
||||
expect(f.raw).toBe(raw)
|
||||
}
|
||||
})
|
||||
it('非范围错误不受影响', () => {
|
||||
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user