Files
Agent-System-Frontend/test/document-render-workspace.test.ts
T
weichenandCursor 12553f53eb Locate review items and render trace by tree node id and mask audit-only trace
Why: RDM be80ad4 (docs/design/render-node-id-contract.md) accepts workspace tree
node ids (dn-/sec-/legacy-) for review annotate/revise, adds treeNodeId to receipts,
rdm.render.review.view items and trace entries while nodeId stays canonical, points
anchored-to at the tree node id, and marks audit-reader trace entries with
contentMasked=true. Grouping review items by the canonical nodeId left them all
unlocated, and the workspace showed no render trace at all.

Evidence: npx tsc --noEmit exit 0; npx vitest run 39 files / 456 tests passed (12 new
in test/document-render-workspace.test.ts: treeNodeId grouping with nodeId fallback
and orphan rules for annotations and revisions, data-locate targets, annotate/revise
payload carrying the selected tree node id, layer=render filtering, anchored-to
against tree nodes without canonical fallback, masked entries never reading body
fields, empty state); npm run build ok; node scripts/check-knip.mjs 51 <= 51;
e2e/document-renderer.playwright.ts type-checks (tsc exit 0) and
npx playwright test --list loads 13 tests.

Impact: locateNodeId (treeNodeId first, nodeId fallback, otherwise unlocated) is
shared by diagnostics, review grouping and trace. Clicking an annotation or revision
group, a reply, or a trace node locates and re-scrolls like diagnostics. Annotate and
revise send the selected tree node id as nodeId. A 渲染链条 panel reads
rdm.inspect.relations (kind=doc) through the single guarded resource owner (stale
responses dropped) and reloads after preview, submit, export, review and decision
actions. Masked entries show metadata only with a 内容已遮蔽 marker and a panel
notice. The e2e spec is unchanged: §11-5b already asserts that the group title is the
selected tree node, and the leak scan is untouched. Not run against a live backend
or browser here.

Refs: RDM be80ad4 docs/design/render-node-id-contract.md; unified renderer spec 10
§11 items 4, 5 and 7; DESIGN.md §8, §9.2, §11.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 00:34:04 +00:00

917 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一渲染 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('原始回执')
})
})