// @vitest-environment jsdom import { act, cleanup, fireEvent, render, renderHook, screen, waitFor, } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreatorDirectActiveTurn } from '../src/app/types'; import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, useDirectActiveTurns, } from '../src/features/agent-runtime/directActiveTurns'; import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; afterEach(() => cleanup()); const ACTIVE_TURN = { projectPath: 'C:/projects/demo', agentId: 'planning-agent-v2', runId: 'run-1', } as unknown as GameCreatorDirectActiveTurn; describe('useDirectActiveTurns', () => { it('keeps the snapshot identity when the poll returns the same content', async () => { // 回归点:轮询每次都 setActiveTurns(新数组) 会让所有依赖 activeTurns 的 effect // 反复重跑(窗口标题栏的活动项目面板曾因此无限 setState)。 const invoke = vi.fn(async () => [ACTIVE_TURN]) as never; const { result } = renderHook(() => useDirectActiveTurns({ invoke, enabled: true, pollIntervalMs: DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, }), ); await waitFor(() => { expect(result.current.activeTurns).toHaveLength(1); }); const firstSnapshot = result.current.activeTurns; await act(async () => { await result.current.refreshActiveTurns(); await result.current.refreshActiveTurns(); }); expect(result.current.activeTurns).toBe(firstSnapshot); }); it('clears to a stable empty snapshot when the hook is disabled', async () => { let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void; const snapshot = new Promise((resolve) => { resolveSnapshot = resolve; }); const invoke = vi.fn(() => snapshot) as never; const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }), { initialProps: { enabled: true } }, ); const emptySnapshot = result.current.activeTurns; await act(async () => { resolveSnapshot([]); await snapshot; }); expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); expect(result.current.activeTurns).toBe(emptySnapshot); }); it('读取失败后的重试定时器会在卸载后清理', async () => { vi.useFakeTimers(); const invoke = vi.fn(async () => { throw new Error('temporarily unavailable'); }); const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); try { const { unmount } = renderHook(() => useDirectActiveTurns({ invoke, enabled: true }), ); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); expect(invoke).toHaveBeenCalledTimes(1); unmount(); expect(clearTimeoutSpy).toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); expect(invoke).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); clearTimeoutSpy.mockRestore(); } }); it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => { let complete!: (turns: GameCreatorDirectActiveTurn[]) => void; const invoke = vi.fn( () => new Promise((resolve) => { complete = resolve; }), ); const { result, rerender } = renderHook( ({ enabled }) => useDirectActiveTurns({ invoke: invoke as never, enabled }), { initialProps: { enabled: true } }, ); rerender({ enabled: false }); const empty = result.current.activeTurns; await act(async () => { complete([ACTIVE_TURN]); await result.current.refreshActiveTurns(); }); expect(result.current.activeTurns).toBe(empty); expect(result.current.snapshotReadFailed).toBe(false); expect(invoke).toHaveBeenCalledTimes(1); }); it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => { let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void; const invoke = vi .fn() .mockImplementationOnce( () => new Promise((resolve) => { completeOld = resolve; }), ) .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); const { result, rerender } = renderHook( ({ enabled }) => useDirectActiveTurns({ invoke: invoke as never, enabled }), { initialProps: { enabled: true } }, ); rerender({ enabled: false }); rerender({ enabled: true }); await waitFor(() => expect(result.current.activeTurns[0]?.runId).toBe('new-run'), ); const current = result.current.activeTurns; await act(async () => { completeOld([ACTIVE_TURN]); }); expect(result.current.activeTurns).toBe(current); expect(invoke).toHaveBeenCalledTimes(2); }); }); describe('ActiveProjectRunsPanel', () => { it('按开始时间展示正在运行的项目并支持进入项目', () => { const onOpenProject = vi.fn(); render( , ); const items = screen.getAllByRole('button'); expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ true, false, ]); fireEvent.click(items[0]); expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); }); it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { render(); expect(screen.getByRole('status').textContent).toBe( '未能读取正在运行的项目', ); }); it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => { const onOpenProject = vi.fn(); render( , ); expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy(); expect(screen.queryByRole('menu')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: /后开始/ })); expect(screen.getByRole('menu')).toBeTruthy(); expect(screen.getAllByRole('menuitem')).toHaveLength(2); fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ })); expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); }); });