Files
2026-07-13 13:20:22 +08:00

178 lines
6.4 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ITeamChildTurnEvent, ITeamRunAck, ITeamRunEvent, ITeamSlotWork } from '@/common/types/team/teamTypes';
import { useTeamRunView } from '@/renderer/pages/team/hooks/useTeamRunView';
type TeamRunHandler = (event: ITeamRunEvent) => void;
type ChildTurnHandler = (event: ITeamChildTurnEvent) => void;
const teamEventMocks = vi.hoisted(() => {
const handlers: Record<string, unknown> = {};
const makeOn = (name: string) =>
vi.fn((handler: unknown) => {
handlers[name] = handler;
return vi.fn();
});
return {
handlers,
invoke: { getRunState: vi.fn() },
on: {
runAccepted: makeOn('runAccepted'),
runStarted: makeOn('runStarted'),
runUpdated: makeOn('runUpdated'),
runCompleted: makeOn('runCompleted'),
runCancelled: makeOn('runCancelled'),
runFailed: makeOn('runFailed'),
childTurnStarted: makeOn('childTurnStarted'),
childTurnCompleted: makeOn('childTurnCompleted'),
childTurnCancelled: makeOn('childTurnCancelled'),
listChanged: makeOn('listChanged'),
sessionChanged: makeOn('sessionChanged'),
agentSpawned: makeOn('agentSpawned'),
agentRemoved: makeOn('agentRemoved'),
agentRenamed: makeOn('agentRenamed'),
reconnected: makeOn('reconnected'),
},
};
});
vi.mock('@/common', () => ({
ipcBridge: {
team: {
getRunState: { invoke: teamEventMocks.invoke.getRunState },
...Object.fromEntries(
Object.entries(teamEventMocks.on)
.filter(([name]) => name !== 'reconnected')
.map(([name, on]) => [name, { on }])
),
},
realtime: { reconnected: { on: teamEventMocks.on.reconnected } },
},
}));
const slotWork = (slot_id: string, overrides: Partial<ITeamSlotWork> = {}): ITeamSlotWork => ({
slot_id,
role: slot_id === 'lead' ? 'lead' : 'teammate',
state: 'queued',
queued_foreground_count: 0,
queued_background_count: 1,
active_turn_id: null,
active_turn_started_at_ms: null,
active_turn_elapsed_ms: null,
active_turn_slow: null,
active_turn_slow_threshold_ms: null,
blocked_reason: null,
team_run_id: null,
...overrides,
});
const runEvent = (overrides: Partial<ITeamRunEvent> = {}): ITeamRunEvent => ({
team_id: 'team-1',
team_run_id: 'run-1',
source: 'user_message',
has_user_intervention: false,
target_slot_id: 'lead',
target_role: 'lead',
status: 'running',
queued_intent_count: 1,
starting_batch_count: 0,
running_batch_count: 1,
active_enqueue_lease_count: 0,
slot_work: [slotWork('lead', { state: 'running', team_run_id: 'run-1' })],
...overrides,
});
describe('useTeamRunView', () => {
beforeEach(() => {
vi.clearAllMocks();
teamEventMocks.invoke.getRunState.mockResolvedValue({
session_generation: null,
active_run: null,
slot_work: [],
});
for (const key of Object.keys(teamEventMocks.handlers)) delete teamEventMocks.handlers[key];
});
it('ack_applies_the_exact_core_run_snapshot', () => {
const { result } = renderHook(() => useTeamRunView('team-1'));
const run = runEvent({
has_user_intervention: true,
queued_intent_count: 3,
slot_work: [slotWork('lead', { queued_foreground_count: 2, team_run_id: 'run-1' })],
});
const ack: ITeamRunAck = { enqueue_status: 'queued', message_id: 'message-1', run };
act(() => result.current.applyAck(ack));
expect(result.current.state.activeRun).toEqual(run);
expect(result.current.state.slotWorkBySlot).toEqual({ lead: run.slot_work[0] });
});
it('terminal_event_clears_only_active_run_and_keeps_global_slot_work', () => {
const { result } = renderHook(() => useTeamRunView('team-1'));
const terminalWork = slotWork('worker', { queued_background_count: 2 });
const runCompleted = teamEventMocks.handlers.runCompleted as TeamRunHandler;
act(() => runCompleted(runEvent({ status: 'completed', slot_work: [terminalWork] })));
expect(result.current.state.activeRun).toBeUndefined();
expect(result.current.state.slotWorkBySlot).toEqual({ worker: terminalWork });
});
it('child_events_do_not_invent_slot_work_counts', () => {
const { result } = renderHook(() => useTeamRunView('team-1'));
const work = slotWork('worker', { queued_background_count: 4 });
const runUpdated = teamEventMocks.handlers.runUpdated as TeamRunHandler;
const childStarted = teamEventMocks.handlers.childTurnStarted as ChildTurnHandler;
const childCompleted = teamEventMocks.handlers.childTurnCompleted as ChildTurnHandler;
const child: ITeamChildTurnEvent = {
team_id: 'team-1',
team_run_id: 'run-1',
slot_id: 'worker',
role: 'teammate',
conversation_id: 'conv-worker',
turn_id: 'turn-worker',
status: 'running',
};
act(() => runUpdated(runEvent({ slot_work: [work] })));
act(() => childStarted(child));
expect(result.current.state.slotWorkBySlot.worker).toEqual(work);
act(() => childCompleted({ ...child, status: 'completed' }));
expect(result.current.state.slotWorkBySlot.worker).toEqual(work);
});
it('reconnect_replaces_all_slot_work_from_snapshot', async () => {
const { result } = renderHook(() => useTeamRunView('team-1'));
const runUpdated = teamEventMocks.handlers.runUpdated as TeamRunHandler;
act(() => runUpdated(runEvent({ slot_work: [slotWork('lead')] })));
const replacement = slotWork('worker', { state: 'blocked', blocked_reason: 'runtime_starting' });
teamEventMocks.invoke.getRunState.mockResolvedValue({
session_generation: 'generation-2',
active_run: null,
slot_work: [replacement],
});
await act(async () => {
(teamEventMocks.handlers.reconnected as () => void)();
});
await waitFor(() => expect(result.current.state.slotWorkBySlot).toEqual({ worker: replacement }));
expect(result.current.state.activeRun).toBeUndefined();
});
it('background_slot_work_is_kept_without_an_active_run', async () => {
const background = slotWork('worker', { queued_background_count: 2 });
teamEventMocks.invoke.getRunState.mockResolvedValue({
session_generation: 'generation-1',
active_run: null,
slot_work: [background],
});
const { result } = renderHook(() => useTeamRunView('team-1'));
await waitFor(() => expect(result.current.state.slotWorkBySlot.worker).toEqual(background));
expect(result.current.state.activeRun).toBeUndefined();
});
});