// @vitest-environment jsdom /** * WorkspaceViewer 组件测试 * * 覆盖:路径显示、核心文件列表(存在/缺失)、文件预览、自动目录、 * 刷新按钮、Agent 完成自动刷新、错误/无数据状态、文件管理器打开。 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { WorkspaceViewer } from '../WorkspaceViewer'; import { useAgentStore } from '@renderer/stores/agent-store'; const agentInitial = useAgentStore.getState(); let infoResult: MetonaWorkspaceInfo | null = null; function makeFile(overrides: Partial = {}): MetonaWorkspaceFileInfo { return { name: 'SOUL.md', path: '/ws/SOUL.md', exists: true, size: 1024, mtime: 1730000000000, preview: '我是 SOUL', ...overrides, }; } function makeDir(overrides: Partial = {}): MetonaWorkspaceDirInfo { return { name: 'logs', path: '/ws/logs', exists: true, fileCount: 3, ...overrides, }; } beforeEach(() => { useAgentStore.setState(agentInitial, true); useAgentStore.setState({ agentStatus: 'idle' }); infoResult = null; ( window.metona.workspace as unknown as { getInfo: () => Promise; } ).getInfo = vi.fn(async () => infoResult) as unknown as () => Promise; ( window.metona.app as unknown as { showItemInFolder: (path: string) => Promise<{ success: boolean }>; } ).showItemInFolder = vi.fn(async () => ({ success: true })); }); describe('WorkspaceViewer — 基础渲染', () => { it('渲染工作空间根路径', async () => { infoResult = { path: '/home/user/ws', files: [], dirs: [], }; render(); expect(await screen.findByText('/home/user/ws')).toBeInTheDocument(); }); it('getInfo 返回 null 时渲染"无数据"', async () => { infoResult = null; render(); expect(await screen.findByText('无数据')).toBeInTheDocument(); }); it('加载失败时渲染错误 Alert(显示异常消息)', async () => { ( window.metona.workspace as unknown as { getInfo: () => Promise; } ).getInfo = vi.fn(async () => { throw new Error('磁盘读取失败'); }); render(); expect(await screen.findByText('磁盘读取失败')).toBeInTheDocument(); expect(document.querySelector('[role="alert"]')).toBeInTheDocument(); }); it('渲染刷新按钮并点击重新拉取', async () => { infoResult = { path: '/ws/a', files: [], dirs: [] }; render(); await screen.findByText('/ws/a'); const refreshBtn = document .querySelector('.lucide-refresh-cw') ?.closest('button') as HTMLElement; fireEvent.click(refreshBtn); await waitFor(() => { expect(window.metona.workspace.getInfo).toHaveBeenCalledTimes(2); }); }); }); describe('WorkspaceViewer — 核心文件', () => { it('渲染核心文件计数与文件列表', async () => { infoResult = { path: '/ws', files: [ makeFile({ name: 'SOUL.md', exists: true }), makeFile({ name: 'MEMORY.md', exists: false }), ], dirs: [], }; render(); expect(await screen.findByText('核心文件(1/2)')).toBeInTheDocument(); expect(screen.getByText('SOUL.md')).toBeInTheDocument(); expect(screen.getByText('MEMORY.md')).toBeInTheDocument(); }); it('存在的文件渲染大小与修改时间', async () => { infoResult = { path: '/ws', files: [makeFile({ name: 'SOUL.md', size: 2048, mtime: 1730000000000 })], dirs: [], }; render(); // 大小与时间在同一 Typography 中拼接 expect(await screen.findByText(/2\.0 KB · \d{4}-\d{2}-\d{2}/)).toBeInTheDocument(); }); it('缺失文件展开后渲染"文件不存在"', async () => { infoResult = { path: '/ws', files: [makeFile({ name: 'MEMORY.md', exists: false })], dirs: [], }; render(); fireEvent.click(await screen.findByText('MEMORY.md')); expect(await screen.findByText('文件不存在')).toBeInTheDocument(); }); it('存在的文件展开渲染内容预览', async () => { infoResult = { path: '/ws', files: [makeFile({ name: 'SOUL.md', preview: '# 我是 Agent' })], dirs: [], }; render(); fireEvent.click(await screen.findByText('SOUL.md')); expect(await screen.findByText('# 我是 Agent')).toBeInTheDocument(); }); it('存在的空文件展开渲染"(空文件)"', async () => { infoResult = { path: '/ws', files: [makeFile({ name: 'SOUL.md', preview: '' })], dirs: [], }; render(); fireEvent.click(await screen.findByText('SOUL.md')); expect(await screen.findByText('(空文件)')).toBeInTheDocument(); }); }); describe('WorkspaceViewer — 自动目录', () => { it('渲染自动目录与文件计数', async () => { infoResult = { path: '/ws', files: [], dirs: [makeDir({ name: 'logs', fileCount: 5 }), makeDir({ name: '.metona', exists: false })], }; render(); expect(await screen.findByText('自动目录')).toBeInTheDocument(); expect(screen.getByText('logs/')).toBeInTheDocument(); expect(screen.getByText('5 个文件')).toBeInTheDocument(); }); it('缺失目录渲染"不存在"', async () => { infoResult = { path: '/ws', files: [], dirs: [makeDir({ name: '.metona', exists: false })], }; render(); expect(await screen.findByText('.metona/')).toBeInTheDocument(); expect(screen.getByText('不存在')).toBeInTheDocument(); }); }); describe('WorkspaceViewer — 打开文件管理器', () => { it('点击路径旁的文件夹按钮调用 showItemInFolder', async () => { infoResult = { path: '/ws/root', files: [], dirs: [] }; render(); await screen.findByText('/ws/root'); const btn = document.querySelector('.lucide-folder-open')?.closest('button') as HTMLElement; fireEvent.click(btn); await waitFor(() => { expect(window.metona.app.showItemInFolder).toHaveBeenCalledWith('/ws/root'); }); }); it('点击文件旁的打开按钮调用 showItemInFolder(文件路径)', async () => { infoResult = { path: '/ws', files: [makeFile({ name: 'SOUL.md', path: '/ws/SOUL.md' })], dirs: [], }; render(); fireEvent.click(await screen.findByText('SOUL.md')); const btns = document.querySelectorAll('.lucide-folder-open'); fireEvent.click(btns[btns.length - 1].closest('button') as HTMLElement); await waitFor(() => { expect(window.metona.app.showItemInFolder).toHaveBeenCalledWith('/ws/SOUL.md'); }); }); }); describe('WorkspaceViewer — Agent 完成自动刷新', () => { it('Agent 从 executing 回到 idle 时自动重新加载', async () => { infoResult = { path: '/ws/a', files: [], dirs: [] }; const { unmount } = render(); await screen.findByText('/ws/a'); const callsAfterMount = (window.metona.workspace.getInfo as ReturnType).mock.calls .length; // 触发状态流转 executing → idle(分步 act 确保 effect 逐次执行) await act(async () => { useAgentStore.setState({ agentStatus: 'executing' }); await Promise.resolve(); }); await act(async () => { useAgentStore.setState({ agentStatus: 'idle' }); await Promise.resolve(); }); await waitFor(() => { expect( (window.metona.workspace.getInfo as ReturnType).mock.calls.length, ).toBeGreaterThan(callsAfterMount); }); unmount(); }); it('非工作状态流转不触发自动刷新', async () => { infoResult = { path: '/ws/a', files: [], dirs: [] }; render(); await screen.findByText('/ws/a'); const callsAfterMount = (window.metona.workspace.getInfo as ReturnType).mock.calls .length; await act(async () => { useAgentStore.setState({ agentStatus: 'error' }); await Promise.resolve(); }); await act(async () => { useAgentStore.setState({ agentStatus: 'idle' }); await Promise.resolve(); }); await new Promise((r) => setTimeout(r, 50)); expect((window.metona.workspace.getInfo as ReturnType).mock.calls.length).toBe( callsAfterMount, ); }); });