// tests/unit/renderer/layout/sidebar-collapse-toggle.test.tsx // // 守 r242「折叠 / 展开左侧栏」按钮的行为契约。 // // 需求:用户要「一个按钮隐藏或展开左侧栏」。本轮的实现落点: // - 按钮在**标题栏右侧**控件簇(r242 起先放左上角品牌区旁,r246 按用户 // 反馈「该按钮不应该在左上角吧,放右边怎么样」挪到右侧)—— 折叠后面板 // 整个不在 DOM 里,面板内的按钮会跟着消失,用户再也点不回来;标题栏 // 是唯一「折叠前后都在」的位置。 // - 折叠态存在 workspace store(sidebarCollapsed),持久化到 // settings.json 的 workspaceLayout(见 shared/sidebar-collapsed-persistence.test.ts)。 // - 快捷键 ⌘B / Ctrl+B(命令 id 'view.toggleSidebar',与主进程 View 菜单 // 同一个 id)。 // - 折叠的实现是「整个面板 return null」,**不是**「宽度设成 0」—— // 宽度 0 会留下一条分隔条 + 边框死白条。 // // 本测锁(行为级,不测实现细节): // A. 标题栏按钮存在、aria-pressed 反映折叠态、点击即翻转 store。 // B. 折叠 ⇒ 列表面板**整个不进 DOM**;展开 ⇒ 正常渲染。 // C. 折叠态会落盘(settings.update 带 sidebarCollapsed)⇒ 重启后仍折叠。 // D. 命令注册表有 view.toggleSidebar(mod+B),执行即翻转折叠态。 // E. 坏数据(非 boolean)不会把左栏永久藏掉。 // F. 按钮在标题栏**右侧**控件簇首位(位置回归防护:曾放左上角)。 import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, fireEvent, cleanup, waitFor, act } from '@testing-library/react'; // i18n:只关心交互与键名,不在乎文案 vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }), })); // settings store:workspace store 的 persistLayout 会调 update() 落盘 —— // 换假实现,避免真 IPC(测试环境没有 window.api)。update 是 vi.fn, // 下面的断言直接拿它当 spy 用。 vi.mock('../../../../src/renderer/src/store/settings', () => { const update = vi.fn(async () => undefined); const load = vi.fn(async () => undefined); const state = { settings: null, update, load }; const useSettingsStore = ((selector?: (s: unknown) => unknown) => selector ? selector(state) : state) as unknown as { (selector?: (s: unknown) => unknown): unknown; getState: () => unknown; }; useSettingsStore.getState = () => state; return { useSettingsStore }; }); // 懒加载的列表组件:本测不关心列表内容,换掉避免 react-virtuoso / IPC 噪声 vi.mock('../../../../src/renderer/src/components/literature/LiteratureList', () => ({ LiteratureList: () =>
, })); vi.mock('../../../../src/renderer/src/components/notes/NotesList', () => ({ NotesList: () =>
, })); import { TitleBar } from '../../../../src/renderer/src/components/layout/TitleBar'; import { WorkbenchListPanel } from '../../../../src/renderer/src/components/layout/WorkbenchListPanel'; import { useWorkspaceStore } from '../../../../src/renderer/src/store/workspace'; import { useSettingsStore } from '../../../../src/renderer/src/store/settings'; import { buildCommands } from '../../../../src/renderer/src/command/registry'; import { buildCommandActions } from '../../../../src/renderer/src/command/actions'; const TOGGLE_LABEL = 'titleBar.toggleSidebar'; function updateMock() { return useSettingsStore.getState().update as unknown as ReturnType; } function resetStore() { useWorkspaceStore.setState({ sidebarCollapsed: false }); } describe('左侧栏折叠 / 展开(r242)', () => { beforeEach(() => { resetStore(); updateMock().mockClear(); localStorage.clear(); }); afterEach(() => { cleanup(); localStorage.clear(); }); it('A1. 标题栏有折叠按钮,标签稳定、aria-pressed 反映当前折叠态', () => { const { container } = render(); const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement; expect(btn, '标题栏必须有折叠 / 展开左栏的按钮').toBeTruthy(); // 展开态 = 未按下 expect(btn.getAttribute('aria-pressed')).toBe('false'); }); it('A2. 点按钮 ⇒ 折叠态翻转(store 是唯一真相源)', () => { const { container } = render(); const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement; fireEvent.click(btn); expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(true); fireEvent.click(btn); expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(false); }); it('B1. 折叠 ⇒ 列表面板整个不进 DOM(不是宽度设 0)', () => { useWorkspaceStore.setState({ sidebarCollapsed: true }); const { container } = render(); expect(container.querySelector('section'), '折叠后不应留下宽度 0 的空壳 section').toBeNull(); expect(container.querySelector('[role="tablist"]')).toBeNull(); expect(container.querySelector('[role="separator"]')).toBeNull(); }); it('B2. 展开 ⇒ 面板正常渲染(折叠不是单向死路)', async () => { useWorkspaceStore.setState({ sidebarCollapsed: false }); // list 是 lazy 组件(React.lazy + Suspense)—— act 里等它 resolve, // 否则 React 报「suspended resource finished loading outside act」。 let container!: HTMLElement; await act(async () => { ({ container } = render()); }); expect(container.querySelector('section')).toBeTruthy(); expect(container.querySelector('[role="tablist"]')).toBeTruthy(); }); it('C. 折叠态会落盘(settings.update 带 sidebarCollapsed)⇒ 重启后仍折叠', async () => { const { container } = render(); const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement; fireEvent.click(btn); // persistLayout 是 200ms trailing debounce await waitFor(() => expect(updateMock()).toHaveBeenCalled(), { timeout: 2000 }); const lastCall = updateMock().mock.calls.at(-1); expect(lastCall?.[0]).toMatchObject({ workspaceLayout: expect.objectContaining({ sidebarCollapsed: true }), }); }); it('D. 命令注册表有 view.toggleSidebar(⌘B),执行即翻转折叠态', () => { const cmds = buildCommands(buildCommandActions()); const cmd = cmds.find((c) => c.id === 'view.toggleSidebar'); expect(cmd, '命令注册表缺少 view.toggleSidebar(主进程菜单会 invoke 不到)').toBeTruthy(); expect(cmd?.shortcut).toBe('mod+B'); cmd?.run(); expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(true); cmd?.run(); expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(false); }); it('E. 坏数据(非 boolean)不会把左栏永久藏掉', () => { useWorkspaceStore.getState().hydrateLayout({ sidebarCollapsed: 'yes' as unknown as boolean, }); expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(false); useWorkspaceStore.getState().hydrateLayout({ sidebarCollapsed: true }); expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(true); }); it('F. 按钮在标题栏右侧控件簇首位(位置回归防护:曾放左上角品牌区旁)', () => { const { container } = render(); const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement; const banner = container.querySelector('[role="banner"]') as HTMLElement; const brand = container.querySelector('#app-title-h1')?.parentElement; expect(brand, '找不到品牌区(徽标 + 应用名)').toBeTruthy(); expect( brand?.contains(btn), '折叠开关不该待在品牌区(左上角)—— 那里是「我是谁」,开关是「操作」', ).toBe(false); // 标题栏的子节点:品牌区 | 弹性拖拽区 | 右侧控件簇 ⇒ 按钮要在最后一簇 const clusters = [...banner.children] as HTMLElement[]; const rightCluster = clusters[clusters.length - 1]; expect(rightCluster.contains(btn), '折叠开关应在标题栏最右侧的控件簇里').toBe(true); // 且是该簇首位(紧邻拖拽区)—— Win 的最小化/最大化/关闭要保持贴右成组 expect( rightCluster.firstElementChild, '折叠开关应紧邻拖拽区,不与窗口三按钮混排', ).toBe(btn); }); });