Why: Running §11 against RDM d63f1db showed three Frontend defects. A validated template rule with a server blockedReason was disabled for preview, although the server only restricts formal delivery. A reviewer opening the document in another session could not find the author's submission until an export existed. Template-instance blocks without a title (cover, sign-off table, fields) rendered as blank canvas nodes and reader headings. Evidence: npx vitest run (35 files, 363 tests passed); npm run build ok; node scripts/check-knip.mjs (43 <= 43); browser run under /tmp/renderer-browser-acceptance/20261016T064052Z (item1 172 nodes, preview generated with the validated template rule). Impact: Preview is offered for validated/draft rules while formal submit stays blocked; the current submission comes from rdm.review.doc.status; untitled nodes show "<kind>(<templateBlockId>)". Refs: unified renderer plan Task 13 §11 Co-authored-by: Cursor <cursoragent@cursor.com>
718 lines
44 KiB
TypeScript
718 lines
44 KiB
TypeScript
// 统一渲染 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,
|
||
nextOperation, createRequestGuard, renderScope,
|
||
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
|
||
nodeFacts, hasFacts,
|
||
normalizeReviewView, partitionAnnotations, annotatePayload, revisePayload, validateReviewText,
|
||
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, DownloadButtons, RenderFailure, RenderReceipt,
|
||
} from '../src/components/DocumentRenderWorkspace'
|
||
|
||
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 })
|
||
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('操作编号:每次动作新编号,重试复用', () => {
|
||
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', nodeId: 's1', text: ' 好 ', operationId: 'op_1' }))
|
||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '好', operationId: 'op_1' })
|
||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', nodeId: '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', nodeId: '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', nodeId: '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('交付记录与下载', () => {
|
||
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('原始回执')
|
||
})
|
||
})
|