Files
Agent-System-Frontend/test/document-render-actions.test.ts
T
weichenandCursor 7f3538da22 Add review decisions, rule binding and package download to the render workspace
Why: Task 11 requires the document render workspace to offer approve, reject and final decisions, rule bind/unbind for document, document type and project default, and the full delivery package, so reviewers and settings holders no longer need other screens.

Evidence: vitest 38 files / 419 tests passed (new test/document-render-actions.test.ts covers decision entries from frozen duties, decision payloads without identity fields, package availability, refusal codes, binding candidates and payloads, perm gating and SSR visibility); tsc --noEmit clean; knip unused exports 51 <= baseline 51.

Impact: decision entries show only when the command is enabled and the frozen submission lists the session user; binding needs rdm.perm.check project.settings allowed=true; package appears only for ready deliveries; organization-default binding is not offered from the workspace (domain admin only).

Refs: unified renderer plan Task 11, 05 section 9, 06 section 6, 07 sections 1 and 5; RDM plugins/rdm/plugin.go review.doc.* and render.profile.* schemas (7169944, 4b7ac2a, 75ba50c).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 02:32:45 +00:00

267 lines
16 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(续):整文审签入口、完整包下载、规则绑定与解绑、诊断代码分组。
// 入口只在服务端冻结职责允许时出现;载荷只含 projectId/docId 与 note|reason|round;
// 拒绝以可读文案显示,原始回执只进折叠区。
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
RDM_RENDER_COMMANDS, NO_RENDER_CAPABILITIES, renderCapabilities,
normalizeDocSubmission, decisionEntries, decisionPayload,
normalizeDeliveries, downloadOptions,
describeRenderFailure, diagnosticCodeLabel, normalizeDiagnostics,
normalizeCandidateRules, bindableRules, currentBindings, profileBindPayload, profileUnbindPayload,
projectSettingsAllowed, createRequestGuard, normalizeProfiles,
type DecisionEntries,
} from '../src/documentRenderPresentation'
import { ReviewDecisionPanel, ProfileBindingPanel } from '../src/components/DocumentRenderActions'
import { DiagnosticsList } from '../src/components/DocumentRenderWorkspace'
const cmd = (id: string, enabled = true) => ({ id, enabled })
const idle = { op: null, status: 'idle' as const, error: '' }
const statusPayload = {
docId: 'd1',
submission: {
submissionId: 'ds_d1_2', round: 2, state: 'submitted', createdBy: 'author-1',
// Go 结构体 domain.Roles 无 json 标签:字段名原样输出为 Author/Reviewers/Approver。
duties: { Author: 'author-1', Reviewers: ['rev-1', 'rev-2'], Approver: 'appr-1' },
reviews: { 'rev-1': true, 'rev-2': false },
},
}
const sub = normalizeDocSubmission(statusPayload)!
const allowedCaps = { approve: true, reject: true, final: true }
const none: DecisionEntries = { approve: false, reject: false, final: false }
describe('审签与绑定能力门控(命令清单逐项判定)', () => {
it('每项审签/绑定命令独立门控,核心渲染能力不受影响', () => {
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
expect(renderCapabilities(all)).toMatchObject({ ready: true, approve: true, reject: true, final: true, bind: true, unbind: true })
const noFinal = renderCapabilities(all.filter(c => c.id !== 'rdm.review.doc.final'))
expect(noFinal).toMatchObject({ ready: true, approve: true, reject: true, final: false })
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profile.unbind'))).toMatchObject({ bind: true, unbind: false })
expect(NO_RENDER_CAPABILITIES).toMatchObject({ approve: false, reject: false, final: false, bind: false, unbind: false })
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).final).toBe(false)
})
})
describe('整文提交归一化(rdm.review.doc.status)', () => {
it('兼容服务端 Go 字段名 Author/Reviewers/Approver,并保留冻结的审核进度', () => {
expect(sub).toEqual({
submissionId: 'ds_d1_2', round: 2, state: 'submitted', author: 'author-1',
reviewers: ['rev-1', 'rev-2'], approver: 'appr-1', reviews: { 'rev-1': true, 'rev-2': false },
})
})
it('无提交或轮次非法即无记录,不补造', () => {
expect(normalizeDocSubmission({ docId: 'd1', submission: null })).toBeNull()
expect(normalizeDocSubmission({ submission: { round: 0, submissionId: 'x' } })).toBeNull()
expect(normalizeDocSubmission({ submission: { round: 1 } })).toBeNull()
})
})
describe('审签入口:只在服务端冻结职责允许时出现', () => {
it('审核人未审时可通过;已通过的审核人仅可驳回;编制人不可自审', () => {
expect(decisionEntries(sub, 'rev-2', allowedCaps)).toEqual({ approve: true, reject: true, final: false })
expect(decisionEntries(sub, 'rev-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
expect(decisionEntries(sub, 'author-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
})
it('批准人仅在全员审核通过后可终批;批准人本身可驳回', () => {
expect(decisionEntries(sub, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
const allDone = { ...sub, reviews: { 'rev-1': true, 'rev-2': true } }
expect(decisionEntries(allDone, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: true })
})
it('非冻结名单成员、无身份、非在途状态、能力未开一律无入口', () => {
expect(decisionEntries(sub, 'outsider', allowedCaps)).toEqual(none)
expect(decisionEntries(sub, undefined, allowedCaps)).toEqual(none)
expect(decisionEntries({ ...sub, state: 'final' }, 'rev-2', allowedCaps)).toEqual(none)
expect(decisionEntries(sub, 'rev-2', { approve: false, reject: true, final: true })).toEqual({ approve: false, reject: true, final: false })
expect(decisionEntries(null, 'rev-2', allowedCaps)).toEqual(none)
})
})
describe('审签载荷:只含 projectId/docId 与 note|reason|round', () => {
it('approve 的 note、reject 的 reason、final 的 round/reason;空说明不携带', () => {
expect(decisionPayload('approve', 'p1', 'd1', ' 通过 ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1', note: '通过' } })
expect(decisionPayload('approve', 'p1', 'd1', ' ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1' } })
expect(decisionPayload('reject', 'p1', 'd1', '依据不足', 2)).toEqual({ command: 'rdm.review.doc.reject', params: { projectId: 'p1', docId: 'd1', reason: '依据不足' } })
expect(decisionPayload('final', 'p1', 'd1', '', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2 } })
expect(decisionPayload('final', 'p1', 'd1', '终批', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2, reason: '终批' } })
})
it('任何审签载荷都不携带身份字段(身份只认会话)', () => {
const identityKey = /actor|user|owner|identity|approvedBy|reviewer|approver|author|signature/i
for (const kind of ['approve', 'reject', 'final'] as const) {
const keys = Object.keys(decisionPayload(kind, 'p1', 'd1', 'x', 2).params)
expect(keys.filter(k => identityKey.test(k))).toEqual([])
}
})
})
describe('完整交付包(rdm.render.download kind=package)', () => {
const ready = normalizeDeliveries({
items: [{ deliveryId: 'd9', mode: 'approved', status: 'ready', profileId: 'x', profileVersion: 1, createdAt: '', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown'] }],
}).items[0]
it('交付已生成(ready)时完整包位于单件下载之后', () => {
expect(downloadOptions(ready).map(o => o.kind)).toEqual(['markdown', 'docx', 'review-docx', 'manifest', 'package'])
expect(downloadOptions(ready).at(-1)?.label).toBe('完整交付包(ZIP)')
})
it('非 ready 的交付不提供完整包', () => {
expect(downloadOptions({ ...ready, status: 'generation-failed' }).map(o => o.kind)).not.toContain('package')
expect(downloadOptions({ ...ready, status: 'frozen' }).map(o => o.kind)).not.toContain('package')
})
})
describe('服务端拒绝码:翻译为可读文案,原始回执保留', () => {
it('受控、未发布、绑定变化三类拒绝', () => {
const controlled = describeRenderFailure(new Error('400: controlled_requires_exact_profile: controlled document d1 requires its exact template profile'))
expect(controlled.message).toContain('受控文档')
expect(controlled.raw).toContain('controlled_requires_exact_profile')
expect(describeRenderFailure(new Error('400: profile_not_published: x@1 is not published (missing)')).message).toContain('尚未发布')
expect(describeRenderFailure(new Error('400: binding_mismatch: document d1 is bound to a@1')).message).toContain('刷新')
})
it('审签拒绝保持服务端原文(去掉状态码前缀)', () => {
expect(describeRenderFailure(new Error('400: 仅本轮(第 2 轮)冻结名单内的审核人可审批')))
.toMatchObject({ message: '仅本轮(第 2 轮)冻结名单内的审核人可审批' })
})
})
describe('诊断:八类代码的中文标签与严重度分组', () => {
const codes = ['required_content_missing', 'intentionally_empty', 'template_placeholder_left', 'mapping_fallback',
'signature_data_missing', 'mapping_unresolved', 'mapping_ambiguous', 'approval_invalid']
it('已知代码显示中文标签,未知代码原样保留', () => {
for (const code of codes) expect(diagnosticCodeLabel(code)).not.toBe(code)
expect(diagnosticCodeLabel('custom_x')).toBe('custom_x')
})
it('错误在前、警告在后;节点按标题定位,代码标签可见', () => {
const diags = normalizeDiagnostics([
{ code: 'mapping_unresolved', severity: 'error', message: '映射未解析', nodeId: 'n1', suggestion: '补充映射' },
{ code: 'template_placeholder_left', severity: 'warning', message: '占位符未替换', nodeId: 'n2' },
])
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
diagnostics: diags, nodeTitle: id => `章节 ${id}`, onSelectNode: () => {}, idPrefix: 'p',
}))
expect(html.indexOf('错误(1)')).toBeGreaterThanOrEqual(0)
expect(html.indexOf('错误(1)')).toBeLessThan(html.indexOf('警告(1)'))
expect(html).toContain(diagnosticCodeLabel('mapping_unresolved'))
expect(html).toContain('章节 n1')
})
})
describe('规则绑定:候选与服务端选项合并、当前绑定、载荷', () => {
const profiles = normalizeProfiles({
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
options: [
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: false, fallbackAllowed: true, blockedReason: '资产校验未通过' },
],
})
const candidates = normalizeCandidateRules({
projectId: 'p1',
candidates: [
{ candidateId: 'c1', state: 'published', publication: { profileId: 'prj.std', version: '2', decision: 'ok' } },
{ candidateId: 'c2', state: 'draft' },
{ candidateId: 'c3', state: 'published', publication: { profileId: 'prj.std', version: '2' } },
],
})
it('可绑定规则 = 服务端选项中已发布项 ∪ 项目已发布候选(去重,保序)', () => {
expect(bindableRules(profiles, candidates).map(r => `${r.profileId}@${r.version}`)).toEqual(['org@3', 'doc@2', 'prj.std@2'])
})
it('当前绑定只取服务端选项中的绑定来源', () => {
expect(currentBindings(profiles)).toEqual({
document: { profileId: 'doc', version: '2' },
'project-default': { profileId: 'prj', version: '5' },
})
})
it('绑定载荷:key 按作用域取值;文档类型缺失则不生成载荷', () => {
const rule = { profileId: 'prj.std', version: '2' }
expect(profileBindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', rule }))
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'prj.std', version: '2' })
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: 'SDD', rule }))
.toEqual({ projectId: 'p1', source: 'document-type', key: 'SDD', profileId: 'prj.std', version: '2' })
expect(profileBindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: undefined, rule }))
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1', profileId: 'prj.std', version: '2' })
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: undefined, rule })).toBeUndefined()
})
it('解绑带前置条件(profileId+version 成对);无绑定时不带前置条件', () => {
expect(profileUnbindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', precondition: { profileId: 'doc', version: '2' } }))
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'doc', version: '2' })
expect(profileUnbindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: 'SDD' }))
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1' })
})
})
describe('项目设置权限:以服务端 rdm.perm.check 的 project.settings 为准', () => {
it('仅 allowed=true 的 project.settings 才放行', () => {
expect(projectSettingsAllowed({ projectId: 'p1', member: 'u', role: 'member', profRole: null, actions: [{ action: 'project.settings', allowed: true, source: 'override' }] })).toBe(true)
expect(projectSettingsAllowed({ actions: [{ action: 'project.settings', allowed: false, source: 'x' }] })).toBe(false)
expect(projectSettingsAllowed({ actions: [{ action: 'doc.read', allowed: true, source: 'x' }] })).toBe(false)
expect(projectSettingsAllowed(null)).toBe(false)
})
})
describe('请求守卫:过期的审签/绑定结果被丢弃', () => {
it('同一作用域的新请求使旧结果失效;取消后全部失效', () => {
const g = createRequestGuard()
const first = g.begin('scope')
const second = g.begin('scope')
expect(g.current(first, 'scope')).toBe(false)
expect(g.current(second, 'scope')).toBe(true)
g.cancel()
expect(g.current(second, 'scope')).toBe(false)
})
})
describe('审签面板:入口门控与拒绝显示', () => {
const noop = () => {}
it('无可用入口时不出现任何动作按钮,只显示轮次与进度', () => {
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
sub, entries: none, busy: false, status: idle, onRetry: noop, onDecide: noop,
}))
expect(html).toContain('第 2 轮')
expect(html).not.toContain('审核通过')
expect(html).not.toContain('驳回')
expect(html).not.toContain('终批')
})
it('只渲染服务端允许的入口;失败以可读文案显示并保留原始回执', () => {
const entries: DecisionEntries = { approve: true, reject: true, final: false }
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
sub, entries, busy: false,
status: { op: { kind: 'reject', operationId: 'op_1' }, status: 'failed', error: '400: 当前身份无权驳回' },
onRetry: noop, onDecide: noop,
}))
expect(html).toContain('审核通过')
expect(html).toContain('驳回')
expect(html).not.toContain('终批')
expect(html).toContain('当前身份无权驳回')
expect(html).toContain('原始回执')
})
})
describe('规则绑定面板:当前绑定、解绑入口与文档类型禁用', () => {
const noop = () => {}
const rules = [{ profileId: 'prj.std', version: '2', label: 'prj.std v2' }]
it('显示三个作用域的当前绑定;可解绑已绑定项;未选规则时绑定按钮禁用', () => {
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
rules, bindings: { document: { profileId: 'doc', version: '2' } }, typeKey: undefined,
busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
}))
expect(html).toContain('本文档')
expect(html).toContain('文档类型')
expect(html).toContain('项目默认')
expect(html).toContain('doc@2')
expect(html).toContain('解绑')
expect(html).toContain('disabled')
})
it('文档类型未知时,文档类型行提示无法绑定', () => {
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
rules, bindings: {}, typeKey: undefined, busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
}))
expect(html).toContain('文档类型未就绪')
})
})
describe('能力表与类型守卫', () => {
it('未知 source 的绑定不进入当前绑定', () => {
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.TPL', version: '1', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false }] })
expect(currentBindings(p)).toEqual({})
})
})