This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

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

View 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');
});
});

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

View File

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

View 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');
});
});