Locate review items and render trace by tree node id and mask audit-only trace
Why: RDM be80ad4 (docs/design/render-node-id-contract.md) accepts workspace tree node ids (dn-/sec-/legacy-) for review annotate/revise, adds treeNodeId to receipts, rdm.render.review.view items and trace entries while nodeId stays canonical, points anchored-to at the tree node id, and marks audit-reader trace entries with contentMasked=true. Grouping review items by the canonical nodeId left them all unlocated, and the workspace showed no render trace at all. Evidence: npx tsc --noEmit exit 0; npx vitest run 39 files / 456 tests passed (12 new in test/document-render-workspace.test.ts: treeNodeId grouping with nodeId fallback and orphan rules for annotations and revisions, data-locate targets, annotate/revise payload carrying the selected tree node id, layer=render filtering, anchored-to against tree nodes without canonical fallback, masked entries never reading body fields, empty state); npm run build ok; node scripts/check-knip.mjs 51 <= 51; e2e/document-renderer.playwright.ts type-checks (tsc exit 0) and npx playwright test --list loads 13 tests. Impact: locateNodeId (treeNodeId first, nodeId fallback, otherwise unlocated) is shared by diagnostics, review grouping and trace. Clicking an annotation or revision group, a reply, or a trace node locates and re-scrolls like diagnostics. Annotate and revise send the selected tree node id as nodeId. A 渲染链条 panel reads rdm.inspect.relations (kind=doc) through the single guarded resource owner (stale responses dropped) and reloads after preview, submit, export, review and decision actions. Masked entries show metadata only with a 内容已遮蔽 marker and a panel notice. The e2e spec is unchanged: §11-5b already asserts that the group title is the selected tree node, and the leak scan is untouched. Not run against a live backend or browser here. Refs: RDM be80ad4 docs/design/render-node-id-contract.md; unified renderer spec 10 §11 items 4, 5 and 7; DESIGN.md §8, §9.2, §11. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,16 +13,17 @@ import {
|
||||
DELIVERY_MODE_LABELS, DOC_TREE_LIMITS, MIND_MAP, REVISION_KIND_LABELS,
|
||||
annotatePayload, buildDocTree, controlledNotice, createRequestGuard, currentSubmissionId, decodeBase64, defaultProfileKey,
|
||||
diagnosticCountsByNode, diagnosticsSummaryText, downloadOptions, formatRenderTime,
|
||||
groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, mergeDeliveries, moveSelection,
|
||||
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles,
|
||||
groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, locateNodeId, mergeDeliveries, moveSelection,
|
||||
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, normalizeRenderTrace,
|
||||
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope,
|
||||
revisePayload, submissionIdOf, truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, walkDocTree,
|
||||
reviewTargetNode, revisePayload, submissionIdOf, traceAnchor, traceEndpointLabel, traceTypeLabel, truncateMiddle,
|
||||
validateReviewText, visibleNodeIds, visibleOrder, walkDocTree,
|
||||
decisionEntries, decisionPayload, normalizeDocSubmission, currentBindings, bindableRules, normalizeCandidateRules,
|
||||
profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel, createSingleFlight,
|
||||
type BindSource, type DecisionKind, type RuleRef,
|
||||
type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind,
|
||||
type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type ReviewAnnotation,
|
||||
type ReviewRevision,
|
||||
type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type RenderTrace,
|
||||
type RenderTraceEntry, type ReviewAnnotation, type ReviewRevision,
|
||||
} from '../documentRenderPresentation'
|
||||
|
||||
// ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ----
|
||||
@@ -190,20 +191,21 @@ function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: Rev
|
||||
)
|
||||
}
|
||||
|
||||
/** byNode 的键是已定位的树节点 id;点击节点名定位(重复点击也重新滚动),回复以该树节点为审阅目标。 */
|
||||
export function AnnotationGroups({ byNode, orphaned, nodeTitle, onSelectNode, onReply }: {
|
||||
byNode: Map<string, ReviewAnnotation[]>
|
||||
orphaned: ReviewAnnotation[]
|
||||
nodeTitle: (id: string) => string | undefined
|
||||
onSelectNode: (id: string) => void
|
||||
onReply?: (a: ReviewAnnotation) => void
|
||||
onReply?: (a: ReviewAnnotation, treeNodeId: string) => void
|
||||
}) {
|
||||
if (byNode.size === 0 && orphaned.length === 0) return <div className="empty-note">暂无批注</div>
|
||||
return (
|
||||
<div className="drw-annos">
|
||||
{[...byNode].map(([nodeId, list]) => (
|
||||
<div key={nodeId} className="drw-anno-group">
|
||||
<button type="button" className="drw-anno-node" onClick={() => onSelectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
|
||||
{list.map(a => <AnnotationItem key={a.id} a={a} onReply={onReply} />)}
|
||||
<button type="button" className="drw-anno-node" data-locate={nodeId} onClick={() => onSelectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
|
||||
{list.map(a => <AnnotationItem key={a.id} a={a} onReply={onReply && (x => onReply(x, nodeId))} />)}
|
||||
</div>
|
||||
))}
|
||||
{orphaned.length > 0 && (
|
||||
@@ -231,6 +233,81 @@ function RevisionItem({ r }: { r: ReviewRevision }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function RevisionGroups({ byNode, orphaned, total, nodeTitle, onSelectNode }: {
|
||||
byNode: Map<string, ReviewRevision[]>
|
||||
orphaned: ReviewRevision[]
|
||||
total: number
|
||||
nodeTitle: (id: string) => string | undefined
|
||||
onSelectNode: (id: string) => void
|
||||
}) {
|
||||
if (byNode.size === 0 && orphaned.length === 0) return null
|
||||
return (
|
||||
<Collapsible id="drw-revisions" defaultOpen triggerClassName="drw-group-trigger"
|
||||
trigger={<span className="drw-group-label">修订({total})</span>}>
|
||||
{[...byNode].map(([nodeId, list]) => (
|
||||
<div key={nodeId} className="drw-anno-group">
|
||||
<button type="button" className="drw-anno-node" data-locate={nodeId} onClick={() => onSelectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
|
||||
{list.map(r => <RevisionItem key={r.id} r={r} />)}
|
||||
</div>
|
||||
))}
|
||||
{orphaned.length > 0 && (
|
||||
<div className="drw-anno-group">
|
||||
<span className="drw-group-label">无法定位({orphaned.length})</span>
|
||||
{orphaned.map(r => <RevisionItem key={r.id} r={r} />)}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
const TRACE_PAGE = 50
|
||||
|
||||
function TraceRow({ entry, nodeTitle, onSelectNode }: {
|
||||
entry: RenderTraceEntry
|
||||
nodeTitle: (id: string) => string | undefined
|
||||
onSelectNode: (id: string) => void
|
||||
}) {
|
||||
const { subject, target } = traceAnchor(entry)
|
||||
const at = target ? locateNodeId(target, id => !!nodeTitle(id)) : undefined
|
||||
return (
|
||||
<li className={`drw-trace${entry.masked ? ' masked' : ''}`}>
|
||||
<span className="drw-trace-type">{traceTypeLabel(entry)}</span>
|
||||
<span className="drw-trace-subject" title={subject.id}>{traceEndpointLabel(subject)}</span>
|
||||
{subject.details.map(d => <Chip key={d} tone="plain">{d}</Chip>)}
|
||||
{target && (at
|
||||
? <button type="button" className="drw-diag-node" data-locate={at} title={nodeTitle(at)} onClick={() => onSelectNode(at)}>{nodeTitle(at)}</button>
|
||||
: <span className="drw-diag-node unlocated">未定位节点</span>)}
|
||||
{entry.masked && <span className="drw-trace-masked">内容已遮蔽</span>}
|
||||
{(subject.actor || subject.at) && <Byline name={subject.actor ?? ''} at={subject.at ?? ''} />}
|
||||
{entry.body && <span className="drw-trace-body">{entry.body}</span>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** 渲染链条:遮蔽条目只显示元数据并标「内容已遮蔽」;批注锚定与批注/修订按树节点定位,未知节点显示为未定位。 */
|
||||
export function RenderTraceList({ trace, nodeTitle, onSelectNode }: {
|
||||
trace: RenderTrace
|
||||
nodeTitle: (id: string) => string | undefined
|
||||
onSelectNode: (id: string) => void
|
||||
}) {
|
||||
const [shown, setShown] = useState(TRACE_PAGE)
|
||||
if (trace.entries.length === 0) return <div className="empty-note">暂无渲染链条记录</div>
|
||||
return (
|
||||
<div className="drw-traces">
|
||||
{trace.masked && <div className="drw-notice">当前身份只能查看渲染链条元数据,正文、批注与修订文字已遮蔽</div>}
|
||||
<ul className="drw-diag-list">
|
||||
{trace.entries.slice(0, shown).map((e, i) => <TraceRow key={`${e.rawType}-${e.to.id}-${i}`} entry={e} nodeTitle={nodeTitle} onSelectNode={onSelectNode} />)}
|
||||
{trace.entries.length > shown && (
|
||||
<li><button type="button" className="btn v-ghost drw-more" onClick={() => setShown(s => s + TRACE_PAGE)}>
|
||||
显示更多(还有 {trace.entries.length - shown} 条)
|
||||
</button></li>
|
||||
)}
|
||||
</ul>
|
||||
{trace.truncated && <div className="drw-muted">已截断(数量上限)</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownloadButtons({ delivery, busyKind, disabled = false, onDownload }: {
|
||||
delivery: RenderDelivery
|
||||
busyKind: DownloadKind | null
|
||||
@@ -683,6 +760,8 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
const submissionId = currentSubmissionId(submissionChoice, docStatus.data, deliveries.items)
|
||||
const review = useGuardedResource(caps.review && submissionId ? `${scope}:review:${submissionId}` : null, identity,
|
||||
() => api.rdmCommand('rdm.render.review.view', { projectId, submissionId }).then(normalizeReviewView))
|
||||
const trace = useGuardedResource(caps.ready ? `${scope}:trace` : null, identity,
|
||||
() => api.rdmCommand('rdm.inspect.relations', { projectId, kind: 'doc', id: docId, depth: 1 }).then(normalizeRenderTrace))
|
||||
|
||||
const profileData = profiles.data
|
||||
const views = useMemo(() => (profileData ? profileOptionViews(profileData) : []), [profileData])
|
||||
@@ -730,6 +809,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
setReceipt(r && typeof r === 'object' ? r as Record<string, unknown> : {})
|
||||
receiptDiagnostics.load(r)
|
||||
deliveries.reload()
|
||||
trace.reload()
|
||||
}
|
||||
const generatePreview = () => {
|
||||
const p = profileParams()
|
||||
@@ -743,6 +823,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
const id = submissionIdOf(r)
|
||||
if (id) setSubmissionChoice(id)
|
||||
deliveries.reload()
|
||||
trace.reload()
|
||||
})
|
||||
}
|
||||
const exportFor = (kind: 'review-export' | 'approved-export') => {
|
||||
@@ -751,16 +832,17 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
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
|
||||
const reviewTarget = reviewTargetNode(tree, selectedId)
|
||||
const reviewDone = () => { review.reload(); trace.reload() }
|
||||
const annotate = (text: string, reply?: string) => {
|
||||
if (!submissionId || !reviewTarget) return
|
||||
reviewOp.run('annotate', operationId => api.rdmCommand('rdm.render.review.annotate',
|
||||
annotatePayload({ projectId, submissionId, nodeId: reviewTarget.id, text, operationId, replyTo: reply })), () => { setReplyTo(null); review.reload() })
|
||||
annotatePayload({ projectId, submissionId, treeNodeId: reviewTarget.id, text, operationId, replyTo: reply })), () => { setReplyTo(null); reviewDone() })
|
||||
}
|
||||
const revise = (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => {
|
||||
if (!submissionId || !reviewTarget) return
|
||||
reviewOp.run('revise', operationId => api.rdmCommand('rdm.render.review.revise',
|
||||
revisePayload({ projectId, submissionId, nodeId: reviewTarget.id, kind, before, after, operationId })), () => review.reload())
|
||||
revisePayload({ projectId, submissionId, treeNodeId: reviewTarget.id, kind, before, after, operationId })), reviewDone)
|
||||
}
|
||||
|
||||
// 审签动作:载荷只含 projectId/docId 与 note|reason|round,轮次取界面所示的在途轮次;结果后刷新审签状态与批注。
|
||||
@@ -771,7 +853,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
const { command, params } = decisionPayload(kind, projectId, docId, text, round)
|
||||
decisionOp.run(kind, operationId => commitDecision(api, {
|
||||
host: hostProject, operationId, decision: kind, doc: { id: docId, title: docTitle }, round, note: text, command, params,
|
||||
}), () => { docStatus.reload(); review.reload() })
|
||||
}), () => { docStatus.reload(); reviewDone() })
|
||||
}
|
||||
|
||||
// 规则绑定:绑定/解绑成功后刷新 rdm.render.profiles(当前绑定与选项以服务端为准)。
|
||||
@@ -890,25 +972,10 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
</div>
|
||||
{review.error ? <RenderFailure error={review.error} id="review" /> : !review.data ? <div className="empty-note">加载中…</div> : (
|
||||
<>
|
||||
<AnnotationGroups byNode={annotations.byNode} orphaned={annotations.orphaned} nodeTitle={nodeTitle} onSelectNode={selectNode}
|
||||
onReply={caps.annotate ? a => { setReplyTo(a); selectNode(a.nodeId) } : undefined} />
|
||||
{(revisions.byNode.size > 0 || revisions.orphaned.length > 0) && (
|
||||
<Collapsible id="drw-revisions" defaultOpen triggerClassName="drw-group-trigger"
|
||||
trigger={<span className="drw-group-label">修订({review.data.revisions.length})</span>}>
|
||||
{[...revisions.byNode].map(([nodeId, list]) => (
|
||||
<div key={nodeId} className="drw-anno-group">
|
||||
<button type="button" className="drw-anno-node" onClick={() => selectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
|
||||
{list.map(r => <RevisionItem key={r.id} r={r} />)}
|
||||
</div>
|
||||
))}
|
||||
{revisions.orphaned.length > 0 && (
|
||||
<div className="drw-anno-group">
|
||||
<span className="drw-group-label">无法定位({revisions.orphaned.length})</span>
|
||||
{revisions.orphaned.map(r => <RevisionItem key={r.id} r={r} />)}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
)}
|
||||
<AnnotationGroups byNode={annotations.byNode} orphaned={annotations.orphaned} nodeTitle={nodeTitle} onSelectNode={revealNode}
|
||||
onReply={caps.annotate ? (a, treeNodeId) => { setReplyTo(a); revealNode(treeNodeId) } : undefined} />
|
||||
<RevisionGroups byNode={revisions.byNode} orphaned={revisions.orphaned} total={review.data.revisions.length}
|
||||
nodeTitle={nodeTitle} onSelectNode={revealNode} />
|
||||
{review.data.decisions.length > 0 && (
|
||||
<div className="drw-decisions">
|
||||
{review.data.decisions.map((d, i) => (
|
||||
@@ -942,6 +1009,15 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="drw-panel">
|
||||
<div className="drw-row drw-row-between">
|
||||
<SectionLabel>渲染链条</SectionLabel>
|
||||
<button type="button" className="drw-link" onClick={trace.reload}>刷新</button>
|
||||
</div>
|
||||
{trace.error ? <RenderFailure error={trace.error} id="trace" /> : !trace.data ? <div className="empty-note">加载中…</div>
|
||||
: <RenderTraceList trace={trace.data} nodeTitle={nodeTitle} onSelectNode={revealNode} />}
|
||||
</section>
|
||||
|
||||
<section className="drw-panel">
|
||||
<div className="drw-row drw-row-between">
|
||||
<SectionLabel>交付记录</SectionLabel>
|
||||
|
||||
@@ -323,20 +323,24 @@ export function diagnosticsSummaryText(s: { errors: number; warnings: number }):
|
||||
return parts.length ? parts.join(' · ') : '无错误或警告'
|
||||
}
|
||||
|
||||
/** 诊断定位到工作区树节点:先 treeNodeId,缺省或不在树中再回退 nodeId;都不在树中即未定位,不猜测。 */
|
||||
export function locateDiagnostic(d: RenderDiagnostic, nodeTitle: (id: string) => string | undefined): { id: string; title: string } | undefined {
|
||||
for (const id of [d.treeNodeId, d.nodeId]) {
|
||||
const title = id ? nodeTitle(id) : undefined
|
||||
if (id && title) return { id, title }
|
||||
/** 定位到工作区树节点(诊断、审阅条目、渲染链条共用):先 treeNodeId,缺省或不在树中再回退 nodeId;都不在树中即未定位,不猜测。 */
|
||||
export function locateNodeId(ids: { treeNodeId?: string; nodeId?: string }, isKnown: (id: string) => boolean): string | undefined {
|
||||
for (const id of [ids.treeNodeId, ids.nodeId]) {
|
||||
if (id && isKnown(id)) return id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function locateDiagnostic(d: RenderDiagnostic, nodeTitle: (id: string) => string | undefined): { id: string; title: string } | undefined {
|
||||
const id = locateNodeId(d, x => !!nodeTitle(x))
|
||||
return id ? { id, title: nodeTitle(id)! } : undefined
|
||||
}
|
||||
|
||||
export function diagnosticCountsByNode(list: RenderDiagnostic[], isKnown: (id: string) => boolean = () => true): Record<string, { errors: number; warnings: number }> {
|
||||
const out: Record<string, { errors: number; warnings: number }> = {}
|
||||
for (const d of list) {
|
||||
if (d.severity === 'info') continue
|
||||
const id = locateDiagnostic(d, x => (isKnown(x) ? x : undefined))?.id
|
||||
const id = locateNodeId(d, isKnown)
|
||||
if (!id) continue
|
||||
const c = out[id] ?? (out[id] = { errors: 0, warnings: 0 })
|
||||
if (d.severity === 'error') c.errors++
|
||||
@@ -762,7 +766,10 @@ interface ReviewPerson { id: string; displayName: string }
|
||||
|
||||
export interface ReviewReply {
|
||||
id: string
|
||||
/** 渲染器规范节点 id(审阅快照写入值)。 */
|
||||
nodeId: string
|
||||
/** 审阅人所选的工作区树节点 id;分组与定位优先用它。 */
|
||||
treeNodeId?: string
|
||||
author: ReviewPerson
|
||||
createdAt: string
|
||||
text: string
|
||||
@@ -775,6 +782,7 @@ type RevisionKind = 'insert' | 'delete' | 'replace'
|
||||
export interface ReviewRevision {
|
||||
id: string
|
||||
nodeId: string
|
||||
treeNodeId?: string
|
||||
kind: RevisionKind
|
||||
before?: string
|
||||
after?: string
|
||||
@@ -801,6 +809,7 @@ function reply(v: unknown): ReviewReply | undefined {
|
||||
return {
|
||||
id, text, nodeId: str(a.nodeId) ?? '', author: person(a.author), createdAt: str(a.createdAt) ?? '',
|
||||
resolved: a.resolved === true, orphaned: a.orphaned === true,
|
||||
...(str(a.treeNodeId) ? { treeNodeId: a.treeNodeId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,6 +832,7 @@ export function normalizeReviewView(raw: unknown): ReviewView {
|
||||
const decision = str(v.decision)
|
||||
revisions.push({
|
||||
id, kind: v.kind as RevisionKind, nodeId: str(v.nodeId) ?? '', author: person(v.author), createdAt: str(v.createdAt) ?? '',
|
||||
...(str(v.treeNodeId) ? { treeNodeId: v.treeNodeId } : {}),
|
||||
...(before ? { before } : {}), ...(after ? { after } : {}), ...(decision ? { decision } : {}),
|
||||
})
|
||||
}
|
||||
@@ -837,19 +847,20 @@ export function normalizeReviewView(raw: unknown): ReviewView {
|
||||
return { annotations, revisions, decisions }
|
||||
}
|
||||
|
||||
function partitionByNode<T extends { nodeId: string; orphaned?: boolean }>(list: T[], known: Set<string>) {
|
||||
function partitionByNode<T extends { nodeId: string; treeNodeId?: string; orphaned?: boolean }>(list: T[], known: Set<string>) {
|
||||
const byNode = new Map<string, T[]>()
|
||||
const orphaned: T[] = []
|
||||
for (const item of list) {
|
||||
if (item.orphaned || !item.nodeId || !known.has(item.nodeId)) { orphaned.push(item); continue }
|
||||
const bucket = byNode.get(item.nodeId)
|
||||
const at = item.orphaned ? undefined : locateNodeId(item, id => known.has(id))
|
||||
if (!at) { orphaned.push(item); continue }
|
||||
const bucket = byNode.get(at)
|
||||
if (bucket) bucket.push(item)
|
||||
else byNode.set(item.nodeId, [item])
|
||||
else byNode.set(at, [item])
|
||||
}
|
||||
return { byNode, orphaned }
|
||||
}
|
||||
|
||||
/** 孤立批注或节点已不在当前文档树中的批注进入「无法定位」,绝不挂到其他节点。 */
|
||||
/** 按树节点分组(treeNodeId 优先、nodeId 回退);孤立或都不在当前文档树中的进入「无法定位」,绝不挂到其他节点。 */
|
||||
export function partitionAnnotations(list: ReviewAnnotation[], known: Set<string>) {
|
||||
return partitionByNode(list, known)
|
||||
}
|
||||
@@ -867,23 +878,171 @@ export function validateReviewText(text: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface ReviewTarget { projectId: string; submissionId: string; nodeId: string; operationId: string }
|
||||
/** 从树上审阅的目标:选中的工作区树节点(文档根不是审阅目标)。 */
|
||||
export function reviewTargetNode(tree: DocTree | null, selectedId: string | null): DocNode | undefined {
|
||||
const node = tree && selectedId ? tree.nodes[selectedId] : undefined
|
||||
return node && node.kind !== 'doc' ? node : undefined
|
||||
}
|
||||
|
||||
/** RDM 的 nodeId 参数接受规范节点或工作区树节点;从树上审阅一律发送所选树节点 id(dn-/sec-/legacy-)。 */
|
||||
interface ReviewTarget { projectId: string; submissionId: string; treeNodeId: string; operationId: string }
|
||||
|
||||
export function annotatePayload(input: ReviewTarget & { text: string; replyTo?: string }): Record<string, unknown> {
|
||||
const { projectId, submissionId, nodeId, operationId } = input
|
||||
return { projectId, submissionId, nodeId, text: input.text.trim(), operationId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) }
|
||||
const { projectId, submissionId, treeNodeId, operationId } = input
|
||||
return { projectId, submissionId, nodeId: treeNodeId, text: input.text.trim(), operationId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) }
|
||||
}
|
||||
|
||||
export function revisePayload(input: ReviewTarget & { kind: RevisionKind; before?: string; after?: string }): Record<string, unknown> {
|
||||
const { projectId, submissionId, nodeId, kind, operationId } = input
|
||||
const { projectId, submissionId, treeNodeId, kind, operationId } = input
|
||||
return {
|
||||
projectId, submissionId, nodeId, kind,
|
||||
projectId, submissionId, nodeId: treeNodeId, kind,
|
||||
...(input.before !== undefined ? { before: input.before } : {}),
|
||||
...(input.after !== undefined ? { after: input.after } : {}),
|
||||
operationId,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 渲染链条(rdm.inspect.relations 中 layer=render 的关系) ----
|
||||
|
||||
const RENDER_TRACE_TYPES = [
|
||||
'selected-profile', 'profile-binding-changed', 'delivered', 'rendered-from', 'rendered-with', 'reviewed-by', 'cites',
|
||||
'restricted-basis', 'submitted', 'annotated', 'anchored-to', 'revised', 'signed-off',
|
||||
] as const
|
||||
type RenderTraceType = (typeof RENDER_TRACE_TYPES)[number]
|
||||
|
||||
const RENDER_TRACE_TYPE_LABELS: Record<RenderTraceType, string> = {
|
||||
'selected-profile': '选用规则', 'profile-binding-changed': '规则绑定变更', delivered: '生成交付', 'rendered-from': '渲染源版本',
|
||||
'rendered-with': '规则快照', 'reviewed-by': '审阅快照', cites: '引用来源', 'restricted-basis': '受限依据', submitted: '提交审阅',
|
||||
annotated: '批注', 'anchored-to': '批注锚定', revised: '修订', 'signed-off': '签署',
|
||||
}
|
||||
|
||||
const TRACE_KIND_LABELS: Record<string, string> = {
|
||||
doc: '文档', 'render-profile': '渲染规则', 'render-profile-change': '规则变更', 'render-delivery': '交付', 'source-version': '源版本',
|
||||
'profile-snapshot': '规则快照', 'review-snapshot': '审阅快照', source: '来源', 'restricted-source': '受限来源',
|
||||
'render-submission': '审阅提交', 'review-annotation': '批注', 'review-revision': '修订', 'sign-off': '签署', 'document-node': '文档节点',
|
||||
}
|
||||
|
||||
const SIGN_OFF_ROLE_LABELS: Record<string, string> = { reviewer: '审核人', approver: '批准人' }
|
||||
|
||||
/** 端点正文字段(RDM renderTraceContentKeys 同集);遮蔽条目绝不读取。 */
|
||||
const TRACE_BODY_KEYS = ['text', 'excerpt', 'bodyMarkdown', 'after', 'afterText', 'before', 'beforeText', 'reason', 'note'] as const
|
||||
|
||||
interface TraceEndpoint {
|
||||
kind: string
|
||||
id: string
|
||||
nodeId?: string
|
||||
treeNodeId?: string
|
||||
actor?: string
|
||||
at?: string
|
||||
details: string[]
|
||||
}
|
||||
|
||||
export interface RenderTraceEntry {
|
||||
type: RenderTraceType | 'other'
|
||||
rawType: string
|
||||
from: TraceEndpoint
|
||||
to: TraceEndpoint
|
||||
/** 审计读者(无正文职责):只有元数据,正文、批注与修订文字已被服务端剔除。 */
|
||||
masked: boolean
|
||||
/** 仅未遮蔽条目可能携带的正文摘录。 */
|
||||
body?: string
|
||||
}
|
||||
|
||||
export interface RenderTrace { entries: RenderTraceEntry[]; masked: boolean; truncated: boolean }
|
||||
|
||||
const isTraceType = (v: unknown): v is RenderTraceType => RENDER_TRACE_TYPES.includes(v as RenderTraceType)
|
||||
|
||||
function traceDetails(e: Record<string, any>): string[] {
|
||||
const mode = str(e.mode)
|
||||
const source = str(e.source)
|
||||
const role = str(e.role)
|
||||
const revisionKind = str(e.revisionKind)
|
||||
return [
|
||||
mode && (DELIVERY_MODE_LABELS[mode] ?? mode),
|
||||
source && (isSource(source) ? PROFILE_SOURCE_LABELS[source] : source),
|
||||
role && (SIGN_OFF_ROLE_LABELS[role] ?? role),
|
||||
revisionKind && (revisionKind in REVISION_KIND_LABELS ? REVISION_KIND_LABELS[revisionKind as RevisionKind] : revisionKind),
|
||||
str(e.decision), str(e.state), str(e.action),
|
||||
].filter((x): x is string => !!x)
|
||||
}
|
||||
|
||||
function traceEndpoint(raw: unknown): TraceEndpoint | undefined {
|
||||
const e = obj(raw)
|
||||
const kind = str(e?.kind)
|
||||
const id = str(e?.id)
|
||||
if (!e || !kind || !id) return undefined
|
||||
const actor = str(e.displayName) ?? str(e.actorId)
|
||||
return {
|
||||
kind, id, details: traceDetails(e),
|
||||
...(str(e.nodeId) ? { nodeId: e.nodeId } : {}),
|
||||
...(str(e.treeNodeId) ? { treeNodeId: e.treeNodeId } : {}),
|
||||
...(actor ? { actor } : {}),
|
||||
...(str(e.at) ? { at: e.at } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function traceBody(raw: unknown): string | undefined {
|
||||
const e = obj(raw)
|
||||
for (const key of TRACE_BODY_KEYS) {
|
||||
const text = displayText(e?.[key], 500)
|
||||
if (text) return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeRenderTrace(raw: unknown): RenderTrace {
|
||||
const r = obj(raw)
|
||||
const entries: RenderTraceEntry[] = []
|
||||
for (const item of arr(r?.items)) {
|
||||
const o = obj(item)
|
||||
if (o?.layer !== 'render') continue
|
||||
const rawType = str(o.type)
|
||||
const from = traceEndpoint(o.from)
|
||||
const to = traceEndpoint(o.to)
|
||||
if (!rawType || !from || !to) continue
|
||||
const masked = o.contentMasked === true
|
||||
const body = masked ? undefined : traceBody(o.to)
|
||||
entries.push({ type: isTraceType(rawType) ? rawType : 'other', rawType, from, to, masked, ...(body ? { body } : {}) })
|
||||
}
|
||||
return { entries, masked: entries.some(e => e.masked), truncated: r?.truncated === true }
|
||||
}
|
||||
|
||||
/** 条目的主体端点与可定位的树节点:anchored-to 的 to.id 即树节点 id(不回退规范 id);批注/修订条目 treeNodeId 优先、nodeId 回退。 */
|
||||
export function traceAnchor(e: RenderTraceEntry): { subject: TraceEndpoint; target?: { treeNodeId?: string; nodeId?: string } } {
|
||||
switch (e.type) {
|
||||
case 'anchored-to':
|
||||
return { subject: e.from, ...(e.to.kind === 'document-node' ? { target: { treeNodeId: e.to.id } } : {}) }
|
||||
case 'annotated':
|
||||
case 'revised':
|
||||
return { subject: e.to, target: { treeNodeId: e.to.treeNodeId, nodeId: e.to.nodeId } }
|
||||
case 'selected-profile':
|
||||
case 'profile-binding-changed':
|
||||
case 'delivered':
|
||||
case 'rendered-from':
|
||||
case 'rendered-with':
|
||||
case 'reviewed-by':
|
||||
case 'cites':
|
||||
case 'restricted-basis':
|
||||
case 'submitted':
|
||||
case 'signed-off':
|
||||
case 'other':
|
||||
return { subject: e.to }
|
||||
default: {
|
||||
const exhaustive: never = e.type
|
||||
throw new Error(`unknown trace type: ${String(exhaustive)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function traceTypeLabel(e: RenderTraceEntry): string {
|
||||
return e.type === 'other' ? e.rawType : RENDER_TRACE_TYPE_LABELS[e.type]
|
||||
}
|
||||
|
||||
export function traceEndpointLabel(p: TraceEndpoint): string {
|
||||
const kind = TRACE_KIND_LABELS[p.kind] ?? p.kind
|
||||
return p.kind === 'restricted-source' ? '存在受限依据' : `${kind} ${truncateMiddle(p.id, 24)}`
|
||||
}
|
||||
|
||||
// ---- 交付记录与下载 ----
|
||||
|
||||
export interface RenderDelivery {
|
||||
|
||||
@@ -748,6 +748,13 @@ h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
|
||||
.drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; }
|
||||
.drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
|
||||
.drw-traces { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-trace { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
|
||||
.drw-trace.masked { border-left-style: dashed; }
|
||||
.drw-trace-type { font-weight: 600; color: var(--text); }
|
||||
.drw-trace-subject { min-width: 0; color: var(--text-dim); overflow-wrap: anywhere; }
|
||||
.drw-trace-masked { padding: 0 6px; border: 1px dashed var(--border-strong); border-radius: var(--radius-xs); color: var(--text-dim); }
|
||||
.drw-trace-body { flex-basis: 100%; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.drw-decisions { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
@@ -11,14 +11,15 @@ import {
|
||||
nextOperation, createRequestGuard, renderScope,
|
||||
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
|
||||
nodeFacts, hasFacts,
|
||||
normalizeReviewView, partitionAnnotations, annotatePayload, revisePayload, validateReviewText,
|
||||
normalizeReviewView, partitionAnnotations, partitionRevisions, annotatePayload, revisePayload, validateReviewText,
|
||||
locateNodeId, reviewTargetNode, normalizeRenderTrace, traceAnchor, traceTypeLabel, traceEndpointLabel,
|
||||
normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64,
|
||||
truncateMiddle, latestSubmissionId, formatRenderTime,
|
||||
submissionIdOf, currentSubmissionId, loadDeliveryDiagnostics, receiptStateView, walkDocTree, DOC_TREE_LIMITS,
|
||||
type RenderProfiles, type DocTree,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import {
|
||||
ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderReceipt,
|
||||
ProfileOptionList, DiagnosticsList, AnnotationGroups, RevisionGroups, RenderTraceList, DownloadButtons, RenderReceipt,
|
||||
} from '../src/components/DocumentRenderWorkspace'
|
||||
import { RenderFailure } from '../src/components/renderFeedback'
|
||||
|
||||
@@ -674,13 +675,13 @@ describe('审阅:批注、修订与载荷', () => {
|
||||
expect(html.indexOf('第一章')).toBeLessThan(html.indexOf('无法定位'))
|
||||
})
|
||||
it('载荷只含业务字段,身份与时间由服务端填写', () => {
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: ' 好 ', operationId: 'op_1' }))
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: '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' }))
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: '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' }))
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: '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' }))
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: '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('文本校验', () => {
|
||||
@@ -690,6 +691,153 @@ describe('审阅:批注、修订与载荷', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('审阅按工作区树节点分组与定位(treeNodeId 优先,nodeId 回退)', () => {
|
||||
// RDM 契约:nodeId 恒为渲染器规范节点(pp.…),treeNodeId 为审阅人所选树节点(dn-/sec-/legacy-)。
|
||||
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
|
||||
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
|
||||
{ kind: 'field', id: 'dn-leaf', title: '目标', parentId: 'dn-head' },
|
||||
{ kind: 'section', id: 'sec-2', title: '第二章' },
|
||||
])
|
||||
const known = new Set(wsTree.order)
|
||||
const person = { id: 'u1', displayName: '审核人' }
|
||||
const view = normalizeReviewView({
|
||||
annotations: [
|
||||
{ id: 'a1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', author: person, createdAt: '', text: '按所选标题', replies: [] },
|
||||
{ id: 'a2', nodeId: 'pp.goal.leaf', treeNodeId: 'dn-head', author: person, createdAt: '', text: '同一树节点', replies: [] },
|
||||
{ id: 'a3', nodeId: 'sec-2', author: person, createdAt: '', text: '旧条目只有 nodeId', replies: [] },
|
||||
{ id: 'a4', nodeId: 'dn-leaf', treeNodeId: 'dn-gone', author: person, createdAt: '', text: 'treeNodeId 不在树中回退 nodeId', replies: [] },
|
||||
{ id: 'a5', nodeId: 'pp.x', treeNodeId: 'dn-gone', author: person, createdAt: '', text: '都不在树中', replies: [] },
|
||||
{ id: 'a6', nodeId: 'pp.y', treeNodeId: 'dn-head', author: person, createdAt: '', text: '服务端标孤立', replies: [], orphaned: true },
|
||||
{ id: 'a7', nodeId: 'pp.z', treeNodeId: '', author: person, createdAt: '', text: '空 treeNodeId 不保留', replies: [] },
|
||||
],
|
||||
revisions: [
|
||||
{ id: 'v1', nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf', kind: 'replace', before: '旧', after: '新', author: person, createdAt: '' },
|
||||
{ id: 'v2', nodeId: 'pp.unknown', kind: 'insert', after: '无处可挂', author: person, createdAt: '' },
|
||||
],
|
||||
})
|
||||
|
||||
it('归一化保留 treeNodeId,nodeId 仍为规范节点', () => {
|
||||
expect(view.annotations[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-head' })
|
||||
expect(view.annotations.find(a => a.id === 'a7')).not.toHaveProperty('treeNodeId')
|
||||
expect(view.revisions[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf' })
|
||||
})
|
||||
it('批注按 treeNodeId 分组,缺省或不在树中回退 nodeId,都不在或孤立进入无法定位', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, known)
|
||||
expect([...byNode.keys()]).toEqual(['dn-head', 'sec-2', 'dn-leaf'])
|
||||
expect(byNode.get('dn-head')?.map(a => a.id)).toEqual(['a1', 'a2'])
|
||||
expect(byNode.get('dn-leaf')?.map(a => a.id)).toEqual(['a4'])
|
||||
expect(orphaned.map(a => a.id)).toEqual(['a5', 'a6', 'a7'])
|
||||
})
|
||||
it('修订同规则分组', () => {
|
||||
const { byNode, orphaned } = partitionRevisions(view.revisions, known)
|
||||
expect([...byNode.keys()]).toEqual(['dn-leaf'])
|
||||
expect(orphaned.map(r => r.id)).toEqual(['v2'])
|
||||
})
|
||||
it('分组标题是树节点名并带定位目标(点击由工作区滚动到该节点)', () => {
|
||||
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
|
||||
const anno = partitionAnnotations(view.annotations, known)
|
||||
const html = renderToStaticMarkup(createElement(AnnotationGroups, { ...anno, nodeTitle, onSelectNode: () => {}, onReply: () => {} }))
|
||||
expect(html).toContain('data-locate="dn-head"')
|
||||
expect(html).toContain('项目内容、目标及意义')
|
||||
expect(html).not.toContain('pp.goal.value')
|
||||
const rev = partitionRevisions(view.revisions, known)
|
||||
const revHtml = renderToStaticMarkup(createElement(RevisionGroups, { ...rev, total: view.revisions.length, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(revHtml).toContain('data-locate="dn-leaf"')
|
||||
expect(revHtml).toContain('修订(2)')
|
||||
expect(revHtml).toContain('无法定位(1)')
|
||||
})
|
||||
it('从树上批注/修订发送所选树节点 id;文档根不是审阅目标', () => {
|
||||
const target = reviewTargetNode(wsTree, 'dn-leaf')
|
||||
expect(target?.id).toBe('dn-leaf')
|
||||
expect(reviewTargetNode(wsTree, 'doc1')).toBeUndefined()
|
||||
expect(reviewTargetNode(wsTree, null)).toBeUndefined()
|
||||
expect(reviewTargetNode(null, 'dn-leaf')).toBeUndefined()
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: target!.id, text: '批', operationId: 'op' }).nodeId).toBe('dn-leaf')
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 'sec-2', kind: 'delete', before: '删', operationId: 'op' }).nodeId).toBe('sec-2')
|
||||
})
|
||||
it('locateNodeId 与诊断定位同规则', () => {
|
||||
expect(locateNodeId({ treeNodeId: 'dn-head', nodeId: 'sec-2' }, id => known.has(id))).toBe('dn-head')
|
||||
expect(locateNodeId({ treeNodeId: 'dn-gone', nodeId: 'sec-2' }, id => known.has(id))).toBe('sec-2')
|
||||
expect(locateNodeId({ nodeId: 'pp.x' }, id => known.has(id))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('渲染链条(rdm.inspect.relations layer=render)', () => {
|
||||
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
|
||||
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
|
||||
{ kind: 'section', id: 'sec-2', title: '第二章' },
|
||||
])
|
||||
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
|
||||
const sub = { kind: 'render-submission', id: 'sub-1' }
|
||||
const full = normalizeRenderTrace({
|
||||
items: [
|
||||
{ layer: 'general', type: 'derives', from: { kind: 'section', id: 'a' }, to: { kind: 'section', id: 'b' } },
|
||||
{ layer: 'render', type: 'selected-profile', from: { kind: 'doc', id: 'doc1' }, to: { kind: 'render-profile', id: 'company.dhf@1', source: 'template' } },
|
||||
{ layer: 'render', type: 'annotated', from: sub, to: { kind: 'review-annotation', id: 'ann-1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', displayName: '审核人', at: '2026-10-17T00:00:00Z', text: '请补充' } },
|
||||
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
|
||||
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-2' }, to: { kind: 'document-node', id: 'dn-gone' } },
|
||||
{ layer: 'render', type: 'revised', from: sub, to: { kind: 'review-revision', id: 'rev-1', nodeId: 'sec-2', revisionKind: 'replace' } },
|
||||
{ layer: 'render', type: 'restricted-basis', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'restricted-source', id: 'restricted' } },
|
||||
{ layer: 'render', type: 'future-edge', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'x', id: 'y' } },
|
||||
{ layer: 'render', type: 'delivered', from: { kind: 'doc', id: 'doc1' } },
|
||||
],
|
||||
truncated: true,
|
||||
})
|
||||
|
||||
it('只取 layer=render 且端点完整的条目,未知类型保留原名', () => {
|
||||
expect(full.entries.map(e => e.rawType)).toEqual(['selected-profile', 'annotated', 'anchored-to', 'anchored-to', 'revised', 'restricted-basis', 'future-edge'])
|
||||
expect(full.entries[6].type).toBe('other')
|
||||
expect(traceTypeLabel(full.entries[6])).toBe('future-edge')
|
||||
expect(full.truncated).toBe(true)
|
||||
expect(full.masked).toBe(false)
|
||||
expect(normalizeRenderTrace(null)).toEqual({ entries: [], masked: false, truncated: false })
|
||||
})
|
||||
it('anchored-to 的目标是树节点 id(不回退规范 id);批注/修订 treeNodeId 优先、nodeId 回退', () => {
|
||||
const [, annotated, anchored, anchoredGone, revised, restricted] = full.entries
|
||||
expect(traceAnchor(anchored)).toMatchObject({ subject: { kind: 'review-annotation', id: 'ann-1' }, target: { treeNodeId: 'dn-head' } })
|
||||
expect(traceAnchor(anchored).target).not.toHaveProperty('nodeId')
|
||||
expect(traceAnchor(anchoredGone).target).toEqual({ treeNodeId: 'dn-gone' })
|
||||
expect(traceAnchor(annotated).target).toEqual({ treeNodeId: 'dn-head', nodeId: 'pp.goal.value' })
|
||||
expect(traceAnchor(revised).target).toEqual({ treeNodeId: undefined, nodeId: 'sec-2' })
|
||||
expect(traceAnchor(restricted).target).toBeUndefined()
|
||||
expect(traceEndpointLabel(restricted.to)).toBe('存在受限依据')
|
||||
})
|
||||
it('渲染:已定位的树节点可点击定位,未知树节点显示未定位', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: full, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html).toContain('批注锚定')
|
||||
expect(html.match(/data-locate="dn-head"/g)).toHaveLength(2)
|
||||
expect(html).toContain('data-locate="sec-2"')
|
||||
expect(html).toContain('未定位节点')
|
||||
expect(html).not.toContain('dn-gone"')
|
||||
expect(html).toContain('模板规则')
|
||||
expect(html).toContain('替换')
|
||||
expect(html).toContain('请补充')
|
||||
expect(html).toContain('已截断')
|
||||
expect(html).not.toContain('内容已遮蔽')
|
||||
})
|
||||
it('遮蔽条目只显示元数据:标「内容已遮蔽」,正文字段即使误发也不读取', () => {
|
||||
const masked = normalizeRenderTrace({
|
||||
items: [
|
||||
{ layer: 'render', type: 'annotated', contentMasked: true, from: sub, to: { kind: 'review-annotation', id: 'ann-1', treeNodeId: 'dn-head', displayName: '审核人', text: 'SECRET-TEXT', excerpt: 'SECRET-EXCERPT' } },
|
||||
{ layer: 'render', type: 'revised', contentMasked: true, from: sub, to: { kind: 'review-revision', id: 'rev-1', treeNodeId: 'sec-2', revisionKind: 'replace', before: 'SECRET-BEFORE', after: 'SECRET-AFTER' } },
|
||||
{ layer: 'render', type: 'anchored-to', contentMasked: true, from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
|
||||
],
|
||||
})
|
||||
expect(masked.masked).toBe(true)
|
||||
expect(masked.entries.every(e => e.masked && e.body === undefined)).toBe(true)
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: masked, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html.match(/内容已遮蔽/g)).toHaveLength(3)
|
||||
expect(html).toContain('正文、批注与修订文字已遮蔽')
|
||||
expect(html).toContain('审核人')
|
||||
expect(html).toContain('data-locate="dn-head"')
|
||||
expect(html).not.toContain('SECRET')
|
||||
})
|
||||
it('空链条有明确空态', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: normalizeRenderTrace({ items: [] }), nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html).toContain('暂无渲染链条记录')
|
||||
})
|
||||
})
|
||||
|
||||
describe('交付记录与下载', () => {
|
||||
const page = normalizeDeliveries({
|
||||
items: [
|
||||
|
||||
Reference in New Issue
Block a user