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