Files
Literature-Management/tests/unit/renderer/layout/workbench-content-empty-state.test.ts
2026-10-21 14:42:59 +08:00

338 lines
12 KiB
TypeScript

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