Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1
# Baidu Maps browser-side AK with JavaScript API v4 enabled
EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak

# Real backend WebSocket URL. Leave empty to use the in-process fake transport in development.
# Example: ws://10.0.2.2:8000/ws/v1
# Real backend WebSocket URL (required for release / production builds).
# The client appends its persisted device_id query parameter automatically.
# Local device example: ws://192.168.1.10:8000/ws
# Emulator example: ws://10.0.2.2:8000/ws/v1
# Production example: wss://api.example.com/ws
# Release builds require wss://. Plain ws:// is accepted only in development.
# Leave empty to use the in-process fake transport in development.
EXPO_PUBLIC_WS_URL=

# Force the fake WebSocket even if EXPO_PUBLIC_WS_URL is set (dev only).
# EXPO_PUBLIC_USE_FAKE_WS=true
# EXPO_PUBLIC_USE_FAKE_WS=true
252 changes: 245 additions & 7 deletions frontend/__tests__/app/AppRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,254 @@
import { describe, expect, it, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react-native';
import { BackHandler } from 'react-native';
import { act, fireEvent, render, screen } from '@testing-library/react-native';

jest.mock('@/app/AppShell', () => {
const { Text } = require('react-native');
return { AppShell: () => <Text>connected-app-shell</Text> };
import { makeSchedule } from '@test/fixtures';
import { AppDialogProvider } from '@/shared/components/AppDialogProvider';

const mockSaveDraft = jest.fn(async () => makeSchedule());
const mockToggle = jest.fn(async () => undefined);
const mockDelete = jest.fn(async () => undefined);
const mockItems = [makeSchedule({ id: 'nav_1', title: '导航日程' })];

jest.mock('@/features/schedule', () => ({
useScheduleCommands: () => ({
items: mockItems,
ready: true,
mutation: { status: 'idle', error: null, pendingId: null },
saveDraft: mockSaveDraft,
toggleScheduleDone: mockToggle,
deleteSchedule: mockDelete,
service: {},
}),
ScheduleProvider: ({ children }: { children: unknown }) => children,
upsertDraftForSchedule: (item: {
id: string;
title: string;
source_mode: string;
schedule_type: string;
}) => ({
schedule_id: item.id,
source_mode: item.source_mode,
schedule_type: item.schedule_type,
title: item.title,
}),
scheduleDraftFromVoiceParse: (draft: { title: string; schedule_type: string }) => ({
source_mode: 'voice',
schedule_type: draft.schedule_type,
title: draft.title,
}),
useSessionSavedLocations: () => ({ locations: [], upsert: jest.fn() }),
ScheduleScreen: ({
onCreate,
onEditSchedule,
scheduleItems,
}: {
onCreate: () => void;
onEditSchedule: (item: (typeof mockItems)[number]) => void;
scheduleItems: typeof mockItems;
}) => {
const { Pressable, Text } = require('react-native');
return (
<>
<Text>{scheduleItems[0]?.title}</Text>
<Pressable accessibilityLabel="mock-create" onPress={onCreate}>
<Text>create</Text>
</Pressable>
<Pressable accessibilityLabel="mock-edit" onPress={() => onEditSchedule(scheduleItems[0]!)}>
<Text>edit</Text>
</Pressable>
</>
);
},
StandardCreateModal: ({
onClose,
onSave,
onUpsertLocation,
initialDraft,
visible,
}: {
onClose: () => void;
onSave: (draft: unknown) => void | Promise<void>;
onUpsertLocation: (location: {
id: string;
address: string;
latitude: number;
longitude: number;
}) => void;
initialDraft?: { schedule_id?: string | null; title?: string } | null;
visible: boolean;
}) => {
const React = require('react') as typeof import('react');
const { Pressable, Text } = require('react-native');
const [saveError, setSaveError] = React.useState('');
if (!visible) return null;
return (
<>
<Text>{initialDraft ? `editing:${initialDraft.title}` : 'creating'}</Text>
{saveError ? <Text>{saveError}</Text> : null}
<Pressable
accessibilityLabel="mock-save-draft"
onPress={() => {
setSaveError('');
void Promise.resolve(
onSave({
source_mode: 'manual',
schedule_type: 'time',
title: '保存的',
start_time: new Date(Date.now() + 60_000).toISOString(),
}),
).catch((error: unknown) => {
setSaveError(error instanceof Error ? error.message : '保存失败');
});
}}
>
<Text>save</Text>
</Pressable>
<Pressable
accessibilityLabel="mock-upsert-loc"
onPress={() =>
onUpsertLocation({
id: 'loc_x',
address: 'A',
latitude: 1,
longitude: 2,
})
}
>
<Text>loc</Text>
</Pressable>
<Pressable accessibilityLabel="mock-close-create" onPress={onClose}>
<Text>close</Text>
</Pressable>
</>
);
},
}));

jest.mock('@/features/assistant', () => ({
AssistantChatSheet: () => null,
AssistantDock: () => null,
useAssistantSession: () => ({
messages: [],
handleVoiceStart: jest.fn(async () => undefined),
handleVoiceEnd: jest.fn(async () => undefined),
handleVoiceCancel: jest.fn(),
handleAction: jest.fn(async () => undefined),
}),
}));

jest.mock('@/app/overlay/OverlayProvider', () => {
let sequence = 0;
return {
OverlayProvider: ({ children }: { children: unknown }) => children,
useOverlay: () => {
const React = require('react') as typeof import('react');
const [stack, setStack] = React.useState<
{ id: string; kind: string; onClose?: () => void }[]
>([]);
const push = React.useCallback((entry: { kind: string; onClose?: () => void }) => {
const id = `overlay_test_${++sequence}`;
setStack((current) => [...current, { ...entry, id }]);
return id;
}, []);
const popKind = React.useCallback((kind: string) => {
setStack((current) => {
const index = current.map((entry) => entry.kind).lastIndexOf(kind);
if (index < 0) return current;
current[index]?.onClose?.();
return current.filter((_, currentIndex) => currentIndex !== index);
});
}, []);
const pop = React.useCallback(() => {
setStack((current) => current.slice(0, -1));
}, []);
return {
stack,
push,
pop,
popKind,
isOpen: (kind: string) => stack.some((entry) => entry.kind === kind),
top: stack.at(-1) ?? null,
};
},
};
});

jest.mock('@/app/session/SessionProvider', () => ({
useSession: () => ({
deviceId: 'device_test',
userId: 'user_test',
connectionStatus: 'ready',
transportMode: 'fake',
sessionEpoch: 1,
client: {
connect: async () => undefined,
close: () => undefined,
onStatus: () => () => undefined,
onMessage: () => () => undefined,
sendJson: () => undefined,
sendBinary: () => undefined,
request: async () => ({ ok: true }),
},
fakeServer: null,
connectionError: null,
}),
}));

import { AppRoot } from '@/app/AppRoot';

function renderAppRoot() {
return render(
<AppDialogProvider>
<AppRoot />
</AppDialogProvider>,
);
}

describe('AppRoot', () => {
it('mounts the connected application shell', () => {
render(<AppRoot />);
expect(screen.getByText('connected-app-shell')).toBeTruthy();
it('opens create and edit sheets and saves drafts', async () => {
renderAppRoot();
expect(screen.getByText('导航日程')).toBeTruthy();

fireEvent.press(screen.getByLabelText('mock-create'));
expect(screen.getByText('creating')).toBeTruthy();
fireEvent.press(screen.getByLabelText('mock-save-draft'));
await act(async () => {
await Promise.resolve();
});
expect(mockSaveDraft).toHaveBeenCalled();

fireEvent.press(screen.getByLabelText('mock-create'));
fireEvent.press(screen.getByLabelText('mock-upsert-loc'));
fireEvent.press(screen.getByLabelText('mock-close-create'));

fireEvent.press(screen.getByLabelText('mock-edit'));
expect(screen.getByText(/editing:/)).toBeTruthy();
});

it('closes the create sheet on hardware back', () => {
let handler: (() => boolean) | null = null;
jest.spyOn(BackHandler, 'addEventListener').mockImplementation((_event, cb) => {
handler = cb as () => boolean;
return { remove: jest.fn() } as never;
});

renderAppRoot();
fireEvent.press(screen.getByLabelText('mock-create'));
expect(screen.getByText('creating')).toBeTruthy();
fireEvent.press(screen.getByLabelText('mock-close-create'));
expect(screen.queryByText('creating')).toBeNull();
void handler;
});

it('returns save failures to the create form', async () => {
mockSaveDraft.mockRejectedValueOnce(new Error('日程不存在'));
renderAppRoot();

fireEvent.press(screen.getByLabelText('mock-create'));
fireEvent.press(screen.getByLabelText('mock-save-draft'));

expect(await screen.findByText('日程不存在')).toBeTruthy();
expect(screen.getByText('creating')).toBeTruthy();
});
});
73 changes: 73 additions & 0 deletions frontend/__tests__/app/providers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, jest } from '@jest/globals';
import { Platform, Text } from 'react-native';
import { render, screen } from '@testing-library/react-native';

jest.mock('@/app/session/SessionProvider', () => ({
SessionProvider: ({ children }: { children: unknown }) => children,
useSession: () => ({
deviceId: 'device_test',
userId: 'user_test',
connectionStatus: 'ready',
transportMode: 'fake',
sessionEpoch: 1,
client: {
connect: async () => undefined,
close: () => undefined,
onStatus: () => () => undefined,
onMessage: () => () => undefined,
sendJson: () => undefined,
request: async () => ({ ok: true }),
},
fakeServer: null,
connectionError: null,
}),
}));

jest.mock('@/features/schedule', () => ({
ScheduleProvider: ({ children }: { children: unknown }) => children,
useScheduleCommands: () => ({
items: [],
ready: true,
mutation: { status: 'idle', error: null, pendingId: null },
saveDraft: jest.fn(),
toggleScheduleDone: jest.fn(),
deleteSchedule: jest.fn(),
service: {},
}),
}));

jest.mock('@/app/overlay/OverlayProvider', () => ({
OverlayProvider: ({ children }: { children: unknown }) => children,
useOverlay: () => ({
stack: [],
push: jest.fn(),
pop: jest.fn(),
popKind: jest.fn(),
isOpen: jest.fn(() => false),
top: null,
}),
}));

import { AppProviders } from '@/app/providers';

describe('AppProviders', () => {
it('wraps children for native platforms', () => {
const { getByText } = render(
<AppProviders>
<Text>child</Text>
</AppProviders>,
);
expect(getByText('child')).toBeTruthy();
});

it('wraps web children in the desktop frame', () => {
(Platform as { OS: string }).OS = 'web';
render(
<AppProviders>
<Text>web-child</Text>
</AppProviders>,
);
expect(screen.getByText('web-child')).toBeTruthy();
(Platform as { OS: string }).OS = 'ios';
});
});
14 changes: 11 additions & 3 deletions frontend/__tests__/app/session/sessionEndpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/se

describe('session endpoint compatibility', () => {
it('adds the persisted device id to the backend WebSocket URL', () => {
expect(buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1')).toBe(
'ws://127.0.0.1:8000/ws?device_id=device+1',
);
expect(
buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1', { allowInsecure: true }),
).toBe('ws://127.0.0.1:8000/ws?device_id=device+1');
});

it('replaces a stale device id while preserving other query parameters', () => {
Expand All @@ -21,6 +21,14 @@ describe('session endpoint compatibility', () => {
);
});

it('rejects plaintext WebSocket endpoints in release mode', () => {
expect(() =>
buildSessionWebSocketUrl('ws://api.example.com/ws', 'device_1', {
allowInsecure: false,
}),
).toThrow('发布构建的 EXPO_PUBLIC_WS_URL 必须使用 wss://');
});

it('uses the MVP backend user when session.ready omits user_id', () => {
expect(resolveSessionUserId(undefined)).toBe('default_user');
expect(resolveSessionUserId(' user_1 ')).toBe('user_1');
Expand Down
Loading
Loading