Files

187 lines
9.5 KiB
JavaScript
Raw Permalink Normal View History

// 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 });
});
});