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