// 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. 【源码顺序】文件里**第一个** // 必须属于 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(initial: T) { let state = initial; const listeners = new Set<() => void>(); return { get: () => state, set: (patch: Partial) => { 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 = (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 = ( 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 = ( 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; } /** 从 } 自带一个 '/>' —— 直接找第一个 '/>' * 会在 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 = / { 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 = ` // function NotesEmptyState() { return ; } `; const sampled = extractEmptyStateBlocks(stripComments(sample)); expect(sampled).toHaveLength(1); expect(sampled[0]).toContain('notes.viewEmptyTitle'); expect(sampled[0]).not.toContain('library.viewEmptyTitle'); }); });