Files
PlantAI-rag-new/tests/e2e/responsive.spec.js
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

187 lines
9.5 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.
// 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 });
});
});