Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app
This commit is contained in:
67
app-expo/tests/core/audio/audio-focus.test.ts
Normal file
67
app-expo/tests/core/audio/audio-focus.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
const mockSetAudioModeAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
jest.mock('expo-audio', () => ({
|
||||
setAudioModeAsync: (...args: unknown[]) => mockSetAudioModeAsync(...args),
|
||||
}));
|
||||
|
||||
import { audioFocus } from '@/core/audio/audio-focus';
|
||||
|
||||
describe('audioFocus', () => {
|
||||
beforeEach(async () => {
|
||||
mockSetAudioModeAsync.mockClear();
|
||||
await audioFocus.release();
|
||||
mockSetAudioModeAsync.mockClear();
|
||||
});
|
||||
|
||||
test('acquires focus for recording', async () => {
|
||||
const result = await audioFocus.acquireForRecording();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(audioFocus.isRecording()).toBe(true);
|
||||
expect(audioFocus.getCurrentOwner()).toBe('recorder');
|
||||
expect(mockSetAudioModeAsync).toHaveBeenCalledWith({
|
||||
playsInSilentMode: true,
|
||||
allowsRecording: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('acquires focus for playback', async () => {
|
||||
const result = await audioFocus.acquireForPlayback();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(audioFocus.isPlaying()).toBe(true);
|
||||
expect(audioFocus.getCurrentOwner()).toBe('player');
|
||||
});
|
||||
|
||||
test('playback cannot steal focus from recorder', async () => {
|
||||
await audioFocus.acquireForRecording();
|
||||
const result = await audioFocus.acquireForPlayback();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(audioFocus.isRecording()).toBe(true);
|
||||
});
|
||||
|
||||
test('recording releases playback before acquiring', async () => {
|
||||
await audioFocus.acquireForPlayback();
|
||||
expect(audioFocus.isPlaying()).toBe(true);
|
||||
|
||||
await audioFocus.acquireForRecording();
|
||||
expect(audioFocus.isRecording()).toBe(true);
|
||||
expect(audioFocus.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
test('notifies listeners on owner change', async () => {
|
||||
const listener = jest.fn();
|
||||
const unsub = audioFocus.onOwnerChange(listener);
|
||||
|
||||
await audioFocus.acquireForRecording();
|
||||
expect(listener).toHaveBeenCalledWith('recorder');
|
||||
|
||||
await audioFocus.release();
|
||||
expect(listener).toHaveBeenCalledWith(null);
|
||||
|
||||
unsub();
|
||||
await audioFocus.acquireForPlayback();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
76
app-expo/tests/core/query/index.test.tsx
Normal file
76
app-expo/tests/core/query/index.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { renderAsync, screen } from '@testing-library/react-native';
|
||||
import { focusManager } from '@tanstack/react-query';
|
||||
import { Text, AppState } from 'react-native';
|
||||
|
||||
import {
|
||||
AppQueryProvider,
|
||||
QUERY_CLIENT_DEFAULT_OPTIONS,
|
||||
createQueryClient,
|
||||
queryClient,
|
||||
} from '@/core/query';
|
||||
|
||||
const mockUseReactQueryDevTools = jest.fn();
|
||||
|
||||
jest.mock('@dev-plugins/react-query', () => ({
|
||||
useReactQueryDevTools: (...args: unknown[]) =>
|
||||
mockUseReactQueryDevTools(...args),
|
||||
}));
|
||||
|
||||
describe('react query infrastructure', () => {
|
||||
let addEventListenerSpy: jest.SpyInstance;
|
||||
let removeSpy: jest.Mock;
|
||||
let setFocusedSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
removeSpy = jest.fn();
|
||||
addEventListenerSpy = jest
|
||||
.spyOn(AppState, 'addEventListener')
|
||||
.mockReturnValue({ remove: removeSpy });
|
||||
setFocusedSpy = jest
|
||||
.spyOn(focusManager, 'setFocused')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
addEventListenerSpy.mockRestore();
|
||||
setFocusedSpy.mockRestore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('creates query clients with the app defaults', () => {
|
||||
const client = createQueryClient();
|
||||
const defaults = client.getDefaultOptions();
|
||||
|
||||
expect(defaults.queries).toMatchObject(
|
||||
QUERY_CLIENT_DEFAULT_OPTIONS.queries,
|
||||
);
|
||||
expect(defaults.mutations).toMatchObject(
|
||||
QUERY_CLIENT_DEFAULT_OPTIONS.mutations,
|
||||
);
|
||||
});
|
||||
|
||||
test('wraps the app with the shared query client and syncs app focus', async () => {
|
||||
await renderAsync(
|
||||
<AppQueryProvider>
|
||||
<Text>query ready</Text>
|
||||
</AppQueryProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('query ready')).toBeOnTheScreen();
|
||||
expect(mockUseReactQueryDevTools).toHaveBeenCalledWith(queryClient);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'change',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const listener = addEventListenerSpy.mock.calls[0][1] as (
|
||||
status: string,
|
||||
) => void;
|
||||
|
||||
listener('active');
|
||||
listener('background');
|
||||
|
||||
expect(setFocusedSpy).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setFocusedSpy).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
});
|
||||
157
app-expo/tests/core/ws/client.test.ts
Normal file
157
app-expo/tests/core/ws/client.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { WsClient } from '@/core/ws/client';
|
||||
import type { WsEvent, WsConnectionState } from '@/core/ws/types';
|
||||
|
||||
jest.mock('@/core/auth/token-manager', () => ({
|
||||
tokenManager: {
|
||||
getAccessToken: jest.fn().mockResolvedValue('test-token'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/core/config', () => ({
|
||||
config: {
|
||||
wsBaseUrl: 'ws://localhost:8000',
|
||||
ws: {
|
||||
reconnectMaxRetries: 3,
|
||||
reconnectBaseDelayMs: 10,
|
||||
reconnectMaxDelayMs: 100,
|
||||
heartbeatIntervalMs: 60000,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
sentMessages: string[] = [];
|
||||
|
||||
constructor(public url: string) {
|
||||
setTimeout(() => this.onopen?.(), 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
}
|
||||
|
||||
simulateMessage(data: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
(global as Record<string, unknown>).WebSocket = MockWebSocket;
|
||||
|
||||
describe('WsClient', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('connects with token and conversation id in URL', async () => {
|
||||
const client = new WsClient('conv-123');
|
||||
|
||||
const states: WsConnectionState[] = [];
|
||||
client.onStateChange((s) => states.push(s));
|
||||
|
||||
await client.connect();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(states).toContain('connecting');
|
||||
expect(states).toContain('connected');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('maps server messages to domain events', async () => {
|
||||
const client = new WsClient('conv-123');
|
||||
const events: WsEvent[] = [];
|
||||
client.onEvent((e) => events.push(e));
|
||||
|
||||
await client.connect();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
||||
|
||||
ws.simulateMessage({
|
||||
type: 'connect',
|
||||
conversation_id: 'conv-123',
|
||||
data: { status: 'connected' },
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
ws.simulateMessage({
|
||||
type: 'agent_response',
|
||||
conversation_id: 'conv-123',
|
||||
data: { text: 'Hello!', index: 0, total: 1 },
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toEqual({
|
||||
kind: 'connected',
|
||||
conversationId: 'conv-123',
|
||||
});
|
||||
expect(events[1]).toEqual({
|
||||
kind: 'agent_response',
|
||||
conversationId: 'conv-123',
|
||||
text: 'Hello!',
|
||||
index: 0,
|
||||
total: 1,
|
||||
isTransition: undefined,
|
||||
segmentIndex: undefined,
|
||||
});
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('sends text messages', async () => {
|
||||
const client = new WsClient('conv-123');
|
||||
|
||||
await client.connect();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
client.sendText('Hi there');
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
||||
expect(ws.sentMessages).toHaveLength(1);
|
||||
|
||||
const sent = JSON.parse(ws.sentMessages[0]);
|
||||
expect(sent).toEqual({
|
||||
type: 'text',
|
||||
conversation_id: 'conv-123',
|
||||
data: { text: 'Hi there' },
|
||||
});
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('ignores unknown message types without crashing', async () => {
|
||||
const client = new WsClient('conv-123');
|
||||
const events: WsEvent[] = [];
|
||||
client.onEvent((e) => events.push(e));
|
||||
|
||||
await client.connect();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
||||
ws.simulateMessage({
|
||||
type: 'unknown_type' as string,
|
||||
conversation_id: 'conv-123',
|
||||
data: {},
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// Unknown types are silently ignored — no events emitted
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user