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>
433 lines
25 KiB
TypeScript
433 lines
25 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,
|
||
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('原始回执')
|
||
})
|
||
})
|