import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ServerEvent } from '../../core/protocol.js'; import { deriveAvatarState, type AvatarStateSource } from './avatar-state.js'; /** Minimal stand-in for RealtimeClient/ResilientRealtimeClient — only .on() is used. */ class FakeSource implements AvatarStateSource { private handler: ((event: ServerEvent) => void) | null = null; on(handler: (event: ServerEvent) => void): () => void { this.handler = handler; return () => { this.handler = null; }; } emit(event: ServerEvent): void { this.handler?.(event); } } describe('deriveAvatarState', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('subscribe() emits the current state immediately', () => { const store = deriveAvatarState(new FakeSource()); const run = vi.fn(); store.subscribe(run); expect(run).toHaveBeenCalledTimes(1); expect(run).toHaveBeenCalledWith('idleActive'); }); it('walks listening -> thinking -> talking -> idleActive across a full voice turn', () => { const source = new FakeSource(); const store = deriveAvatarState(source); const seen: string[] = []; store.subscribe((s) => seen.push(s)); store.setListening(true); store.setListening(false); source.emit({ type: 'output_audio.chunk', audio: '' }); source.emit({ type: 'output_audio.done' }); expect(seen).toEqual(['idleActive', 'listening', 'thinking', 'talking', 'idleActive']); }); it('notifyResponseRequested() drives thinking for the typed-text path', () => { const source = new FakeSource(); const store = deriveAvatarState(source); const seen: string[] = []; store.subscribe((s) => seen.push(s)); store.notifyResponseRequested(); source.emit({ type: 'transcript.delta', role: 'assistant', text: 'hi' }); source.emit({ type: 'transcript.done', role: 'assistant' }); expect(seen).toEqual(['idleActive', 'thinking', 'talking', 'idleActive']); }); it('ignores user transcript events (no signal either way)', () => { const source = new FakeSource(); const store = deriveAvatarState(source); const seen: string[] = []; store.subscribe((s) => seen.push(s)); source.emit({ type: 'transcript.delta', role: 'user', text: 'hello' }); expect(seen).toEqual(['idleActive']); }); it('session.ready and session.closed reset to idleActive from any state', () => { const source = new FakeSource(); const store = deriveAvatarState(source); const seen: string[] = []; store.subscribe((s) => seen.push(s)); source.emit({ type: 'output_audio.chunk', audio: '' }); // -> talking source.emit({ type: 'session.closed', reason: 'client_requested' }); expect(seen).toEqual(['idleActive', 'talking', 'idleActive']); }); it('response.done is a fallback that stops talking even without output_audio.done', () => { const source = new FakeSource(); const store = deriveAvatarState(source); const seen: string[] = []; store.subscribe((s) => seen.push(s)); source.emit({ type: 'output_audio.chunk', audio: '' }); source.emit({ type: 'response.done' }); expect(seen).toEqual(['idleActive', 'talking', 'idleActive']); }); it('destroy() stops reacting to further events', () => { const source = new FakeSource(); const store = deriveAvatarState(source); const seen: string[] = []; store.subscribe((s) => seen.push(s)); store.destroy(); source.emit({ type: 'output_audio.chunk', audio: '' }); expect(seen).toEqual(['idleActive']); }); it('settles idleActive into idlePassive after the configured timeout with no other signal', () => { const source = new FakeSource(); const store = deriveAvatarState(source, { idlePassiveTimeoutMs: 5_000 }); const seen: string[] = []; store.subscribe((s) => seen.push(s)); vi.advanceTimersByTime(5_000); expect(seen).toEqual(['idleActive', 'idlePassive']); }); it('does not fire idle_timeout while something else is happening', () => { const source = new FakeSource(); const store = deriveAvatarState(source, { idlePassiveTimeoutMs: 5_000 }); const seen: string[] = []; store.subscribe((s) => seen.push(s)); vi.advanceTimersByTime(4_000); store.setListening(true); // -> listening, cancels/re-arms the idle timer vi.advanceTimersByTime(4_000); // < 5s since re-arm — still listening store.setListening(false); // -> thinking expect(seen).toEqual(['idleActive', 'listening', 'thinking']); }); it('re-arms the idle timer once back in idleActive, and destroy() cancels it', () => { const source = new FakeSource(); const store = deriveAvatarState(source, { idlePassiveTimeoutMs: 5_000 }); const seen: string[] = []; store.subscribe((s) => seen.push(s)); source.emit({ type: 'output_audio.chunk', audio: '' }); // -> talking source.emit({ type: 'output_audio.done' }); // -> idleActive, re-arms timer store.destroy(); vi.advanceTimersByTime(5_000); expect(seen).toEqual(['idleActive', 'talking', 'idleActive']); }); });