Files
jerryandClaude Fable 5 1e94de7464 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>
2026-08-25 20:47:52 +08:00

191 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)));
}