PlantAI-rag-new 首次提交:文献知识工作台
- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化 - Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab - 四档响应式(桌面/笔记本/平板/手机)、深浅主题 - 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测 - 真实凭据已脱敏,运行时配置在 backend/.env(不入 git) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"version":"2.1.8","results":[[":bench.store.test.js",{"duration":35.50389600000017,"failed":false}],[":kg.pane.test.js",{"duration":37.471475000000055,"failed":false}],[":extraction.pane.test.js",{"duration":156.10350700000026,"failed":false}],[":retrieval.pane.test.js",{"duration":98.93684500000018,"failed":false}],[":graphLayout.test.js",{"duration":65.44546100000002,"failed":false}],[":benchpanel.test.js",{"duration":124.59809399999995,"failed":false}],[":sources.pane.test.js",{"duration":5.1857659999998305,"failed":false}]]}
|
||||
@@ -0,0 +1,176 @@
|
||||
// bench.store.test.js —— B3 单测:bench store
|
||||
// 覆盖:tab 切换、相位筛选、轮询启停(fake timers)、sources 两种来源切换、会话级去重。
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createPinia, setActivePinia, getActivePinia } from 'pinia';
|
||||
import { useBenchStore, normalizeStageEvent, __setHttpGet } from '../../../../frontend/src/stores/bench.js';
|
||||
|
||||
const extractionApi = () => import('../../../../frontend/src/api/extraction.js');
|
||||
|
||||
// http.js(B1 归属)尚未交付:用注入式 get 模拟统一信封,不依赖文件存在
|
||||
const httpGet = vi.fn();
|
||||
__setHttpGet(httpGet);
|
||||
|
||||
vi.mock('../../../../frontend/src/api/extraction.js', () => ({
|
||||
listJobs: vi.fn(),
|
||||
getJob: vi.fn(),
|
||||
uploadPaper: vi.fn(),
|
||||
getRunLog: vi.fn(),
|
||||
getProcessingStatus: vi.fn(),
|
||||
getSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../frontend/src/api/kg.js', () => ({
|
||||
kgStatus: vi.fn(),
|
||||
kgOverview: vi.fn(),
|
||||
searchEntities: vi.fn(),
|
||||
getEntity: vi.fn(),
|
||||
getNeighborhood: vi.fn(),
|
||||
}));
|
||||
|
||||
const stageEvent = (stage, phase, label, details = {}, at = '2026-08-25T09:41:00Z') => ({
|
||||
stage,
|
||||
phase,
|
||||
label,
|
||||
details,
|
||||
at,
|
||||
});
|
||||
|
||||
const jobs = [
|
||||
{ job_id: 'j1', title: 'a.pdf', status: 'completed', stage: 'completed', progress_pct: 100 },
|
||||
{ job_id: 'j2', title: 'b.pdf', status: 'running', stage: 'llm', progress_pct: 75 },
|
||||
{ job_id: 'j3', title: 'c.pdf', status: 'failed', stage: 'failed', progress_pct: null },
|
||||
{ job_id: 'j4', title: 'd.pdf', status: 'queued', stage: 'queued', progress_pct: 5 },
|
||||
];
|
||||
|
||||
describe('bench store', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
store = useBenchStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.stopPolling();
|
||||
});
|
||||
|
||||
it('tab 切换:默认 retrieval;切到 extract 会暂停轮询', () => {
|
||||
expect(store.activeTab).toBe('retrieval');
|
||||
store.pollPaused = false;
|
||||
store.setActiveTab('extract');
|
||||
expect(store.activeTab).toBe('extract');
|
||||
store.pollPaused = true; // 模拟曾启动
|
||||
store.setActiveTab('sources');
|
||||
expect(store.pollPaused).toBe(true);
|
||||
});
|
||||
|
||||
it('相位筛选:默认 all,切到 retrieval 后 filteredStageEvents 仅含检索相位', () => {
|
||||
store.recordStageEvent(stageEvent('understanding_started', 'planning', '开始理解问题'));
|
||||
store.recordStageEvent(stageEvent('concepts_found', 'retrieval', '发现相关概念'));
|
||||
store.recordStageEvent(stageEvent('evidence_accepted', 'evidence', '证据已采纳'));
|
||||
expect(store.filteredStageEvents).toHaveLength(3);
|
||||
store.setPhaseFilter('retrieval');
|
||||
expect(store.filteredStageEvents).toHaveLength(1);
|
||||
expect(store.filteredStageEvents[0].stage).toBe('concepts_found');
|
||||
});
|
||||
|
||||
it('normalizeStageEvent:字段缺失时兜底(phase 默认 retrieval)', () => {
|
||||
const n = normalizeStageEvent({ stage: 'x' });
|
||||
expect(n.phase).toBe('retrieval');
|
||||
expect(n.label).toBe('x');
|
||||
expect(n.details).toEqual({});
|
||||
expect(normalizeStageEvent(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('轮询启停(fake timers):有 running 任务且 extract 激活才启动;终态后停止', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { listJobs } = await extractionApi();
|
||||
listJobs.mockResolvedValue({ jobs, count: 4 });
|
||||
|
||||
store.setActiveTab('extract');
|
||||
await store.loadJobs({ force: true });
|
||||
expect(store.hasActiveJobs).toBe(true);
|
||||
|
||||
const started = store.startPolling();
|
||||
expect(started).toBe(true);
|
||||
expect(store.isPolling()).toBe(true);
|
||||
|
||||
// 模拟一轮轮询返回全部终态 → 不再排下一轮
|
||||
listJobs.mockResolvedValue({
|
||||
jobs: jobs.map((j) => ({ ...j, status: 'completed', progress_pct: 100 })),
|
||||
count: 4,
|
||||
});
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(store.isPolling()).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('轮询条件:tab 非 extract 或 pollPaused 时不启动', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { listJobs } = await extractionApi();
|
||||
listJobs.mockResolvedValue({ jobs, count: 4 });
|
||||
|
||||
// 未激活 extract tab
|
||||
await store.loadJobs({ force: true });
|
||||
expect(store.startPolling()).toBe(false);
|
||||
|
||||
// 激活后启动成功
|
||||
store.setActiveTab('extract');
|
||||
expect(store.startPolling()).toBe(true);
|
||||
|
||||
// 暂停后再启动被拒
|
||||
store.pausePolling();
|
||||
expect(store.isPolling()).toBe(false);
|
||||
expect(store.startPolling()).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('无 running/queued 任务时不空转(FR-EXTRACT-005)', async () => {
|
||||
const { listJobs } = await extractionApi();
|
||||
listJobs.mockResolvedValue({
|
||||
jobs: jobs.filter((j) => j.status === 'completed' || j.status === 'failed'),
|
||||
count: 2,
|
||||
});
|
||||
store.setActiveTab('extract');
|
||||
await store.loadJobs({ force: true });
|
||||
expect(store.hasActiveJobs).toBe(false);
|
||||
expect(store.startPolling()).toBe(false);
|
||||
});
|
||||
|
||||
it('sources 两种来源切换:current 取消息 sources 块,session 取聚合接口', async () => {
|
||||
// 无 B2 chat store → current 为 null
|
||||
expect(store.currentSources).toBeNull();
|
||||
expect(store.sources).toBeNull();
|
||||
|
||||
// session 聚合(无活动会话 → null,不发请求)
|
||||
store.setSourceScope('session');
|
||||
expect(store.sources).toBeNull();
|
||||
expect(httpGet).not.toHaveBeenCalled();
|
||||
|
||||
// 有活动会话时拉聚合
|
||||
const sessionsStore = { activeId: 'sess-1' };
|
||||
getActivePinia()._s.set('sessions', sessionsStore);
|
||||
httpGet.mockResolvedValue({
|
||||
session_id: 'sess-1',
|
||||
total: 3,
|
||||
sources: [
|
||||
{ id: 's1', paper_id: 'PMID1', title: 't1', count: 2 },
|
||||
{ id: 's2', paper_id: 'PMID1', title: 't1', count: 1 },
|
||||
{ id: 's3', paper_id: 'PMID2', title: 't2', count: 1 },
|
||||
],
|
||||
});
|
||||
await store.loadSessionSources();
|
||||
expect(httpGet).toHaveBeenCalledWith('/api/sessions/sess-1/sources', undefined);
|
||||
expect(store.sessionSourceItems).toHaveLength(2); // paper_id 去重
|
||||
const byPaper = store.sessionSourceItems.find((s) => s.paper_id === 'PMID1');
|
||||
expect(byPaper.count).toBe(3); // 累计出现次数
|
||||
expect(store.sources).toHaveLength(2);
|
||||
|
||||
// 切回 current:仍为 null(无 B2)
|
||||
store.setSourceScope('current');
|
||||
expect(store.sources).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// benchpanel.test.js —— B3 单测:BenchPanel 四 tab 切换与 ExtractionPane 轮询联动
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBenchStore } from '../../../../frontend/src/stores/bench.js';
|
||||
import BenchPanel from '../../../../frontend/src/components/bench/BenchPanel.vue';
|
||||
|
||||
vi.mock('../../../../frontend/src/api/extraction.js', () => ({
|
||||
listJobs: vi.fn().mockResolvedValue({ jobs: [], count: 0 }),
|
||||
getJob: vi.fn(),
|
||||
uploadPaper: vi.fn(),
|
||||
getRunLog: vi.fn().mockResolvedValue({ text: '' }),
|
||||
getProcessingStatus: vi.fn(),
|
||||
getSummary: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../../frontend/src/api/kg.js', () => ({
|
||||
kgStatus: vi.fn().mockResolvedValue({ ok: true }),
|
||||
kgOverview: vi.fn(),
|
||||
searchEntities: vi.fn(),
|
||||
getEntity: vi.fn(),
|
||||
getNeighborhood: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('BenchPanel(FR-RETRIEVAL/SOURCE/EXTRACT/KG 入口)', () => {
|
||||
let store;
|
||||
let pinia;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
store = useBenchStore();
|
||||
});
|
||||
|
||||
it('四 tab 来自 labels.BENCH_TABS,默认激活检索过程', () => {
|
||||
const wrapper = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
const tabs = wrapper.findAll('.bench-tab');
|
||||
expect(tabs).toHaveLength(4);
|
||||
expect(tabs.map((t) => t.text())).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱']);
|
||||
expect(wrapper.find('.bench-tab.on').text()).toBe('检索过程');
|
||||
});
|
||||
|
||||
it('点击 tab 切换 pane,激活 tab 有渐变下划线(on 类)', async () => {
|
||||
const wrapper = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
const tabs = wrapper.findAll('.bench-tab');
|
||||
await tabs[2].trigger('click');
|
||||
expect(wrapper.find('.bench-tab.on').text()).toBe('提取任务');
|
||||
expect(wrapper.findComponent({ name: 'ExtractionPane' }).exists()).toBe(true);
|
||||
await tabs[3].trigger('click');
|
||||
expect(wrapper.findComponent({ name: 'KgPane' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('Tab 切走 extract 时暂停轮询(FR-EXTRACT-005)', async () => {
|
||||
const wrapper = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
const tabs = wrapper.findAll('.bench-tab');
|
||||
await tabs[2].trigger('click'); // extract
|
||||
store.pollPaused = false;
|
||||
await tabs[0].trigger('click'); // retrieval
|
||||
expect(store.pollPaused).toBe(true);
|
||||
expect(store.isPolling()).toBe(false);
|
||||
});
|
||||
|
||||
it('折叠状态经 props/emit 同步(FR-SHELL-005 手柄由 B1 对接)', async () => {
|
||||
const wrapper = mount(BenchPanel, {
|
||||
props: { collapsed: false },
|
||||
global: { plugins: [pinia] },
|
||||
});
|
||||
const handle = wrapper.find('.bench-handle-btn');
|
||||
expect(handle.exists()).toBe(true);
|
||||
await handle.trigger('click');
|
||||
expect(wrapper.emitted('collapse')).toBeTruthy();
|
||||
expect(wrapper.emitted('collapse')[0]).toEqual([true]);
|
||||
expect(wrapper.classes()).toContain('collapsed-local');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// extraction.pane.test.js —— B3 单测:ExtractionPane(progress_pct 派生展示、failed 错误态、轮询条件)
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { deriveProgressPct, shouldPoll } from '../../../../frontend/src/components/bench/kgPaneCompat.js';
|
||||
import { useBenchStore } from '../../../../frontend/src/stores/bench.js';
|
||||
import ProgressItem from '../../../../frontend/src/components/bench/ProgressItem.vue';
|
||||
|
||||
vi.mock('../../../../frontend/src/api/extraction.js', () => ({
|
||||
listJobs: vi.fn().mockResolvedValue({ jobs: [], count: 0 }),
|
||||
getJob: vi.fn(),
|
||||
uploadPaper: vi.fn(),
|
||||
getRunLog: vi.fn().mockResolvedValue({ text: '' }),
|
||||
getProcessingStatus: vi.fn(),
|
||||
getSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ExtractionPane progress_pct 派生(FR-EXTRACT-001)', () => {
|
||||
it('按 stage 派生:queued=5 / mineru=35 / llm=75 / completed=100 / failed=null', () => {
|
||||
expect(deriveProgressPct({ stage: 'queued' })).toBe(5);
|
||||
expect(deriveProgressPct({ stage: 'mineru' })).toBe(35);
|
||||
expect(deriveProgressPct({ stage: 'llm' })).toBe(75);
|
||||
expect(deriveProgressPct({ stage: 'completed' })).toBe(100);
|
||||
expect(deriveProgressPct({ stage: 'failed' })).toBeNull();
|
||||
expect(deriveProgressPct({ stage: 'unknown' })).toBeNull();
|
||||
expect(deriveProgressPct(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('BFF 已给 progress_pct 时优先使用', () => {
|
||||
expect(deriveProgressPct({ stage: 'llm', progress_pct: 77 })).toBe(77);
|
||||
});
|
||||
|
||||
it('ProgressItem:failed 显示错误态且不渲染进度条', () => {
|
||||
const wrapper = mount(ProgressItem, {
|
||||
props: {
|
||||
job: {
|
||||
job_id: 'j3',
|
||||
title: 'Rice_NAC_2020.pdf',
|
||||
status: 'failed',
|
||||
stage: 'failed',
|
||||
progress_pct: null,
|
||||
message: 'MinerU 解析失败:/home/xia-lab/miniconda3/envs/minerupy311/bin/mineru 不存在',
|
||||
},
|
||||
open: false,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.pi-pct.err').text()).toBe('失败');
|
||||
expect(wrapper.find('.prog-track').exists()).toBe(false); // 不显示进度条
|
||||
expect(wrapper.find('.pi-errmsg').exists()).toBe(true);
|
||||
expect(wrapper.find('.pi-errmsg').text()).toContain('MinerU 解析失败');
|
||||
});
|
||||
|
||||
it('ProgressItem:completed 显示 100% success 停扫光', () => {
|
||||
const wrapper = mount(ProgressItem, {
|
||||
props: {
|
||||
job: { job_id: 'j1', title: 'wang2019.pdf', status: 'completed', stage: 'completed', progress_pct: 100 },
|
||||
open: false,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.pi-pct.ok').text()).toBe('100%');
|
||||
expect(wrapper.find('.prog-fill.full').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ProgressItem:running 显示派生的 75% 渐变进度条', () => {
|
||||
const wrapper = mount(ProgressItem, {
|
||||
props: {
|
||||
job: { job_id: 'j2', title: 'Pecan_SPL_2021.pdf', status: 'running', stage: 'llm', progress_pct: 75 },
|
||||
open: false,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.pi-pct').text()).toBe('75%');
|
||||
expect(wrapper.find('.prog-fill').exists()).toBe(true);
|
||||
expect(wrapper.find('.prog-fill.full').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtractionPane 轮询条件(FR-EXTRACT-005)', () => {
|
||||
it('存在 running/queued 才轮询;全终态停止', () => {
|
||||
expect(shouldPoll([{ status: 'completed' }, { status: 'failed' }])).toBe(false);
|
||||
expect(shouldPoll([{ status: 'completed' }, { status: 'running' }])).toBe(true);
|
||||
expect(shouldPoll([{ status: 'queued' }])).toBe(true);
|
||||
expect(shouldPoll([])).toBe(false);
|
||||
});
|
||||
|
||||
it('store.hasActiveJobs 与 shouldPoll 一致', () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
store.jobs = [
|
||||
{ job_id: 'a', status: 'completed' },
|
||||
{ job_id: 'b', status: 'failed' },
|
||||
];
|
||||
expect(store.hasActiveJobs).toBe(false);
|
||||
store.jobs.push({ job_id: 'c', status: 'running' });
|
||||
expect(store.hasActiveJobs).toBe(true);
|
||||
});
|
||||
|
||||
it('tab 切走即暂停:setActiveTab 离开 extract 调用 pausePolling', () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
store.pollPaused = false;
|
||||
store.setActiveTab('extract');
|
||||
store.pollPaused = true; // 轮询曾启动
|
||||
store.setActiveTab('retrieval');
|
||||
expect(store.pollPaused).toBe(true); // 保持暂停
|
||||
store.setActiveTab('extract');
|
||||
expect(store.pollPaused).toBe(false); // 回到 extract 解除暂停
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// graphLayout.test.js —— B3 单测:手写力导向布局(graphLayout.js)
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { forceLayout, buildPalette, hashString } from '../../../../frontend/src/components/bench/graphLayout.js';
|
||||
|
||||
describe('forceLayout 手写力导向(FR-KG-001)', () => {
|
||||
const small = {
|
||||
nodes: [
|
||||
{ id: 'a', label: 'A', type: 'gene' },
|
||||
{ id: 'b', label: 'B', type: 'tissue' },
|
||||
{ id: 'c', label: 'C', type: 'process' },
|
||||
],
|
||||
edges: [
|
||||
{ source: 'a', target: 'b', relation: 'expressed_in' },
|
||||
{ source: 'a', target: 'c', relation: 'involved_in' },
|
||||
],
|
||||
};
|
||||
|
||||
it('输出节点带有限位置(无 NaN/Infinity),且包围盒适配画布', () => {
|
||||
const { nodes } = forceLayout(small, { width: 800, height: 600 });
|
||||
expect(nodes).toHaveLength(3);
|
||||
for (const n of nodes) {
|
||||
expect(Number.isFinite(n.x)).toBe(true);
|
||||
expect(Number.isFinite(n.y)).toBe(true);
|
||||
expect(n.x).toBeGreaterThanOrEqual(0);
|
||||
expect(n.x).toBeLessThanOrEqual(800);
|
||||
expect(n.y).toBeGreaterThanOrEqual(0);
|
||||
expect(n.y).toBeLessThanOrEqual(600);
|
||||
}
|
||||
});
|
||||
|
||||
it('边端点引用不存在的节点 → 被过滤,不抛错', () => {
|
||||
const { nodes, edges } = forceLayout({
|
||||
nodes: [{ id: 'a' }, { id: 'b' }],
|
||||
edges: [{ source: 'a', target: 'ghost' }, { source: 'a', target: 'b' }],
|
||||
});
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('空图 → 空结果', () => {
|
||||
const { nodes } = forceLayout({ nodes: [], edges: [] });
|
||||
expect(nodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('单节点不崩溃', () => {
|
||||
const { nodes } = forceLayout({ nodes: [{ id: 'x', type: 'gene' }], edges: [] });
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(Number.isFinite(nodes[0].x)).toBe(true);
|
||||
});
|
||||
|
||||
it('大图(60 节点)也能稳定布局(供降级前渲染兜底)', () => {
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
nodes.push({ id: `n${i}`, type: `t${i % 5}` });
|
||||
if (i > 0) edges.push({ source: `n${i}`, target: `n${i - 1}` });
|
||||
}
|
||||
const out = forceLayout({ nodes, edges }, { width: 800, height: 600, iterations: 120 });
|
||||
expect(out.nodes).toHaveLength(60);
|
||||
for (const n of out.nodes) {
|
||||
expect(Number.isFinite(n.x)).toBe(true);
|
||||
expect(Number.isFinite(n.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('相同输入两次运行结果一致(确定性布局)', () => {
|
||||
const a = forceLayout(small, { width: 800, height: 600 });
|
||||
const b = forceLayout(small, { width: 800, height: 600 });
|
||||
for (let i = 0; i < a.nodes.length; i += 1) {
|
||||
expect(a.nodes[i].x).toBeCloseTo(b.nodes[i].x, 6);
|
||||
expect(a.nodes[i].y).toBeCloseTo(b.nodes[i].y, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPalette 稳定映射', () => {
|
||||
it('hashString 稳定', () => {
|
||||
expect(hashString('gene')).toBe(hashString('gene'));
|
||||
expect(hashString('gene')).not.toBe(hashString('tissue'));
|
||||
});
|
||||
|
||||
it('调色板大小写敏感(Gene 与 gene 视为不同 type,与后端一致)', () => {
|
||||
const p = buildPalette(['Gene', 'gene']);
|
||||
expect(p.get('Gene')).not.toBe(p.get('gene'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// kg.pane.test.js —— B3 单测:KgPane(节点配色稳定性、>50 节点降级、离线态、搜索流程 mock)
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPinia, setActivePinia, getActivePinia } from 'pinia';
|
||||
import { buildPalette } from '../../../../frontend/src/components/bench/graphLayout.js';
|
||||
import {
|
||||
isDegradedGraph,
|
||||
isKgOffline,
|
||||
NODE_LIMIT_DESKTOP,
|
||||
NODE_LIMIT_MOBILE,
|
||||
} from '../../../../frontend/src/components/bench/kgPaneCompat.js';
|
||||
import { useBenchStore } from '../../../../frontend/src/stores/bench.js';
|
||||
|
||||
vi.mock('../../../../frontend/src/api/extraction.js', () => ({
|
||||
listJobs: vi.fn(),
|
||||
getJob: vi.fn(),
|
||||
uploadPaper: vi.fn(),
|
||||
getRunLog: vi.fn(),
|
||||
getProcessingStatus: vi.fn(),
|
||||
getSummary: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../../frontend/src/api/kg.js', () => ({
|
||||
kgStatus: vi.fn(),
|
||||
searchEntities: vi.fn().mockResolvedValue({
|
||||
query: 'OsSPL',
|
||||
results: [
|
||||
{ id: 'cent_ospl14', name: 'OsSPL14', type: 'gene', support_count: 18 },
|
||||
{ id: 'cent_ospl7', name: 'OsSPL7', type: 'gene', support_count: 9 },
|
||||
],
|
||||
}),
|
||||
getEntity: vi.fn(),
|
||||
getNeighborhood: vi.fn().mockResolvedValue({
|
||||
center: 'cent_ospl14',
|
||||
center_name: 'OsSPL14',
|
||||
depth: 1,
|
||||
nodes: [{ id: 'cent_ospl14', label: 'OsSPL14', type: 'gene' }],
|
||||
edges: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('KgPane 节点配色稳定性(FR-KG-002)', () => {
|
||||
it('同一 type 在会话内颜色稳定(重复调用结果一致)', () => {
|
||||
const types = ['gene', 'tissue', 'process', 'gene', 'molecule'];
|
||||
const p1 = buildPalette(types);
|
||||
const p2 = buildPalette(types);
|
||||
expect(p1.get('gene')).toBe(p2.get('gene'));
|
||||
expect(p1.get('tissue')).toBe(p2.get('tissue'));
|
||||
expect(p1.get('process')).toBe(p2.get('process'));
|
||||
});
|
||||
|
||||
it('不同 type 颜色互不相同(调色板无重复)', () => {
|
||||
const types = ['gene', 'tissue', 'process', 'molecule', 'trait', 'experimental_material'];
|
||||
const p = buildPalette(types);
|
||||
const values = [...p.values()];
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
|
||||
it('未知类型归入 unknown 色,且稳定', () => {
|
||||
const p1 = buildPalette(['gene']);
|
||||
const p2 = buildPalette(['gene']);
|
||||
expect(p1.get('unknown')).toBe(p2.get('unknown'));
|
||||
});
|
||||
|
||||
it('同一 type 在不同图之间配色一致(固定基数,会话内跨图稳定)', () => {
|
||||
const a = buildPalette(['gene', 'tissue']);
|
||||
const b = buildPalette(['gene']);
|
||||
expect(a.get('gene')).toBe(b.get('gene'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('KgPane 节点降级(FR-KG-001)', () => {
|
||||
it('>50 节点(桌面)自动降级列表视图', () => {
|
||||
expect(isDegradedGraph(50, NODE_LIMIT_DESKTOP)).toBe(false);
|
||||
expect(isDegradedGraph(51, NODE_LIMIT_DESKTOP)).toBe(true);
|
||||
});
|
||||
|
||||
it('移动端上限 30(FR-RESP-012)', () => {
|
||||
expect(isDegradedGraph(30, NODE_LIMIT_MOBILE)).toBe(false);
|
||||
expect(isDegradedGraph(31, NODE_LIMIT_MOBILE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KgPane 离线降级(FR-KG-007)', () => {
|
||||
it('kgReady=false 且状态已探测 → 离线(搜索/邻域禁用,答案图谱仍可用)', () => {
|
||||
expect(isKgOffline(false, { ok: false })).toBe(true);
|
||||
expect(isKgOffline(false, null)).toBe(false); // 尚未探测:不判离线
|
||||
expect(isKgOffline(true, { ok: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('store.checkKgStatus 失败 → kgReady=false + kgError=true', async () => {
|
||||
const { kgStatus } = await import('../../../../frontend/src/api/kg.js');
|
||||
kgStatus.mockRejectedValue({ code: 'UPSTREAM_DOWN', message: 'Neo4j 不可用', degraded: true });
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
await expect(store.checkKgStatus()).rejects.toBeTruthy();
|
||||
expect(store.kgReady).toBe(false);
|
||||
expect(store.kgError).toBe(true);
|
||||
});
|
||||
|
||||
it('store.checkKgStatus 成功 → kgReady=true', async () => {
|
||||
const { kgStatus } = await import('../../../../frontend/src/api/kg.js');
|
||||
kgStatus.mockResolvedValue({ ok: true, active: 'neo4j-plant-srt-direct-v1-full' });
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
await store.checkKgStatus();
|
||||
expect(store.kgReady).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KgPane 搜索流程(mock,FR-KG-004)', () => {
|
||||
it('搜索返回 name_norm 匹配结果(精确/前缀),选中后加载邻域', async () => {
|
||||
const { searchEntities, getNeighborhood } = await import('../../../../frontend/src/api/kg.js');
|
||||
const data = await searchEntities('OsSPL', 20);
|
||||
expect(data.results).toHaveLength(2);
|
||||
expect(data.results[0].name).toBe('OsSPL14');
|
||||
|
||||
const nb = await getNeighborhood('cent_ospl14', { depth: 1, limit: 50 });
|
||||
expect(nb.center).toBe('cent_ospl14');
|
||||
expect(nb.depth).toBe(1);
|
||||
expect(nb.nodes.length).toBeGreaterThan(0);
|
||||
expect(getNeighborhood).toHaveBeenCalledWith('cent_ospl14', { depth: 1, limit: 50 });
|
||||
});
|
||||
|
||||
it('答案图谱在 Neo4j 不可用时仍可用(双数据源策略,FR-KG-007)', () => {
|
||||
// 答案图谱来自消息 graph 块,不依赖 kg API;模拟 B2 消息存在时 currentGraph 返回
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
const chatStore = {
|
||||
currentMessage: {
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'graph', nodes: [{ id: 'OsSPL14', label: 'OsSPL14', type: 'Gene' }], edges: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
getActivePinia()._s.set('chat', chatStore);
|
||||
expect(store.currentGraph).not.toBeNull();
|
||||
expect(store.currentGraph.nodes).toHaveLength(1);
|
||||
// 且此时 kgReady 为 false 也不影响答案图谱
|
||||
expect(store.kgReady).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// retrieval.pane.test.js —— B3 单测:RetrievalPane(相位筛选、空态)
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPinia, setActivePinia, getActivePinia } from 'pinia';
|
||||
import { useBenchStore, normalizeStageEvent } from '../../../../frontend/src/stores/bench.js';
|
||||
import { STAGE_PHASE } from '../../../../frontend/src/i18n/labels.js';
|
||||
|
||||
vi.mock('../../../../frontend/src/api/extraction.js', () => ({
|
||||
listJobs: vi.fn(),
|
||||
getJob: vi.fn(),
|
||||
uploadPaper: vi.fn(),
|
||||
getRunLog: vi.fn(),
|
||||
getProcessingStatus: vi.fn(),
|
||||
getSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
const frames = [
|
||||
{ stage: 'understanding_started', label: '开始理解问题', details: {} },
|
||||
{ stage: 'entities_identified', label: '已识别实体', details: { entities: ['SPL', 'OsSPL14'] } },
|
||||
{ stage: 'concepts_found', label: '发现相关概念', details: { concept_count: 3 } },
|
||||
{ stage: 'evidence_review_completed', label: '证据审核完成', details: { accepted_count: 4 } },
|
||||
{ stage: 'completed', label: '检索完成', details: {} },
|
||||
];
|
||||
|
||||
describe('RetrievalPane 相位筛选(FR-RETRIEVAL-002)', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
store = useBenchStore();
|
||||
for (const f of frames) {
|
||||
const phase = STAGE_PHASE[f.stage] || 'retrieval';
|
||||
store.recordStageEvent({ ...f, phase, at: '2026-08-25T09:41:00Z' });
|
||||
}
|
||||
});
|
||||
|
||||
it('阶段卡数量与 progress 事件数一致(FR-RETRIEVAL-001)', () => {
|
||||
expect(store.stageEvents).toHaveLength(frames.length);
|
||||
});
|
||||
|
||||
it('全部:显示全部卡片', () => {
|
||||
expect(store.filteredStageEvents).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('筛选 planning:仅 2 张', () => {
|
||||
store.setPhaseFilter('planning');
|
||||
const out = store.filteredStageEvents;
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.every((e) => e.phase === 'planning')).toBe(true);
|
||||
});
|
||||
|
||||
it('筛选 retrieval / evidence / generation:各归其位', () => {
|
||||
store.setPhaseFilter('retrieval');
|
||||
expect(store.filteredStageEvents).toHaveLength(1);
|
||||
expect(store.filteredStageEvents[0].stage).toBe('concepts_found');
|
||||
|
||||
store.setPhaseFilter('evidence');
|
||||
expect(store.filteredStageEvents).toHaveLength(1);
|
||||
expect(store.filteredStageEvents[0].stage).toBe('evidence_review_completed');
|
||||
});
|
||||
|
||||
it('筛选后无匹配 → 空列表(不渲染假卡片)', () => {
|
||||
store.setPhaseFilter('generation');
|
||||
expect(store.filteredStageEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('未知 stage 兜底 phase=retrieval(API.md §4 规则)', () => {
|
||||
const n = normalizeStageEvent({ stage: 'mystery_stage', label: 'mystery_stage' });
|
||||
expect(n.phase).toBe('retrieval');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RetrievalPane 空态(FR-RETRIEVAL-006)', () => {
|
||||
it('未提问时无阶段卡(不显示假卡片)', () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
expect(store.stageEvents).toHaveLength(0);
|
||||
expect(store.filteredStageEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('B2 chat store 暴露事件时优先采用其数据源', () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
const chatStore = {
|
||||
currentEvents: [
|
||||
{ stage: 'entities_identified', phase: 'planning', label: '已识别实体', details: {}, at: '2026-08-25T09:41:01Z' },
|
||||
],
|
||||
};
|
||||
getActivePinia()._s.set('chat', chatStore);
|
||||
expect(store.stageEvents).toHaveLength(1);
|
||||
expect(store.stageEvents[0].label).toBe('已识别实体');
|
||||
});
|
||||
|
||||
it('B2 未就绪时回退本地队列', () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useBenchStore();
|
||||
store.recordStageEvent({ stage: 'concepts_found', phase: 'retrieval', label: '发现相关概念', details: {}, at: '2026-08-25T09:41:02Z' });
|
||||
expect(store.stageEvents).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// sources.pane.test.js —— B3 单测:SourcesPane 相关(外链优先级、null 字段不渲染)
|
||||
// 组件渲染走 @vue/test-utils,判定规则走 kgPaneCompat 纯函数双保险。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveSourceLink, aggregateSessionSources } from '../../../../frontend/src/components/bench/kgPaneCompat.js';
|
||||
|
||||
describe('SourcesPane 外链优先级(FR-SOURCE-003)', () => {
|
||||
it('url > doi > pmid > pmcid', () => {
|
||||
const s = {
|
||||
url: 'https://doi.org/10.1038/ng.591',
|
||||
doi: '10.1038/ng.591',
|
||||
pmid: '20393464',
|
||||
pmcid: 'PMC123',
|
||||
};
|
||||
expect(resolveSourceLink(s)).toBe('https://doi.org/10.1038/ng.591');
|
||||
});
|
||||
|
||||
it('无 url 时 doi 拼 https://doi.org/{doi}', () => {
|
||||
expect(resolveSourceLink({ doi: '10.1186/s12870-019-1903-y', pmid: '31286919' })).toBe(
|
||||
'https://doi.org/10.1186/s12870-019-1903-y',
|
||||
);
|
||||
});
|
||||
|
||||
it('无 url 无 doi 时 pmid 拼 PubMed', () => {
|
||||
expect(resolveSourceLink({ pmid: '22429691' })).toBe('https://pubmed.ncbi.nlm.nih.gov/22429691/');
|
||||
});
|
||||
|
||||
it('仅 pmcid 时拼 PMC', () => {
|
||||
expect(resolveSourceLink({ pmcid: 'PMC6612345' })).toBe(
|
||||
'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6612345/',
|
||||
);
|
||||
});
|
||||
|
||||
it('均无(无 doi 无 pmid 无 pmcid)→ null,不显示链接图标', () => {
|
||||
expect(resolveSourceLink({ title: 'x' })).toBeNull();
|
||||
expect(resolveSourceLink({ doi: null, pmid: null, pmcid: null })).toBeNull();
|
||||
expect(resolveSourceLink(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('空字符串按缺失处理(不产生假链接)', () => {
|
||||
expect(resolveSourceLink({ doi: '', pmid: '', pmcid: '' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SourcesPane null 字段不渲染(FR-SOURCE-002)', () => {
|
||||
it('聚合去重:缺失字段键不影响渲染数据,null 值透传不抛错', () => {
|
||||
const out = aggregateSessionSources([
|
||||
{ id: 's1', paper_id: null, pmid: null, doi: null, count: 2 },
|
||||
{ id: 's2', paper_id: null, pmid: null, doi: null, count: 1 },
|
||||
{ id: 's3', paper_id: 'P2', count: 1 },
|
||||
]);
|
||||
// 无 paper_id 时按 id 去重(API.md §5):s1/s2 各保留
|
||||
expect(out).toHaveLength(3);
|
||||
// null 值字段在渲染层判定 falsy 不显示(组件内 v-if 守卫),聚合不丢键也不抛错
|
||||
for (const it of out) {
|
||||
expect(it.pmid == null).toBe(true);
|
||||
expect(it.doi == null).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// vitest.config.js —— B3 单测局部配置(B3 归属,位于 tests/frontend/unit/b3/)
|
||||
// B1 尚未交付 frontend/vitest.config.js,此处提供最小可用配置保证 B3 单测可独立运行。
|
||||
// 因本目录在 frontend/ 之外,node_modules 解析与别名均需显式指到 frontend/node_modules。
|
||||
// 待 B1 总配置就绪后,PM 集成时可删除本文件,改用统一配置(需 include 覆盖本目录)。
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from '../../../../frontend/node_modules/vitest/dist/config.js';
|
||||
import vue from '../../../../frontend/node_modules/@vitejs/plugin-vue/dist/index.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const nm = (rel) => path.resolve(__dirname, '../../../../frontend/node_modules', rel);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
vue: nm('vue/dist/vue.runtime.esm-bundler.js'),
|
||||
pinia: nm('pinia/dist/pinia.mjs'),
|
||||
vitest: nm('vitest/dist/index.js'),
|
||||
'@vue/test-utils': nm('@vue/test-utils/dist/vue-test-utils.esm-bundler.mjs'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['**/*.test.js'],
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user