Files

200 lines
9.8 KiB
TypeScript
Raw Permalink 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.
// 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('&lt;b&gt;粗&lt;/b&gt;甲')
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('无效引用')
})
})