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>
267 lines
16 KiB
TypeScript
267 lines
16 KiB
TypeScript
// 统一渲染 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({})
|
||
})
|
||
}) |