// @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_CHANGED_EVENT, useDirectActiveTurns, } from '../src/features/agent-runtime/directActiveTurns'; import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; import { createTauriEventFake, type TauriEventFake } from './tauriEventFake'; let eventFake: TauriEventFake | null = null; afterEach(() => { cleanup(); eventFake?.restore(); eventFake = null; }); 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 an event-triggered read returns the same content', async () => { // 回归点:事件重复或内容未变时不能换掉数组身份,避免窗口标题栏重新发布。 const invoke = vi.fn(async () => [ACTIVE_TURN]) as never; const { result } = renderHook(() => useDirectActiveTurns({ invoke, enabled: true, }), ); 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('subscribes before the first snapshot and does not read while quiet', async () => { const invoke = vi.fn(async () => [ACTIVE_TURN]); eventFake = createTauriEventFake({ invoke }); eventFake.install(); const setIntervalSpy = vi.spyOn(window, 'setInterval'); const { result } = renderHook(() => useDirectActiveTurns({ invoke: invoke as never, enabled: true }), ); await waitFor(() => expect( eventFake?.listenInvokeCounts.get(DIRECT_ACTIVE_TURNS_CHANGED_EVENT), ).toBe(1), ); const listReads = () => invoke.mock.calls.filter( ([command]) => command === 'list_game_creator_direct_active_turns', ).length; await waitFor(() => expect(result.current.activeTurns).toHaveLength(1)); expect(listReads()).toBe(1); // The native event registration is already in place before the first list read; // the fake keeps the callback eval pending until we explicitly flush it. expect(eventFake.pendingRegistrationEvals()).toBe(1); eventFake.flushRegistrationEvals(); expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 5_000)).toBe( false, ); await new Promise((resolve) => setTimeout(resolve, 0)); expect(listReads()).toBe(1); setIntervalSpy.mockRestore(); }); it('coalesces repeated in-flight notifications into one follow-up read', async () => { let resolveFirst!: (turns: GameCreatorDirectActiveTurn[]) => void; const first = new Promise((resolve) => { resolveFirst = resolve; }); const invoke = vi .fn() .mockReturnValueOnce(first) .mockResolvedValue([ACTIVE_TURN]); eventFake = createTauriEventFake({ invoke }); eventFake.install(); renderHook(() => useDirectActiveTurns({ invoke: invoke as never, enabled: true }), ); await waitFor(() => expect( eventFake?.listenInvokeCounts.get(DIRECT_ACTIVE_TURNS_CHANGED_EVENT), ).toBe(1), ); eventFake.flushRegistrationEvals(); await waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); eventFake.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 1 }); eventFake.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 2 }); resolveFirst([ACTIVE_TURN]); await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2)); await new Promise((resolve) => setTimeout(resolve, 0)); expect(invoke).toHaveBeenCalledTimes(2); }); it('keeps the last snapshot and reports observability failure when an event read fails', async () => { let fail = false; const invoke = vi.fn(async () => { if (fail) { throw new Error('snapshot unavailable'); } return [ACTIVE_TURN]; }); eventFake = createTauriEventFake({ invoke }); eventFake.install(); const { result } = renderHook(() => useDirectActiveTurns({ invoke: invoke as never, enabled: true }), ); eventFake.flushRegistrationEvals(); await waitFor(() => expect(result.current.activeTurns).toHaveLength(1)); const firstSnapshot = result.current.activeTurns; vi.useFakeTimers(); try { fail = true; act(() => { eventFake?.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 1 }); }); await act(async () => { await vi.runAllTimersAsync(); }); expect(result.current.activeTurns).toBe(firstSnapshot); expect(result.current.snapshotReadFailed).toBe(true); } finally { vi.useRealTimers(); } }); 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 }), { 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'); }); });