// 右键菜单回归(docs/21):标签菜单与文件树菜单的文件类动作、点别处关闭。 import { describe, it, expect, vi } from 'vitest' import { createRoot } from 'react-dom/client' import { act } from 'react-dom/test-utils' vi.mock('../src/services/platform', async (importOriginal) => { const orig = await importOriginal() return { ...orig, listDir: async (dir: string) => dir === 'C:/ws' ? [{ name: 'a.md', path: 'C:/ws/a.md', isDir: false }] : [], } }) import TabBar from '../src/components/TabBar' import Sidebar from '../src/components/Sidebar' import { createTab } from '../src/stores/tabs' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) function rightClick(el: Element) { el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })) } function mouseDownOutside() { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) } function menuTexts(container: HTMLElement, selector: string) { const menu = container.querySelector(selector) return menu ? [...menu.querySelectorAll('button')].map((b) => b.textContent) : null } describe('标签右键菜单', () => { it('含文件类动作;动作回调带标签 id;点别处关闭', async () => { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) const tab = createTab({ title: 'a', content: 'x', path: 'C:/a.md' }) const onSaveAsTab = vi.fn() await act(async () => { root.render( undefined} onClose={() => undefined} onCloseOthers={() => undefined} onCloseRight={() => undefined} onNew={() => undefined} onMove={() => undefined} onSaveAsTab={onSaveAsTab} onOpenInNewWindowTab={() => undefined} />, ) }) const tabEl = container.querySelector('.tab') as Element await act(async () => { rightClick(tabEl) }) const texts = menuTexts(container, '.context-menu') ?? [] for (const label of ['另存为…', '在新窗口打开', '导出…', '打印 / 导出 PDF…', '关闭']) { expect(texts.some((t) => t === label)).toBe(true) } // 点菜单外(文档任意处按下)→ 关闭 await act(async () => { mouseDownOutside() }) expect(container.querySelector('.context-menu')).toBeNull() // 重新打开并点「另存为…」→ 回调触发且菜单关闭 await act(async () => { rightClick(tabEl) }) const saveAsBtn = [...container.querySelectorAll('.context-menu button')].find((b) => b.textContent === '另存为…') await act(async () => { saveAsBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onSaveAsTab).toHaveBeenCalledWith(tab.id) expect(container.querySelector('.context-menu')).toBeNull() await act(async () => { root.unmount() }) container.remove() }) }) describe('文件树右键菜单', () => { it('文件条目含打开/新窗口/另存为/导出/打印;点别处关闭', async () => { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) const onExportEntry = vi.fn() await act(async () => { root.render( undefined} workspaceRoot="C:/ws" activePath={null} onOpenFile={() => undefined} onOpenFolder={() => undefined} outline={[]} onJumpHeading={() => undefined} tauriMode onRenameEntry={() => undefined} onOpenInNewWindowEntry={() => undefined} onSaveAsEntry={() => undefined} onExportEntry={onExportEntry} onPrintEntry={() => undefined} />, ) }) await act(async () => { await sleep(30) }) const item = [...container.querySelectorAll('.tree-item')].find((el) => el.textContent?.includes('a.md')) as Element expect(item).toBeTruthy() await act(async () => { rightClick(item) }) const texts = menuTexts(container, '.tree-context-menu') ?? [] for (const label of ['打开', '在新窗口打开', '另存为…', '导出…', '打印 / 导出 PDF…', '重命名…']) { expect(texts.some((t) => t === label)).toBe(true) } const exportBtn = [...container.querySelectorAll('.tree-context-menu button')].find((b) => b.textContent === '导出…') await act(async () => { exportBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onExportEntry).toHaveBeenCalledTimes(1) expect(onExportEntry.mock.calls[0][0].path).toBe('C:/ws/a.md') expect(container.querySelector('.tree-context-menu')).toBeNull() // 再次打开后点别处关闭 await act(async () => { rightClick(item) }) expect(container.querySelector('.tree-context-menu')).not.toBeNull() await act(async () => { mouseDownOutside() }) expect(container.querySelector('.tree-context-menu')).toBeNull() await act(async () => { root.unmount() }) container.remove() }) })