update
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// tests/unit/renderer/layout/folder-row-callback-stability.test.tsx
|
||||
//
|
||||
// 守 FolderRowOption 的**拖拽四件套回调契约** + **渲染稳定性**
|
||||
// (src/renderer/src/components/common/FolderChrome.tsx r136/r166 头注指向本文件)。
|
||||
//
|
||||
// 契约四条,每条都对应一个**用户可见**的后果:
|
||||
//
|
||||
// 1. dragstart 交出的是**当前** folder 对象。
|
||||
// 退化(闭包捕获了旧对象)⇒ 拖走的还是上一次的目录,落点算错,
|
||||
// 文件被搬进错误的位置。
|
||||
// 2. dragover / dragleave / drop 交出的是 `folder.relPath` —— 它就是
|
||||
// DiskFolderNode 的「id」语义别名(r161 disk-folder 之后没有独立 id 字段)。
|
||||
// 写成 `folder.id` ⇒ 父级收到 undefined ⇒ 落点区收不到任何目录 id,
|
||||
// 拖拽文件夹整条链路静默失效(用户看到「拖了没反应」)。
|
||||
// 3. relPath 变了以后,回调必须跟着变(不许 stale closure)。
|
||||
// 退化(useMemo 依赖写漏)⇒ 文件夹改名 / 搬移后,拖拽仍按旧路径上报,
|
||||
// 文件被搬到一个已不存在的位置,用户「东西不见了」。
|
||||
// 4. props 不变时父级重渲不得让行重渲 —— 行是 memo 组件,每次重渲都要
|
||||
// 重建四个 handler 并让整棵侧栏树重渲(手柄拖拽时尤其明显)。
|
||||
// 退化(去掉 memo / 每次造新 props)⇒ 侧栏滚动与拖拽掉帧。
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - handleDragOver / handleDragLeave / handleDrop 里把 folder.relPath
|
||||
// 换成 folder.id(⇒ undefined)或 folder.name(⇒ 名字当 id)
|
||||
// - handleDragStart 的 useMemo 依赖去掉 folder
|
||||
// - handleDragOver 等的 useMemo 依赖去掉 folder.relPath
|
||||
// - 去掉外层 memo(⇒ 渲染稳定性用例红)
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { FolderRowOption } from '../../../../src/renderer/src/components/common/FolderChrome';
|
||||
import type { DiskFolderNode } from '../../../../src/shared/types';
|
||||
|
||||
// 渲染计数探针:FolderRowOption 每次渲染都会调一次 useTranslation,
|
||||
// 计数变化 = 行真的重渲了(不是「重新渲染了但输出一样」)。
|
||||
const renderProbe = vi.hoisted(() => ({ count: 0 }));
|
||||
|
||||
// i18n:只关心交互,不关心文案;顺带当渲染探针。
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => {
|
||||
renderProbe.count += 1;
|
||||
return { t: (k: string) => k };
|
||||
},
|
||||
}));
|
||||
|
||||
const FOLDER: DiskFolderNode = { relPath: 'a/b', name: 'b', parentRelPath: 'a' };
|
||||
|
||||
type RowProps = React.ComponentProps<typeof FolderRowOption>;
|
||||
|
||||
function makeProps(overrides: Partial<RowProps> = {}): RowProps {
|
||||
return {
|
||||
folder: FOLDER,
|
||||
domId: 'folder-row-a/b',
|
||||
isActive: false,
|
||||
isCut: false,
|
||||
isDropHot: false,
|
||||
isRenaming: false,
|
||||
depth: 1,
|
||||
expanded: false,
|
||||
hasChildren: true,
|
||||
isTarget: false,
|
||||
posInSet: 1,
|
||||
setSize: 3,
|
||||
onActivate: vi.fn(),
|
||||
onContextMenu: vi.fn(),
|
||||
onContextMenuAt: vi.fn(),
|
||||
onHover: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
onDragLeave: vi.fn(),
|
||||
onDrop: vi.fn(),
|
||||
onCommitRename: vi.fn().mockResolvedValue(true),
|
||||
onCancelRename: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(props: RowProps) {
|
||||
const utils = render(<FolderRowOption {...props} />);
|
||||
const row = utils.container.querySelector('[data-folder-relpath]') as HTMLElement;
|
||||
return { ...utils, row };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renderProbe.count = 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('FolderRowOption 拖拽四件套:回调载荷契约', () => {
|
||||
it('dragstart 把当前 folder 对象交给父级', () => {
|
||||
const props = makeProps();
|
||||
const { row } = renderRow(props);
|
||||
fireEvent.dragStart(row);
|
||||
expect(props.onDragStart).toHaveBeenCalledTimes(1);
|
||||
expect((props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe(FOLDER);
|
||||
});
|
||||
|
||||
it('dragover / dragleave / drop 收到的是 folder.relPath(不是 name / parentRelPath)', () => {
|
||||
const props = makeProps();
|
||||
const { row } = renderRow(props);
|
||||
|
||||
fireEvent.dragOver(row);
|
||||
fireEvent.dragLeave(row);
|
||||
fireEvent.drop(row);
|
||||
|
||||
expect(props.onDragOver).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDragLeave).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDrop).toHaveBeenCalledTimes(1);
|
||||
for (const cb of [props.onDragOver, props.onDragLeave, props.onDrop]) {
|
||||
const [id] = (cb as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
// DiskFolderNode 没有 id 字段 —— relPath 就是它的 id 语义别名。
|
||||
expect(id).toBe('a/b');
|
||||
expect(id).not.toBeUndefined();
|
||||
expect(id).not.toBe('b'); // name
|
||||
expect(id).not.toBe('a'); // parentRelPath
|
||||
}
|
||||
});
|
||||
|
||||
it('行 DOM 上的 data-folder-relpath 与 folder.relPath 一致(父级靠它定位行)', () => {
|
||||
const { row } = renderRow(makeProps());
|
||||
expect(row.getAttribute('data-folder-relpath')).toBe('a/b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FolderRowOption 拖拽回调:不许 stale closure', () => {
|
||||
it('folder 换成新对象(改名)后,dragstart 交出的是新对象', () => {
|
||||
const props = makeProps();
|
||||
const { row, rerender } = renderRow(props);
|
||||
|
||||
const renamed: DiskFolderNode = { relPath: 'a/b', name: 'b2', parentRelPath: 'a' };
|
||||
rerender(<FolderRowOption {...props} folder={renamed} />);
|
||||
|
||||
fireEvent.dragStart(row);
|
||||
const [f] = (props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(f).toBe(renamed);
|
||||
expect(f).not.toBe(FOLDER);
|
||||
});
|
||||
|
||||
it('relPath 变了(文件夹被搬移)后,drop 收到的是**新** relPath', () => {
|
||||
const props = makeProps();
|
||||
const { row, rerender } = renderRow(props);
|
||||
|
||||
const moved: DiskFolderNode = { relPath: 'x/c', name: 'c', parentRelPath: 'x' };
|
||||
rerender(<FolderRowOption {...props} folder={moved} />);
|
||||
|
||||
fireEvent.drop(row);
|
||||
expect(props.onDrop).toHaveBeenCalledTimes(1);
|
||||
const [id] = (props.onDrop as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(id).toBe('x/c');
|
||||
});
|
||||
|
||||
it('relPath 变了之后,dragover / dragleave 也跟着走新路径', () => {
|
||||
const props = makeProps();
|
||||
const { row, rerender } = renderRow(props);
|
||||
|
||||
const moved: DiskFolderNode = { relPath: 'x/c', name: 'c', parentRelPath: 'x' };
|
||||
rerender(<FolderRowOption {...props} folder={moved} />);
|
||||
|
||||
fireEvent.dragOver(row);
|
||||
fireEvent.dragLeave(row);
|
||||
expect((props.onDragOver as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe('x/c');
|
||||
expect((props.onDragLeave as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe('x/c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FolderRowOption 渲染稳定性(memo 命中)', () => {
|
||||
it('props 引用不变时,父级重渲不会让行重渲', () => {
|
||||
const props = makeProps();
|
||||
const { rerender } = renderRow(props);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
|
||||
rerender(<FolderRowOption {...props} />);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
|
||||
rerender(<FolderRowOption {...props} />);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
});
|
||||
|
||||
it('folder 变了时行确实重渲(不能被过度 memo 锁死成旧数据)', () => {
|
||||
const props = makeProps();
|
||||
const { rerender } = renderRow(props);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
|
||||
const renamed: DiskFolderNode = { relPath: 'a/b', name: 'b2', parentRelPath: 'a' };
|
||||
rerender(<FolderRowOption {...props} folder={renamed} />);
|
||||
expect(renderProbe.count).toBe(2);
|
||||
});
|
||||
});
|
||||
238
tests/unit/renderer/layout/library-reader-flex-chain.test.tsx
Normal file
238
tests/unit/renderer/layout/library-reader-flex-chain.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// tests/unit/renderer/layout/library-reader-flex-chain.test.tsx
|
||||
//
|
||||
// 守「文献列 → Reader」的**flex 高度链**契约
|
||||
// (src/renderer/src/components/layout/WorkbenchContentPanel.tsx r120 头注指向本文件)。
|
||||
//
|
||||
// 背景(audit-r120 的真实故障):文献列的承载容器一度是**块级**容器,
|
||||
// 子 reader(PDF / Image / Markdown / Code / Plain)的根节点高度全靠
|
||||
// flex-1 撑 —— 块级父下 flex-1 直接失效,iframe 的 h-full 百分比解析
|
||||
// 失败,回退 UA 默认 300×150 ⇒ PDF 塌成一条 150px 横条并报障。
|
||||
//
|
||||
// 契约(每一层都在用户可见的后果上):
|
||||
//
|
||||
// 1. 承载 Reader 的那层容器必须是 **flex + flex-col**:
|
||||
// - 少 flex ⇒ 子 reader 的 flex-1 不生效(高度塌成内容高)
|
||||
// - 少 flex-col ⇒ flex-1 沿主轴(横轴)分配,高度反而撑不开
|
||||
// 2. 该容器还要带 **flex-1 + min-h-0**:
|
||||
// - 少 flex-1 ⇒ 它不吃掉剩余高度,reader 被挤到容器顶部
|
||||
// - 少 min-h-0 ⇒ overflow 逃逸,PDF 内容把主区撑出滚动条
|
||||
// 3. 从 reader 根往上的**每一层祖先都必须是 flex 容器** ——
|
||||
// 中间任何一层退化成块级,整条链的高度计算就断在那儿。
|
||||
// 4. 双列形态下,文献列容器自身也要是 flex 列(min-h-0 同样不能少)。
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - 把 LibraryPanel 里 Reader 外层的 className 去掉 flex / flex-col
|
||||
// - 去掉该层的 flex-1 或 min-h-0
|
||||
// - 双列分支里给文献列容器换成块级(去掉 flex flex-col)
|
||||
// - 把单列分支的包裹层换成块级
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, screen, act } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心布局链,不关心文案
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// --- 三个 store 的最小可订阅替身 -------------------------------------------
|
||||
const stores = vi.hoisted(() => {
|
||||
function makeStore<T extends object>(initial: T) {
|
||||
let state = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: () => state,
|
||||
set: (patch: Partial<T>) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspace = makeStore<{ active: 'library' | 'notes' }>({ active: 'library' });
|
||||
const notes = makeStore<{ selectedId: string | null; select: (id: string | null) => void }>({
|
||||
selectedId: null,
|
||||
select: () => {},
|
||||
});
|
||||
notes.set({
|
||||
select: (id: string | null) => {
|
||||
notes.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
const literature = makeStore<{
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}>({ items: [], selectedId: null, select: () => {} });
|
||||
literature.set({
|
||||
select: (id: string | null) => {
|
||||
literature.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
return { workspace, notes, literature };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.workspace;
|
||||
const useWorkspaceStore = <T,>(selector: (s: { active: 'library' | 'notes' }) => T): T =>
|
||||
useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/notes', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.notes;
|
||||
const useNotesStore = <T,>(
|
||||
selector: (s: { selectedId: string | null; select: (id: string | null) => void }) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useNotesStore.getState = () => store.get();
|
||||
return { useNotesStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/literature', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.literature;
|
||||
const useLiteratureStore = <T,>(
|
||||
selector: (s: {
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useLiteratureStore.getState = () => store.get();
|
||||
return { useLiteratureStore };
|
||||
});
|
||||
|
||||
// 阅读器替身:复刻真 PDFReader 的根节点形态 ——
|
||||
// 根: flex-1 flex flex-col (高度靠 flex-1 撑)
|
||||
// 内层: relative flex-1 min-h-0 overflow-hidden (iframe 的定位上下文)
|
||||
vi.mock('../../../../src/renderer/src/components/reader/Reader', () => ({
|
||||
Reader: () => (
|
||||
<div data-testid="reader-root" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div data-testid="reader-stage" className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<iframe data-testid="reader-frame" title="doc" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesView', () => ({
|
||||
NotesView: () => <div data-testid="notes-view" />,
|
||||
}));
|
||||
|
||||
import { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
|
||||
const LIT_ITEM = { id: 'l1', absPath: 'C:/papers/a.pdf' };
|
||||
|
||||
async function flushLazy(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function hasClass(el: Element, cls: string): boolean {
|
||||
return el.classList.contains(cls);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stores.workspace.set({ active: 'library' });
|
||||
stores.notes.set({ selectedId: null });
|
||||
stores.literature.set({ items: [], selectedId: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('文献列 → Reader 的 flex 高度链(单列形态)', () => {
|
||||
it('承载 Reader 的容器是 flex 列 + flex-1 + min-h-0', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const readerRoot = screen.getByTestId('reader-root');
|
||||
const host = readerRoot.parentElement as HTMLElement;
|
||||
|
||||
expect(hasClass(host, 'flex'), '承载 Reader 的容器必须是 flex 容器').toBe(true);
|
||||
expect(hasClass(host, 'flex-col'), '必须是 flex 列 —— 否则子 reader 的 flex-1 撑不开高度').toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasClass(host, 'flex-1'), '必须吃掉剩余高度').toBe(true);
|
||||
expect(hasClass(host, 'min-h-0'), '必须 min-h-0 —— 否则 PDF 内容把主区撑出滚动条').toBe(true);
|
||||
});
|
||||
|
||||
it('从 reader 根往上的每一层祖先都是 flex 容器(链不能断)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const readerRoot = screen.getByTestId('reader-root');
|
||||
const panelRoot = container.firstElementChild as HTMLElement;
|
||||
|
||||
const chain: HTMLElement[] = [];
|
||||
let cur: HTMLElement | null = readerRoot.parentElement;
|
||||
while (cur) {
|
||||
chain.push(cur);
|
||||
if (cur === panelRoot) break;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
// 走到了主区根容器(没走丢)
|
||||
expect(chain[chain.length - 1]).toBe(panelRoot);
|
||||
expect(chain.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (const el of chain) {
|
||||
expect(hasClass(el, 'flex'), `祖先 <${el.className}> 不是 flex 容器 —— 高度链在此断裂`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('文献列 → Reader 的 flex 高度链(双列形态)', () => {
|
||||
it('双列时文献列容器本身是 flex 列 + min-h-0', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const separator = document.querySelector('[role="separator"]') as HTMLElement;
|
||||
expect(separator).toBeTruthy();
|
||||
const readerCol = separator.previousElementSibling as HTMLElement;
|
||||
|
||||
expect(hasClass(readerCol, 'flex')).toBe(true);
|
||||
expect(hasClass(readerCol, 'flex-col')).toBe(true);
|
||||
expect(hasClass(readerCol, 'min-h-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('双列时承载 Reader 的容器同样是 flex 列 + flex-1 + min-h-0', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const host = screen.getByTestId('reader-root').parentElement as HTMLElement;
|
||||
expect(hasClass(host, 'flex')).toBe(true);
|
||||
expect(hasClass(host, 'flex-col')).toBe(true);
|
||||
expect(hasClass(host, 'flex-1')).toBe(true);
|
||||
expect(hasClass(host, 'min-h-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('双列时笔记列吃掉剩余宽度(flex-1),文献列按固定宽度', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const separator = document.querySelector('[role="separator"]') as HTMLElement;
|
||||
const readerCol = separator.previousElementSibling as HTMLElement;
|
||||
const notesCol = separator.nextElementSibling as HTMLElement;
|
||||
|
||||
expect(hasClass(notesCol, 'flex-1')).toBe(true);
|
||||
expect(hasClass(notesCol, 'min-h-0')).toBe(true);
|
||||
// 文献列宽是持久化的固定值(不是百分比)
|
||||
expect(readerCol.style.width).toBe('640px');
|
||||
});
|
||||
});
|
||||
181
tests/unit/renderer/layout/sidebar-collapse-toggle.test.tsx
Normal file
181
tests/unit/renderer/layout/sidebar-collapse-toggle.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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: () => <div data-testid="lit-list" />,
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesList', () => ({
|
||||
NotesList: () => <div data-testid="notes-list" />,
|
||||
}));
|
||||
|
||||
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<typeof vi.fn>;
|
||||
}
|
||||
|
||||
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(<TitleBar />);
|
||||
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(<TitleBar />);
|
||||
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(<WorkbenchListPanel />);
|
||||
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(<WorkbenchListPanel />));
|
||||
});
|
||||
expect(container.querySelector('section')).toBeTruthy();
|
||||
expect(container.querySelector('[role="tablist"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('C. 折叠态会落盘(settings.update 带 sidebarCollapsed)⇒ 重启后仍折叠', async () => {
|
||||
const { container } = render(<TitleBar />);
|
||||
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(<TitleBar />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
// tests/unit/renderer/layout/workbench-content-decoupled-columns.test.tsx
|
||||
//
|
||||
// 守 WorkbenchContentPanel 的**主区 / 左栏 Tab 解耦**契约
|
||||
// (src/renderer/src/components/layout/WorkbenchContentPanel.tsx 头注指向本文件)。
|
||||
//
|
||||
// 契约(2026-10-18 改造后的形态):
|
||||
//
|
||||
// 1. 主区**不跟左栏 Tab 切换**,只跟两个模块各自的「打开态」
|
||||
// (literature.selectedId / notes.selectedId)走:
|
||||
// 两边都没开 → 空态(身份才取 active)
|
||||
// 开一个 → 该模块单列占满
|
||||
// 两个都开 → 双列(文献在左 ┃ NotesView 在右)
|
||||
// 退化(又用 active 门控 panel 分支)⇒ 用户开着文献去点笔记 Tab,
|
||||
// 文献被顶掉、读者位置丢失 —— 这正是这次改造要消灭的现象。
|
||||
//
|
||||
// 2. **有打开态时,切 active 一律不许动主区**。
|
||||
// 退化 ⇒ 切 Tab 把正在读的东西顶掉(用户明确报障的现象)。
|
||||
//
|
||||
// 3. 关列按钮只清**本模块**的打开态,不动 active、也不动另一列:
|
||||
// 双列关一列 → 另一列自动占满
|
||||
// 单列关列 → 主区落回空态(身份取当前 Tab)
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - 把 litFile !== null && selectedId 的分支改回看 active
|
||||
// - 在「有打开态」的分支里读 active(例如按 active 决定渲染哪列)
|
||||
// - closePanel 里额外改 active / 额外清另一模块的 selectedId
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup, screen, act } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心结构,不关心文案(键原样返回即可)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// --- 三个 store 的最小可订阅替身 -------------------------------------------
|
||||
// 真 zustand store 有订阅语义(状态一变,订阅该 selector 的组件重渲)。
|
||||
// 这里用 useSyncExternalStore 复刻这一点 —— 否则「切 active 后主区不动」
|
||||
// 根本测不出来(不重渲 ⇒ DOM 当然一样,变成空过)。
|
||||
const stores = vi.hoisted(() => {
|
||||
function makeStore<T extends object>(initial: T) {
|
||||
let state = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: () => state,
|
||||
set: (patch: Partial<T>) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspace = makeStore<{ active: 'library' | 'notes' }>({ active: 'library' });
|
||||
const notes = makeStore<{ selectedId: string | null; select: (id: string | null) => void }>({
|
||||
selectedId: null,
|
||||
select: () => {},
|
||||
});
|
||||
notes.set({
|
||||
select: (id: string | null) => {
|
||||
notes.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
const literature = makeStore<{
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}>({ items: [], selectedId: null, select: () => {} });
|
||||
literature.set({
|
||||
select: (id: string | null) => {
|
||||
literature.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
return { workspace, notes, literature };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.workspace;
|
||||
const useWorkspaceStore = <T,>(selector: (s: { active: 'library' | 'notes' }) => T): T =>
|
||||
useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/notes', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.notes;
|
||||
const useNotesStore = <T,>(
|
||||
selector: (s: { selectedId: string | null; select: (id: string | null) => void }) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useNotesStore.getState = () => store.get();
|
||||
return { useNotesStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/literature', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.literature;
|
||||
const useLiteratureStore = <T,>(
|
||||
selector: (s: {
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useLiteratureStore.getState = () => store.get();
|
||||
return { useLiteratureStore };
|
||||
});
|
||||
|
||||
// 懒加载的阅读器 / 笔记视图:本测只关心主区结构,换掉避免 IPC / CodeMirror 噪声
|
||||
vi.mock('../../../../src/renderer/src/components/reader/Reader', () => ({
|
||||
Reader: () => <div data-testid="reader" />,
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesView', () => ({
|
||||
NotesView: () => <div data-testid="notes-view" />,
|
||||
}));
|
||||
|
||||
import { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
|
||||
const LIT_ITEM = { id: 'l1', absPath: 'C:/papers/a.pdf' };
|
||||
|
||||
// Reader / NotesView 都是 React.lazy —— 首次渲染只出 Suspense 占位,
|
||||
// 组件本体要等动态 import 的 promise 落地才出现。断言「不存在」之前
|
||||
// 也必须 flush,否则「还没加载出来」会被误当成「不存在」(假绿)。
|
||||
// 改 store 状态会触发订阅组件重渲 —— 必须包在 act 里,
|
||||
// 否则 React 报「update not wrapped in act」并且重渲落在断言之后。
|
||||
function setActive(active: 'library' | 'notes'): void {
|
||||
act(() => {
|
||||
stores.workspace.set({ active });
|
||||
});
|
||||
}
|
||||
|
||||
async function flushLazy(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stores.workspace.set({ active: 'library' });
|
||||
stores.notes.set({ selectedId: null });
|
||||
stores.literature.set({ items: [], selectedId: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('主区形态由「打开态」决定,不由左栏 Tab 决定', () => {
|
||||
it('文献开着、笔记没开 ⇒ 单列文献(没有笔记列)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
expect(document.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('笔记开着、文献没开 ⇒ 单列笔记(没有文献列)', async () => {
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
expect(document.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('两边都开着 ⇒ 双列 + 分隔条', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(document.querySelector('[role="separator"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('文献 selectedId 命中不到 items ⇒ 按「没打开」处理(冷挂载竞态)', async () => {
|
||||
stores.literature.set({ items: [], selectedId: 'l1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('有打开态时切 active:主区必须原样不动', () => {
|
||||
it('文献开着时切到笔记 Tab ⇒ 仍然是文献列(DOM 一字不变)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
const before = container.innerHTML;
|
||||
|
||||
setActive('notes');
|
||||
|
||||
expect(container.innerHTML).toBe(before);
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('笔记开着时切到文献 Tab ⇒ 仍然是笔记列(DOM 一字不变)', async () => {
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
const before = container.innerHTML;
|
||||
|
||||
setActive('library');
|
||||
|
||||
expect(container.innerHTML).toBe(before);
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
});
|
||||
|
||||
it('双列时切 active ⇒ 仍是双列(DOM 一字不变)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
const before = container.innerHTML;
|
||||
|
||||
setActive('notes');
|
||||
|
||||
expect(container.innerHTML).toBe(before);
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('空态身份只在「都没打开」时跟随左栏 Tab', () => {
|
||||
it('都没打开 + active=notes ⇒ 笔记空态', async () => {
|
||||
setActive('notes');
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
expect(screen.getByText('notes.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.queryByText('literature.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('都没打开 + active=library ⇒ 文献空态', async () => {
|
||||
setActive('library');
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
expect(screen.getByText('literature.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.queryByText('notes.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('关列按钮:只关本列,不动 active,不动另一列', () => {
|
||||
it('双列关掉文献列 ⇒ 笔记列仍在,主区回到单列', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('literature.closePanelAria'));
|
||||
|
||||
expect(stores.literature.get().selectedId).toBeNull();
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(document.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('双列关掉笔记列 ⇒ 文献列仍在', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('notes.closePanelAria'));
|
||||
|
||||
expect(stores.notes.get().selectedId).toBeNull();
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('关列不会顺手切走左栏 Tab(active 不变)', async () => {
|
||||
setActive('notes');
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('notes.closePanelAria'));
|
||||
|
||||
expect(stores.workspace.get().active).toBe('notes');
|
||||
});
|
||||
|
||||
it('单列关掉 ⇒ 主区落回空态(身份取当前 Tab)', async () => {
|
||||
setActive('notes');
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('notes.closePanelAria'));
|
||||
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
expect(screen.getByText('notes.viewEmptyTitle')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
337
tests/unit/renderer/layout/workbench-content-empty-state.test.ts
Normal file
337
tests/unit/renderer/layout/workbench-content-empty-state.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
// tests/unit/renderer/layout/workbench-content-empty-state.test.ts
|
||||
//
|
||||
// 守 WorkbenchContentPanel 的**空态契约**
|
||||
// (src/renderer/src/components/layout/WorkbenchContentPanel.tsx 头注指向本文件)。
|
||||
//
|
||||
// 契约两条:
|
||||
//
|
||||
// 1. 【行为级】两个模块都没打开时,主区给空态,身份跟随左栏 active:
|
||||
// active='notes' → 笔记空态(notes.viewEmptyTitle / notes.viewEmptyDesc)
|
||||
// active='library' → 文献空态(literature.viewEmptyTitle / …)
|
||||
// 空态是 role="status" + aria-live="polite"(读屏用户听得见「未选中」,
|
||||
// 但不打断当前播报 —— 旧状态若变成 assertive 会在用户读文献时插播)。
|
||||
// 视觉身份跟随模块色(notes 蓝 / library 紫),用户一眼知道该点左边哪一栏。
|
||||
//
|
||||
// 2. 【源码顺序】文件里**第一个** <EmptyState … liveRegion="polite" />
|
||||
// 必须属于 NotesEmptyState(第二个才是 LibraryEmptyState)。
|
||||
// 退化(把两个 EmptyState 的定义顺序对调 / 把 library 的搬前面)⇒
|
||||
// 顺序型守卫取样会打到 library 块上,「笔记空态先播报」的约定被打偏,
|
||||
// 而且后续任何按「第一个 EmptyState」取样的工具都会静默取错。
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - 把 LibraryEmptyState 的定义搬到 NotesEmptyState 前面
|
||||
// - 把 liveRegion="polite" 改成 "assertive" / 去掉
|
||||
// - 把 notes 空态的 title / description 换成 literature 的键
|
||||
// - 把 accent="notes" 改成 accent="library"(模块色身份串了)
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, screen, act } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// i18n:键原样返回 —— 空态文案本身就是契约的一部分(直接断言键名)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// --- 三个 store 的最小可订阅替身 -------------------------------------------
|
||||
const stores = vi.hoisted(() => {
|
||||
function makeStore<T extends object>(initial: T) {
|
||||
let state = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: () => state,
|
||||
set: (patch: Partial<T>) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspace = makeStore<{ active: 'library' | 'notes' }>({ active: 'library' });
|
||||
const notes = makeStore<{ selectedId: string | null; select: (id: string | null) => void }>({
|
||||
selectedId: null,
|
||||
select: () => {},
|
||||
});
|
||||
notes.set({
|
||||
select: (id: string | null) => {
|
||||
notes.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
const literature = makeStore<{
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}>({ items: [], selectedId: null, select: () => {} });
|
||||
literature.set({
|
||||
select: (id: string | null) => {
|
||||
literature.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
return { workspace, notes, literature };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.workspace;
|
||||
const useWorkspaceStore = <T,>(selector: (s: { active: 'library' | 'notes' }) => T): T =>
|
||||
useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/notes', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.notes;
|
||||
const useNotesStore = <T,>(
|
||||
selector: (s: { selectedId: string | null; select: (id: string | null) => void }) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useNotesStore.getState = () => store.get();
|
||||
return { useNotesStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/literature', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.literature;
|
||||
const useLiteratureStore = <T,>(
|
||||
selector: (s: {
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useLiteratureStore.getState = () => store.get();
|
||||
return { useLiteratureStore };
|
||||
});
|
||||
|
||||
// 本文件是 .ts(指针指定)⇒ 不用 JSX,mock 组件用 createElement 造。
|
||||
vi.mock('../../../../src/renderer/src/components/reader/Reader', async () => {
|
||||
const { createElement } = await import('react');
|
||||
return { Reader: () => createElement('div', { 'data-testid': 'reader' }) };
|
||||
});
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesView', async () => {
|
||||
const { createElement } = await import('react');
|
||||
return { NotesView: () => createElement('div', { 'data-testid': 'notes-view' }) };
|
||||
});
|
||||
|
||||
import { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
|
||||
const SRC_PATH = resolve(
|
||||
process.cwd(),
|
||||
'src/renderer/src/components/layout/WorkbenchContentPanel.tsx',
|
||||
);
|
||||
|
||||
/** 去掉注释(行注释 + 块注释),保留字符串字面量。
|
||||
* 源码头部注释里写着「取样只认裸名字」,但注释一旦写到同样的字面组合
|
||||
* 就会把取样起点带偏 —— 与其指望人不写,不如让取样器自己跳过注释。 */
|
||||
function stripComments(src: string): string {
|
||||
let out = '';
|
||||
let state: 'code' | 'line' | 'block' | 'sq' | 'dq' = 'code';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i]!;
|
||||
const next = src[i + 1];
|
||||
if (state === 'code') {
|
||||
if (c === '/' && next === '/') {
|
||||
state = 'line';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
state = 'block';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
state = 'sq';
|
||||
} else if (c === '"') {
|
||||
state = 'dq';
|
||||
}
|
||||
out += c;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (state === 'line') {
|
||||
if (c === '\n') {
|
||||
state = 'code';
|
||||
out += '\n';
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (state === 'block') {
|
||||
if (c === '*' && next === '/') {
|
||||
state = 'code';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// 字符串内部
|
||||
if (c === '\\') {
|
||||
out += src.slice(i, i + 2);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ((state === 'sq' && c === "'") || (state === 'dq' && c === '"')) state = 'code';
|
||||
out += c;
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 从 <EmptyState 开始取到**配平的**自闭合标签。
|
||||
* 属性里的 icon={<NotebookPen … />} 自带一个 '/>' —— 直接找第一个 '/>'
|
||||
* 会在 icon 处截断,取不到 title / liveRegion。 */
|
||||
function extractJsxElement(src: string, start: number): string {
|
||||
let curly = 0;
|
||||
let angle = 0;
|
||||
let paren = 0;
|
||||
for (let i = start; i < src.length; i += 1) {
|
||||
const c = src[i]!;
|
||||
if (c === '{') curly += 1;
|
||||
else if (c === '}') curly -= 1;
|
||||
else if (c === '<') angle += 1;
|
||||
else if (c === '>') angle -= 1;
|
||||
else if (c === '(') paren += 1;
|
||||
else if (c === ')') paren -= 1;
|
||||
else if (
|
||||
c === '/' &&
|
||||
src[i + 1] === '>' &&
|
||||
curly === 0 &&
|
||||
angle === 0 &&
|
||||
paren === 0
|
||||
) {
|
||||
return src.slice(start, i + 2);
|
||||
}
|
||||
}
|
||||
return src.slice(start);
|
||||
}
|
||||
|
||||
function extractEmptyStateBlocks(src: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
const re = /<EmptyState\b/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
blocks.push(extractJsxElement(src, m.index));
|
||||
re.lastIndex = m.index + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// NotesView / Reader 都是 React.lazy —— 组件本体要等动态 import 落地。
|
||||
// 「断言不存在」之前也必须 flush,否则「还没加载出来」会被误当成「不存在」。
|
||||
async function flushLazy(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function setActive(active: 'library' | 'notes'): void {
|
||||
act(() => {
|
||||
stores.workspace.set({ active });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stores.workspace.set({ active: 'library' });
|
||||
stores.notes.set({ selectedId: null });
|
||||
stores.literature.set({ items: [], selectedId: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('空态:身份跟随左栏 Tab(两个模块都没打开时)', () => {
|
||||
it('active=notes ⇒ 笔记空态', () => {
|
||||
setActive('notes');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
expect(screen.getByText('notes.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.getByText('notes.viewEmptyDesc')).toBeTruthy();
|
||||
expect(screen.queryByText('literature.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('active=library ⇒ 文献空态', () => {
|
||||
setActive('library');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
expect(screen.getByText('literature.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.getByText('literature.viewEmptyDesc')).toBeTruthy();
|
||||
expect(screen.queryByText('notes.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('空态是 polite live region(读屏播报但不打断)', () => {
|
||||
setActive('notes');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
const status = screen.getByRole('status');
|
||||
expect(status.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('空态视觉身份跟随模块色(notes 蓝 / library 紫)', () => {
|
||||
setActive('notes');
|
||||
const { unmount } = render(createElement(WorkbenchContentPanel));
|
||||
const notesIconBox = screen.getByRole('status').firstElementChild as HTMLElement;
|
||||
expect(notesIconBox.getAttribute('style')).toContain('--module-notes-rgb');
|
||||
unmount();
|
||||
|
||||
setActive('library');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
const libIconBox = screen.getByRole('status').firstElementChild as HTMLElement;
|
||||
expect(libIconBox.getAttribute('style')).toContain('--module-library-rgb');
|
||||
});
|
||||
|
||||
it('有打开态时不出现空态', async () => {
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
await flushLazy();
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(screen.queryByText('notes.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('源码顺序:第一个 polite EmptyState 必须是笔记空态', () => {
|
||||
const source = readFileSync(SRC_PATH, 'utf8');
|
||||
const blocks = extractEmptyStateBlocks(stripComments(source));
|
||||
|
||||
it('文件里恰好两个 EmptyState', () => {
|
||||
expect(blocks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('两个都声明 liveRegion="polite"', () => {
|
||||
for (const b of blocks) expect(b).toContain('liveRegion="polite"');
|
||||
});
|
||||
|
||||
it('第一个是笔记空态(title/desc/accent 都是 notes)', () => {
|
||||
expect(blocks[0]).toContain('notes.viewEmptyTitle');
|
||||
expect(blocks[0]).toContain('notes.viewEmptyDesc');
|
||||
expect(blocks[0]).toContain('accent="notes"');
|
||||
expect(blocks[0]).not.toContain('literature.viewEmptyTitle');
|
||||
});
|
||||
|
||||
it('第二个是文献空态(title/desc/accent 都是 library)', () => {
|
||||
expect(blocks[1]).toContain('literature.viewEmptyTitle');
|
||||
expect(blocks[1]).toContain('literature.viewEmptyDesc');
|
||||
expect(blocks[1]).toContain('accent="library"');
|
||||
expect(blocks[1]).not.toContain('notes.viewEmptyTitle');
|
||||
});
|
||||
|
||||
it('取样器不会被注释里的同名字面量带偏(自检)', () => {
|
||||
// 构造一份「注释里先出现 polite EmptyState」的样本 —— 真源码也可能
|
||||
// 以后被人加注释写进同样的字面量,取样器必须只认代码里的。
|
||||
const sample = `
|
||||
// <EmptyState liveRegion="polite" title={'library.viewEmptyTitle'} />
|
||||
function NotesEmptyState() {
|
||||
return <EmptyState liveRegion="polite" title={t('notes.viewEmptyTitle')} />;
|
||||
}
|
||||
`;
|
||||
const sampled = extractEmptyStateBlocks(stripComments(sample));
|
||||
expect(sampled).toHaveLength(1);
|
||||
expect(sampled[0]).toContain('notes.viewEmptyTitle');
|
||||
expect(sampled[0]).not.toContain('library.viewEmptyTitle');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user