Add the unified document render workspace with review and range annotations #106

Merged
weichen merged 25 commits from feature/unified-document-renderer-task11 into main 2026-10-18 12:39:24 +00:00
21 changed files with 6062 additions and 116 deletions
+775
View File
@@ -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()
}
}
})
+1 -1
View File
@@ -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
View File
@@ -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 }
+125
View File
@@ -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
+84
View File
@@ -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')
})
})
+182 -105
View File
@@ -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 && (
+33
View File
@@ -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
}
+124
View File
@@ -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
+121
View File
@@ -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)
})
})
+184
View File
@@ -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
}
+42
View File
@@ -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)
})
})
+59
View File
@@ -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-')
}
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
}
+250
View File
@@ -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('项目已关闭')
})
})
+267
View File
@@ -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({})
})
})
+916
View File
@@ -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('原始回执')
})
})
+167
View File
@@ -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('当前编制人不是你')
})
})