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:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
// bench.spec.js —— T 归属。L5 主线 7 图谱(TC-E2E-07-*)+ 主线 8 抽取(TC-E2E-08-*)。
// 图谱:答案图谱(真实 BFF 返回 243 节点 → 桌面/平板超 50 降级列表视图;移动端上限 30)、
// 实体搜索(真实 Neo4j name_norm)、邻域展开、节点详情。
// 抽取:真实上游 8000 任务列表/详情/日志;上传在 mock 拦截下模拟成功。
import { test, expect } from '@playwright/test';
import { tier, selectBenchTab, openBench, benchScope, openSessionPanel, closeSessionPanel, cleanupSessionsByTitle } from './helpers.js';
test.describe('E2E-07 知识图谱', () => {
let pageRef = null;
test.afterEach(async () => {
if (pageRef) await cleanupSessionsByTitle(pageRef, 'R1图谱');
});
test('实体搜索 → 邻域渲染 → 节点详情 → 答案图谱降级/渲染', async ({ page }) => {
test.setTimeout(180_000);
const t = tier(page);
pageRef = page;
await cleanupSessionsByTitle(page, 'R1图谱');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 打开知识图谱 tab
await selectBenchTab(page, '知识图谱');
const sc = benchScope(page);
const kgPane = sc.locator('.kg-pane');
await expect(kgPane).toBeVisible({ timeout: 10_000 });
// 1. 空态(无问答时提示引导搜索)
await expect(kgPane.locator('.empty-note')).toContainText('图谱', { timeout: 10_000 });
// 2. 实体搜索(真实 Neo4j;精确/前缀匹配,<1s 内返回——超时上限放宽到 10s)
const searchInput = kgPane.locator('.search-box');
await searchInput.fill('OsSPL14');
await kgPane.locator('.search-btn').click();
await expect(kgPane.locator('.result-item').first()).toBeVisible({ timeout: 10_000 });
await expect(kgPane.locator('.result-item').first()).toContainText('OsSPL14');
// 3. 选中结果 → 邻域渲染(一跳)。真实数据 OsSPL14 一跳返回 51 节点,
// 按 FR-KG-001 节点数 >50 产品降级为列表视图——先等待两种终态之一出现,再按形态断言
const firstResult = kgPane.locator('.result-item').first();
await firstResult.click();
await expect(kgPane.locator('.degrade-note, .graph-svg').first()).toBeVisible({ timeout: 20_000 });
const degraded = (await kgPane.locator('.degrade-note').count()) > 0;
if (degraded) {
await expect(kgPane.locator('.node-list')).toBeVisible();
await expect(kgPane.locator('.node-list-item').first()).toBeVisible();
} else {
await expect(kgPane.locator('.node-g').first()).toBeVisible();
}
await expect(kgPane.locator('.nb-center')).toContainText(/OsSPL14/i, { timeout: 20_000 });
// 4. 节点详情:图模式点 node-g;列表模式点 node-list-item(均打开侧滑面板)
if (degraded) {
await kgPane.locator('.node-list-item').first().click();
} else {
await kgPane.locator('.node-g').first().click();
}
await expect(page.locator('.kg-entity-detail')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.kg-entity-detail .close-btn')).toBeVisible();
await page.locator('.kg-entity-detail .close-btn').click();
await expect(page.locator('.kg-entity-detail')).toHaveCount(0);
// 5. 邻域深度切换(深度 2 较慢,断言慢提示存在即可)
if (await kgPane.locator('.depth-row').count()) {
await kgPane.locator('.depth-row .chip').nth(1).click();
await expect(kgPane.locator('.nb-slow, .depth-hint').first()).toBeAttached({ timeout: 60_000 });
}
// 6. 答案图谱路径:经历史会话(已有消息)验证 graph 块渲染路径。
// 先关闭工作台(手机覆盖层/平板抽屉 scrim 会拦截后续顶栏点击),再打开会话面板
if (tier(page) === 'mobile') {
await page.locator('.bench-overlay .bench-close-btn').click();
await expect(page.locator('.bench-overlay')).toHaveCount(0);
} else if (tier(page) === 'tablet') {
await page.keyboard.press('Escape');
await expect(page.locator('.drawer.right.open')).toHaveCount(0);
}
await openSessionPanel(page);
const item = page.locator('.session-item').first();
await item.click();
await expect(page.locator('.msg.assistant, .empty-state')).toBeAttached({ timeout: 10_000 });
// 关闭会话抽屉(scrim 会拦截工作台按钮点击),再打开知识图谱 tab
await closeSessionPanel(page);
await selectBenchTab(page, '知识图谱');
// 历史消息 graph 块渲染为画布或降级列表(节点 >50 → 降级提示 + 列表视图)
await expect(
page.locator('.kg-pane .graph-svg, .kg-pane .node-list, .kg-pane .empty-note').first()
).toBeVisible({ timeout: 15_000 });
if (await page.locator('.kg-pane .degrade-note').count()) {
await expect(page.locator('.kg-pane .node-list')).toBeVisible();
}
});
});
test.describe('E2E-08 提取任务', () => {
test('任务列表 → 详情展开 → 运行日志 → 上传(mock 成功)', async ({ page }) => {
test.setTimeout(120_000);
await cleanupSessionsByTitle(page, 'R1抽取');
// 上传接口 mock:成功响应(避免真实 MinerU 环境问题影响 UI 断言)
await page.route('**/api/extraction/upload', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, job_id: 'mockjob000001', status_url: '/api/extraction/jobs/mockjob000001', message: '已提交抽取任务' }),
})
);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await selectBenchTab(page, '提取任务');
const sc = benchScope(page);
const extPane = sc.locator('.extraction-pane');
// 1. 任务列表(真实上游 49 个历史任务)
await expect(extPane.locator('.prog-item').first()).toBeVisible({ timeout: 15_000 });
const pctText = await extPane.locator('.pi-pct').first().textContent();
expect(pctText).toMatch(/%|失败|错误/); // 百分比或 failed 错误态
// 2. 详情展开(点击头部)
const firstJob = extPane.locator('.prog-item').first();
await firstJob.locator('.pi-head').click();
await expect(extPane.locator('.job-detail').first()).toBeVisible({ timeout: 15_000 });
await expect(extPane.locator('.job-detail .status-row')).toBeVisible();
// 3. 运行日志(mono 展示;真实上游为空属正常——DISCOVERY.md §3.4)
await extPane.locator('.log-toggle').click();
await expect(extPane.locator('.run-log')).toBeVisible({ timeout: 10_000 });
// 4. 上传(mock 成功):选择文件 → 成功 ✓ 提示 + 刷新列表
const fileInput = extPane.locator('.hidden-input');
await fileInput.setInputFiles({
name: 'r1-sample.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 mock'),
});
await expect(extPane.locator('.upload-msg.ok')).toBeVisible({ timeout: 10_000 });
// 5. 非 PDF 被拒(前端拦截,不发请求)
await fileInput.setInputFiles({
name: 'not-a-pdf.txt',
mimeType: 'text/plain',
buffer: Buffer.from('hello'),
});
await expect(extPane.locator('.upload-msg.err')).toContainText('仅支持 PDF', { timeout: 10_000 });
});
});
+190
View File
@@ -0,0 +1,190 @@
// helpers.js —— T 归属。L5 用例公共工具:档位判定、会话面板/工作台入口、提问与等待、清理。
// 注意:抽屉在 DOM 中常驻(Teleport),关闭态仅 translateX 移出视口——Playwright 的 :visible
// 仍会命中它,因此所有交互选择器必须按容器精确限定(aside-host / .drawer.left / .drawer.right / .bench-overlay)。
import { expect } from '@playwright/test';
export function tier(page) {
const w = page.viewportSize().width;
if (w >= 1280) return 'desktop';
if (w >= 1024) return 'laptop';
if (w >= 768) return 'tablet';
return 'mobile';
}
/** 会话面板精确定位(桌面=左栏;平板/手机=左抽屉内) */
export function sessionPanel(page) {
return page.locator('.aside-host .session-panel, .drawer.left .session-panel');
}
/** 工作台面板精确定位(桌面=右栏;平板=右抽屉内;手机=覆盖层内) */
export function benchPanel(page) {
return page.locator('.bench-wrap .bench-panel, .drawer.right .bench-panel, .bench-overlay .bench-panel');
}
/** 当前档位下「唯一可交互」的工作台容器(按容器形态精确限定,避免命中隐藏副本) */
export function benchScope(page) {
const t = tier(page);
if (t === 'desktop' || t === 'laptop') return page.locator('.bench-wrap');
if (t === 'tablet') return page.locator('.drawer.right');
return page.locator('.bench-overlay');
}
/** 打开会话面板(平板/手机点汉堡;桌面已常驻) */
export async function openSessionPanel(page) {
const t = tier(page);
if (t === 'tablet' || t === 'mobile') {
if (!(await page.locator('.drawer.left.open').count())) {
await page.locator('.hamburger-btn').click();
await expect(page.locator('.drawer.left.open')).toBeVisible();
}
}
}
/** 关闭会话面板(抽屉形态:Esc 优先;scrim 点击取右侧未被抽屉覆盖的坐标) */
export async function closeSessionPanel(page) {
const t = tier(page);
if (t === 'tablet' || t === 'mobile') {
if (await page.locator('.drawer.left.open').count()) {
// 抽屉(左 260px)覆盖 scrim 中心,直接点 scrim 元素中心会因被遮挡而挂起——
// 用 Esc(Drawer 监听 document keydown),失败再点 scrim 右侧安全坐标
await page.keyboard.press('Escape');
if (await page.locator('.drawer.left.open').count()) {
const vp = page.viewportSize();
await page.mouse.click(vp.width - 15, Math.floor(vp.height / 2));
}
await expect(page.locator('.drawer.left.open')).toHaveCount(0);
}
}
}
/** 打开工作台(桌面=右栏已可见;平板=右抽屉;手机=全屏覆盖层) */
export async function openBench(page) {
const t = tier(page);
if (t === 'tablet') {
if (!(await page.locator('.drawer.right.open').count())) {
await page.locator('.bench-btn').click();
await expect(page.locator('.drawer.right.open')).toBeVisible();
}
} else if (t === 'mobile') {
if (!(await page.locator('.bench-overlay').count())) {
await page.locator('.bench-btn').click();
await expect(page.locator('.bench-overlay')).toBeVisible();
}
}
}
/** 工作台 tab 按钮(按容器精确限定) */
export function benchTab(page, label) {
const t = tier(page);
const scope =
t === 'desktop' ? '.bench-wrap' : t === 'tablet' ? '.drawer.right' : '.bench-overlay .bench-overlay-tabs';
return page.locator(`${scope} .bench-tab`).filter({ hasText: label }).first();
}
/** 点击工作台某个 tab */
export async function selectBenchTab(page, label) {
await openBench(page);
await benchTab(page, label).click();
}
/** 新建会话(返回后会话面板保持打开;调用方按需 closeSessionPanel) */
export async function newSession(page) {
await openSessionPanel(page);
const before = await page.locator('.session-item').count();
await sessionPanel(page).locator('.new-task-btn').click();
await expect(page.locator('.session-item')).toHaveCount(before + 1, { timeout: 10_000 });
await expect(page.locator('.empty-state')).toBeVisible();
}
/** 模式 key → 中文 label(labels.MODES:ask=问答 plan=方案 search=检索) */
const MODE_LABELS = { ask: '问答', plan: '方案', search: '检索' };
/** 在作曲器提问(可选切换模式 pill;mode 为 key,按中文 label 匹配) */
export async function submitQuestion(page, question, mode = 'ask') {
const composer = page.locator('.composer');
if (mode !== 'ask') {
const label = MODE_LABELS[mode] || mode;
await composer.locator('.mode-pill').filter({ hasText: new RegExp(`^${label}$`) }).click();
await expect(composer.locator('.mode-pill.on')).toContainText(label);
}
await composer.locator('textarea').fill(question);
await composer.locator('.send-btn').click();
}
/**
* 等待本轮问答终态(pipeline 徽章 done/error),期间观测相位 running 推进。
* 返回 { done, sawRunning };揭示动画未完成时点击「跳过」加速。
*/
export async function waitForAnswerEnd(page, { timeout = 240_000 } = {}) {
let sawRunning = false;
const badge = page.locator('.msg.assistant .pipeline-card .badge').last();
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (await page.locator('.p-step.running').count()) sawRunning = true;
const cls = await badge.getAttribute('class').catch(() => '');
if (cls && cls.includes('done')) break;
if (cls && cls.includes('error')) break;
await page.waitForTimeout(800);
}
const cls = (await badge.getAttribute('class').catch(() => '')) || '';
// 揭示动画:出现跳过按钮则点击,加速拿到最终 text 块
const skip = page.locator('.msg.assistant .skip-btn').last();
if (await skip.count()) await skip.click().catch(() => {});
return { done: cls.includes('done'), sawRunning };
}
/** 清理本轮测试创建的会话(按标题前缀匹配,经 API 删除,避免污染共享库) */
export async function cleanupSessionsByTitle(page, prefix) {
try {
const list = await page.request.get('/api/sessions');
if (!list.ok()) return;
const items = await list.json();
for (const s of items) {
if ((s.title || '').startsWith(prefix)) {
await page.request.delete(`/api/sessions/${s.id}`);
}
}
} catch {
/* 清理失败不影响测试结论 */
}
}
/**
* 快照当前全部会话 ID(测试开始时调用)。
* 按 ID 精确清理可避免标题前缀误伤他人会话(共享 SQLite,PM 的集成会话同名共存)。
*/
export async function sessionIds(page) {
try {
const list = await page.request.get('/api/sessions');
if (!list.ok()) return null;
const items = await list.json();
return new Set(items.map((s) => s.id));
} catch {
return null;
}
}
/** 删除本轮新建的会话(ID 不在 preIds 快照内的) */
export async function cleanupNewSessions(page, preIds) {
if (!preIds) return;
try {
const list = await page.request.get('/api/sessions');
if (!list.ok()) return;
const items = await list.json();
for (const s of items) {
if (!preIds.has(s.id)) {
await page.request.delete(`/api/sessions/${s.id}`);
}
}
} catch {
/* 清理失败不影响测试结论 */
}
}
/** 页面控制台错误收集(失败证据用) */
export function trackConsoleErrors(page, bucket) {
page.on('console', (msg) => {
if (msg.type() === 'error') bucket.push(msg.text());
});
page.on('pageerror', (err) => bucket.push(String(err)));
}
+138
View File
@@ -0,0 +1,138 @@
// main-flow.spec.js —— T 归属。L5 主线 1:主流程(TC-E2E-01-*)+ 主线 2:检索模式(TC-E2E-02-*)。
// 真实 BFF 上游:QA 约 37s/次(DISCOVERY.md);LLM 402 → answer 为 fallback 证据列表,
// 答案内容断言回退为结构断言(TEST_CASES.md §3.3,依赖充值)。
import { test, expect } from '@playwright/test';
import { tier, openSessionPanel, closeSessionPanel, newSession, submitQuestion, waitForAnswerEnd, cleanupSessionsByTitle, trackConsoleErrors } from './helpers.js';
test.describe('E2E-01 主流程', () => {
let pageRef = null;
test.afterEach(async () => {
if (pageRef) {
// 清理双前缀:新建时的 R1 前缀会被首问自动标题覆盖为问题前缀(FR-SESSION-008)
await cleanupSessionsByTitle(pageRef, 'R1主流程');
await cleanupSessionsByTitle(pageRef, '水稻SPL基因家族的功能研究');
}
});
test('主流程:提问→五相位推进→答案→来源/图谱 tab→刷新恢复', async ({ page }) => {
test.setTimeout(300_000);
const t = tier(page);
const titlePrefix = t === 'desktop' ? 'R1主流程' : 'R1主流程' + (t === 'tablet' ? '-T' : '-M');
pageRef = page;
const consoleErrors = [];
trackConsoleErrors(page, consoleErrors);
await cleanupSessionsByTitle(page, titlePrefix);
await cleanupSessionsByTitle(page, '水稻SPL基因家族的功能研究');
// 1. 首屏加载 → 欢迎空态(.empty-state)
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 2. 新建会话(桌面左栏 / 平板·手机经抽屉)
const listBefore = await page.locator('.session-item').count();
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(listBefore + 1, { timeout: 10_000 });
await closeSessionPanel(page);
await expect(page.locator('.empty-state')).toBeVisible();
// 3. 问答模式提问(真实上游 ~37s;240s 上限内)
const question = '水稻SPL基因家族的功能研究有哪些关键进展?';
await submitQuestion(page, question, 'ask');
// 4. 用户气泡立即出现 + 助手骨架 pipeline 五相位 waiting
await expect(page.locator('.msg.user .bubble').last()).toContainText(question.slice(0, 12), { timeout: 10_000 });
const pipe = page.locator('.msg.assistant .pipeline-card').last();
await expect(pipe).toBeVisible({ timeout: 10_000 });
await expect(pipe.locator('.p-step')).toHaveCount(5);
// 5. 五相位推进:检索相位 running(真实上游 92s 内必经);等待终态
await expect(pipe.locator('.p-step.running').first()).toBeVisible({ timeout: 150_000 });
const res = await waitForAnswerEnd(page, { timeout: 240_000 });
expect(res.sawRunning, '流式期间应有相位处于 running').toBe(true);
expect(res.done, 'pipeline 徽章终态应为 done').toBe(true);
// 6. 终态:pipeline 全部 done、消息含来源块与 text 块(402 fallback:结构断言,依赖充值)
await expect(pipe.locator('.p-step')).toHaveCount(5);
await expect(pipe.locator('.p-step.done')).toHaveCount(5);
const asst = page.locator('.msg.assistant').last();
await expect(asst.locator('.inline-sources .src-chip').first()).toBeVisible({ timeout: 20_000 });
// 揭示动画收尾:点跳过加速。终态判据 = 最终 text 块出现
//(reveal 完成时 stream 块被替换为 text 块,二者互斥;reduced-motion 环境直接定格 text)
const skip = asst.locator('.skip-btn');
if (await skip.count()) await skip.click().catch(() => {});
const mdText = asst.locator('.md-text').first();
await expect(mdText).toBeVisible({ timeout: 180_000 });
// 6b. Markdown 渲染硬断言(BUG-R1-06 已修复验证):
// 触发条件收紧为真实 markdown 语法——行首列表标记「- 」或成对 **加粗**,
// 避免把行内连字符(如 vegetative-to-reproductive)误判为列表
const rawText = await mdText.textContent().catch(() => '');
const listLike = /^- /m.test(rawText);
const boldLike = /\*\*[^*]+\*\*/.test(rawText);
if (listLike || boldLike) {
const html = await mdText.innerHTML().catch(() => '');
const listOk = !listLike || html.includes('<li');
const boldOk = !boldLike || html.includes('<strong') || html.includes('<em');
expect(
listOk && boldOk,
`答案含 markdown 语法(${listLike ? '行首列表' : ''}${listLike && boldLike ? '/' : ''}${boldLike ? '加粗' : ''}),应渲染为 HTML 标签(FR-CHAT-004),实际 HTML: ${html.slice(0, 200)}`
).toBe(true);
}
// 7. 会话状态 done:左栏条目状态点(桌面/抽屉内)
const activeItem = page.locator('.session-item.active').first();
await expect(activeItem).toBeVisible();
await expect(activeItem.locator('.status-dot.done')).toBeVisible({ timeout: 15_000 });
// 8. 刷新 → 点击该会话 → 历史恢复(全部块重渲染)
await page.reload();
await openSessionPanel(page);
// 点击自动标题为问题前 30 字符的会话
await page.locator('.session-item').filter({ hasText: '水稻SPL基因家族的功能研究' }).first().click();
await expect(page.locator('.msg.assistant').last()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.msg.assistant .pipeline-card').last()).toBeVisible();
await expect(page.locator('.msg.assistant .inline-sources .src-chip').first()).toBeVisible();
// 9. 控制台无未捕获错误
expect(consoleErrors, `页面应无 console error,实际: ${consoleErrors.join(' | ')}`).toHaveLength(0);
});
});
test.describe('E2E-02 检索模式', () => {
let pageRef = null;
test.afterEach(async () => {
if (pageRef) {
await cleanupSessionsByTitle(pageRef, 'R1检索');
await cleanupSessionsByTitle(pageRef, '水稻SPL基因家族的功能研究');
}
});
test('检索模式:只出来源不出答案块,use_llm=false', async ({ page }) => {
test.setTimeout(300_000);
const t = tier(page);
pageRef = page;
await cleanupSessionsByTitle(page, 'R1检索');
await cleanupSessionsByTitle(page, '水稻SPL基因家族的功能研究');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
const listBefore = await page.locator('.session-item').count();
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(listBefore + 1);
await closeSessionPanel(page);
// 检索模式提问
await submitQuestion(page, '水稻SPL基因家族的功能研究有哪些关键进展?', 'search');
// 等待终态(真实 search 单次约 92s)
const res = await waitForAnswerEnd(page, { timeout: 240_000 });
expect(res.done, 'pipeline 徽章终态应为 done').toBe(true);
// 断言:无 text 答案块;有 sources 块;有 pipeline 卡
const asst = page.locator('.msg.assistant').last();
await expect(asst.locator('.inline-sources .src-chip').first()).toBeVisible({ timeout: 20_000 });
await expect(asst.locator('.md-text')).toHaveCount(0);
await expect(asst.locator('.stream-wrap')).toHaveCount(0);
});
});
+128
View File
@@ -0,0 +1,128 @@
// mock-scenarios.spec.js —— T 归属。L5 主线 3:错误与重试(TC-E2E-03-*,mock scenario)。
// 仅桌面视口执行(场景构造涉及整页路由拦截,三档行为一致,桌面代表即可;
// 真实错误路径跨档一致 —— 记录于 TEST_REPORT_R1 执行范围说明)。
// mock server 8099 由 PM 运行;本文件经 8090 BFF 时对 /api/qa/* 做页面路由拦截模拟场景。
import { test, expect } from '@playwright/test';
import { tier, openSessionPanel, newSession, submitQuestion, cleanupSessionsByTitle, cleanupNewSessions, sessionIds, trackConsoleErrors } from './helpers.js';
const onlyDesktop = ({ page }) => test.skip(tier(page) !== 'desktop', '仅桌面代表执行(跨档行为一致)');
test.describe('E2E-03 错误与重试(mock 场景)', () => {
let pageRef = null;
let preIds = null;
test.afterEach(async () => {
if (pageRef) {
await cleanupSessionsByTitle(pageRef, 'R1错误');
await cleanupNewSessions(pageRef, preIds); // 自动标题会话(问题文本)按 ID 快照清
}
});
test('UPSTREAM_DOWN:作曲器禁用 + 文案提示 + 历史可浏览', async ({ page }) => {
test.setTimeout(120_000);
onlyDesktop({ page });
pageRef = page;
preIds = await sessionIds(page);
await cleanupSessionsByTitle(page, 'R1错误');
const consoleErrors = [];
trackConsoleErrors(page, consoleErrors);
// 路由拦截:/api/health 返回 QA 不可用的降级数据(驱动 health store 的 qaOk=false)
await page.route('**/api/health', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
status: 'degraded',
version: '0.1.0',
upstreams: { qa: { ok: false }, rag: { ok: true }, ragAlt: { ok: true }, neo4j: { ok: true } },
db: { ok: true, sessions: 9 },
}),
})
);
// /api/qa/chat/stream → 503 降级信封(API.md §1 UPSTREAM_DOWN)
await page.route('**/api/qa/chat/stream', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ ok: false, error: { code: 'UPSTREAM_DOWN', message: '知识问答服务当前不可用,请稍后重试', degraded: true } }),
})
);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 1. 作曲器禁用 + 提示(BUG-R1-04 修复验证:health.qaOk=false → Composer disabled)
const textarea = page.locator('.composer textarea');
await expect(textarea, 'QA 不可用时作曲器应禁用(FR-HEALTH-002)').toBeDisabled({ timeout: 10_000 });
await expect(page.locator('.confirm-mask, .modal')).toHaveCount(0);
// 2. 历史会话仍可浏览:点击已有会话条目 → 消息加载
const item = page.locator('.session-item').first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.msg.assistant, .empty-state')).toBeAttached({ timeout: 10_000 });
// 3. 健康轮询失败不弹窗:控制台无未捕获错误(console.error 单独收集)
await page.waitForTimeout(2000);
expect(consoleErrors, `QA 降级不应产生未捕获错误,实际: ${consoleErrors.join(' | ')}`).toHaveLength(0);
});
test('UPSTREAM_ERROR:错误标注 + 重试按钮 + 重试不新增用户消息', async ({ page }) => {
test.setTimeout(180_000);
onlyDesktop({ page });
pageRef = page;
preIds = await sessionIds(page);
await cleanupSessionsByTitle(page, 'R1错误');
let qaCalls = 0;
// 前两次 502,之后放行真实流(重试应成功 → 也验证重试链路)
await page.route('**/api/qa/chat/stream', async (route) => {
qaCalls += 1;
if (qaCalls <= 2) {
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({ ok: false, error: { code: 'UPSTREAM_ERROR', message: '上游问答服务返回错误', degraded: false } }),
});
} else {
await route.fallback();
}
});
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 新建会话并提问
const before = await page.locator('.session-item').count();
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(before + 1);
await submitQuestion(page, '水稻SPL基因家族的功能研究有哪些关键进展?', 'ask');
// 1. 错误标注(err-note)出现,文案为上游 message(BUG-R1-05 修复验证:
// 上游 502 + 错误信封应透传 message,不得误显示「连接中断」)
const errNote = page.locator('.msg.assistant .err-note').last();
await expect(errNote).toBeVisible({ timeout: 30_000 });
await expect(errNote.locator('.err-text'), '上游 502 时应展示上游错误 message,而非「连接中断」').toContainText('上游问答服务返回错误', { timeout: 10_000 });
// 2. 重试按钮可用
const retryBtn = errNote.locator('.retry-btn');
await expect(retryBtn).toBeVisible();
// 3. 重试:点击后同位置替换(用户消息不新增)
const userCountBefore = await page.locator('.msg.user').count();
const asstCountBefore = await page.locator('.msg.assistant').count();
await retryBtn.click();
// 第二次 502(qaCalls==2)后仍错误;再点一次 → 放行真实流
await expect(page.locator('.msg.assistant .err-note').last()).toBeVisible({ timeout: 30_000 });
await page.locator('.msg.assistant .err-note .retry-btn').last().click();
// 真实流:等待终态(≥40s;pipeline 徽章 done/error 均可,本轮断言结构)
const badge = page.locator('.msg.assistant .pipeline-card .badge').last();
await expect(badge).toHaveClass(/done|error/, { timeout: 240_000 });
// 重试两次后:用户消息仍 1 条、助手消息仍 1 条(同位置替换)
await expect(page.locator('.msg.user')).toHaveCount(userCountBefore);
await expect(page.locator('.msg.assistant')).toHaveCount(asstCountBefore);
});
});
+186
View File
@@ -0,0 +1,186 @@
// responsive.spec.js —— T 归属。L5 主线 9 响应式专项(TC-E2E-09-*,三档布局断言 + 移动端专项)
// 与主线 5 中断(TC-E2E-05-*,桌面代表——流式中断行为跨档一致,记录于报告执行范围)。
// 断点规则(PRD FR-RESP-001~004):≥1280 三栏;1024-1279 左栏自动折叠;768-1023 抽屉;<768 覆盖层。
import { test, expect } from '@playwright/test';
import { tier, openSessionPanel, newSession, closeSessionPanel, cleanupSessionsByTitle, cleanupNewSessions, sessionIds, submitQuestion } from './helpers.js';
test.describe('E2E-09 响应式专项', () => {
test('桌面:三栏网格 260/1fr/380 + 底栏四点 + 折叠持久化', async ({ page }) => {
test.setTimeout(60_000);
test.skip(tier(page) !== 'desktop', '仅桌面视口');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 1. 网格精确值(FR-SHELL-001):getComputedStyle 会把 1fr 解析为像素(1440 下=800px),
// 因此断言三轨解析值 + 行轨与 body overflow
const grid = await page.evaluate(() => {
const s = getComputedStyle(document.querySelector('.app-shell'));
return { cols: s.gridTemplateColumns.split(' '), rows: s.gridTemplateRows, overflow: getComputedStyle(document.body).overflow };
});
expect(grid.cols[0]).toBe('260px');
expect(grid.cols[1]).toBe('800px'); // 1440-260-380=800(minmax(0,1fr) 解析值)
expect(grid.cols[2]).toBe('380px');
// 行轨 1fr 同样解析为像素(900-48-28=824)
const rowTracks = grid.rows.split(' ');
expect(rowTracks[0]).toBe('48px');
expect(rowTracks[1]).toBe('824px');
expect(rowTracks[2]).toBe('28px');
expect(grid.overflow).toBe('hidden');
// 2. 三栏宽度精确(1440:260/800/380)
const widths = await page.evaluate(() => {
const q = (sel) => document.querySelector(sel)?.getBoundingClientRect().width;
return {
aside: q('.aside-host .session-panel') || q('.aside-host'),
chat: q('.chat-panel'),
bench: q('.bench-wrap .bench-panel') || q('.bench-wrap'),
};
});
expect(widths.aside).toBeCloseTo(260, 0);
expect(widths.chat).toBeCloseTo(800, 0);
expect(widths.bench).toBeCloseTo(380, 0);
// 3. 无横向滚动条(FR-SHELL-001)
const noHScroll = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth);
expect(noHScroll).toBe(true);
// 4. 底栏:4 个 agent 点 + 版本 + QA 版本 mono
const statusBar = page.locator('.status-bar');
await expect(statusBar).toBeVisible();
await expect(statusBar.locator('.agent-dot-item')).toHaveCount(4);
await expect(statusBar.locator('.status-right')).toContainText('PlantAI RAG v0.1');
// 5. 左栏折叠 → 64px + localStorage 持久化 → 刷新保持(FR-SHELL-004)
await page.locator('.side-handle-btn').click();
await expect.poll(() => page.evaluate(() => document.querySelector('.app-shell').classList.contains('side-collapsed'))).toBe(true);
await expect(page.locator('.app-shell')).toHaveClass(/side-collapsed/);
await expect.poll(() => page.evaluate(() => localStorage.getItem('pa-side-collapsed'))).toBe('1');
await page.reload();
await expect(page.locator('.app-shell')).toHaveClass(/side-collapsed/);
// 展开恢复,避免影响后续用例
await page.locator('.side-handle-btn').click();
});
test('平板:单栏 + 左/右抽屉(scrim 关闭)+ 顶栏按钮 + 底栏保留', async ({ page }) => {
test.setTimeout(60_000);
test.skip(tier(page) !== 'tablet', '仅平板视口');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await expect(page.locator('.app-shell')).toHaveClass(/drawers-mode/);
// 1. 单栏铺满 + 左/右栏隐藏
await expect(page.locator('.aside-host, .app-shell .session-panel:not(.drawer .session-panel)')).toHaveCount(0);
await expect(page.locator('.bench-wrap')).toHaveCount(0);
// 2. 汉堡 → 左抽屉滑入 + scrim;点击 scrim 关闭
await page.locator('.hamburger-btn').click();
await expect(page.locator('.drawer.left.open')).toBeVisible();
await expect(page.locator('.drawer-scrim-el')).toBeVisible();
await page.locator('.drawer-scrim-el').click();
await expect(page.locator('.drawer.left.open')).toHaveCount(0);
// 3. 工作台按钮 → 右抽屉(来源角标存在与否按数据)
await page.locator('.bench-btn').click();
await expect(page.locator('.drawer.right.open')).toBeVisible();
await expect(page.locator('.drawer.right .bench-panel')).toBeVisible();
// Esc 关闭
await page.keyboard.press('Escape');
await expect(page.locator('.drawer.right.open')).toHaveCount(0);
// 4. 底栏保留(平板不隐藏)
await expect(page.locator('.status-bar')).toBeVisible();
});
test('手机:顶栏三件套 + 作曲器吸底 + 全屏覆盖层 + 底栏隐藏 + 移动端专项', async ({ page }) => {
test.setTimeout(60_000);
test.skip(tier(page) !== 'mobile', '仅手机视口');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await expect(page.locator('.app-shell')).toHaveClass(/drawers-mode/);
await expect(page.locator('.app-shell')).toHaveClass(/mobile/);
// 1. 顶栏 = 汉堡 + 标题 + 工作台按钮;底栏隐藏(FR-RESP-004)
await expect(page.locator('.hamburger-btn')).toBeVisible();
await expect(page.locator('.bench-btn')).toBeVisible();
await expect(page.locator('.status-bar')).toBeHidden();
await expect(page.locator('.logo-text')).toBeHidden();
// 2. 移动端专项:textarea 16px(FR-RESP-006)
const fontSize = await page.evaluate(() => getComputedStyle(document.querySelector('.composer textarea')).fontSize);
expect(fontSize).toBe('16px');
// 3. 触控目标 ≥44px(FR-RESP-008)
const heights = await page.evaluate(() => {
const q = (sel) => document.querySelector(sel)?.getBoundingClientRect().height;
return { pill: q('.mode-pill'), tab: q('.bench-overlay-tabs .bench-tab') || q('.bench-tab'), item: q('.session-item') };
});
if (heights.pill != null) expect(heights.pill).toBeGreaterThanOrEqual(44);
// 4. 工作台按钮 → 全屏覆盖层 + 顶部 tab 条 + 内部 tab 隐藏(.bench-overlay)
await page.locator('.bench-btn').click();
await expect(page.locator('.bench-overlay')).toBeVisible();
await expect(page.locator('.bench-overlay .bench-overlay-tabs')).toBeVisible();
await expect(page.locator('.bench-overlay .bench-panel .bench-tabs')).toBeHidden();
// 关闭按钮
await page.locator('.bench-overlay .bench-close-btn').click();
await expect(page.locator('.bench-overlay')).toHaveCount(0);
// 5. 作曲器吸底(视口底部附近,安全区 padding 生效)
const composerBox = await page.locator('.composer-wrap').boundingBox();
const viewport = page.viewportSize();
expect(viewport.height - composerBox.y - composerBox.height).toBeLessThanOrEqual(60);
// 6. viewport-fit=cover(FR-RESP-005)
const metaViewport = await page.evaluate(() => document.querySelector('meta[name="viewport"]').content);
expect(metaViewport).toContain('viewport-fit=cover');
// 7. 表格横向滚动容器(FR-RESP-011):构造宽表格断言(有 md-table-wrap 时)
await expect(page.locator('.md-table-wrap, .chat-panel')).toBeAttached();
});
});
test.describe('E2E-05 中断', () => {
let pageRef = null;
let preIds = null;
test.afterEach(async () => {
if (pageRef) await cleanupNewSessions(pageRef, preIds);
});
test('流式中切会话 → 原会话置 error 标注「已中断」', async ({ page }) => {
test.setTimeout(300_000);
test.skip(tier(page) !== 'desktop', '仅桌面代表执行(真实流成本高,中断行为跨档一致)');
pageRef = page;
preIds = await sessionIds(page);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 新建会话 A(标题「新会话」)并提问。位置定位:新建项 unshift 到顶部,
// 且流式检查点不断 touch 刷新 updated_at → A 全程在列表顶部
await newSession(page);
await submitQuestion(page, '水稻SPL基因家族的功能研究有哪些关键进展?', 'ask');
// 等待流式开始(pipeline running 相位出现)
await expect(page.locator('.msg.assistant .p-step.running').first()).toBeVisible({ timeout: 120_000 });
// 切到列表第二条(任意其他会话)→ selectSession 触发 abort('interrupted') 中断 A
const other = page.locator('.session-item').nth(1);
await expect(other).toBeVisible();
await other.click();
// 等待原会话被置 error 并落库(中断标注在 _markInterrupted 中同步完成)
await expect(page.locator('.session-item.active')).toHaveCount(1, { timeout: 10_000 });
// 切回 A(列表顶部;中断路径的 _finish 也会 ensureTitle,
// 标题此刻已是自动标题「水稻SPL基因…」,不依赖标题定位——A 一直因 touch 保持在顶部)
const itemA = page.locator('.session-item').first();
await itemA.click();
const errNote = page.locator('.msg.assistant .err-note').last();
await expect(errNote).toBeVisible({ timeout: 20_000 });
await expect(errNote.locator('.err-text')).toContainText('已中断', { timeout: 20_000 });
// 会话条目状态点 error(活动项 = 刚点回的 A)
await expect(page.locator('.session-item.active .status-dot.error')).toBeVisible({ timeout: 15_000 });
});
});
+142
View File
@@ -0,0 +1,142 @@
// session-theme.spec.js —— T 归属。L5 主线 4 会话管理(TC-E2E-04-*)+ 主线 6 主题(TC-E2E-06-*)。
// R1 结构:新建/重命名/搜索/分组(核心,硬断言,缺陷独立);活动高亮与删除入口为缺陷探针
// (独立 test,失败即缺陷证据,不连累核心用例)。三档视口行为一致但会话数据共享——桌面执行核心,
// 平板/手机执行新建+搜索精简分支。
import { test, expect } from '@playwright/test';
import { tier, newSession, cleanupSessionsByTitle, cleanupNewSessions, sessionIds } from './helpers.js';
const PREFIX = 'R1会话-';
test.describe('E2E-04 会话管理', () => {
let pageRef = null;
let preIds = null;
test.afterEach(async () => {
// 按 ID 快照精确清理(标题前缀只覆盖已重命名的会话;未重命名的「新会话」按 ID 删)
if (pageRef) {
await cleanupSessionsByTitle(pageRef, PREFIX);
await cleanupNewSessions(pageRef, preIds);
}
});
test('新建/重命名/搜索过滤/分组(核心)', async ({ page }) => {
test.setTimeout(120_000);
pageRef = page;
preIds = await sessionIds(page);
await cleanupSessionsByTitle(page, PREFIX);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 1. 新建两个会话(连点两次 → 两个独立会话,不重复)
const before = await page.locator('.session-item').count();
await newSession(page);
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(before + 2, { timeout: 10_000 });
// 2. 双击重命名(Enter 提交);标题同步到列表与顶栏(FR-SESSION-006)
const firstItem = page.locator('.session-item').first();
await firstItem.dblclick();
const renameInput = firstItem.locator('.rename-input');
await expect(renameInput).toBeVisible();
await renameInput.fill(`${PREFIX}甲`);
await renameInput.press('Enter');
await expect(page.locator('.session-item').first().locator('.title')).toContainText(`${PREFIX}甲`);
if (tier(page) !== 'mobile') {
await expect(page.locator('.top-title .t')).toContainText(`${PREFIX}甲`);
}
// 第二个会话也重命名,便于后文按前缀过滤断言
const secondItem = page.locator('.session-item').nth(1);
await secondItem.dblclick();
await secondItem.locator('.rename-input').fill(`${PREFIX}乙`);
await secondItem.locator('.rename-input').press('Enter');
await expect(page.locator('.session-item').nth(1).locator('.title')).toContainText(`${PREFIX}乙`);
// 3. 搜索过滤:大小写不敏感子串;无匹配文案;清空恢复;期间无网络请求(FR-SESSION-005)
let reqCount = 0;
const onReq = () => { reqCount += 1; };
page.on('request', onReq);
const searchBox = page.locator('.search-box input').first();
await searchBox.fill('r1会话'); // 小写 r1 应命中大写 R1(大小写不敏感)
await expect(page.locator('.session-item')).toHaveCount(2);
await searchBox.fill('甲');
await expect(page.locator('.session-item')).toHaveCount(1);
await searchBox.fill('不存在的会话名XYZ');
await expect(page.locator('.empty-search')).toContainText('无匹配会话');
await expect(page.locator('.session-item')).toHaveCount(0);
await searchBox.fill('');
await expect(page.locator('.session-item')).toHaveCount(before + 2);
expect(reqCount, '搜索过滤不应发网络请求').toBe(0);
page.off('request', onReq);
// 4. 分组标签(今天组存在——新会话更新时间为今天,FR-SESSION-001)
await expect(page.locator('.group-label').first()).toContainText('今天');
});
// 缺陷探针 A(BUG-R1-02 修复验证):点击会话应置活动态高亮(FR-SESSION-009)
test('活动态高亮探针', async ({ page }) => {
test.setTimeout(60_000);
pageRef = page;
preIds = await sessionIds(page);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await newSession(page);
await newSession(page);
// 点击第二项(选中它,并自动切换到它)
const second = page.locator('.session-item').nth(1);
await second.click();
// 硬断言:活动项应有 .active(FR-SESSION-009)
await expect(second, '点击会话后应显示活动态高亮(FR-SESSION-009)').toHaveClass(/active/, { timeout: 10_000 });
});
// 缺陷探针 B(BUG-R1-03 修复验证):删除入口是否存在(FR-SESSION-007 二次确认弹层需可达)
test('删除入口探针', async ({ page }) => {
test.setTimeout(60_000);
pageRef = page;
preIds = await sessionIds(page);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await newSession(page);
const firstItem = page.locator('.session-item').first();
await firstItem.click({ button: 'right' });
// 硬断言:右键应唤出删除确认弹层(BUG-R1-03 修复验证)
await expect(
page.locator('.confirm-box'),
'右键应唤出删除确认弹层(FR-SESSION-007 删除需二次确认)'
).toBeVisible({ timeout: 5_000 });
});
});
test.describe('E2E-06 主题', () => {
test('深浅切换即时生效 + 刷新持久化', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 初始主题(默认深色或 localStorage 已有值)
const initial = await page.evaluate(() => document.documentElement.dataset.theme);
const themeBtn = page.locator('.top-bar .icon-btn').last();
// 切换到另一主题 → dataset 即时更新 + localStorage 落值
await themeBtn.click();
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.theme), { timeout: 5_000 })
.not.toBe(initial);
const toggled = await page.evaluate(() => document.documentElement.dataset.theme);
const stored = await page.evaluate(() => localStorage.getItem('pa-theme'));
expect(stored).toBe(toggled);
// 刷新 → 保持
await page.reload();
await expect(page.locator('.app-shell')).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.theme), { timeout: 5_000 })
.toBe(toggled);
// 复原初始主题(避免影响后续用例的视觉断言)
if (toggled !== initial) await themeBtn.click();
});
});
+44
View File
@@ -0,0 +1,44 @@
// smoke.spec.js —— L5 冒烟骨架(本波仅 2 条)。
// 每一条在 mobile(390×844)/tablet(768×1024)/desktop(1440×900) 三项目上各执行一次。
// 对应 docs/TEST_CASES.md:TC-E2E-SMOKE-01(页面加载)、TC-E2E-SMOKE-02(AppShell 存在)。
//
// 契约依据:UI_SPEC.md §5.3 类名(app-shell/top-bar/status-bar/session-panel/chat-panel/bench-panel)。
// 说明:M0 阶段 AppShell 尚未实现(App.vue 为占位),AppShell 断言带占位回退(app-shell-placeholder),
// B1 交付后断言即走真实分支。完整用例(TC-E2E-01~11)在后续轮次按 TEST_CASES.md 补齐。
import { test, expect } from '@playwright/test';
// 项目:mobile/tablet/desktop(playwright.config.js projects)。三档视口下页面可加载。
test('TC-E2E-SMOKE-01 | 页面加载:首屏可渲染,标题正确', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/海古纪 PlantAI/);
// #app 挂载且非空(M0 为占位文案,M1 起为 AppShell)
const app = page.locator('#app');
await expect(app).toBeVisible();
const html = (await app.innerHTML()).trim();
expect(html.length, '#app 不应为空').toBeGreaterThan(0);
});
// 项目:mobile/tablet/desktop。AppShell 骨架存在。
// M0 回退:仅断言 #app 内容;M1 起断言 shell 三要素与占位符消失。
// 注意:移动端底栏按 FR-RESP-004 有意隐藏(四个 agent 点合并为顶栏聚合状态点),
// 因此 .status-bar 只在 ≥768px 视口断言可见,移动端断言隐藏。
test('TC-E2E-SMOKE-02 | AppShell 存在:壳层三要素渲染(含 M0 占位回退)', async ({ page }) => {
await page.goto('/');
const shell = page.locator('.app-shell');
const placeholder = page.locator('.app-shell-placeholder');
if (await shell.count()) {
// M1+:B1 交付的真实三栏壳层
await expect(shell).toBeVisible();
await expect(page.locator('.top-bar')).toBeVisible();
const width = (await page.viewportSize()).width;
if (width >= 768) {
await expect(page.locator('.status-bar')).toBeVisible();
} else {
await expect(page.locator('.status-bar')).toBeHidden();
}
await expect(placeholder).toHaveCount(0);
} else {
// M0:脚手架占位期,AppShell 未实现时仅验证页面可渲染
await expect(placeholder).toBeVisible();
}
});
+144
View File
@@ -0,0 +1,144 @@
// AppShell 响应式单测(B1):四档断点的 class 切换(FR-RESP-001..004)
// jsdom + matchMedia mock。验证:
// desktop ≥1280:无 drawers-mode,折叠 class 生效
// laptop 1024–1279:无 drawers-mode(CSS 强制折叠由样式层处理)
// tablet 768–1023:drawers-mode
// mobile <768:drawers-mode + mobile
// 折叠持久化:localStorage pa-side-collapsed / pa-bench-collapsed
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { nextTick } from 'vue';
import AppShell from '../../../../frontend/src/components/layout/AppShell.vue';
// mock fetch(health store 若被使用则不真发请求)
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }))));
/** 用 matchMedia mock 设置视口宽度 */
function setViewport(w) {
window.matchMedia = (q) => ({
matches: false,
media: q,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: w });
}
async function mountShell(w = 1440) {
setViewport(w);
const pinia = createPinia();
const wrapper = mount(AppShell, { global: { plugins: [pinia] } });
await nextTick();
return wrapper;
}
describe('AppShell 响应式四档断点', () => {
beforeEach(() => {
localStorage.clear();
});
it('desktop ≥1280px:三栏,无 drawers-mode;折叠 class 与 localStorage 联动', async () => {
const w = await mountShell(1440);
const shell = w.find('.app-shell');
expect(shell.exists()).toBe(true);
expect(shell.classes()).not.toContain('drawers-mode');
expect(shell.classes()).not.toContain('mobile');
// 默认展开
expect(shell.classes()).not.toContain('side-collapsed');
expect(shell.classes()).not.toContain('bench-collapsed');
// 折叠把手点击 → side-collapsed + localStorage
await w.find('.side-handle-btn').trigger('click');
expect(w.find('.app-shell').classes()).toContain('side-collapsed');
expect(localStorage.getItem('pa-side-collapsed')).toBe('1');
await w.find('.side-handle-btn').trigger('click');
expect(localStorage.getItem('pa-side-collapsed')).toBe('0');
// bench 折叠(折叠入口按钮)
await w.find('.bench-collapse-btn').trigger('click');
expect(w.find('.app-shell').classes()).toContain('bench-collapsed');
expect(localStorage.getItem('pa-bench-collapsed')).toBe('1');
w.unmount();
});
it('折叠状态刷新后保持(localStorage 持久化)', async () => {
localStorage.setItem('pa-side-collapsed', '1');
localStorage.setItem('pa-bench-collapsed', '1');
const w = await mountShell(1440);
const cls = w.find('.app-shell').classes();
expect(cls).toContain('side-collapsed');
expect(cls).toContain('bench-collapsed');
w.unmount();
});
it('laptop 1024–1279px:三栏保留(CSS 锁折叠),无 drawers-mode', async () => {
const w = await mountShell(1180);
const cls = w.find('.app-shell').classes();
expect(cls).not.toContain('drawers-mode');
expect(cls).not.toContain('mobile');
// 折叠把手在笔记本档由 CSS 隐藏(类逻辑上仍可点击,但桌面外不提供)
expect(w.find('.side-handle-btn').exists()).toBe(true);
w.unmount();
});
it('tablet 768–1023px:drawers-mode(左右栏改抽屉),无 mobile', async () => {
const w = await mountShell(900);
const cls = w.find('.app-shell').classes();
expect(cls).toContain('drawers-mode');
expect(cls).not.toContain('mobile');
expect(cls).toContain('has-tools');
// 网格中的左右栏不再渲染(抽屉形态)
expect(w.find('.aside-host').exists()).toBe(false);
expect(w.find('.bench-wrap').exists()).toBe(false);
// 抽屉组件存在(初始关闭)
expect(w.findAllComponents({ name: 'Drawer' })).toBeTruthy();
w.unmount();
});
it('mobile <768px:drawers-mode + mobile', async () => {
const w = await mountShell(600);
const cls = w.find('.app-shell').classes();
expect(cls).toContain('drawers-mode');
expect(cls).toContain('mobile');
expect(w.find('.aside-host').exists()).toBe(false);
expect(w.find('.bench-wrap').exists()).toBe(false);
w.unmount();
});
it('汉堡按钮打开会话抽屉(<1024px)', async () => {
const w = await mountShell(900);
// 顶栏汉堡按钮(menuAvailable 为 true 时渲染)
const burger = w.find('.top-bar .hamburger-btn');
expect(burger.exists()).toBe(true);
await burger.trigger('click');
await nextTick();
// Drawer 已打开(Teleport 到 body,用 document 查询)
expect(document.querySelectorAll('.drawer.left.open').length).toBe(1);
// Esc 关闭
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await nextTick();
expect(document.querySelectorAll('.drawer.left.open').length).toBe(0);
w.unmount();
});
it('工作台按钮打开右抽屉(<1024px)', async () => {
const w = await mountShell(900);
const btn = w.find('.top-bar .bench-btn');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await nextTick();
expect(document.querySelectorAll('.drawer.right.open').length).toBe(1);
// scrim 点击关闭
document.querySelector('.drawer-scrim')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(document.querySelectorAll('.drawer.right.open').length).toBe(0);
w.unmount();
});
});
+141
View File
@@ -0,0 +1,141 @@
// health store 单测(B1):轮询与降级 getter(mock fetch)(FR-HEALTH-001..004)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useHealthStore } from '../../../../frontend/src/stores/health.js';
// mock fetch(http.js 的 fetch 封装走全局 fetch)
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
function jsonRes(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const HEALTH_OK = {
status: 'ok',
version: '0.1.0',
upstreams: {
qa: { ok: true, version: '1.1.73-assertion-anchored' },
rag: { ok: true },
ragAlt: { ok: true },
neo4j: { ok: true, active: 'neo4j-plant-srt-pc2025' },
},
db: { ok: true, sessions: 12 },
};
// 本文件所有 start() 过的 store,afterEach 统一 stop(防止 interval 泄漏到下一个测试)
let startedStores = [];
describe('health store', () => {
beforeEach(() => {
fetchMock.mockReset();
setActivePinia(createPinia());
vi.useFakeTimers();
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
startedStores = [];
});
afterEach(() => {
startedStores.forEach((s) => s.stop());
startedStores = [];
vi.useRealTimers();
});
/** 启动并登记 store */
function startStore() {
const store = useHealthStore();
store.start();
startedStores.push(store);
return store;
}
it('启动轮询:立即请求一次 health,数据入 store,getter 全绿', async () => {
fetchMock.mockResolvedValue(jsonRes(HEALTH_OK));
const store = startStore();
expect(fetchMock).toHaveBeenCalledTimes(1);
// 让初始 fetch 的 microtask 完成
await vi.advanceTimersByTimeAsync(0);
expect(store.data).toEqual(HEALTH_OK);
expect(store.error).toBeNull();
expect(store.qaOk).toBe(true);
expect(store.neo4jOk).toBe(true);
expect(store.extractionOk).toBe(true);
expect(store.sessionCount).toBe(12);
expect(store.unknown).toBe(false);
});
it('30s 间隔轮询;页面 hidden 时跳过', async () => {
fetchMock.mockResolvedValue(jsonRes(HEALTH_OK));
const store = startStore();
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30000);
expect(fetchMock).toHaveBeenCalledTimes(2);
// hidden:跳过本次 tick
Object.defineProperty(document, 'hidden', { configurable: true, value: true });
await vi.advanceTimersByTimeAsync(30000);
expect(fetchMock).toHaveBeenCalledTimes(2);
// 恢复可见:继续轮询
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
await vi.advanceTimersByTimeAsync(30000);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(store.polling).toBe(true);
});
it('降级 getter:QA 不可用时 qaOk=false(不弹窗,仅标记)', async () => {
const degraded = {
status: 'degraded',
upstreams: {
qa: { ok: false },
rag: { ok: true },
ragAlt: { ok: false },
neo4j: { ok: true },
},
db: { ok: true, sessions: 3 },
};
fetchMock.mockResolvedValue(jsonRes(degraded));
const store = startStore();
await vi.advanceTimersByTimeAsync(0);
expect(store.qaOk).toBe(false);
expect(store.neo4jOk).toBe(true);
expect(store.extractionOk).toBe(true); // 8000 在线
expect(store.bffHealthy).toBe(false); // status degraded
});
it('网络失败:error 置位、data 保留旧值、unknown=false(不弹窗)', async () => {
fetchMock.mockResolvedValueOnce(jsonRes(HEALTH_OK));
const store = startStore();
await vi.advanceTimersByTimeAsync(0);
expect(store.data).toEqual(HEALTH_OK);
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await vi.advanceTimersByTimeAsync(30000);
expect(store.error).not.toBeNull();
expect(store.error.name).toBe('ApiError');
expect(store.data).toEqual(HEALTH_OK); // 旧值保留供降级展示
expect(store.unknown).toBe(false);
});
it('fetchQaInfo:存版本号;失败置 null 不抛', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({ name: 'QA', version: '1.1.73-assertion-anchored' }));
const store = useHealthStore();
await store.fetchQaInfo();
expect(store.qaVersion).toBe('1.1.73-assertion-anchored');
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await store.fetchQaInfo();
expect(store.qaInfo).toBeNull();
});
it('stop 后不再轮询', async () => {
fetchMock.mockResolvedValue(jsonRes(HEALTH_OK));
const store = startStore();
expect(fetchMock).toHaveBeenCalledTimes(1);
store.stop();
await vi.advanceTimersByTimeAsync(120000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+169
View File
@@ -0,0 +1,169 @@
// http.js 单测(B1):错误信封解析 + 非 2xx / 网络错误归一(API.md §1)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, ApiError } from '../../../../frontend/src/api/http.js';
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
function jsonRes(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('api/http.js', () => {
beforeEach(() => fetchMock.mockReset());
afterEach(() => vi.clearAllMocks());
it('GET 成功:返回业务 JSON(不包 ok:true 信封)', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({ status: 'ok', version: '0.1.0' }));
const data = await api.get('/api/health');
expect(data).toEqual({ status: 'ok', version: '0.1.0' });
expect(fetchMock).toHaveBeenCalledWith(
'/api/health',
expect.objectContaining({ method: 'GET' })
);
});
it('POST:设置 Content-Type 并序列化 body', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({ id: 'x' }));
await api.post('/api/sessions', { title: '新会话' });
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('/api/sessions');
expect(opts.method).toBe('POST');
expect(opts.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(opts.body)).toEqual({ title: '新会话' });
});
it('错误信封:抛结构化 ApiError(code/message/degraded)', async () => {
fetchMock.mockResolvedValueOnce(
jsonRes(
{ ok: false, error: { code: 'UPSTREAM_DOWN', message: '知识问答服务当前不可用,请稍后重试', degraded: true } },
503
)
);
await expect(api.post('/api/qa/chat', { question: 'x' })).rejects.toSatisfy((e) => {
expect(e).toBeInstanceOf(ApiError);
expect(e.code).toBe('UPSTREAM_DOWN');
expect(e.message).toBe('知识问答服务当前不可用,请稍后重试');
expect(e.degraded).toBe(true);
expect(e.status).toBe(503);
return true;
});
});
it('错误信封 degraded 缺省为 false', async () => {
fetchMock.mockResolvedValueOnce(
jsonRes({ ok: false, error: { code: 'INTERNAL', message: 'BFF 内部错误' } }, 500)
);
await expect(api.get('/api/x')).rejects.toMatchObject({
code: 'INTERNAL',
degraded: false,
status: 500,
});
});
it('非 2xx 且无信封:归一为 INTERNAL + HTTP 状态', async () => {
fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
await expect(api.get('/api/x')).rejects.toMatchObject({ code: 'INTERNAL', status: 500 });
});
it('网络错误:归一为 NETWORK ApiError(不抛原始 TypeError)', async () => {
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(api.get('/api/health')).rejects.toSatisfy((e) => {
expect(e).toBeInstanceOf(ApiError);
expect(e.code).toBe('NETWORK');
expect(e.status).toBe(0);
expect(e.degraded).toBe(false);
return true;
});
});
it('调用方主动 abort:透传原始 AbortError(供 B2 流式中断判断)', async () => {
const ctrl = new AbortController();
fetchMock.mockImplementationOnce(
(url, opts) =>
new Promise((_, reject) => {
opts.signal.addEventListener('abort', () =>
reject(new DOMException('The operation was aborted.', 'AbortError'))
);
})
);
const p = api.post('/api/qa/chat/stream', { question: 'x' }, { signal: ctrl.signal });
ctrl.abort();
await expect(p).rejects.toBeInstanceOf(DOMException);
});
// ---------- BUG-R1-01 修复:get 的 params 序列化为 query string ----------
it('get(path, params):params 拼装 query string(BUG-R1-01)', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({ query: 'OsSPL14', results: [] }));
await api.get('/api/kg/entities', { q: 'OsSPL14', limit: 20 });
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/kg/entities?q=OsSPL14&limit=20');
});
it('get(params):undefined/null 跳过、数字转字符串、中文正确编码', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({}));
await api.get('/api/kg/entities', { q: '水稻 SPL', limit: 20, skip: undefined, none: null });
const [url] = fetchMock.mock.calls[0];
// URLSearchParams 标准 form 编码:空格 → '+',中文 → %XX(FastAPI 按此解析)
expect(url).toBe('/api/kg/entities?q=' + encodeURIComponent('水稻') + '+SPL&limit=20');
});
it('get 不带 params:URL 原样(无 ? 尾巴)', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({}));
await api.get('/api/health');
expect(fetchMock.mock.calls[0][0]).toBe('/api/health');
});
it('get 第三参仍收 opts:{ timeout, signal } 透传', async () => {
const ctrl = new AbortController();
fetchMock.mockResolvedValueOnce(jsonRes({}));
await api.get('/api/kg/entities', { q: 'x' }, { timeout: 0, signal: ctrl.signal });
const [, opts] = fetchMock.mock.calls[0];
expect(opts.signal).toBe(ctrl.signal);
});
// ---------- BUG-R1-07 修复:默认读超时覆盖真实上游最长路径 ----------
it('默认读超时 ≥600s(BUG-R1-07:92s 的 qa search 不再被 30s 误杀)', async () => {
const ctrl = new AbortController();
fetchMock.mockImplementationOnce(
(url, opts) =>
new Promise((resolve) => {
// 在「30s」时刻检查未中止(旧默认超时在此处就会 abort)
setTimeout(() => {
expect(opts.signal.aborted).toBe(false);
resolve(new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }));
}, 30000);
})
);
vi.useFakeTimers();
try {
const p = api.post('/api/qa/search', { question: 'x' }, { signal: ctrl.signal });
await vi.advanceTimersByTimeAsync(30000);
const data = await p;
expect(data).toEqual({});
expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('调用方 timeout 覆盖默认值;raw:true 不设读超时', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({}));
await api.get('/api/kg/entities', { q: 'x' }, { timeout: 0 });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
// ---------- FormData 走 multipart(FR-EXTRACT-004 上传契约) ----------
it('post FormData:不设 JSON Content-Type、body 原样透传', async () => {
fetchMock.mockResolvedValueOnce(jsonRes({ success: true, job_id: 'j1' }));
const form = new FormData();
form.append('file', new File(['x'], 'a.pdf'));
await api.post('/api/extraction/upload', form);
const [, opts] = fetchMock.mock.calls[0];
expect(opts.body).toBe(form);
expect(opts.headers['Content-Type']).toBeUndefined();
});
});
+46
View File
@@ -0,0 +1,46 @@
// setup.js —— B1 归属,vitest 全局 setup(vitest.config.js setupFiles)。
// 环境适配(jsdom 25 + Node ≥22 组合下 window.localStorage 为 undefined):
// - Node ≥22 自带实验性 globalThis.localStorage(未配 --localstorage-file 时为 undefined),会遮蔽 jsdom 实现
// - jsdom 25 在较新 Node 上 window.localStorage 亦可能缺失
// 统一策略:window.localStorage 缺失时注入内存实现,并将其同时暴露到 globalThis。
class MemoryStorage {
constructor() {
this._m = new Map();
}
get length() {
return this._m.size;
}
key(i) {
return [...this._m.keys()][i] ?? null;
}
getItem(k) {
const key = String(k);
return this._m.has(key) ? this._m.get(key) : null;
}
setItem(k, v) {
this._m.set(String(k), String(v));
}
removeItem(k) {
this._m.delete(String(k));
}
clear() {
this._m.clear();
}
}
if (typeof window !== 'undefined') {
let storage = window.localStorage;
if (!storage) {
storage = new MemoryStorage();
try {
Object.defineProperty(window, 'localStorage', { value: storage, configurable: true });
} catch {
window.localStorage = storage;
}
}
try {
Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true });
} catch {
globalThis.localStorage = storage;
}
}
+54
View File
@@ -0,0 +1,54 @@
// theme store 单测(B1):持久化与 dataset 同步(FR-THEME-002 / UI_SPEC §6)
import { beforeEach, describe, expect, it } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useThemeStore, applyTheme, currentTheme } from '../../../../frontend/src/stores/theme.js';
describe('theme store', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.dataset.theme = 'dark';
setActivePinia(createPinia());
});
it('从 dataset 初始化状态(dataset 缺失时回退 dark)', () => {
document.documentElement.dataset.theme = 'light';
setActivePinia(createPinia());
expect(useThemeStore().theme).toBe('light');
delete document.documentElement.dataset.theme;
setActivePinia(createPinia());
expect(useThemeStore().theme).toBe('dark');
});
it('toggle:dark→light,同步 dataset 与 localStorage', () => {
const store = useThemeStore();
expect(store.theme).toBe('dark');
store.toggle();
expect(store.theme).toBe('light');
expect(document.documentElement.dataset.theme).toBe('light');
expect(localStorage.getItem('pa-theme')).toBe('light');
store.toggle();
expect(store.theme).toBe('dark');
expect(document.documentElement.dataset.theme).toBe('dark');
expect(localStorage.getItem('pa-theme')).toBe('dark');
});
it('applyTheme:写 dataset + localStorage', () => {
applyTheme('light');
expect(document.documentElement.dataset.theme).toBe('light');
expect(localStorage.getItem('pa-theme')).toBe('light');
});
it('set:非法值归一为 dark', () => {
const store = useThemeStore();
store.set('neon');
expect(store.theme).toBe('dark');
});
it('currentTheme:恒有值(未知 dataset 值归一 dark)', () => {
document.documentElement.dataset.theme = 'hacker';
expect(currentTheme()).toBe('dark');
document.documentElement.dataset.theme = 'light';
expect(currentTheme()).toBe('light');
});
});
+416
View File
@@ -0,0 +1,416 @@
// @vitest-environment jsdom
// chat.store.test.js —— chat store 状态机单测
// 覆盖:send 全流程(FR-CHAT-008 时序 1–7,SSE 帧序列驱动 → 相位推进 →
// 揭示动画(fake timers)→ 持久化调用时序)、stop(FR-CHAT-011)、
// retry(FR-CHAT-012)、恢复与「已中断」、错误帧映射(FR-CHAT-008)。
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { newSkeletonMessage, qaToBlocks, PIPELINE_PHASES } from '../../../../frontend/src/stores/chat.js';
// ---- mock 依赖模块 ----
let postImpl;
let frames = [];
let updateSessionCalls = [];
vi.mock('../../../../frontend/src/api/qa.js', () => ({
chatStream: vi.fn(async (body, handlers) => {
for (const f of frames) {
if (f.event === 'complete') handlers.onComplete({ payload: f.data });
else if (f.event === 'error') handlers.onError({ payload: f.data });
// 与真实 api/qa.js 一致:timeout 帧补 code TIMEOUT 后走 onError
else if (f.event === 'timeout') handlers.onError({ payload: { code: 'TIMEOUT', ...f.data } });
else if (f.event === 'progress') handlers.onProgress({ payload: f.data });
else handlers.onRaw({ payload: f.data });
}
}),
search: vi.fn(async () => { throw new Error('unused'); }),
info: vi.fn(async () => ({})),
}));
vi.mock('../../../../frontend/src/api/sessions.js', () => {
const calls = { messages: [], patches: [] };
return {
createMessage: vi.fn(async (sid, body) => {
calls.messages.push({ sid, body });
return { id: `mid-${calls.messages.length}`, ...body, created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z' };
}),
updateMessage: vi.fn(async (sid, mid, body) => {
calls.patches.push({ sid, mid, body });
return { id: mid, ...body, updated_at: '2026-08-25T00:00:05Z' };
}),
getSession: vi.fn(),
listSessions: vi.fn(async () => []),
createSession: vi.fn(async () => ({ id: 'sess-1', title: '新会话', status: 'idle', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 0, meta: {} })),
updateSession: vi.fn(async (sid, body) => { updateSessionCalls.push({ sid, body }); return { id: sid, ...body }; }),
deleteSession: vi.fn(async () => {}),
getSessionSources: vi.fn(async () => ({ session_id: 's1', total: 0, sources: [] })),
_calls: calls,
};
});
import { useChatStore } from '../../../../frontend/src/stores/chat.js';
import { useSessionsStore } from '../../../../frontend/src/stores/sessions.js';
import * as qaApi from '../../../../frontend/src/api/qa.js';
import * as sessApi from '../../../../frontend/src/api/sessions.js';
const calls = sessApi._calls;
const progress = (phase, label, extra = {}) => ({
event: 'progress',
data: { stage: `${phase}_stage`, phase, label, details: {}, at: '2026-08-25T00:00:01Z', ...extra },
});
const completePayload = {
answer: '水稻SPL家族共有19个成员',
source_count: 1,
sources: [{ id: 'src-1', title: 'T', paper_id: 'PMID1', pmid: '1', snippet: 's', score: 0.9, url: 'https://x' }],
chains: [],
graph: { nodes: [{ id: 'OsSPL14', label: 'OsSPL14', type: 'Gene' }], edges: [], chains: [] },
raw: {},
};
async function setupChat({ sessionId = 'sess-1', active = true } = {}) {
setActivePinia(createPinia());
const chat = useChatStore();
const sessions = useSessionsStore();
sessions.items = [{ id: 'sess-1', title: '新会话', status: 'idle', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 0, meta: {} }];
if (active) sessions.activeId = sessionId;
chat.sessionId = sessionId;
frames = [];
updateSessionCalls = [];
calls.messages.length = 0;
calls.patches.length = 0;
return { chat, sessions };
}
describe('chat store:send 全流程(FR-CHAT-008)', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); });
it('时序:用户消息落库 → 会话 running → 骨架 → 相位推进 → 揭示 → 终态落库', async () => {
const { chat, sessions } = await setupChat();
frames = [
progress('planning', '开始理解问题'),
progress('retrieval', '发现相关概念'),
progress('evidence', '证据审核完成'),
progress('generation', '开始生成答案'),
progress('done', '检索完成'),
{ event: 'complete', data: completePayload },
];
// 揭示动画走 matchMedia 默认 false(jsdom 无 matchMedia → 直接跳过揭示)
const p = chat.send('水稻SPL基因家族的功能研究有哪些关键进展?');
await vi.advanceTimersByTimeAsync(0);
await p;
// 1. 用户消息 POST
expect(calls.messages[0]).toMatchObject({ sid: 'sess-1', body: { role: 'user', text: '水稻SPL基因家族的功能研究有哪些关键进展?' } });
// 2. 会话 running PATCH
expect(updateSessionCalls.some((c) => c.body.status === 'running')).toBe(true);
// 3. 骨架 POST(pipeline 五相位 waiting)
const skel = calls.messages[1];
expect(skel.body.role).toBe('assistant');
expect(skel.body.blocks[0].type).toBe('pipeline');
expect(skel.body.blocks[0].steps.map((s) => s.state)).toEqual(['waiting', 'waiting', 'waiting', 'waiting', 'waiting']);
// 5. 消息内:用户 + 助手
expect(chat.messages).toHaveLength(2);
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('done');
// BUG-R1-06:text 块 html 必须经 markdown 渲染(<p> 包裹),而非原始 answer 字符串
const textBlock = aMsg.blocks.find((b) => b.type === 'text');
expect(textBlock).toBeTruthy();
expect(textBlock.html).toContain(`<p>${completePayload.answer}</p>`);
expect(aMsg.blocks.some((b) => b.type === 'sources')).toBe(true);
expect(aMsg.blocks.some((b) => b.type === 'graph')).toBe(true);
// pipeline 全部 done
const pipe = aMsg.blocks.find((b) => b.type === 'pipeline');
expect(pipe.status).toBe('done');
expect(pipe.steps.every((s) => s.state === 'done')).toBe(true);
// 6/7. 终态落库 PATCH:status done + blocks(stream 瞬态不落库)
const finalPatch = calls.patches[calls.patches.length - 1];
expect(finalPatch.body.status).toBe('done');
expect(finalPatch.body.blocks.some((b) => b.type === 'stream')).toBe(false);
expect(finalPatch.body.blocks.some((b) => b.type === 'text')).toBe(true);
// 会话状态同步 done
expect(updateSessionCalls.some((c) => c.body.status === 'done')).toBe(true);
expect(sessions.items[0].status).toBe('done');
// busy 复位 + 结果可观测
expect(chat.busy).toBe(false);
expect(chat.lastResult.ok).toBe(true);
// 阶段事件供 bench(B3 契约)
expect(chat.currentEvents).toHaveLength(5);
});
it('检查点:~3s 间隔 PATCH streaming(FR-CHAT-008 步骤 6)', async () => {
const { chat } = await setupChat();
let gateResolve;
qaApi.chatStream.mockImplementationOnce(async (body, handlers) => {
handlers.onProgress({ payload: progress('planning', '开始理解问题').data });
await new Promise((r) => { gateResolve = r; }); // 流挂起,检查点定时器得以触发
handlers.onComplete({ payload: completePayload });
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(3100);
// 检查点 PATCH(status streaming)
expect(calls.patches.some((c) => c.body.status === 'streaming')).toBe(true);
gateResolve();
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.busy).toBe(false);
});
it('error 帧:TIMEOUT 文案映射 + 保留已收块 + error 落库', async () => {
const { chat, sessions } = await setupChat();
frames = [
progress('planning', '开始理解问题'),
{ event: 'timeout', data: { message: '检索超时' } },
];
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).toBe('timeout');
// 已收到的块保留(pipeline 保留)
expect(aMsg.blocks.some((b) => b.type === 'pipeline')).toBe(true);
const finalPatch = calls.patches[calls.patches.length - 1];
expect(finalPatch.body.status).toBe('error');
expect(chat.lastResult.ok).toBe(false);
expect(chat.lastResult.error.message).toBe('检索超时');
expect(sessions.items[0].status).toBe('error');
});
it('error 帧:INVALID_CITATION 映射', async () => {
const { chat } = await setupChat();
frames = [{ event: 'error', data: { code: 'INVALID_CITATION', message: '', retryable: true } }];
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.messages[1].streamErr).toBe('citation');
expect(chat.lastResult.error.message).toBe('引用校验失败,请重试');
});
it('连接中断:fetch reject(网络)→ disconnected 文案', async () => {
const { chat } = await setupChat();
qaApi.chatStream.mockImplementationOnce(async () => { throw new TypeError('Failed to fetch'); });
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).toBe('disconnected');
expect(chat.lastResult.error.message).toBe('连接中断,请检查网络后重试');
});
it('BUG-R1-05:502 UPSTREAM_ERROR 信封 → 展示上游 message 而非「连接中断」', async () => {
const { chat } = await setupChat();
// 模拟 http.js 抛出的 ApiError(统一错误信封已解析)
qaApi.chatStream.mockImplementationOnce(async () => {
throw { name: 'ApiError', code: 'UPSTREAM_ERROR', message: '上游问答服务返回错误', status: 502 };
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).not.toBe('disconnected');
expect(aMsg.errText).toBe('上游问答服务返回错误');
expect(chat.lastResult.error).toMatchObject({ code: 'UPSTREAM_ERROR', message: '上游问答服务返回错误' });
// 终态落库 error
expect(calls.patches[calls.patches.length - 1].body.status).toBe('error');
});
it('BUG-R1-05:503 UPSTREAM_DOWN 信封 → 降级文案', async () => {
const { chat } = await setupChat();
qaApi.chatStream.mockImplementationOnce(async () => {
throw { name: 'ApiError', code: 'UPSTREAM_DOWN', message: '知识问答服务当前不可用,请稍后重试', status: 503, degraded: true };
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.messages[1].errText).toBe('知识问答服务当前不可用,请稍后重试');
expect(chat.lastResult.error.code).toBe('UPSTREAM_DOWN');
});
it('BUG-R1-06:qaToBlocks 把 markdown answer 渲染为 HTML(列表/加粗/表格)', () => {
const payload = {
...completePayload,
answer: '- 成员一\n- 成员二\n\n**加粗结论**\n\n| 列A | 列B |\n|---|---|\n| x | y |',
};
const blocks = qaToBlocks(payload);
const text = blocks.find((b) => b.type === 'text');
expect(text).toBeTruthy();
expect(text.html).toContain('<li>');
expect(text.html).toContain('<strong>');
expect(text.html).toContain('<table class="md-table">');
// 落库路径:_persistableBlocks 不输出 stream、text 为渲染后 HTML
expect(text.html).not.toContain('**');
});
it('BUG-R1-02:selectSession 同步 sessions.activeId(活动高亮/顶栏依赖)', async () => {
const { chat, sessions } = await setupChat({ sessionId: 'sess-1' });
sessions.items = [
{ id: 'sess-1', title: 'A', status: 'done', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} },
{ id: 'sess-2', title: 'B', status: 'done', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} },
];
sessApi.getSession.mockImplementationOnce(async () => ({ id: 'sess-2', title: 'B', status: 'done', meta: {}, messages: [] }));
await chat.selectSession('sess-2');
expect(chat.sessionId).toBe('sess-2');
expect(sessions.activeId).toBe('sess-2');
expect(sessions.active?.id).toBe('sess-2');
});
it('检索模式:走 /api/qa/search,无揭示', async () => {
const { chat } = await setupChat();
const searchPayload = { ...completePayload, answer: '', mode: 'search' };
qaApi.search.mockImplementationOnce(async () => searchPayload);
const p = chat.send('检索问题', { mode: 'search' });
await vi.advanceTimersByTimeAsync(0);
await p;
expect(qaApi.search).toHaveBeenCalledWith(expect.objectContaining({ use_llm: false }));
expect(qaApi.chatStream).not.toHaveBeenCalled();
expect(chat.messages[1].status).toBe('done');
// 无 answer → 无 text 块
expect(chat.messages[1].blocks.some((b) => b.type === 'text')).toBe(false);
});
it('模式参数集按 config/modes.js 拼装', async () => {
const { chat } = await setupChat();
frames = [{ event: 'complete', data: completePayload }];
const p = chat.send('问题', { mode: 'plan' });
await vi.advanceTimersByTimeAsync(0);
await p;
expect(qaApi.chatStream).toHaveBeenCalledWith(
expect.objectContaining({ use_llm: true, native_chain_enabled: true, native_chain_max_hops: 3, native_chain_top_k: 5 }),
expect.anything(),
);
});
});
describe('chat store:stop / retry / 恢复', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); });
it('stop:abort → 消息标「已停止」保留块(FR-CHAT-011)', async () => {
const { chat } = await setupChat();
let abortTrigger;
qaApi.chatStream.mockImplementationOnce(async (body, handlers) => {
handlers.onProgress({ payload: progress('planning', '开始理解问题').data });
await new Promise((_, reject) => {
abortTrigger = () => reject(new DOMException('The user aborted a request.', 'AbortError'));
});
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
chat.stop(); // 触发 AbortController.abort()
abortTrigger(); // mock 以 AbortError 模拟流读取中断
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).toBe('stopped');
// 已收到的块保留
expect(aMsg.blocks.some((b) => b.type === 'pipeline')).toBe(true);
const finalPatch = calls.patches[calls.patches.length - 1];
expect(finalPatch.body.status).toBe('error');
expect(chat.lastResult.error.code).toBe('ABORTED');
});
it('retry:同消息位置替换(FR-CHAT-012),不新增用户消息', async () => {
const { chat } = await setupChat();
frames = [
progress('planning', '开始理解问题'),
{ event: 'timeout', data: { message: '检索超时' } },
];
let p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const userMsg = chat.messages[0];
expect(chat.messages).toHaveLength(2);
// 第二轮:成功
frames = [
progress('planning', '开始理解问题'),
{ event: 'complete', data: completePayload },
];
p = chat.retry();
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.messages).toHaveLength(2);
expect(chat.messages[0]).toBe(userMsg); // 同一用户消息对象
expect(chat.messages[1].status).toBe('done');
// 不再 POST 用户消息:骨架 POST 是第三个 messages 调用(用户 1 + 骨架 2 + retry 骨架 1)
const userPosts = calls.messages.filter((c) => c.body.role === 'user');
expect(userPosts).toHaveLength(1);
});
it('恢复:detail 加载 + interrupted 消息标「已中断」(FR-CHAT-013)', async () => {
const { chat } = await setupChat();
sessApi.getSession.mockImplementationOnce(async () => ({
id: 'sess-1',
title: 'T',
status: 'error',
meta: {},
messages: [
{ id: 'm1', role: 'user', kind: 'qa', status: 'done', blocks: [], raw: null, text: '问题', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z' },
{
id: 'm2', role: 'assistant', kind: 'qa', status: 'interrupted',
blocks: [
{ type: 'pipeline', status: 'running', steps: PIPELINE_PHASES.map((ph) => ({ name: ph, state: 'waiting', desc: '', phase: ph })) },
{ type: 'stream', text: '瞬态残留' },
],
raw: {}, created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z',
},
],
}));
await chat.loadSession('sess-1');
expect(chat.messages).toHaveLength(2);
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.interrupted).toBe(true);
expect(aMsg.streamErr).toBe('interrupted');
// stream 瞬态块被过滤(防御:绝不渲染落库的 stream)
expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false);
// 用户消息转换为 text 块渲染
expect(chat.messages[0].blocks[0].type).toBe('text');
});
it('切换会话中断进行中的流并把原会话置 error(FR-SESSION-004)', async () => {
const { chat, sessions } = await setupChat();
sessions.items = [
{ id: 'sess-1', title: 'A', status: 'running', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 2, meta: {} },
{ id: 'sess-2', title: 'B', status: 'done', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 2, meta: {} },
];
sessions.activeId = 'sess-1';
chat.sessionId = 'sess-1';
chat.messages = [
{ id: 'u1', role: 'user', kind: 'qa', status: 'done', text: 'q', blocks: [], raw: null },
{ id: 'a1', role: 'assistant', kind: 'qa', status: 'streaming', blocks: [{ type: 'pipeline', status: 'running', steps: [] }], raw: null },
];
chat.busy = true;
chat.activeAbort = new AbortController();
sessApi.getSession.mockImplementationOnce(async () => ({ id: 'sess-2', title: 'B', status: 'done', meta: {}, messages: [] }));
await chat.selectSession('sess-2');
// 原会话消息置 error 标「已中断」
expect(chat.messages).toHaveLength(0); // 已切到 sess-2(空消息)
expect(updateSessionCalls.some((c) => c.sid === 'sess-1' && c.body.status === 'error')).toBe(true);
expect(sessions.items.find((s) => s.id === 'sess-1').status).toBe('error');
});
});
describe('qaToBlocks', () => {
it('answer/sources/graph 装配;search 模式无 answer 不出 text 块', () => {
const blocks = qaToBlocks(completePayload);
expect(blocks.map((b) => b.type)).toEqual(['text', 'sources', 'graph']);
const sBlocks = qaToBlocks({ ...completePayload, answer: '' });
expect(sBlocks.map((b) => b.type)).toEqual(['sources', 'graph']);
});
});
describe('newSkeletonMessage', () => {
it('pipeline 五相位全 waiting', () => {
const m = newSkeletonMessage();
expect(m.status).toBe('streaming');
expect(m.blocks[0].steps.map((s) => s.phase)).toEqual(PIPELINE_PHASES);
expect(m.blocks[0].steps.every((s) => s.state === 'waiting')).toBe(true);
});
});
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
// chatpanel.health.test.js —— BUG-R1-04 回归:QA 降级时作曲器禁用 + 降级提示(FR-HEALTH-002)
// 契约:health store(B1)暴露 qaOk getter;ChatPanel 读 health.data != null && !qaOk → 禁用。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
// mock health store(B1 归属,不实改其文件):qaOk 由 data.upstreams.qa.ok 派生
vi.mock('../../../../frontend/src/stores/health.js', () => ({
useHealthStore: vi.fn(() => healthState),
}));
// mock bench store(B3):ChatPanel 对 bench 的引用须容错(本测试不关心工作台)
vi.mock('../../../../frontend/src/stores/bench.js', () => ({
useBenchStore: vi.fn(() => ({ setActiveTab: vi.fn(), setSourceScope: vi.fn() })),
}));
// sessions/chat store 用真实现(chat.store 内部 mock api 层,见下)
vi.mock('../../../../frontend/src/api/sessions.js', () => ({
listSessions: vi.fn(async () => []),
createSession: vi.fn(async () => ({ id: 's1', title: '新会话', status: 'idle', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} })),
getSession: vi.fn(async () => ({ id: 's1', title: 't', status: 'done', meta: {}, messages: [] })),
updateSession: vi.fn(async (id, b) => ({ id, ...b })),
deleteSession: vi.fn(async () => {}),
createMessage: vi.fn(),
updateMessage: vi.fn(),
getSessionSources: vi.fn(),
}));
vi.mock('../../../../frontend/src/api/qa.js', () => ({
chatStream: vi.fn(),
chat: vi.fn(),
search: vi.fn(),
info: vi.fn(),
}));
import ChatPanel from '../../../../frontend/src/components/chat/ChatPanel.vue';
/** health 状态(响应式替换:mock 工厂每次返回同一对象) */
let healthState;
function makeHealthState(data) {
return {
data,
qaOk: !!(data && data.upstreams && data.upstreams.qa && data.upstreams.qa.ok === true),
};
}
async function mountPanel() {
const w = mount(ChatPanel, { global: { stubs: { teleport: true } } });
await flushPromises();
return w;
}
describe('ChatPanel × health(BUG-R1-04)', () => {
beforeEach(() => {
setActivePinia(createPinia());
healthState = makeHealthState(null); // 未知态:首轮探测未返回
});
it('未知态(data=null):作曲器不禁用(避免首屏闪烁)', async () => {
const w = await mountPanel();
const ta = w.find('.composer textarea');
expect(ta.attributes('disabled')).toBeUndefined();
expect(w.find('.hint').text()).toContain('Enter');
});
it('qaOk=false:作曲器禁用 + 降级提示(labels.qaDown 文案)', async () => {
healthState = makeHealthState({
status: 'degraded',
upstreams: { qa: { ok: false }, rag: { ok: true }, ragAlt: { ok: true }, neo4j: { ok: true } },
});
const w = await mountPanel();
const ta = w.find('.composer textarea');
expect(ta.attributes('disabled')).toBeDefined();
expect(w.find('.hint').text()).toBe('知识问答服务当前不可用,请稍后重试');
});
it('qaOk=true:作曲器可用,提示为快捷键说明', async () => {
healthState = makeHealthState({ status: 'ok', upstreams: { qa: { ok: true } } });
const w = await mountPanel();
expect(w.find('.composer textarea').attributes('disabled')).toBeUndefined();
expect(w.find('.hint').text()).toContain('Enter');
});
});
+257
View File
@@ -0,0 +1,257 @@
// @vitest-environment jsdom
// components.test.js —— Composer 快捷键与禁用逻辑 + AssistantMessage 块联合渲染
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import Composer from '../../../../frontend/src/components/chat/Composer.vue';
import AssistantMessage from '../../../../frontend/src/components/chat/AssistantMessage.vue';
import SessionItem from '../../../../frontend/src/components/session/SessionItem.vue';
describe('Composer(FR-CHAT-005/007/011)', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const mountComposer = (props = {}) =>
mount(Composer, {
props: { mode: 'ask', busy: false, disabled: false, ...props },
});
it('空输入禁用发送;⌘/Ctrl+Enter 发送;Enter 换行', async () => {
const w = mountComposer();
const btn = w.find('.send-btn');
expect(btn.attributes('disabled')).toBeDefined();
const ta = w.find('textarea');
await ta.setValue('水稻SPL基因家族?');
// Enter 单独按下:不触发发送(换行)
await ta.trigger('keydown', { key: 'Enter' });
expect(w.emitted('send')).toBeUndefined();
// Ctrl+Enter:发送
await ta.trigger('keydown', { key: 'Enter', ctrlKey: true });
expect(w.emitted('send')).toBeTruthy();
expect(w.emitted('send')[0][0]).toBe('水稻SPL基因家族?');
// 发送后清空输入
expect(ta.element.value).toBe('');
});
it('⌘+Enter 同样发送(mac)', async () => {
const w = mountComposer();
const ta = w.find('textarea');
await ta.setValue('问题');
await ta.trigger('keydown', { key: 'Enter', metaKey: true });
expect(w.emitted('send')).toBeTruthy();
});
it('busy 时按钮变「停止」;点击 stop 触发事件(FR-CHAT-011)', async () => {
const w = mountComposer({ busy: true });
const btn = w.find('.send-btn');
expect(btn.text()).toContain('停止');
await btn.trigger('click');
expect(w.emitted('stop')).toBeTruthy();
});
it('disabled(健康降级)时输入与发送均不可用', async () => {
const w = mountComposer({ disabled: true });
expect(w.find('textarea').attributes('disabled')).toBeDefined();
const ta = w.find('textarea');
await ta.setValue('问题');
expect(w.find('.send-btn').attributes('disabled')).toBeDefined();
});
it('模式 pill 三态切换(labels.MODES)', async () => {
const w = mountComposer({ mode: 'ask' });
const pills = w.findAll('.mode-pill');
expect(pills).toHaveLength(3);
expect(pills[0].classes()).toContain('on');
await pills[1].trigger('click');
expect(w.emitted('change-mode')[0][0]).toBe('plan');
});
it('输入法组合键 Enter 不触发发送', async () => {
const w = mountComposer();
const ta = w.find('textarea');
await ta.setValue('问题');
await ta.trigger('keydown', { key: 'Enter', ctrlKey: true, isComposing: true });
expect(w.emitted('send')).toBeUndefined();
});
});
describe('AssistantMessage 块联合渲染', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const mountMsg = (blocks, extra = {}) =>
mount(AssistantMessage, {
props: { message: { role: 'assistant', status: 'done', blocks, timeLabel: '10:00' }, ...extra },
});
it('text 块:markdown 渲染 + 脚本注入被消毒(FR-CHAT-004)', () => {
const w = mountMsg([{ type: 'text', html: '<p>加粗 <strong>SPL</strong></p><img src=x onerror="alert(1)"><script>alert(1)</script>' }]);
const html = w.find('.msg-body').html();
expect(html).toContain('<strong>SPL</strong>');
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script');
});
it('table 块:md-table + 表头「说明」列非 mono,其余 mono(UI_SPEC §4.3)', () => {
const w = mountMsg([{
type: 'table',
headers: ['成员', '说明'],
rows: [['OsSPL14', '穗分枝']],
}]);
const tds = w.findAll('tbody td');
expect(tds).toHaveLength(2);
expect(tds[0].classes()).toContain('mono');
expect(tds[1].classes()).not.toContain('mono');
});
it('code 块:code-block 类', () => {
const w = mountMsg([{ type: 'code', text: 'ATGC' }]);
expect(w.find('.code-block').text()).toBe('ATGC');
});
it('stream 块:stream-cursor + shimmer-line + 跳过按钮', () => {
const w = mountMsg([{ type: 'stream', text: '部分文字' }], { reveal: { text: '全文' } });
expect(w.find('.stream-cursor').exists()).toBe(true);
expect(w.find('.shimmer-line').exists()).toBe(true);
expect(w.find('.skip-btn').exists()).toBe(true);
awaitFlush(w);
w.find('.skip-btn').trigger('click');
expect(w.emitted('skip-reveal')).toBeTruthy();
});
it('pipeline 块:五相位步骤 + 状态点字形(✓/●/◌)', () => {
const steps = [
{ name: '规划', state: 'done', desc: '意图理解', phase: 'planning' },
{ name: '检索', state: 'running', desc: '多路检索', phase: 'retrieval' },
{ name: '证据', state: 'waiting', desc: '证据筛选', phase: 'evidence' },
{ name: '生成', state: 'waiting', desc: '答案生成', phase: 'generation' },
{ name: '完成', state: 'waiting', desc: '检索完成', phase: 'done' },
];
const w = mountMsg([{ type: 'pipeline', status: 'running', steps }]);
const dots = w.findAll('.p-dot');
expect(dots).toHaveLength(5);
expect(dots[0].text()).toBe('✓');
expect(dots[1].text()).toBe('●');
expect(dots[2].text()).toBe('◌');
expect(w.find('.pipeline-card .pc-title').text()).toContain('检索流水线');
// 整体语义徽章
expect(w.find('.pipeline-card .badge.running').exists()).toBe(true);
});
it('tool 块:折叠头 + 展开体 + 日志类', () => {
const block = {
type: 'tool', icon: '🔬', name: '证据筛选', status: 'done', dur: '3.2s', open: true, phase: 'evidence',
params: 'accepted_count: 4', result: '4 条证据通过审核',
logs: [{ text: '> ok', cls: 'log-ok' }],
};
const w = mountMsg([block]);
expect(w.find('.tool-card.open').exists()).toBe(true);
expect(w.find('.t-name').text()).toBe('证据筛选');
expect(w.find('.t-dur').text()).toBe('3.2s');
expect(w.find('.tb-params').text()).toContain('accepted_count: 4');
expect(w.find('.log-ok').exists()).toBe(true);
});
it('sources 块:内联引用 chip 编号从 1 起(FR-CHAT-015)', () => {
const w = mountMsg([{
type: 'sources',
items: [
{ id: 's1', title: 'A' },
{ id: 's2', title: 'B' },
],
}]);
const chips = w.findAll('.src-chip');
expect(chips).toHaveLength(2);
expect(chips[0].text()).toBe('[1]');
expect(chips[1].text()).toBe('[2]');
chips[0].trigger('click');
expect(w.emitted('jump-source')[0][0]).toMatchObject({ index: 1 });
});
it('graph 块:轻量摘要(节点/边数),完整图在 bench(kg 跳转事件)', () => {
const w = mountMsg([{
type: 'graph',
nodes: [{ id: 'n1' }, { id: 'n2' }, { id: 'n3' }],
edges: [{ source: 'n1', target: 'n2' }],
chains: [],
}]);
expect(w.find('.graph-summary').exists()).toBe(true);
expect(w.find('.graph-summary').text()).toContain('3');
expect(w.find('.graph-summary').text()).toContain('1');
w.find('.gs-link').trigger('click');
expect(w.emitted('jump-graph')).toBeTruthy();
});
it('error 标注 + 重试按钮(FR-CHAT-012)', () => {
const w = mountMsg([{ type: 'pipeline', status: 'error', steps: [] }], {
message: {
role: 'assistant', status: 'error', streamErr: 'timeout',
blocks: [{ type: 'pipeline', status: 'error', steps: [] }], timeLabel: '10:00',
},
onRetry: () => {},
});
expect(w.find('.err-note').text()).toContain('检索超时');
expect(w.find('.retry-btn').exists()).toBe(true);
});
it('BUG-R1-05:errText(后端 message)优先于 streamErr 标注展示', () => {
const w = mountMsg([{ type: 'pipeline', status: 'error', steps: [] }], {
message: {
role: 'assistant', status: 'error', streamErr: 'message',
errText: '上游问答服务返回错误',
blocks: [{ type: 'pipeline', status: 'error', steps: [] }], timeLabel: '10:00',
},
onRetry: () => {},
});
expect(w.find('.err-note .err-text').text()).toBe('上游问答服务返回错误');
expect(w.find('.retry-btn').exists()).toBe(true);
});
it('BUG-R1-06:text 块 HTML 直接 v-html 输出(渲染路径不再 sanitize 一次即吞掉格式)', () => {
const w = mountMsg([{ type: 'text', html: '<p>加粗 <strong>SPL</strong></p><ul><li>成员</li></ul>' }]);
const html = w.find('.md-text').html();
expect(html).toContain('<strong>SPL</strong>');
expect(html).toContain('<li>成员</li>');
});
});
describe('SessionItem 删除入口(BUG-R1-03)', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const mountItem = () =>
mount(SessionItem, {
props: { session: { id: 's1', title: '测试会话', status: 'done', meta: {} }, active: false, metaText: '2 · 10:00' },
});
it('右键唤出删除事件(不触发选择)', async () => {
const w = mountItem();
await w.find('.session-item').trigger('contextmenu');
expect(w.emitted('delete')).toBeTruthy();
expect(w.emitted('delete')[0][0]).toMatchObject({ id: 's1' });
expect(w.emitted('select')).toBeUndefined();
});
it('删除按钮点击触发 delete 且不冒泡选择', async () => {
const w = mountItem();
await w.find('.item-del').trigger('click');
expect(w.emitted('delete')).toBeTruthy();
expect(w.emitted('select')).toBeUndefined();
});
it('双击仍进入重命名(delete 入口不破坏既有交互)', async () => {
const w = mountItem();
await w.find('.session-item').trigger('dblclick');
expect(w.find('.rename-input').exists()).toBe(true);
});
});
function awaitFlush(w) {
return w.vm.$nextTick();
}
+50
View File
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
// markdown.test.js —— Markdown 渲染 + DOMPurify 消毒 + 自定义表格/代码类(FR-CHAT-004)
import { describe, it, expect } from 'vitest';
import { renderMarkdown, NON_MONO_HEADER } from '../../../../frontend/src/components/chat/markdown.js';
describe('renderMarkdown(FR-CHAT-004)', () => {
it('LLM 输出 markdown → 消毒 HTML', () => {
const html = renderMarkdown('**加粗** 与 *斜体* 与 `行内代码`');
expect(html).toContain('<strong>加粗</strong>');
expect(html).toContain('<em>斜体</em>');
expect(html).toContain('<code>行内代码</code>');
});
it('脚本注入被消毒(活 DOM 中无可执行元素/属性/协议)', () => {
const html = renderMarkdown('你好 <script>alert(1)</script> <img src=x onerror="alert(2)"> [x](javascript:alert(3))');
const doc = new DOMParser().parseFromString(html, 'text/html');
expect(doc.querySelectorAll('script').length).toBe(0);
expect(doc.querySelectorAll('img').length).toBe(0);
expect(doc.querySelectorAll('[onerror]').length).toBe(0);
const hrefs = [...doc.querySelectorAll('a')].map((a) => a.getAttribute('href') || '');
expect(hrefs.filter((h) => h.toLowerCase().startsWith('javascript:'))).toEqual([]);
});
it('表格挂 md-table 类(thead th / tbody td)', () => {
const html = renderMarkdown('| A | B |\n|---|---|\n| 1 | 2 |');
expect(html).toContain('<table class="md-table">');
expect(html).toContain('<thead>');
expect(html).toContain('<th>A</th>');
expect(html).toContain('<tbody>');
});
it('代码块挂 code-block 类并转义内容', () => {
const html = renderMarkdown('```\nconst x = "<script>";\n```');
expect(html).toContain('<div class="code-block">');
expect(html).toContain('&lt;script&gt;');
});
it('渲染失败时回退转义原文,不抛异常、活 DOM 无事件属性', () => {
const html = renderMarkdown(null);
expect(typeof html).toBe('string');
const evil = renderMarkdown('<b onclick=alert(1)>x</b>');
const doc = new DOMParser().parseFromString(evil, 'text/html');
expect(doc.querySelectorAll('[onclick]').length).toBe(0);
expect(evil).not.toContain('<b onclick');
});
it('NON_MONO_HEADER 常量为「说明」', () => {
expect(NON_MONO_HEADER).toBe('说明');
});
});
+123
View File
@@ -0,0 +1,123 @@
// @vitest-environment node
// sse/parser.test.js —— SSE 解析器单测(多行 data / 注释帧 / 无 event 名 / CRLF / 分块 UTF-8)
import { describe, it, expect, vi } from 'vitest';
import { parseFrame, SseParser, consumeSseStream } from '../../../../frontend/src/sse/parser.js';
describe('parseFrame', () => {
it('解析单行 data 与 event 名', () => {
const ev = parseFrame('event: progress\ndata: {"phase":"planning"}\n');
expect(ev.event).toBe('progress');
expect(ev.data).toEqual({ phase: 'planning' });
});
it('多行 data 按 \\n 合并后 JSON 解析', () => {
const ev = parseFrame('data: {"a":1,\ndata: "b":2}\n');
expect(ev.data).toEqual({ a: 1, b: 2 });
expect(ev.dataStr).toBe('{"a":1,\n"b":2}');
});
it('注释帧(keepalive)返回 null', () => {
expect(parseFrame(': ping\n')).toBeNull();
expect(parseFrame(': 任何注释\n: 再来一行\n')).toBeNull();
});
it('无 event 名时默认 message', () => {
const ev = parseFrame('data: {"x":1}\n');
expect(ev.event).toBe('message');
expect(ev.data).toEqual({ x: 1 });
});
it('CRLF 行尾与空行容忍', () => {
const ev = parseFrame('event: complete\r\ndata: {"ok":true}\r\n');
expect(ev.event).toBe('complete');
expect(ev.data).toEqual({ ok: true });
});
it('非 JSON data 保留原字符串', () => {
const ev = parseFrame('data: 纯文本\n');
expect(ev.data).toBe('纯文本');
expect(ev.dataStr).toBe('纯文本');
});
it('无 data 行返回 null;冒号后仅去一个空格', () => {
expect(parseFrame('event: progress\n')).toBeNull();
const ev = parseFrame('data: 保留多余空格\n');
expect(ev.dataStr).toBe(' 保留多余空格');
});
});
describe('SseParser(增量)', () => {
it('跨 chunk 截断的完整帧', () => {
const got = [];
const p = new SseParser({ onEvent: (ev) => got.push(ev) });
p.feed('event: progress\nda');
p.feed('ta: {"phase":"planning"}\n\n');
p.end();
expect(got).toHaveLength(1);
expect(got[0].event).toBe('progress');
expect(got[0].data).toEqual({ phase: 'planning' });
});
it('多字节 UTF-8 字符跨 chunk 不丢字', () => {
const got = [];
const p = new SseParser({ onEvent: (ev) => got.push(ev) });
const text = JSON.stringify({ label: '证据审核完成' });
const bytes = new TextEncoder().encode(`data: ${text}\n\n`);
p.feed(bytes.slice(0, 5));
p.feed(bytes.slice(5));
p.end();
expect(got[0].data.label).toBe('证据审核完成');
});
it('注释帧被忽略、CRLF 帧正常', () => {
const got = [];
const p = new SseParser({ onEvent: (ev) => got.push(ev) });
p.feed(': ping\n\n');
p.feed('event: timeout\r\ndata: {"message":"检索超时"}\r\n\r\n');
p.end();
expect(got).toHaveLength(1);
expect(got[0].event).toBe('timeout');
expect(got[0].data.message).toBe('检索超时');
});
it('一 chunk 多帧', () => {
const got = [];
const p = new SseParser({ onEvent: (ev) => got.push(ev) });
p.feed('data: {"a":1}\n\ndata: {"b":2}\n\n: ping\n\n');
p.end();
expect(got.map((e) => e.data)).toEqual([{ a: 1 }, { b: 2 }]);
});
});
describe('consumeSseStream', () => {
const chunkStream = (chunks) => {
const enc = new TextEncoder();
let i = 0;
return {
body: {
getReader() {
return {
read: vi.fn(async () => (i < chunks.length ? { done: false, value: enc.encode(chunks[i++]) } : { done: true })),
releaseLock: vi.fn(),
};
},
},
};
};
it('消费完整流并逐帧回调', async () => {
const got = [];
const resp = chunkStream([
'event: progress\ndata: {"phase":"planning"}\n\n',
': ping\n\n',
'event: complete\ndata: {"answer":"好"}\n\n',
]);
await consumeSseStream(resp, (ev) => got.push(ev));
expect(got.map((e) => e.event)).toEqual(['progress', 'complete']);
expect(got[1].data.answer).toBe('好');
});
it('非流响应抛错', async () => {
await expect(consumeSseStream({ body: null }, () => {})).rejects.toThrow(/readable stream/);
});
});
@@ -0,0 +1,154 @@
// @vitest-environment jsdom
// sessions.store.test.js —— sessions store 单测
// 覆盖:分组(今天/昨天/更早)、搜索过滤、CRUD、标题自动生成(FR-SESSION-001/005/008)。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
const updateCalls = [];
vi.mock('../../../../frontend/src/api/sessions.js', () => ({
listSessions: vi.fn(async () => []),
createSession: vi.fn(async () => ({ id: 'new-1', title: '新会话', status: 'idle', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 0, meta: {} })),
getSession: vi.fn(async () => ({})),
updateSession: vi.fn(async (id, body) => { updateCalls.push({ id, body }); return { id, ...body }; }),
deleteSession: vi.fn(async () => {}),
createMessage: vi.fn(),
updateMessage: vi.fn(),
getSessionSources: vi.fn(),
}));
import { useSessionsStore, groupKeyOf, deriveTitle } from '../../../../frontend/src/stores/sessions.js';
const iso = (s) => new Date(s).toISOString();
describe('groupKeyOf', () => {
const now = new Date('2026-08-25T15:00:00');
it('今天/昨天/更早分界(本地日界)', () => {
expect(groupKeyOf(iso('2026-08-25T00:01:00'), now)).toBe('today');
expect(groupKeyOf(iso('2026-08-24T23:59:00'), now)).toBe('yesterday');
expect(groupKeyOf(iso('2026-08-24T00:00:00'), now)).toBe('yesterday');
expect(groupKeyOf(iso('2026-08-23T23:59:00'), now)).toBe('older');
expect(groupKeyOf(iso('2026-08-20T09:00:00'), now)).toBe('older');
});
it('无效/缺失时间归更早', () => {
expect(groupKeyOf(null, now)).toBe('older');
expect(groupKeyOf('不是时间', now)).toBe('older');
});
it('未来时间归今天', () => {
expect(groupKeyOf(iso('2026-08-26T00:00:00'), now)).toBe('today');
});
});
describe('deriveTitle(FR-SESSION-008)', () => {
it('前 30 字符 + 省略号', () => {
const q = '水稻SPL基因家族的功能研究有哪些关键进展?这是超过三十个字符的问题内容继续延长';
expect(deriveTitle(q).length).toBeLessThanOrEqual(30);
expect(deriveTitle(q)).toBe(q.slice(0, 30));
});
it('空白回退「新建会话」(labels.T.newTask)', () => {
expect(deriveTitle(' ')).toBe('新建会话');
});
});
describe('sessions store', () => {
beforeEach(() => {
setActivePinia(createPinia());
updateCalls.length = 0;
vi.clearAllMocks();
});
it('分组:按 updated_at 倒序 + 三组', async () => {
const { listSessions } = await import('../../../../frontend/src/api/sessions.js');
listSessions.mockResolvedValueOnce([
{ id: 'a', title: '今天会话', status: 'done', created_at: 'x', updated_at: iso('2026-08-25T10:00:00'), message_count: 2, meta: {} },
{ id: 'b', title: '昨天会话', status: 'done', created_at: 'x', updated_at: iso('2026-08-24T10:00:00'), message_count: 4, meta: {} },
{ id: 'c', title: '更早会话', status: 'error', created_at: 'x', updated_at: iso('2026-08-20T10:00:00'), message_count: 1, meta: {} },
]);
const store = useSessionsStore();
await store.load();
expect(store.groups.map((g) => g.label)).toEqual(['今天', '昨天', '更早']);
expect(store.groups[0].items.map((s) => s.id)).toEqual(['a']);
expect(store.groups[1].items.map((s) => s.id)).toEqual(['b']);
expect(store.groups[2].items.map((s) => s.id)).toEqual(['c']);
});
it('搜索过滤:title 子串不区分大小写 + 无匹配(FR-SESSION-005)', async () => {
const { listSessions } = await import('../../../../frontend/src/api/sessions.js');
listSessions.mockResolvedValueOnce([
{ id: 'a', title: '水稻SPL基因家族', status: 'done', created_at: 'x', updated_at: iso('2026-08-25T10:00:00'), message_count: 2, meta: {} },
{ id: 'b', title: 'NAC 转录因子研究综述', status: 'done', created_at: 'x', updated_at: iso('2026-08-24T10:00:00'), message_count: 2, meta: {} },
]);
const store = useSessionsStore();
await store.load();
store.searchQuery = 'spl';
expect(store.matchedCount).toBe(1);
expect(store.filtered[0].id).toBe('a');
store.searchQuery = '不存在的词';
expect(store.noMatch).toBe(true);
expect(store.matchedCount).toBe(0);
store.searchQuery = '';
expect(store.matchedCount).toBe(2);
expect(store.noMatch).toBe(false);
});
it('新建会话:切为活动会话 + 连点两次产生两个独立会话(FR-SESSION-003)', async () => {
const { createSession } = await import('../../../../frontend/src/api/sessions.js');
let n = 0;
createSession.mockImplementation(async () => ({ id: `new-${++n}`, title: '新会话', status: 'idle', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} }));
const store = useSessionsStore();
const s1 = await store.create();
const s2 = await store.create();
expect(s1.id).not.toBe(s2.id);
expect(store.activeId).toBe(s2.id);
expect(store.items).toHaveLength(2);
});
it('删除:删除当前会话 → 切最新一个;列表空 → activeId=null(FR-SESSION-007)', async () => {
const { listSessions, deleteSession } = await import('../../../../frontend/src/api/sessions.js');
listSessions.mockResolvedValueOnce([
{ id: 'a', title: 'A', status: 'done', created_at: 'x', updated_at: iso('2026-08-25T10:00:00'), message_count: 2, meta: {} },
{ id: 'b', title: 'B', status: 'done', created_at: 'x', updated_at: iso('2026-08-24T10:00:00'), message_count: 2, meta: {} },
]);
const store = useSessionsStore();
await store.load();
store.activeId = 'a';
await store.remove('a');
expect(store.activeId).toBe('b');
expect(store.items.map((s) => s.id)).toEqual(['b']);
await store.remove('b');
expect(store.activeId).toBeNull();
expect(store.empty).toBe(true);
expect(deleteSession).toHaveBeenCalledTimes(2);
});
it('标题自动生成:首问 PATCH 前 30 字符;title_manual 不再覆盖(FR-SESSION-008)', async () => {
const { listSessions } = await import('../../../../frontend/src/api/sessions.js');
const q = '水稻SPL基因家族的功能研究有哪些关键进展?';
listSessions.mockResolvedValueOnce([
{ id: 'a', title: '新会话', status: 'running', created_at: 'x', updated_at: iso('2026-08-25T10:00:00'), message_count: 1, meta: { title_manual: false } },
{ id: 'b', title: '手动改名', status: 'running', created_at: 'x', updated_at: iso('2026-08-25T10:00:00'), message_count: 1, meta: { title_manual: true } },
]);
const store = useSessionsStore();
await store.load();
await store.ensureTitle('a', q);
expect(store.items.find((s) => s.id === 'a').title).toBe(q.slice(0, 30));
expect(updateCalls.some((c) => c.id === 'a' && c.body.title === q.slice(0, 30))).toBe(true);
await store.ensureTitle('b', q);
expect(store.items.find((s) => s.id === 'b').title).toBe('手动改名');
expect(updateCalls.some((c) => c.id === 'b')).toBe(false);
});
it('重命名:PATCH 后本地同步(FR-SESSION-006)', async () => {
const { listSessions } = await import('../../../../frontend/src/api/sessions.js');
listSessions.mockResolvedValueOnce([
{ id: 'a', title: '旧标题', status: 'done', created_at: 'x', updated_at: iso('2026-08-25T10:00:00'), message_count: 2, meta: { title_manual: false } },
]);
const store = useSessionsStore();
await store.load();
await store.rename('a', '新标题');
expect(store.items[0].title).toBe('新标题');
expect(store.items[0].meta.title_manual).toBe(true);
});
});
@@ -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}]]}
+176
View File
@@ -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();
});
});
+74
View File
@@ -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'));
});
});
+141
View File
@@ -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);
}
});
});
+28
View File
@@ -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,
},
});
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# smoke_upstreams.sh —— L2s 上游连通性冒烟(T 归属,TEST_PLAN §1/§7)。
# 只读探测远端机上四个服务的健康契约,输出 PASS/FAIL 表。不改任何状态。
# 运行处:远端 plantai-bff 机(上游只绑 127.0.0.1,须在本机执行);Mac 本地经 SSH 隧道
# 运行时用环境变量改 URL(见文末「远程探测」注释)。
# 非 CI 常驻:仅集成点(M2)与验收前手动执行一次。
#
# 环境变量覆盖:
# QA_URL QA 服务根,默认 http://127.0.0.1:7920
# EXTRACT_URL 抽取服务根,默认 http://127.0.0.1:8000
# ALT_URL 回落抽取服务根,默认 http://127.0.0.1:7900
# MOCK_URL mock server 根,默认 http://127.0.0.1:8099
# NEO4J_HTTP Neo4j HTTP 根,默认 http://127.0.0.1:18575(direct-v1-full 实例,DISCOVERY.md §1)
# NEO4J_USER / NEO4J_PASSWORD Neo4j 认证,默认 neo4j / <NEO4J_PASSWORD from backend/.env>(DISCOVERY.md §2.1)
# TIMEOUT curl 超时秒,默认 4
#
# 注意:不探测 QA /search 与 /chat/stream —— 实测单次 37~92s(DISCOVERY.md §4),
# 冒烟只验证 /health 与 /info 可达。
set -u
QA_URL="${QA_URL:-http://127.0.0.1:7920}"
EXTRACT_URL="${EXTRACT_URL:-http://127.0.0.1:8000}"
ALT_URL="${ALT_URL:-http://127.0.0.1:7900}"
MOCK_URL="${MOCK_URL:-http://127.0.0.1:8099}"
NEO4J_HTTP="${NEO4J_HTTP:-http://127.0.0.1:18575}"
TIMEOUT="${TIMEOUT:-4}"
PASS=0
FAIL=0
FAILED_LINES=()
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
# check_http <名称> <URL> [预期子串]
check_http() {
local name="$1" url="$2" expect="${3:-}"
local out code
out=$(curl -s -m "$TIMEOUT" -o "$TMP" -w '%{http_code}' "$url" 2>/dev/null)
code=$?
if [ "$code" -ne 0 ]; then
printf '%-16s FAIL 连接失败(curl exit %s)\n' "$name" "$code"
FAIL=$((FAIL+1)); FAILED_LINES+=("$name")
return
fi
if [ "$out" != "200" ]; then
printf '%-16s FAIL HTTP %s\n' "$name" "$out"
FAIL=$((FAIL+1)); FAILED_LINES+=("$name")
return
fi
if [ -n "$expect" ] && ! grep -q "$expect" "$TMP"; then
printf '%-16s FAIL HTTP 200 但响应不含「%s」\n' "$name" "$expect"
FAIL=$((FAIL+1)); FAILED_LINES+=("$name")
return
fi
printf '%-16s PASS HTTP 200\n' "$name"
PASS=$((PASS+1))
}
# check_neo4j <名称> <HTTP 根>:POST db/neo4j/tx/commit 一条最小只读 Cypher。
# 契约(DISCOVERY.md §2.1):direct-v1-full 需要 Basic auth(neo4j/<NEO4J_PASSWORD from backend/.env>);
# 凭据经环境变量 NEO4J_USER/NEO4J_PASSWORD 覆盖(默认指向当选实例)。
check_neo4j() {
local name="$1" base="$2"
local user="${NEO4J_USER:-neo4j}"
local pass="${NEO4J_PASSWORD:-<NEO4J_PASSWORD from backend/.env>}"
local out code
out=$(curl -s -m "$TIMEOUT" -o "$TMP" -w '%{http_code}' \
-u "$user:$pass" \
-X POST -H 'Content-Type: application/json' \
-d '{"statements":[{"statement":"MATCH (e:SRT2CanonicalEntity) RETURN e.name LIMIT 1"}]}' \
"$base/db/neo4j/tx/commit" 2>/dev/null)
code=$?
if [ "$code" -ne 0 ]; then
printf '%-16s FAIL 连接失败(curl exit %s)\n' "$name" "$code"
FAIL=$((FAIL+1)); FAILED_LINES+=("$name")
return
fi
if [ "$out" != "200" ]; then
printf '%-16s FAIL HTTP %s(认证或实例异常,检查 NEO4J_USER/NEO4J_PASSWORD)\n' "$name" "$out"
FAIL=$((FAIL+1)); FAILED_LINES+=("$name")
return
fi
if grep -q '"errors":\[\]' "$TMP" && grep -q '"results"' "$TMP"; then
printf '%-16s PASS HTTP 200(Cypher 查询有结果)\n' "$name"
PASS=$((PASS+1))
else
printf '%-16s FAIL HTTP 200 但 Cypher 查询返回错误/空 results\n' "$name"
FAIL=$((FAIL+1)); FAILED_LINES+=("$name")
fi
}
echo "== 上游连通性冒烟(timeout ${TIMEOUT}s,$(date '+%F %T'))=="
echo
echo "-- QA (7920),契约:DISCOVERY.md §4 --"
check_http "QA health" "$QA_URL/api/v1/qa-v1-1-73/health" '"status":"ok"'
check_http "QA info" "$QA_URL/api/v1/qa-v1-1-73/info" 'version'
echo "-- 抽取 (8000),契约:DISCOVERY.md §3 --"
check_http "抽取 health" "$EXTRACT_URL/health" '"status":"healthy"'
check_http "抽取 jobs" "$EXTRACT_URL/api/v1/methodology/extract-jobs?limit=1" '"jobs"'
echo "-- 抽取回落 (7900),契约:DISCOVERY.md §3.7 --"
check_http "7900 health" "$ALT_URL/health"
echo "-- Neo4j (bolt 18788 ↔ HTTP 18575),契约:DISCOVERY.md §2 --"
check_neo4j "Neo4j 查询" "$NEO4J_HTTP"
echo "-- mock (8099),契约:ARCHITECTURE.md §6 / API.md --"
check_http "mock health" "$MOCK_URL/api/health" '"upstreams"'
echo
echo "== 汇总 =="
echo "PASS: $PASS | FAIL: $FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "失败项: ${FAILED_LINES[*]}"
exit 1
fi
exit 0
# ---- 远程探测(Mac 本地运行,经 SSH 隧道)----
# 远端上游只绑 127.0.0.1,本地需先起隧道(以 QA 7920 与 Neo4j 18575 为例):
# ssh -N -L 7920:127.0.0.1:7920 -L 18575:127.0.0.1:18575 xzq@100.109.52.26
# 然后:
# QA_URL=http://127.0.0.1:7920 NEO4J_HTTP=http://127.0.0.1:18575 \
# bash tests/smoke_upstreams.sh
# 注意:隧道只解决了连通,Neo4j 数据源真实内容仍以远端为准。