Files
Genarrative/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
T
kdletters 8701f43f75 AGC 渲染层网络与状态下沉 Rust,并修复画布交互与失败文案
- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。
- 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。
- 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。
- 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
2026-09-28 14:15:02 +08:00

342 lines
11 KiB
TypeScript

// @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<GameCreatorDirectActiveTurn[]>((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<GameCreatorDirectActiveTurn[]>((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<GameCreatorDirectActiveTurn[]>((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<GameCreatorDirectActiveTurn[]>((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(
<ActiveProjectRunsPanel
activeTurns={[
{
projectPath: 'C:/projects/later',
projectName: '后开始',
turnId: 'turn-later',
startedAt: 200,
status: 'streaming',
updatedAt: 220,
sequence: 2,
},
{
projectPath: 'C:/projects/first',
projectName: '先开始',
turnId: 'turn-first',
startedAt: 100,
status: 'running',
updatedAt: 120,
sequence: 1,
},
]}
onOpenProject={onOpenProject}
/>,
);
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(<ActiveProjectRunsPanel activeTurns={[]} readFailed />);
expect(screen.getByRole('status').textContent).toBe(
'未能读取正在运行的项目',
);
});
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
const onOpenProject = vi.fn();
render(
<ActiveProjectRunsPanel
placement="titlebar"
activeTurns={[
{
projectPath: 'C:/projects/first',
projectName: '先开始',
turnId: 'turn-first',
startedAt: 100,
status: 'running',
updatedAt: 120,
sequence: 1,
},
{
projectPath: 'C:/projects/later',
projectName: '后开始',
turnId: 'turn-later',
startedAt: 200,
status: 'streaming',
updatedAt: 220,
sequence: 2,
},
]}
onOpenProject={onOpenProject}
/>,
);
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');
});
});