Files
Agent-System-Frontend/test/document-render-workspace.test.ts
T
weichenandCursor 85428b4633 Add document render workspace with profiles, preview, review and downloads
Why: unified renderer Task 11 needs a reader/delivery UI over the RDM
document tree: four-level rule selection with controlled-document
no-fallback explanation, preview with node-linked diagnostics, a
structure canvas and a linear reader sharing one selection and filter,
review annotations/revisions, and per-delivery downloads.

Evidence: npx vitest run -> 35 files, 338 tests passed (45 new in
test/document-render-workspace.test.ts, design-guard green);
npm run build -> tsc -b + vite build ok; npx knip -> findings identical
to the pre-existing baseline (check-knip 43 <= 43); git diff --check
clean. e2e/document-renderer.playwright.ts authored, not executed.

Impact: new src/documentRenderPresentation.ts (pure helpers) and
src/components/DocumentRenderWorkspace.tsx, opened from a per-document
"阅读与交付" action in the audit materials tree; store gains an
rdmRender capability probe (fail-closed); audit export reuses the shared
base64 file saver; token-only styles appended to styles.css. Async
state is owned per hook with request guards plus identity epoch, and
the workspace remounts per identity/project/document scope.

Refs: unified renderer plan Task 11
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 23:44:39 +00:00

433 lines
25 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,
nextOperation, createRequestGuard, renderScope,
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
nodeFacts, hasFacts,
normalizeReviewView, partitionAnnotations, annotatePayload, revisePayload, validateReviewText,
normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64,
truncateMiddle, latestSubmissionId, formatRenderTime,
type RenderProfiles, type DocTree,
} from '../src/documentRenderPresentation'
import {
ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderFailure,
} 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', 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', 'project', 'template', '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 [, blocked, draft] = 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', 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('归一化丢弃不完整或未知来源的选项,refusal 保留', () => {
const p = normalizeProfiles({ options: [{ source: 'galaxy', profileId: 'x', version: '1' }, { source: 'document', profileId: 'd' }, { source: 'document', profileId: 'd', version: 2, state: 'published' }], refusal: '无权读取规则' })
expect(p.options).toHaveLength(1)
expect(p.options[0]).toMatchObject({ profileId: 'd', version: '2', publicationAllowed: false, controlled: false, fallbackAllowed: false })
expect(p.refusal).toBe('无权读取规则')
expect(normalizeProfiles(null).options).toEqual([])
})
})
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('原始回执')
})
})