Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app

This commit is contained in:
Kevin
2026-03-19 01:12:17 +08:00
parent 9e4f301ab9
commit b4f4369b7d
544 changed files with 23707 additions and 67151 deletions

View 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);
});
});

View 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);
});
});

View 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();
});
});