diff --git a/e2e/document-renderer.playwright.ts b/e2e/document-renderer.playwright.ts index 75c7730..b8b4873 100644 --- a/e2e/document-renderer.playwright.ts +++ b/e2e/document-renderer.playwright.ts @@ -101,11 +101,11 @@ test.describe.serial('统一渲染 Task 11 文档阅读与交付', () => { .toBeVisible({ timeout: 10_000 }) await expectReadableFailures(page) for (const option of await rules.locator('.drw-profile').all()) { - await expect(option.locator('.drw-profile-source')).toHaveText(/文档规则|项目规则|模板规则|公司默认规则/) + await expect(option.locator('.drw-profile-source')).toHaveText(/^(文档规则|文档类型规则|模板规则|项目默认规则|公司默认规则)$/) await expect(option.locator('.drw-profile-ver')).toHaveText(/^v.+/) if (await option.locator('input[disabled]').count()) await expect(option.locator('.drw-profile-reason')).not.toBeEmpty() } - if (await rules.locator('.drw-notice').count()) await expect(rules.locator('.drw-notice')).toContainText('不会回退到公司默认规则') + if (await rules.locator('.drw-notice').count()) await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则') }) test('生成预览:状态与诊断可见,点击诊断选中对应节点', async ({ page }) => { @@ -116,18 +116,38 @@ test.describe.serial('统一渲染 Task 11 文档阅读与交付', () => { test.skip(await generate.isDisabled(), '无可预览规则') await generate.click() const preview = panel(page, '预览与提交') - await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-failure')).first()).toBeVisible({ timeout: 30_000 }) + await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 30_000 }) await expectReadableFailures(page) - if (await preview.locator('.drw-failure').count()) { + if (await preview.locator('.drw-op .drw-failure').count()) { await expect(preview.getByRole('button', { name: '重试' })).toBeVisible() return } - const located = preview.locator('button.drw-diag-node').first() - if (await located.count()) { - const title = await located.getAttribute('title') + const receipt = preview.locator('.drw-receipt') + await expect(receipt.locator('.ui-chip').first()).toHaveText(/^(已生成|生成中|排队中|生成失败|已取消|状态未知)$/) + // 诊断不在生成回执里:必须另行下载 diagnostics 后才出现(读取中 → 列表 / 无问题 / 可读失败)。 + await expect(receipt.getByText('诊断读取中')).toHaveCount(0, { timeout: 30_000 }) + await expect(receipt.locator('.drw-diags').or(receipt.getByText('没有诊断问题')).or(receipt.locator('.drw-failure')).first()).toBeVisible() + await expectReadableFailures(page) + // 错误/警告定位到节点:每个锚点都对应画布上带计数徽标的同名节点(提示级不计徽标)。 + const counted = receipt.locator('.drw-diag.sev-error button.drw-diag-node, .drw-diag.sev-warning button.drw-diag-node') + const countedTotal = await counted.count() + for (let i = 0; i < Math.min(countedTotal, 5); i++) { + const title = (await counted.nth(i).getAttribute('title')) ?? '' + expect(title).not.toBe('') + await expect(page.locator(`.drw-canvas-node[title="${title.replace(/"/g, '\\"')}"] .drw-badge`).first()).toBeVisible() + } + const anchors = receipt.locator('button.drw-diag-node') + if (await anchors.count()) { + const located = countedTotal ? counted.first() : anchors.first() + const title = (await located.getAttribute('title')) ?? '' await located.click() - await expect(page.locator('.drw-canvas-node[aria-selected="true"], .drw-reader-title[aria-current="true"]').first()) - .toContainText(title ?? '') + await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', title) + await page.getByRole('tab', { name: '阅读视图' }).click() + const section = page.locator('section.drw-reader-node.sel') + await expect(section).toHaveAttribute('id', /^drw-node-/) + await expect(section.locator('.drw-reader-title')).toHaveText(title) + if (countedTotal) await expect(section.locator('.drw-badge').first()).toBeVisible() + await expect(section.locator('a.drw-anchor')).toHaveAttribute('href', `#${await section.getAttribute('id')}`) } await expect(page.locator('.drw-delivery').first()).toBeVisible({ timeout: 15_000 }) }) diff --git a/src/components/DocumentRenderWorkspace.tsx b/src/components/DocumentRenderWorkspace.tsx index 10d851d..163a779 100644 --- a/src/components/DocumentRenderWorkspace.tsx +++ b/src/components/DocumentRenderWorkspace.tsx @@ -6,14 +6,14 @@ import { Btn, Chip, Collapsible, SectionLabel } from '@ui' import { api } from '../api' import { store, useStore } from '../store' import { - DELIVERY_MODE_LABELS, MIND_MAP, REVISION_KIND_LABELS, + DELIVERY_MODE_LABELS, DOC_TREE_LIMITS, MIND_MAP, REVISION_KIND_LABELS, annotatePayload, buildDocTree, controlledNotice, createRequestGuard, decodeBase64, defaultProfileKey, describeRenderFailure, diagnosticCountsByNode, diagnosticsSummaryText, downloadOptions, formatRenderTime, - groupDiagnostics, hasFacts, latestSubmissionId, layoutMindMap, mergeDeliveries, moveSelection, nextOperation, - nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDiagnostics, normalizeDownload, normalizeProfiles, - normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, renderScope, revisePayload, - truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, - type DocFilter, type DocTree, type DocTreeItem, type DownloadKind, type NodeFacts, type OperationKind, + groupDiagnostics, hasFacts, latestSubmissionId, layoutMindMap, loadDeliveryDiagnostics, mergeDeliveries, moveSelection, + nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, + normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope, + revisePayload, submissionIdOf, truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, walkDocTree, + type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind, type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type ReviewAnnotation, type ReviewRevision, } from '../documentRenderPresentation' @@ -134,6 +134,34 @@ export function DiagnosticsList({ diagnostics, nodeTitle, onSelectNode, idPrefix ) } +type ReceiptDiagnostics = { status: 'loading' } | { status: 'ready'; items: RenderDiagnostic[] } | { status: 'error'; error: string } + +export function RenderReceipt({ receipt, diagnostics, nodeTitle, onSelectNode }: { + receipt: Record + diagnostics: ReceiptDiagnostics + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void +}) { + const state = receiptStateView(receipt.state) + const items = diagnostics.status === 'ready' ? diagnostics.items : [] + return ( +
+
+ {state.label} + {typeof receipt.fileName === 'string' && receipt.fileName && } +
+ {diagnostics.status === 'loading' &&
诊断读取中
} + {diagnostics.status === 'error' && } + {diagnostics.status === 'ready' && ( + <> +
{diagnosticsSummaryText({ errors: items.filter(d => d.severity === 'error').length, warnings: items.filter(d => d.severity === 'warning').length })}
+ + + )} +
+ ) +} + function Byline({ name, at }: { name: string; at: string }) { const time = formatRenderTime(at) return ( @@ -260,7 +288,7 @@ function NodeFactsView({ facts }: { facts: NodeFacts }) { const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)) /** 单一资源加载:requestKey 变化或 reload 发起新请求,旧请求的响应一律丢弃。 */ -function useGuardedResource(requestKey: string | null, identity: number, load: () => Promise) { +function useGuardedResource(requestKey: string | null, identity: number, load: (live: () => boolean) => Promise) { const guard = useRef(createRequestGuard()) const loadRef = useRef(load) loadRef.current = load @@ -272,7 +300,7 @@ function useGuardedResource(requestKey: string | null, identity: number, load const ticket = g.begin(requestKey) const live = () => g.current(ticket, requestKey) && identity === store.getIdentityEpoch() setState(s => (s.key === requestKey ? { ...s, error: '' } : { key: requestKey, data: null, error: '' })) - loadRef.current().then( + loadRef.current(live).then( data => { if (live()) setState({ key: requestKey, data, error: '' }) }, e => { if (live()) setState(s => ({ key: requestKey, data: s.key === requestKey ? s.data : null, error: errText(e) })) }, ) @@ -316,19 +344,30 @@ function useOperationRunner(identity: number) { } } -const TREE_PAGE_LIMIT = 20 +/** walkDocTree 只在守卫失效时返回 null;转为拒绝后同样被守卫丢弃,绝不写入空树。 */ +async function loadDocTree(projectId: string, docId: string, live: () => boolean) { + const out = await walkDocTree( + (parent, cursor) => api.rdmCommand('rdm.inspect.tree', { projectId, kind: 'docs', parent, cursor }), docId, live) + if (!out) throw new Error('stale document tree') + return out +} -async function loadDocSections(projectId: string, docId: string): Promise { - const items: DocTreeItem[] = [] - let cursor = '' - for (let page = 0; page < TREE_PAGE_LIMIT; page++) { - const shell = await api.rdmCommand<{ items?: DocTreeItem[]; nextCursor?: string; hasMore?: boolean }>( - 'rdm.inspect.tree', { projectId, kind: 'docs', parent: docId, cursor }) - items.push(...(shell.items ?? []).filter(i => i && typeof i.id === 'string' && typeof i.title === 'string')) - if (!shell.hasMore || !shell.nextCursor) break - cursor = shell.nextCursor +/** 预览/导出回执的诊断:按 deliveryId 另取 diagnostics 交付物;同一守卫串行化,过期即丢弃。 */ +function useReceiptDiagnostics(projectId: string, identity: number) { + const guard = useRef(createRequestGuard()) + const [state, setState] = useState(null) + useEffect(() => { const g = guard.current; return () => g.cancel() }, []) + const load = (receipt: unknown) => { + const scope = String(identity) + const ticket = guard.current.begin(scope) + const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch() + setState({ status: 'loading' }) + loadDeliveryDiagnostics((command, params) => api.rdmCommand(command, params), projectId, receipt, live).then(out => { + if (!out || !live()) return + setState('diagnostics' in out ? { status: 'ready', items: out.diagnostics } : { status: 'error', error: out.error }) + }) } - return items + return { state, load } } /** 节点事实缓存:按节点 id 归档,只写回发起请求的那个节点;组件按作用域重挂载即整体失效。 */ @@ -625,15 +664,16 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works const [replyTo, setReplyTo] = useState(null) const [receipt, setReceipt] = useState | null>(null) - const sections = useGuardedResource(`${scope}:tree`, identity, () => loadDocSections(projectId, docId)) + const sections = useGuardedResource(`${scope}:tree`, identity, live => loadDocTree(projectId, docId, live)) const profiles = useGuardedResource(caps.ready ? `${scope}:profiles` : null, identity, () => api.rdmCommand('rdm.render.profiles', { projectId, docId }).then(normalizeProfiles)) const deliveries = useDeliveries(projectId, docId, identity, caps.ready) const renderOp = useOperationRunner(identity) const reviewOp = useOperationRunner(identity) const downloader = useDownloader(projectId, identity) + const receiptDiagnostics = useReceiptDiagnostics(projectId, identity) - const tree = useMemo(() => (sections.data ? buildDocTree({ id: docId, title: docTitle }, sections.data) : null), [sections.data, docId, docTitle]) + const tree = useMemo(() => (sections.data ? buildDocTree({ id: docId, title: docTitle }, sections.data.items) : null), [sections.data, docId, docTitle]) const facts = useNodeFacts(projectId, identity, tree, selectedId) const submissionId = submissionChoice ?? latestSubmissionId(deliveries.items) ?? null @@ -645,7 +685,8 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works const selectedProfileKey = profileKeyChoice ?? (profileData ? defaultProfileKey(profileData) : '') const selectedProfile = views.find(v => v.key === selectedProfileKey) - const diagnostics = useMemo(() => normalizeDiagnostics(receipt?.diagnostics), [receipt]) + const diagState = receiptDiagnostics.state + const diagnostics = useMemo(() => (diagState?.status === 'ready' ? diagState.items : []), [diagState]) const diagCounts = useMemo(() => diagnosticCountsByNode(diagnostics), [diagnostics]) const knownIds = useMemo(() => new Set(tree ? tree.order : []), [tree]) @@ -678,27 +719,29 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works } const profileParams = () => selectedProfile && { profileId: selectedProfile.option.profileId, profileVersion: selectedProfile.option.version } + const acceptReceipt = (r: unknown) => { + setReceipt(r && typeof r === 'object' ? r as Record : {}) + receiptDiagnostics.load(r) + deliveries.reload() + } const generatePreview = () => { const p = profileParams() if (!p) return - renderOp.run('preview', operationId => api.rdmCommand('rdm.render.preview', { projectId, docId, operationId, ...p }), r => { - setReceipt(r && typeof r === 'object' ? r as Record : {}) - deliveries.reload() - }) + renderOp.run('preview', operationId => api.rdmCommand('rdm.render.preview', { projectId, docId, operationId, ...p }), acceptReceipt) } const submitReview = () => { const p = profileParams() if (!p || !selectedProfile?.formalAllowed) return renderOp.run('submit', operationId => api.rdmCommand('rdm.render.submit', { projectId, docId, operationId, ...p }), r => { - const id = (r as { id?: unknown })?.id - if (typeof id === 'string' && id) setSubmissionChoice(id) + const id = submissionIdOf(r) + if (id) setSubmissionChoice(id) deliveries.reload() }) } const exportFor = (kind: 'review-export' | 'approved-export') => { if (!submissionId) return const command = kind === 'review-export' ? 'rdm.render.review.export' : 'rdm.render.approved.export' - renderOp.run(kind, operationId => api.rdmCommand(command, { projectId, docId, operationId, submissionId }), () => deliveries.reload()) + renderOp.run(kind, operationId => api.rdmCommand(command, { projectId, docId, operationId, submissionId }), acceptReceipt) } const selectedNode = tree && selectedId ? tree.nodes[selectedId] : undefined const reviewTarget = selectedNode && selectedNode.kind !== 'doc' ? selectedNode : undefined @@ -741,6 +784,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works {sections.error ? : !tree ?
加载中…
: ( <> + {sections.data?.truncated &&
结构过大,已截断:仅显示前 {tree.order.length - 1} 个节点(最多 {DOC_TREE_LIMITS.maxDepth} 层)
} {tree.order.length === 1 &&
该文档暂无章节
} {order.length <= 1 && tree.order.length > 1 &&
没有符合过滤条件的节点
} {view === 'canvas' @@ -787,16 +831,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works {renderOp.state.status === 'done' && renderOp.state.op && renderOp.state.op.kind !== 'preview' && (
{OP_LABELS[renderOp.state.op.kind]}已完成
)} - {receipt && ( -
-
- {String(receipt.state || '已生成')} - {typeof receipt.fileName === 'string' && receipt.fileName && } -
-
{diagnosticsSummaryText({ errors: diagnostics.filter(d => d.severity === 'error').length, warnings: diagnostics.filter(d => d.severity === 'warning').length })}
- -
- )} + {receipt && diagState && } {caps.review && ( diff --git a/src/documentRenderPresentation.ts b/src/documentRenderPresentation.ts index a01aa89..ed3dcc6 100644 --- a/src/documentRenderPresentation.ts +++ b/src/documentRenderPresentation.ts @@ -93,18 +93,22 @@ export function describeRenderFailure(err: unknown): RenderFailureView { return { message: readable(body), raw } } -// ---- 规则选择(四级来源) ---- +// ---- 规则选择(五级来源;与服务端 RenderProfileOptionsFor 的发出顺序一致) ---- -const PROFILE_SOURCES = ['document', 'project', 'template', 'organization-default'] as const +/** 服务端解析优先级:文档 > 文档类型 > 项目默认 > 公司默认;模板规则只作为受控文档的显式选择。 */ +const PROFILE_SOURCES = ['document', 'document-type', 'template', 'project-default', 'organization-default'] as const type ProfileSource = (typeof PROFILE_SOURCES)[number] const PROFILE_SOURCE_LABELS: Record = { document: '文档规则', - project: '项目规则', + 'document-type': '文档类型规则', template: '模板规则', + 'project-default': '项目默认规则', 'organization-default': '公司默认规则', } +const FALLBACK_SOURCES: readonly ProfileSource[] = ['project-default', 'organization-default'] + const PROFILE_STATE_LABELS: Record = { published: '已发布', draft: '草稿', blocked: '已停用', retired: '已废弃', deprecated: '已废弃', } @@ -154,7 +158,9 @@ export function normalizeProfiles(raw: unknown): RenderProfiles { const sid = str(s?.profileId) const sver = versionOf(s?.version) if (s && isSource(s.source) && sid && sver) out.selected = { source: s.source, profileId: sid, version: sver, hash: str(s.hash) ?? '' } - if (str(r?.refusal)) out.refusal = r!.refusal + const refusal = obj(r?.refusal) + const refusalText = str(refusal?.message) ?? str(refusal?.code) + if (refusalText) out.refusal = refusalText return out } @@ -185,8 +191,8 @@ export function profileOptionViews(p: RenderProfiles): ProfileOptionView[] { if (option.state === 'blocked' || option.blockedReason) { return { ...base, previewAllowed: false, formalAllowed: false, reason: displayText(option.blockedReason) ?? '该规则已停用,不能用于生成' } } - if (option.source === 'organization-default' && (option.controlled || docControlled) && !option.fallbackAllowed) { - return { ...base, previewAllowed: false, formalAllowed: false, reason: '受控文档不回退到公司默认规则,请使用文档、项目或模板中明确指定的规则' } + if (FALLBACK_SOURCES.includes(option.source) && (option.controlled || docControlled) && !option.fallbackAllowed) { + return { ...base, previewAllowed: false, formalAllowed: false, reason: '受控文档不回退到项目或公司默认规则,请使用文档、文档类型或模板中明确指定的规则' } } if (option.state === 'draft') { return { ...base, previewAllowed: true, formalAllowed: false, reason: '草稿规则只能用于预览,不能用于正式审阅或批准交付' } @@ -200,7 +206,7 @@ export function profileOptionViews(p: RenderProfiles): ProfileOptionView[] { export function controlledNotice(p: RenderProfiles): string | undefined { if (!p.options.some(o => o.controlled)) return undefined - return '受控文档:只使用文档、项目或模板中明确指定的规则,不会回退到公司默认规则' + return '受控文档:只使用文档、文档类型或模板中明确指定的规则,不会回退到项目或公司默认规则' } export function defaultProfileKey(p: RenderProfiles): string { @@ -273,6 +279,47 @@ export function diagnosticCountsByNode(list: RenderDiagnostic[]): Record = { + ready: { label: '已生成', tone: 'ok' }, + running: { label: '生成中', tone: 'info' }, + frozen: { label: '排队中', tone: 'plain' }, + 'generation-failed': { label: '生成失败', tone: 'danger' }, + cancelled: { label: '已取消', tone: 'warn' }, +} + +export function receiptStateView(state: unknown): { label: string; tone: ChipTone } { + return (typeof state === 'string' && RECEIPT_STATES[state]) || { label: '状态未知', tone: 'plain' } +} + +type RdmCall = (command: string, params: Record) => Promise +type DiagnosticsLoad = { deliveryId: string; diagnostics: RenderDiagnostic[] } | { deliveryId: string; error: string } + +/** 生成回执不带诊断;按 deliveryId 另取 diagnostics 交付物。守卫失效(过期)返回 null,失败返回可读文案而不抛出。 */ +export async function loadDeliveryDiagnostics(call: RdmCall, projectId: string, receipt: unknown, live: () => boolean): Promise { + const deliveryId = str(obj(receipt)?.deliveryId) + if (!deliveryId) return { deliveryId: '', error: '回执缺少交付编号,无法读取诊断' } + let raw: unknown + try { + raw = await call('rdm.render.download', { projectId, deliveryId, kind: 'diagnostics' }) + } catch (e) { + return live() ? { deliveryId, error: describeRenderFailure(e).message } : null + } + if (!live()) return null + let data: string + try { + data = normalizeDownload(raw).dataBase64 + } catch (e) { + return { deliveryId, error: describeRenderFailure(e).message } + } + try { + return { deliveryId, diagnostics: normalizeDiagnostics(JSON.parse(new TextDecoder('utf-8').decode(decodeBase64(data)))) } + } catch { + return { deliveryId, error: '诊断报告无法解析' } + } +} + // ---- 操作编号与请求守卫(异步状态唯一所有者 + 过期响应丢弃) ---- export type OperationKind = 'preview' | 'submit' | 'review-export' | 'approved-export' | 'annotate' | 'revise' @@ -312,7 +359,7 @@ export function renderScope(identity: number, projectId: string, docId: string): // ---- 文档树 ---- -export interface DocTreeItem { +interface DocTreeItem { kind: string id: string title: string @@ -321,6 +368,85 @@ export interface DocTreeItem { depth?: number parentId?: string parent?: string + hasChildren?: boolean +} + +export const DOC_TREE_LIMITS = { maxDepth: 12, maxNodes: 2000, concurrency: 4, maxPages: 20 } as const +type DocTreeLimits = { [K in keyof typeof DOC_TREE_LIMITS]: number } +interface DocTreeShell { items?: unknown[]; nextCursor?: string; hasMore?: boolean } + +function treeItemOf(raw: unknown, parent: string): DocTreeItem | undefined { + const o = obj(raw) + if (!o || !str(o.id) || typeof o.title !== 'string') return undefined + return { + kind: str(o.kind) ?? 'section', id: o.id, title: o.title, parentId: parent, + ...(str(o.state) ? { state: o.state } : {}), + ...(typeof o.hasChildren === 'boolean' ? { hasChildren: o.hasChildren } : {}), + } +} + +/** + * rdm.inspect.tree 每次只返回 parent 的直接子节点:逐层广度展开(有界深度/节点数/并发), + * 再按文档顺序(先序)输出,父节点总在子节点之前。live() 失效即停止发请求并返回 null。 + */ +export async function walkDocTree( + fetchLevel: (parent: string, cursor: string) => Promise, + docId: string, + live: () => boolean, + limits: DocTreeLimits = DOC_TREE_LIMITS, +): Promise<{ items: DocTreeItem[]; truncated: boolean } | null> { + const seen = new Set([docId]) + const children = new Map() + let count = 0 + let truncated = false + let stale = false + let level = [docId] + for (let depth = 0; level.length > 0; depth++) { + const fetched = new Map() + const readParent = async (parent: string) => { + const found: DocTreeItem[] = [] + let cursor = '' + for (let page = 0; ; page++) { + if (page >= limits.maxPages) { truncated = true; break } + if (!live()) { stale = true; return } + const shell = await fetchLevel(parent, cursor) + if (!live()) { stale = true; return } + for (const raw of arr(shell?.items)) { + const item = treeItemOf(raw, parent) + if (item) found.push(item) + } + if (!shell?.hasMore || !shell.nextCursor) break + cursor = shell.nextCursor + } + fetched.set(parent, found) + } + let cursorIdx = 0 + const current = level + const worker = async () => { while (!stale && cursorIdx < current.length) await readParent(current[cursorIdx++]) } + await Promise.all(Array.from({ length: Math.min(limits.concurrency, current.length) }, worker)) + if (stale) return null + const next: string[] = [] + for (const parent of current) { + const accepted: DocTreeItem[] = [] + for (const item of fetched.get(parent) ?? []) { + if (seen.has(item.id)) continue + if (count >= limits.maxNodes) { truncated = true; break } + seen.add(item.id) + count++ + accepted.push(item) + if (item.hasChildren === false) continue + if (depth + 1 < limits.maxDepth) next.push(item.id) + else if (item.hasChildren) truncated = true + } + children.set(parent, accepted) + } + if (count >= limits.maxNodes && next.length > 0) { truncated = true; break } + level = next + } + const items: DocTreeItem[] = [] + const emit = (parent: string) => { for (const item of children.get(parent) ?? []) { items.push(item); emit(item.id) } } + emit(docId) + return { items, truncated } } export interface DocNode { @@ -723,6 +849,11 @@ export function latestSubmissionId(list: RenderDelivery[]): string | undefined { .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))[0]?.submissionId } +/** rdm.render.submit 回执即 DocSubmissionRec,提交编号字段名为 submissionId。 */ +export function submissionIdOf(receipt: unknown): string | undefined { + return str(obj(receipt)?.submissionId) +} + export const DELIVERY_MODE_LABELS: Record = { preview: '预览', review: '审阅', approved: '批准' } const DOWNLOAD_ORDER = ['markdown', 'docx', 'review-docx', 'manifest', 'diagnostics'] as const diff --git a/test/document-render-workspace.test.ts b/test/document-render-workspace.test.ts index 21dde8f..0d94a47 100644 --- a/test/document-render-workspace.test.ts +++ b/test/document-render-workspace.test.ts @@ -14,10 +14,11 @@ import { normalizeReviewView, partitionAnnotations, annotatePayload, revisePayload, validateReviewText, normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64, truncateMiddle, latestSubmissionId, formatRenderTime, + submissionIdOf, loadDeliveryDiagnostics, receiptStateView, walkDocTree, DOC_TREE_LIMITS, type RenderProfiles, type DocTree, } from '../src/documentRenderPresentation' import { - ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderFailure, + ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderFailure, RenderReceipt, } from '../src/components/DocumentRenderWorkspace' const cmd = (id: string, enabled = true) => ({ id, enabled }) @@ -44,36 +45,36 @@ const profiles = (over: Partial = {}): RenderProfiles => ({ { 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: '资产校验未通过' }, + { source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: true, fallbackAllowed: false, blockedReason: '资产校验未通过' }, ], ...over, }) -describe('规则选择(四级来源)', () => { - it('按 文档 > 项目 > 模板 > 公司默认 排序并给出来源/版本/状态', () => { +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.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 [, blocked, draft] = profileOptionViews(profiles()) + 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('受控文档不回退到公司默认,并给出明确说明', () => { + it('受控文档不回退到项目或公司默认,并给出明确说明', () => { const org = profileOptionViews(profiles())[3] expect(org).toMatchObject({ previewAllowed: false, formalAllowed: false }) - expect(org.reason).toContain('不回退到公司默认') - expect(controlledNotice(profiles())).toContain('不会回退到公司默认规则') + 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 }] }) + 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;否则第一个可预览项', () => { @@ -82,15 +83,262 @@ describe('规则选择(四级来源)', () => { 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: '无权读取规则' }) + 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).toBe('无权读取规则') + 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('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() + }) +}) + +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][] = [] + 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[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([[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']) + }) +}) + describe('拒绝与失败转为可读文案(原始回执另行折叠)', () => { it('非 2xx JSON 回执取 error 文本,不直出 JSON', () => { const f = describeRenderFailure(new Error('400: {"receipt":{"state":"rejected","error":"规则未发布,不能用于正式交付"}}')) @@ -417,7 +665,7 @@ describe('规则列表渲染', () => { const html = renderToStaticMarkup(createElement(ProfileOptionList, { profiles: profiles(), selectedKey: 'document:doc@2', onSelect: () => {}, })) - for (const s of ['文档规则', '项目规则', '模板规则', '公司默认规则', 'v2', '已发布', '草稿', '资产校验未通过', '不会回退到公司默认规则']) { + for (const s of ['文档规则', '项目默认规则', '模板规则', '公司默认规则', 'v2', '已发布', '草稿', '资产校验未通过', '不会回退到项目或公司默认规则']) { expect(html).toContain(s) } expect(html).toContain('disabled')