200 lines
9.8 KiB
TypeScript
200 lines
9.8 KiB
TypeScript
// U7 法规结构化引用:标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数,
|
||
// 以及正文渲染不经 HTML(契约:Dev 仓 specs/2026-10-18-regulations-citation-contract.md)。
|
||
import { describe, expect, it } from 'vitest'
|
||
import { createElement } from 'react'
|
||
import { renderToStaticMarkup } from 'react-dom/server'
|
||
import {
|
||
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||
excerptView, parseCitedBlocks, redactionLabel, regsTreeParams, safeWebHref, staleLabel,
|
||
type CitationView,
|
||
} from '../src/regCitations'
|
||
import { CitationCard, CitedBody } from '../src/components/RegCitation'
|
||
|
||
const A = 'cit-0123456789ab'
|
||
const B = 'cit-abcdef012345'
|
||
const C = 'cit-ffffffffffff'
|
||
|
||
const cite = (id: string, over: Partial<CitationView> = {}): CitationView => ({
|
||
id, batchId: 'rb-1', state: 'verified', docNo: 'GB 9706.1-2020', clauseNo: '4.2',
|
||
title: '医用电气设备 第1部分', editionLabel: '2020 版', legalStatus: '现行', corpusRevision: 'rev-7',
|
||
headingPath: ['4 通用要求', '4.2 风险管理'], excerpt: '应实施风险管理过程。', disclosure: 'excerpt', ...over,
|
||
})
|
||
|
||
describe('parseCitedBlocks:标记解析与编号', () => {
|
||
it('多个标记按首次出现编号,文本原样切分', () => {
|
||
const { blocks, order } = parseCitedBlocks([`甲[${A}]乙[${B}]丙`])
|
||
expect(order).toEqual([A, B])
|
||
expect(blocks[0]).toEqual([
|
||
{ kind: 'text', text: '甲' },
|
||
{ kind: 'cite', id: A, n: 1 },
|
||
{ kind: 'text', text: '乙' },
|
||
{ kind: 'cite', id: B, n: 2 },
|
||
{ kind: 'text', text: '丙' },
|
||
])
|
||
})
|
||
|
||
it('重复标记复用编号,编号跨段连续', () => {
|
||
const { blocks, order } = parseCitedBlocks([`[${B}]开头[${A}]`, `再引[${B}]与[${C}][${A}]`])
|
||
expect(order).toEqual([B, A, C])
|
||
const nums = blocks.map(b => b.flatMap(s => (s.kind === 'cite' ? [`${s.id}:${s.n}`] : [])))
|
||
expect(nums).toEqual([[`${B}:1`, `${A}:2`], [`${B}:1`, `${C}:3`, `${A}:2`]])
|
||
})
|
||
|
||
it('未知 ID 也按出现顺序编号(渲染时判为无效引用)', () => {
|
||
const { order } = parseCitedBlocks([`[${C}]`])
|
||
expect(order).toEqual([C])
|
||
})
|
||
|
||
it('没有标记的文本原样返回单个文本片段;空文本没有片段', () => {
|
||
const { blocks, order } = parseCitedBlocks(['普通正文 [slc-1] 不变', ''])
|
||
expect(order).toEqual([])
|
||
expect(blocks).toEqual([[{ kind: 'text', text: '普通正文 [slc-1] 不变' }], []])
|
||
})
|
||
|
||
it('形似标记但 ID 不合规的文本不解析', () => {
|
||
const texts = [
|
||
'[cit-0123456789a]', // 11 位
|
||
'[cit-0123456789abc]', // 13 位
|
||
'[cit-0123456789AB]', // 大写
|
||
'[cit-0123456789ag]', // 非十六进制
|
||
'cit-0123456789ab', // 无方括号
|
||
'[cit 0123456789ab]',
|
||
'[CIT-0123456789ab]',
|
||
]
|
||
const { blocks, order } = parseCitedBlocks(texts)
|
||
expect(order).toEqual([])
|
||
blocks.forEach((b, i) => expect(b).toEqual([{ kind: 'text', text: texts[i] }]))
|
||
})
|
||
|
||
it('嵌套方括号只取合规的内层标记', () => {
|
||
const { blocks } = parseCitedBlocks([`[[${A}]]`])
|
||
expect(blocks[0]).toEqual([{ kind: 'text', text: '[' }, { kind: 'cite', id: A, n: 1 }, { kind: 'text', text: ']' }])
|
||
})
|
||
|
||
it('引用清单:正文编号在前,未出现在正文的引用续编', () => {
|
||
expect(citationListOrder([B, C], [cite(A), cite(B)])).toEqual([B, C, A])
|
||
expect(citationListOrder([], null)).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('核验状态文案与样式', () => {
|
||
it('八种状态的中文文案;未知状态原样显示', () => {
|
||
expect(['verified', 'single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'invalid']
|
||
.map(citationStateLabel))
|
||
.toEqual(['已核验', '单一来源', '降级', '版本未定', '冲突', '未核验', '不适用', '无效引用'])
|
||
expect(citationStateLabel('mystery')).toBe('mystery')
|
||
expect(citationStateLabel(undefined)).toBe('未核验')
|
||
})
|
||
|
||
it('仅 verified 为有效样式;invalid 与缺失 ID 为无效样式;其余为未核验样式', () => {
|
||
expect(citationStyle({ state: 'verified' })).toBe('valid')
|
||
for (const s of ['single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'mystery']) {
|
||
expect(citationStyle({ state: s })).toBe('unverified')
|
||
}
|
||
expect(citationStyle({ state: 'invalid' })).toBe('invalid')
|
||
expect(citationStyle(undefined)).toBe('invalid')
|
||
expect(CITATION_CHIP_TONE).toEqual({ valid: 'ok', unverified: 'warn', invalid: 'danger' })
|
||
})
|
||
|
||
it('stale 的 verified 不再呈现为有效依据,显示「待复核」及原因', () => {
|
||
expect(citationStyle({ state: 'verified', stale: true })).toBe('unverified')
|
||
expect(staleLabel({ stale: false })).toBeNull()
|
||
expect(staleLabel({ stale: true })).toBe('待复核')
|
||
expect(staleLabel({ stale: true, staleReason: 'node_text_changed' })).toBe('待复核(条款文本已变更)')
|
||
expect(staleLabel({ stale: true, staleReason: 'legal_status_changed' })).toBe('待复核(效力状态已变更)')
|
||
expect(staleLabel({ stale: true, staleReason: 'edition_superseded' })).toBe('待复核(版本已被替代)')
|
||
expect(staleLabel({ stale: true, staleReason: 'other_reason' })).toBe('待复核(other_reason)')
|
||
})
|
||
|
||
it('标记文字:有效只给编号,其余附状态文案', () => {
|
||
expect(citationMarkText(1, cite(A))).toBe('1')
|
||
expect(citationMarkText(2, cite(A, { state: 'single_source' }))).toBe('2·单一来源')
|
||
expect(citationMarkText(3, cite(A, { stale: true }))).toBe('3·待复核')
|
||
expect(citationMarkText(4, cite(A, { state: 'invalid' }))).toBe('4·无效引用')
|
||
expect(citationMarkText(5, undefined)).toBe('5·无效引用')
|
||
})
|
||
})
|
||
|
||
describe('原文省略原因', () => {
|
||
it('已知原因中文化;未知原因原样;缺省给通用说明', () => {
|
||
expect(redactionLabel('withheld_by_rights')).toBe('受版权限制')
|
||
expect(redactionLabel('metadata_only')).toBe('仅元数据')
|
||
expect(redactionLabel('no_source_text_permission')).toBe('无原文查看权限')
|
||
expect(redactionLabel('source_withdrawn')).toBe('来源已撤回')
|
||
expect(redactionLabel('court_order')).toBe('court_order')
|
||
expect(redactionLabel(undefined)).toBe('未提供原文')
|
||
})
|
||
|
||
it('有片段给片段;省略时给原因', () => {
|
||
expect(excerptView({ excerpt: '原文' })).toEqual({ excerpt: '原文' })
|
||
expect(excerptView({ redactionReason: 'withheld_by_rights' })).toEqual({ redaction: '受版权限制' })
|
||
})
|
||
|
||
it('只有 http(s) 地址可作外链', () => {
|
||
expect(safeWebHref('https://www.nmpa.gov.cn/x')).toBe('https://www.nmpa.gov.cn/x')
|
||
expect(safeWebHref('javascript:alert(1)')).toBeNull()
|
||
expect(safeWebHref(undefined)).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('regsTreeParams:rdm.inspect.tree kind=regs 请求参数', () => {
|
||
it('根层:kind=regs、空游标、无 parent', () => {
|
||
expect(regsTreeParams('p1')).toEqual({ projectId: 'p1', kind: 'regs', cursor: '' })
|
||
})
|
||
it('分页沿用游标', () => {
|
||
expect(regsTreeParams('p1', { cursor: 'c2' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c2' })
|
||
})
|
||
it('展开证据批:parent=<batchId>', () => {
|
||
expect(regsTreeParams('p1', { parent: 'rb-1' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: '', parent: 'rb-1' })
|
||
expect(regsTreeParams('p1', { parent: 'rb-1', cursor: 'c3' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c3', parent: 'rb-1' })
|
||
})
|
||
})
|
||
|
||
describe('引用呈现', () => {
|
||
it('正文标记编号渲染,未知与 invalid 显示无效引用,正文不作 HTML', () => {
|
||
const html = renderToStaticMarkup(createElement(CitedBody, {
|
||
texts: [`<b>粗</b>甲[${A}]乙[${B}]丙[${C}]`, `再引[${A}]`],
|
||
citations: [cite(A), cite(B, { state: 'invalid' })],
|
||
scope: 't',
|
||
}))
|
||
expect(html).toContain('<b>粗</b>甲')
|
||
expect(html).not.toContain('<b>')
|
||
expect(html).toContain('regcite-mark regcite-valid')
|
||
expect(html).toContain('[1]')
|
||
expect(html).toContain('[2·无效引用]')
|
||
expect(html).toContain('[3·无效引用]')
|
||
expect(html.match(/regcite-mark regcite-valid/g)).toHaveLength(2)
|
||
expect(html).toContain('引用(3)')
|
||
expect(html).not.toContain('<details')
|
||
})
|
||
|
||
it('无标记且无引用时正文原样,不出现引用清单', () => {
|
||
const html = renderToStaticMarkup(createElement(CitedBody, { texts: ['普通 [cit-xyz] 正文'], citations: undefined, scope: 't' }))
|
||
expect(html).toBe('<p class="cockpit-reader-para">普通 [cit-xyz] 正文</p>')
|
||
})
|
||
|
||
it('引用卡片:省略原文时显示中文原因;非 verified 标注未核验', () => {
|
||
const html = renderToStaticMarkup(createElement(CitationCard, {
|
||
id: A, scope: 't',
|
||
citation: cite(A, { state: 'conflict', excerpt: undefined, redactionReason: 'no_source_text_permission', stale: true, staleReason: 'edition_superseded' }),
|
||
}))
|
||
expect(html).toContain('regcite-card-unverified')
|
||
expect(html).toContain('冲突')
|
||
expect(html).toContain('待复核(版本已被替代)')
|
||
expect(html).toContain('未核验引用')
|
||
expect(html).toContain('原文片段未展示:无原文查看权限')
|
||
expect(html).toContain('GB 9706.1-2020')
|
||
expect(html).toContain('4 通用要求 › 4.2 风险管理')
|
||
})
|
||
|
||
it('引用卡片:verified 为有效样式,原文片段在折叠区内;缺失 ID 为无效卡片', () => {
|
||
const ok = renderToStaticMarkup(createElement(CitationCard, { id: A, scope: 't', citation: cite(A) }))
|
||
expect(ok).toContain('regcite-card-valid')
|
||
expect(ok).toContain('mk-col')
|
||
expect(ok).not.toContain('未核验引用')
|
||
const missing = renderToStaticMarkup(createElement(CitationCard, { id: C, scope: 't' }))
|
||
expect(missing).toContain('regcite-card-invalid')
|
||
expect(missing).toContain('无效引用')
|
||
})
|
||
})
|