diff --git a/src/contract/result.ts b/src/contract/result.ts index 71f0837..34a81fe 100644 --- a/src/contract/result.ts +++ b/src/contract/result.ts @@ -52,6 +52,13 @@ export type ResultNode = | { kind: 'gua'; lines: GuaLine[]; title?: string; subtitle?: string } /** 文本行(断语、解释) */ | { kind: 'text'; lines: string[]; lead?: boolean } + /** + * 可核出处:引文原样列出,并给一个"查原文"的入口。 + * + * 权威性不是靠排版出来的——读者点进去必须在阅览室搜到这句话, + * 所以这一节单独成节点,而不是塞进 text 里。 + */ + | { kind: 'quote'; book: string; loc?: string; text: string; gloss?: string } /** 一句话提示 / 免责 */ | { kind: 'note'; text: string; tone?: Tone } diff --git a/src/engine/resonance.ts b/src/engine/resonance.ts index b3b2a0e..cf08297 100644 --- a/src/engine/resonance.ts +++ b/src/engine/resonance.ts @@ -105,11 +105,20 @@ function statementNode(m: Matched, index: number): ResultNode { } const s = st.source - const lines = - s.kind === 'canon' - ? [`《${s.book ?? '佚'}》${s.loc ?? ''}`, `「${s.quote ?? ''}」`, s.gloss ?? ''] - : [s.kind === 'psych' ? '现代心理观察' : '经验观察', s.gloss ?? ''] - children.push({ kind: 'text', lines: lines.filter(Boolean) }) + if (s.kind === 'canon' && s.quote) { + // 出处必须是可点开的引文节点,不是排好版的字:读者点进去要真能搜到 + children.push({ + kind: 'quote', + book: s.book ?? '佚', + text: s.quote, + ...(s.loc ? { loc: s.loc } : {}), + ...(s.gloss ? { gloss: s.gloss } : {}), + }) + } else { + const lines = + s.kind === 'psych' ? ['现代心理观察', s.gloss ?? ''] : ['经验观察', s.gloss ?? ''] + children.push({ kind: 'text', lines: lines.filter(Boolean) }) + } if (st.action) children.push({ kind: 'note', text: '可以做的:' + st.action, tone: 'good' }) diff --git a/src/pages/Home.vue b/src/pages/Home.vue index 8280d1b..2c40312 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -10,6 +10,8 @@ import { CATEGORIES, TOOLS } from '@/tools/registry' import { getManifest, type Manifest } from '@/data/corpus' import { STATEMENTS } from '@/engine/resonance' import { SITUATION_LABEL, SITUATIONS } from '@/engine/features' +import type { ResultNode } from '@/contract/result' +import ResultView from '@/ui/ResultView.vue' const manifest = ref(null) onMounted(async () => { @@ -34,6 +36,21 @@ const daily = computed(() => { }) const sitChips = SITUATIONS.slice(0, 9).map((s) => SITUATION_LABEL[s]) + +/** 今日一签的出处也走同一个 quote 节点:能点开核对才算出处,不然只是排版 */ +const dailySource = computed(() => { + const s = daily.value.source + if (!s) return null + if (s.kind === 'canon' && s.quote) + return { + kind: 'quote', + book: s.book ?? '佚', + text: s.quote, + ...(s.loc ? { loc: s.loc } : {}), + ...(s.gloss ? { gloss: s.gloss } : {}), + } + return { kind: 'text', lines: [s.gloss ?? '经验观察'] } +}) @@ -452,4 +476,35 @@ function widthOf(value: number, max?: number): string { border: 1px dashed var(--line); color: var(--ink-3); } +.qt { + display: grid; + gap: 4px; + padding: 10px 12px; + border-left: 2px solid var(--accent-2); + background: var(--surface-2); + border-radius: 0 6px 6px 0; +} +.qt-x { + margin: 0; + font-size: 13px; + line-height: 1.9; + color: var(--ink-1); +} +.qt-s { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + margin: 0; + font-size: 11px; + color: var(--ink-3); +} +.qt-a { + margin-left: auto; + padding: 2px 9px; + border: 1px solid var(--accent-2); + border-radius: 999px; + color: var(--accent-2); + text-decoration: none; +} diff --git a/tests/app.spec.ts b/tests/app.spec.ts index 6e282dc..8a390ba 100644 --- a/tests/app.spec.ts +++ b/tests/app.spec.ts @@ -61,8 +61,10 @@ describe('界面冒烟', () => { await flushPromises() expect(w.text()).toContain('梅花易数') expect(w.findAll('.hm-card')).toHaveLength(1) - // 每日一签必须带出处或释义,否则等于匿名断语 + // 每日一签必须带出处或释义,否则等于匿名断语;canon 的话还得能点开核对 expect(w.find('.hm-daily-s').text().length).toBeGreaterThan(4) + const dailyLink = w.find('.hm-daily-s .qt-a') + if (dailyLink.exists()) expect(dailyLink.attributes('href')).toContain('#/reading') }) it('工具页:填领域 → 点起一卦 → 出卦画、断语与反馈区', async () => { @@ -128,6 +130,7 @@ describe('界面冒烟', () => { it('ResultView 能解释全部节点类型(新工具不需要新组件)', () => { const nodes: ResultNode[] = [ { kind: 'text', lines: ['x'], lead: true }, + { kind: 'quote', book: '黄帝内经', loc: '素问 · 四气调神大论', text: '不治已病治未病', gloss: '先防' }, { kind: 'note', text: 'n', tone: 'warn' }, { kind: 'chips', items: [{ text: 'c', tone: 'good' }] }, { kind: 'kv', items: [{ label: 'a', value: 'b', sub: 'c' }] }, @@ -145,10 +148,31 @@ describe('界面冒烟', () => { { kind: 'section', title: 'S', children: [{ kind: 'note', text: 'in' }] }, ] const w = mount(ResultView, { props: { node: { kind: 'stack', children: nodes } } }) - for (const cls of ['.tx', '.nt', '.ch', '.kv', '.gr', '.br', '.sco', '.tb', '.mx', '.ga', '.rw', '.sc']) + for (const cls of [ + '.tx', + '.nt', + '.ch', + '.kv', + '.gr', + '.br', + '.sco', + '.tb', + '.mx', + '.ga', + '.rw', + '.sc', + '.qt', + ]) expect(w.find(cls).exists(), `节点未渲染:${cls}`).toBe(true) // 环形评分的描边长度必须随分值变化,否则是摆设 expect(w.find('.sco-fg').attributes('stroke-dasharray')).toBe('215.6 326.7') + // 出处的"查原文"必须把书名与引文带进阅览室查询串,别名交给阅览室解析 + const href = w.find('.qt-a').attributes('href')! + expect(href).toContain('#/reading') + const q = new URLSearchParams(href.split('?')[1]!) + expect(q.get('bk')).toBe('黄帝内经') + expect(q.get('q')).toBe('不治已病治未病') + expect(q.get('loc')).toBe('素问 · 四气调神大论') }) it('FormRenderer 按 schema 生成控件,必填未填时提示', async () => { diff --git a/tests/citation.spec.ts b/tests/citation.spec.ts new file mode 100644 index 0000000..b5b154d --- /dev/null +++ b/tests/citation.spec.ts @@ -0,0 +1,87 @@ +/** + * 出处闭环 + * + * 断语上那句"查原文"是唯一能让权威性当场兑现的入口: + * 读者点进去必须在阅览室搜到这句话。这条链路跨了三个模块 + * (引擎出 quote 节点 → ResultView 拼查询串 → 阅览室解析书名别名并检索), + * 任一处写歪,用户看到的就是一句无处可核的引文。 + */ +import { readFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from 'vue-router' +import { flushPromises, mount } from '@vue/test-utils' +import { makeRouter } from '@/router' +import Reading from '@/pages/Reading.vue' +import ResultView from '@/ui/ResultView.vue' +import { STATEMENTS } from '@/engine/resonance' +import type { ResultNode } from '@/contract/result' + +/** 与 corpus.spec 同一口径:读 public/corpus,即部署产物本身 */ +async function fakeFetch(input: string) { + const path = input.replace(/^(\.\/|\/)/, '') + try { + const body = JSON.parse(readFileSync(`public/${path}`, 'utf8')) + return { ok: true, json: async () => body } as unknown as Response + } catch { + return { ok: false, status: 404, json: async () => ({}) } as unknown as Response + } +} + +async function settle(rounds = 8): Promise { + for (let i = 0; i < rounds; i++) { + await flushPromises() + await new Promise((r) => setTimeout(r, 0)) + } +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(fakeFetch)) +}) + +describe('断语 → 原文 的闭环', () => { + it('每条 canon 断语都渲染成带查询串的出处链接', () => { + const canon = STATEMENTS.filter((s) => s.source?.kind === 'canon' && s.source.quote) + expect(canon.length).toBeGreaterThanOrEqual(20) + for (const st of canon) { + const node: ResultNode = { + kind: 'quote', + book: st.source!.book!, + text: st.source!.quote!, + ...(st.source!.loc ? { loc: st.source!.loc } : {}), + } + const w = mount(ResultView, { props: { node } }) + const href = w.find('.qt-a').attributes('href') + expect(href, `${st.id} 的出处不可点`).toContain('#/reading') + const q = new URLSearchParams(href!.split('?')[1]!) + // 引文进查询串时必须是去省略号后的首段,否则阅览室永远搜不到 + expect(q.get('q')!.length).toBeGreaterThan(3) + expect(q.get('q')).not.toContain('…') + } + }) + + it('点进阅览室:《黄帝内经》按别名解析到素问/灵枢,并搜出那句话', async () => { + const router = makeRouter(createMemoryHistory()) + await router.push({ + name: 'reading', + query: { bk: '黄帝内经', q: '恬淡虛無,真氣從之' }, + }) + await router.isReady() + const w = mount(Reading, { global: { plugins: [router] } }) + await settle(30) // 首次要载 opencc + 拉两部书的 JSON + + expect(w.find('.rd-cite').text()).toContain('素问') + expect(w.findAll('.rd-hit').length, '引文在站内搜不到').toBeGreaterThan(0) + expect(w.find('.rd-hit').text()).toContain('恬淡虚无') // snippet 已转简体 + }) + + it('书名对不上时如实说无法核对,不假装命中', async () => { + const router = makeRouter(createMemoryHistory()) + await router.push({ name: 'reading', query: { bk: '不存在的书', q: '随便一句' } }) + await router.isReady() + const w = mount(Reading, { global: { plugins: [router] } }) + await settle() + + expect(w.find('.rd-err').text()).toContain('站内没有《不存在的书》') + expect(w.findAll('.rd-hit').length).toBe(0) + }) +}) diff --git a/tests/tool.spec.ts b/tests/tool.spec.ts index 4c3b428..830ec04 100644 --- a/tests/tool.spec.ts +++ b/tests/tool.spec.ts @@ -23,6 +23,7 @@ const NODE_KINDS = [ 'matrix', 'gua', 'text', + 'quote', 'note', ] as const const TONES: Tone[] = [ @@ -150,13 +151,30 @@ describe('工具输出结构', () => { const out = await cases[0]!.tool.compute(cases[0]!.input) if (!('body' in out)) throw new Error('no body') const texts = collectNodes(out.body) - .filter((n) => n.kind === 'note' || n.kind === 'text') - .map((n) => (n.kind === 'note' ? n.text : n.lines.join(''))) + .filter((n) => n.kind === 'note' || n.kind === 'text' || n.kind === 'quote') + .map((n) => + n.kind === 'note' ? n.text : n.kind === 'text' ? n.lines.join('') : `《${n.book}》${n.text}`, + ) .join('\n') expect(texts).toContain('梅花易数') expect(texts).toMatch(/不作医疗|不作为医疗/) }) + it('canon 出必须是 quote 节点(可点开核对),不许退化成排好版的纯文本', async () => { + const out = await cases[0]!.tool.compute(cases[0]!.input) + if (!('body' in out)) throw new Error('no body') + const quotes = collectNodes(out.body).filter((n) => n.kind === 'quote') + // 至少要有 canon 出现,否则这条断言是空过的 + expect(quotes.length, '结果里一条 canon 出处都没有').toBeGreaterThan(0) + for (const q of quotes) { + if (q.kind !== 'quote') continue + expect(q.book.length, '出处没书名').toBeGreaterThan(0) + expect(q.text.length, '出处没引文').toBeGreaterThan(3) + // 引文不该被截断成省略号尾巴——截断就是凭印象引书的痕迹 + expect(q.text).not.toMatch(/…$/) + } + }) + it('非法输入返回 error 而不是抛异常或空结果', async () => { const t = toolById('meihua')! const bad = await t.compute({ mode: 'number', numA: 0, numB: 1, domain: 'career' })