diff --git a/frontend/.env.example b/frontend/.env.example
index 877160b..58135df 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,2 +1,14 @@
-# Android emulator: 10.0.2.2 reaches the host machine.
-EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1
+# Baidu Maps browser-side AK with JavaScript API enabled.
+# Allow localhost for web development and https://timeflow.local/* for the native WebView.
+EXPO_PUBLIC_BAIDU_MAP_AK=replace-with-your-baidu-map-ak
+
+# 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
+# Production example: wss://api.example.com/ws
+EXPO_PUBLIC_WS_URL=
+
+# Use in-process FakeWsServer when EXPO_PUBLIC_WS_URL is empty.
+# In __DEV__, Fake is allowed by default if this is unset.
+# Release builds always ignore this flag and fail fast without a URL.
+# EXPO_PUBLIC_USE_FAKE_WS=true
diff --git a/frontend/__tests__/app/overlay/OverlayProvider.test.tsx b/frontend/__tests__/app/overlay/OverlayProvider.test.tsx
new file mode 100644
index 0000000..b65fedc
--- /dev/null
+++ b/frontend/__tests__/app/overlay/OverlayProvider.test.tsx
@@ -0,0 +1,61 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import { StrictMode } from 'react';
+import { Pressable, Text, View } from 'react-native';
+
+import { OverlayProvider, useOverlay, type OverlayKind } from '@/app/overlay/OverlayProvider';
+
+function Harness({ onClose }: { onClose: () => void }) {
+ const { pop, popKind, push, stack } = useOverlay();
+
+ const add = (kind: OverlayKind) => {
+ push({ kind, onClose });
+ };
+
+ return (
+ <>
+ {stack.map((entry) => entry.kind).join(',')}
+
+ add('standardCreate')} />
+ add('assistant')} />
+
+ popKind('standardCreate')} />
+
+ >
+ );
+}
+
+function renderHarness(onClose: () => void) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('OverlayProvider', () => {
+ it('invokes onClose once when the top overlay is popped', () => {
+ const onClose = jest.fn();
+ renderHarness(onClose);
+
+ fireEvent.press(screen.getByLabelText('push-standard'));
+ fireEvent.press(screen.getByLabelText('pop'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes and closes only the latest matching overlay', () => {
+ const onClose = jest.fn();
+ renderHarness(onClose);
+
+ fireEvent.press(screen.getByLabelText('push-standard'));
+ fireEvent.press(screen.getByLabelText('push-assistant'));
+ fireEvent.press(screen.getByLabelText('push-standard'));
+ fireEvent.press(screen.getByLabelText('pop-standard'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(screen.getByText('standardCreate,assistant')).toBeTruthy();
+ });
+});
diff --git a/frontend/__tests__/app/session/sessionEndpoint.test.ts b/frontend/__tests__/app/session/sessionEndpoint.test.ts
new file mode 100644
index 0000000..457c936
--- /dev/null
+++ b/frontend/__tests__/app/session/sessionEndpoint.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/sessionEndpoint';
+
+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',
+ );
+ });
+
+ it('replaces a stale device id while preserving other query parameters', () => {
+ expect(
+ buildSessionWebSocketUrl('wss://api.example.com/ws?token=test&device_id=stale', 'current'),
+ ).toBe('wss://api.example.com/ws?token=test&device_id=current');
+ });
+
+ it('rejects non-WebSocket URLs', () => {
+ expect(() => buildSessionWebSocketUrl('http://127.0.0.1:8000/ws', 'device_1')).toThrow(
+ '必须使用 ws:// 或 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');
+ });
+});
diff --git a/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts
new file mode 100644
index 0000000..1a66b56
--- /dev/null
+++ b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from '@jest/globals';
+
+import type { Schedule, ScheduleUpsertCommand } from '@/contracts';
+
+import { upsertSchedule } from '@/dev/fakes/schedule/scheduleConflicts';
+
+function makeSchedule(overrides: Partial = {}): Schedule {
+ return {
+ id: 'schedule_existing',
+ user_id: 'default_user',
+ source_mode: 'manual',
+ schedule_type: 'time',
+ status: 'scheduled',
+ title: '已有日程',
+ notes: null,
+ start_time: new Date(2026, 6, 29, 9, 0).toISOString(),
+ end_time: new Date(2026, 6, 29, 10, 0).toISOString(),
+ timezone: 'Asia/Shanghai',
+ location_name: null,
+ location_address: null,
+ latitude: null,
+ longitude: null,
+ geofence_radius_meters: 100,
+ geofence_armed: false,
+ time_remind_offset_minutes: 15,
+ time_triggered_at: null,
+ geo_triggered_at: null,
+ system_schedule_ref_id: null,
+ system_alarm_ref_id: null,
+ created_at: new Date(2026, 6, 20, 10, 0).toISOString(),
+ updated_at: new Date(2026, 6, 20, 10, 0).toISOString(),
+ ...overrides,
+ };
+}
+
+function makeCommand(startHour: number, endHour: number | null): ScheduleUpsertCommand {
+ return {
+ type: 'schedule.upsert.command',
+ request_id: 'req_test',
+ payload: {
+ source_mode: 'manual',
+ schedule_type: 'time',
+ title: '新日程',
+ start_time: new Date(2026, 6, 29, startHour, 0).toISOString(),
+ end_time: endHour === null ? null : new Date(2026, 6, 29, endHour, 0).toISOString(),
+ },
+ };
+}
+
+describe('upsertSchedule conflict detection', () => {
+ const existing = [makeSchedule()];
+
+ it('flags a schedule that overlaps an existing one', () => {
+ const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_new');
+ expect(result.payload.conflicts.map((conflict) => conflict.schedule_id)).toEqual([
+ 'schedule_existing',
+ ]);
+ });
+
+ it('flags a partial overlap', () => {
+ const result = upsertSchedule(makeCommand(9, 11), existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(1);
+ });
+
+ it('treats touching boundaries as a conflict', () => {
+ const result = upsertSchedule(makeCommand(10, 11), existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(1);
+ });
+
+ it('reports nothing for a non-overlapping slot', () => {
+ const result = upsertSchedule(makeCommand(11, 12), existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('does not conflict a schedule with itself while editing', () => {
+ const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_existing');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('ignores deleted schedules', () => {
+ const deleted = [makeSchedule({ status: 'deleted' })];
+ const result = upsertSchedule(makeCommand(9, 10), deleted, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('reports nothing when the new schedule has no start time', () => {
+ const command: ScheduleUpsertCommand = {
+ type: 'schedule.upsert.command',
+ request_id: 'req_test',
+ payload: {
+ source_mode: 'manual',
+ schedule_type: 'location',
+ title: '地点日程',
+ start_time: null,
+ },
+ };
+ const result = upsertSchedule(command, existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('echoes the request id and schedule id back', () => {
+ const result = upsertSchedule(makeCommand(14, 15), existing, 'schedule_new');
+ expect(result.request_id).toBe('req_test');
+ expect(result.payload.schedule_id).toBe('schedule_new');
+ });
+
+ it('ignores unparseable start times on the new command', () => {
+ const command: ScheduleUpsertCommand = {
+ type: 'schedule.upsert.command',
+ request_id: 'req_test',
+ payload: {
+ source_mode: 'manual',
+ schedule_type: 'time',
+ title: '坏时间',
+ start_time: 'not-a-date',
+ end_time: null,
+ },
+ };
+ expect(upsertSchedule(command, existing, 'schedule_new').payload.conflicts).toHaveLength(0);
+ });
+
+ it('ignores existing items whose times do not parse', () => {
+ const broken = [makeSchedule({ id: 'broken', start_time: 'bad', end_time: 'also-bad' })];
+ expect(
+ upsertSchedule(makeCommand(9, 10), broken, 'schedule_new').payload.conflicts,
+ ).toHaveLength(0);
+ });
+
+ it('inherits geofence_armed from the existing schedule when omitted', () => {
+ const armed = [makeSchedule({ id: 'schedule_existing', geofence_armed: true })];
+ const command = makeCommand(14, 15);
+ delete (command.payload as { geofence_armed?: boolean }).geofence_armed;
+ expect(upsertSchedule(command, armed, 'schedule_existing').payload.geofence_armed).toBe(true);
+ });
+});
diff --git a/frontend/__tests__/features/schedule/application/ScheduleService.test.ts b/frontend/__tests__/features/schedule/application/ScheduleService.test.ts
new file mode 100644
index 0000000..aea4b9c
--- /dev/null
+++ b/frontend/__tests__/features/schedule/application/ScheduleService.test.ts
@@ -0,0 +1,211 @@
+import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
+
+import type {
+ Schedule,
+ ScheduleUpsertCommand,
+ ScheduleUpsertPayload as ScheduleDraft,
+ ScheduleUpsertResponse,
+} from '@/contracts';
+import { ScheduleService } from '@/features/schedule/application/ScheduleService';
+import { ScheduleCache } from '@/features/schedule/data/ScheduleCache';
+import type { ScheduleRepositoryPort } from '@/features/schedule/data/ScheduleRepositoryPort';
+import { makeSchedule } from '@test/fixtures';
+
+function makeDraft(overrides: Partial = {}): ScheduleDraft {
+ return {
+ source_mode: 'manual',
+ schedule_type: 'time',
+ title: '新会议',
+ start_time: new Date(Date.now() + 3_600_000).toISOString(),
+ end_time: null,
+ time_remind_offset_minutes: 5,
+ ...overrides,
+ };
+}
+
+function upsertOk(command: ScheduleUpsertCommand, id: string): ScheduleUpsertResponse {
+ return {
+ type: 'schedule.upsert.result',
+ request_id: command.request_id,
+ ok: true,
+ payload: {
+ schedule_id: id,
+ schedule_type: command.payload.schedule_type,
+ status: 'scheduled',
+ conflicts: [],
+ geofence_armed: true,
+ },
+ };
+}
+
+describe('ScheduleService', () => {
+ let cache: ScheduleCache;
+ let repository: jest.Mocked;
+ let syncForSchedule: jest.MockedFunction<
+ NonNullable[0]['alarmAdapter']>['syncForSchedule']
+ >;
+ let cancel: jest.MockedFunction<
+ NonNullable[0]['alarmAdapter']>['cancel']
+ >;
+ let notifyConflicts: jest.MockedFunction<
+ NonNullable[0]['notifyConflicts']>
+ >;
+ let service: ScheduleService;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ cache = new ScheduleCache();
+ repository = {
+ list: jest.fn(async () => [] as Schedule[]),
+ upsert: jest.fn(async (command: ScheduleUpsertCommand) =>
+ upsertOk(command, command.payload.schedule_id ?? 'schedule_auto'),
+ ),
+ updateStatus: jest.fn(async (id: string, status: 'scheduled' | 'done') => ({
+ type: 'schedule.status.result' as const,
+ request_id: `req_status_${id}`,
+ ok: true as const,
+ payload: { schedule_id: id, status },
+ })),
+ notifyDeleted: jest.fn(async (id: string) => ({
+ type: 'schedule.deleted.ack' as const,
+ request_id: `req_deleted_${id}`,
+ schedule_id: id,
+ ok: true as const,
+ })),
+ subscribe: jest.fn(() => () => undefined),
+ };
+ syncForSchedule = jest.fn(async () => null);
+ cancel = jest.fn(async () => null);
+ notifyConflicts = jest.fn();
+ service = new ScheduleService({
+ repository,
+ cache,
+ getUserId: () => 'default_user',
+ alarmAdapter: { syncForSchedule, cancel },
+ notifyConflicts,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('bootstraps from repository.list into cache', async () => {
+ const seed = [makeSchedule({ id: 'seed' })];
+ repository.list.mockResolvedValueOnce(seed);
+ await service.bootstrap();
+ expect(service.getItems()).toEqual(seed);
+ });
+
+ it('creates a schedule via upsert and caches it', async () => {
+ const saved = await service.saveDraft(makeDraft({ title: 'A' }));
+ expect(saved.title).toBe('A');
+ expect(saved.id).toBe('schedule_auto');
+ expect(service.getItems()).toHaveLength(1);
+ expect(repository.upsert).toHaveBeenCalled();
+ expect(repository.upsert.mock.calls[0]![0].payload.schedule_id).toBeUndefined();
+ });
+
+ it('alerts when upsert reports conflicts', async () => {
+ repository.upsert.mockImplementation(async (command) => ({
+ type: 'schedule.upsert.result',
+ request_id: command.request_id,
+ ok: true,
+ payload: {
+ schedule_id: command.payload.schedule_id ?? 'x',
+ schedule_type: command.payload.schedule_type,
+ status: 'scheduled',
+ conflicts: [
+ {
+ schedule_id: 'other',
+ title: '已有会议',
+ start_time: new Date().toISOString(),
+ end_time: null,
+ },
+ ],
+ geofence_armed: true,
+ },
+ }));
+
+ await service.saveDraft(makeDraft());
+ expect(notifyConflicts).toHaveBeenCalledWith([expect.objectContaining({ title: '已有会议' })]);
+ });
+
+ it('updates an existing schedule when schedule_id is set', async () => {
+ await service.saveDraft(makeDraft({ schedule_id: 'schedule_edit', title: '旧标题' }));
+ await service.saveDraft(makeDraft({ schedule_id: 'schedule_edit', title: '新标题' }));
+ expect(service.getItems()).toHaveLength(1);
+ expect(service.getItems()[0]?.title).toBe('新标题');
+ });
+
+ it('syncs android alarms when adapter returns an id', async () => {
+ syncForSchedule.mockResolvedValue('alarm_99');
+ const saved = await service.saveDraft(makeDraft({ title: '安卓会议' }));
+ expect(syncForSchedule).toHaveBeenCalled();
+ expect(saved.system_schedule_ref_id).toBe('alarm_99');
+ });
+
+ it('toggles done through updateStatus and re-arms on undo', async () => {
+ syncForSchedule.mockResolvedValue('alarm_old');
+ await service.saveDraft(
+ makeDraft({
+ schedule_id: 'toggle_1',
+ start_time: new Date(Date.now() + 3_600_000).toISOString(),
+ }),
+ );
+ const item = service.getItems()[0]!;
+ await service.toggleDone(item);
+ expect(repository.updateStatus).toHaveBeenCalledWith('toggle_1', 'done');
+ expect(repository.notifyDeleted).not.toHaveBeenCalled();
+ expect(service.getItems()[0]?.status).toBe('done');
+
+ syncForSchedule.mockResolvedValue('alarm_rearm');
+ await service.toggleDone(service.getItems()[0]!);
+ expect(repository.updateStatus).toHaveBeenCalledWith('toggle_1', 'scheduled');
+ expect(service.getItems()[0]?.status).toBe('scheduled');
+ expect(syncForSchedule).toHaveBeenCalled();
+ });
+
+ it('marks a schedule deleted and cancels its alarm', async () => {
+ syncForSchedule.mockResolvedValue('alarm_del');
+ await service.saveDraft(makeDraft({ schedule_id: 'del_1' }));
+ await service.deleteSchedule(service.getItems()[0]!);
+ expect(repository.notifyDeleted).toHaveBeenCalledWith('del_1');
+ expect(cancel).toHaveBeenCalledWith('alarm_del');
+ expect(service.getItems()[0]?.status).toBe('deleted');
+ expect(service.getItems()[0]?.system_schedule_ref_id).toBeNull();
+ });
+
+ it('keeps the alarm reference returned by the platform adapter', async () => {
+ cancel.mockResolvedValue('remote_alarm');
+ cache.replaceAll([makeSchedule({ id: 'remote', system_schedule_ref_id: 'local_alarm' })]);
+
+ await service.deleteSchedule(service.getItems()[0]!);
+
+ expect(service.getItems()[0]?.system_schedule_ref_id).toBe('remote_alarm');
+ });
+
+ it('does not mutate cache or alarms when delete is rejected', async () => {
+ repository.notifyDeleted.mockResolvedValueOnce({
+ type: 'schedule.deleted.ack',
+ request_id: 'req_delete_failed',
+ schedule_id: 'reject',
+ ok: false,
+ error: { code: 'denied', message: '删除被拒绝', details: null },
+ });
+ const schedule = makeSchedule({ id: 'reject', system_schedule_ref_id: 'alarm_reject' });
+ cache.replaceAll([schedule]);
+
+ await expect(service.deleteSchedule(schedule)).rejects.toThrow('删除被拒绝');
+ expect(cancel).not.toHaveBeenCalled();
+ expect(service.getItems()[0]).toEqual(schedule);
+ });
+
+ it('ignores toggle and delete for already deleted items', async () => {
+ cache.replaceAll([makeSchedule({ id: 'gone', status: 'deleted' })]);
+ await service.toggleDone(service.getItems()[0]!);
+ await service.deleteSchedule(service.getItems()[0]!);
+ expect(repository.updateStatus).not.toHaveBeenCalled();
+ expect(repository.notifyDeleted).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/__tests__/features/schedule/calendar/MonthView.test.tsx b/frontend/__tests__/features/schedule/calendar/MonthView.test.tsx
new file mode 100644
index 0000000..23a2939
--- /dev/null
+++ b/frontend/__tests__/features/schedule/calendar/MonthView.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+import { makeSchedule } from '@test/fixtures';
+
+import { MonthView } from '@/features/schedule/calendar/MonthView';
+import { buildScheduleIndex } from '@/features/schedule/calendar/scheduleIndex';
+
+describe('MonthView', () => {
+ const now = new Date(2026, 6, 31);
+ const month = new Date(2026, 6, 1);
+
+ it('navigates months and selects a day with events', () => {
+ const onMonthChange = jest.fn();
+ const onSelectDate = jest.fn();
+ const onOpenSchedule = jest.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('7月')).toBeTruthy();
+ expect(screen.getByText('月底会议')).toBeTruthy();
+
+ fireEvent.press(screen.getByLabelText('上个月'));
+ expect(onMonthChange).toHaveBeenCalled();
+ fireEvent.press(screen.getByLabelText('下个月'));
+ expect(onMonthChange).toHaveBeenCalledTimes(2);
+
+ fireEvent.press(screen.getByText('月底会议'));
+ expect(onOpenSchedule).toHaveBeenCalledWith('m1');
+ });
+
+ it('shows empty agenda copy when the selected day has no events', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('这一天暂无详细安排')).toBeTruthy();
+ });
+
+ it('renders an undated location reminder and lets the user open it', () => {
+ const onOpenSchedule = jest.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText('地点提醒')).toBeTruthy();
+ fireEvent.press(screen.getByText('到公司提醒'));
+ expect(onOpenSchedule).toHaveBeenCalledWith('location-1');
+ });
+});
diff --git a/frontend/__tests__/features/schedule/calendar/ScheduleRow.test.tsx b/frontend/__tests__/features/schedule/calendar/ScheduleRow.test.tsx
new file mode 100644
index 0000000..5569c86
--- /dev/null
+++ b/frontend/__tests__/features/schedule/calendar/ScheduleRow.test.tsx
@@ -0,0 +1,40 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+import { makeSchedule } from '@test/fixtures';
+
+import { ScheduleRow } from '@/features/schedule/calendar/ScheduleRow';
+
+describe('ScheduleRow', () => {
+ it('renders title, time and optional meta', () => {
+ const onPress = jest.fn();
+ const onToggle = jest.fn();
+ render(
+ ,
+ );
+ expect(screen.getByText('晨会')).toBeTruthy();
+ expect(screen.getByText('会议室')).toBeTruthy();
+ fireEvent.press(screen.getByLabelText('09:05 晨会'));
+ expect(onPress).toHaveBeenCalled();
+ fireEvent.press(screen.getByLabelText('完成 晨会'));
+ expect(onToggle).toHaveBeenCalled();
+ });
+
+ it('shows restore affordance for done items in compact mode', () => {
+ const onToggle = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByLabelText('恢复 已做完'));
+ expect(onToggle).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/__tests__/features/schedule/data/WsScheduleRepository.test.ts b/frontend/__tests__/features/schedule/data/WsScheduleRepository.test.ts
new file mode 100644
index 0000000..6813609
--- /dev/null
+++ b/frontend/__tests__/features/schedule/data/WsScheduleRepository.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it, jest } from '@jest/globals';
+
+import type { WsJsonMessage } from '@/contracts';
+import type { ScheduleTransport } from '@/features/schedule/data/ScheduleTransport';
+import { WsScheduleRepository } from '@/features/schedule/data/WsScheduleRepository';
+
+describe('WsScheduleRepository backend compatibility', () => {
+ it('accepts the MVP delete acknowledgement without request_id', async () => {
+ const request = jest.fn(
+ async (
+ _message: WsJsonMessage & { request_id: string },
+ isMatch?: (response: WsJsonMessage) => boolean,
+ ) => {
+ const response = {
+ type: 'schedule.deleted.ack',
+ schedule_id: 'schedule_1',
+ ok: true,
+ };
+ expect(isMatch?.(response)).toBe(true);
+ return response;
+ },
+ );
+ const transport = {
+ onMessage: () => () => undefined,
+ request,
+ sendJson: () => undefined,
+ } as unknown as ScheduleTransport;
+ const repository = new WsScheduleRepository(transport);
+
+ await expect(repository.notifyDeleted('schedule_1')).resolves.toMatchObject({ ok: true });
+ repository.dispose();
+ });
+});
diff --git a/frontend/__tests__/features/schedule/data/adapters.test.ts b/frontend/__tests__/features/schedule/data/adapters.test.ts
new file mode 100644
index 0000000..e7fbcfe
--- /dev/null
+++ b/frontend/__tests__/features/schedule/data/adapters.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { upsertDraftForSchedule } from '@/features/schedule/data/adapters';
+import { makeSchedule } from '@test/fixtures';
+
+describe('upsertDraftForSchedule', () => {
+ it('maps schedule fields into a domain draft', () => {
+ const schedule = makeSchedule({
+ id: 'schedule_42',
+ notes: '备注',
+ location_name: '办公室',
+ location_address: '南京东路1号',
+ latitude: 31.2,
+ longitude: 121.4,
+ geofence_radius_meters: 200,
+ geofence_armed: true,
+ time_remind_offset_minutes: 10,
+ });
+
+ expect(upsertDraftForSchedule(schedule)).toEqual({
+ schedule_id: 'schedule_42',
+ source_mode: 'manual',
+ schedule_type: 'time',
+ title: '测试日程',
+ notes: '备注',
+ start_time: schedule.start_time,
+ end_time: null,
+ timezone: 'Asia/Shanghai',
+ location_name: '办公室',
+ location_address: '南京东路1号',
+ latitude: 31.2,
+ longitude: 121.4,
+ geofence_radius_meters: 200,
+ geofence_armed: true,
+ time_remind_offset_minutes: 10,
+ });
+ });
+});
diff --git a/frontend/__tests__/features/schedule/detail/ScheduleDetailSheet.test.tsx b/frontend/__tests__/features/schedule/detail/ScheduleDetailSheet.test.tsx
new file mode 100644
index 0000000..3fd7f92
--- /dev/null
+++ b/frontend/__tests__/features/schedule/detail/ScheduleDetailSheet.test.tsx
@@ -0,0 +1,84 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import type { ReactElement } from 'react';
+
+import { makeSchedule } from '@test/fixtures';
+import { ScheduleDetailSheet } from '@/features/schedule/detail/ScheduleDetailSheet';
+import { AppDialogProvider } from '@/shared/components/AppDialogProvider';
+
+function renderWithDialog(element: ReactElement) {
+ return render({element});
+}
+
+describe('ScheduleDetailSheet', () => {
+ it('is hidden when schedule is null', () => {
+ renderWithDialog(
+ ,
+ );
+ expect(screen.queryByText('安排详情')).toBeNull();
+ });
+
+ it('shows schedule content and opens the day view', () => {
+ const onClose = jest.fn();
+ const onOpenDay = jest.fn();
+ renderWithDialog(
+ ,
+ );
+ expect(screen.getByText('评审会')).toBeTruthy();
+ fireEvent.press(screen.getByLabelText('查看当天日程'));
+ expect(onClose).toHaveBeenCalled();
+ expect(onOpenDay).toHaveBeenCalled();
+ });
+
+ it('offers edit when editable and confirms delete', async () => {
+ const onEdit = jest.fn();
+ const onDelete = jest.fn();
+ const onClose = jest.fn();
+ renderWithDialog(
+ ,
+ );
+ fireEvent.press(screen.getByLabelText('编辑日程'));
+ expect(onEdit).toHaveBeenCalled();
+
+ fireEvent.press(screen.getByLabelText('删除日程'));
+ expect(screen.getByText('确定删除这个日程吗?相关提醒也会一并取消。')).toBeTruthy();
+ fireEvent.press(screen.getByLabelText('删除'));
+ await waitFor(() => expect(onDelete).toHaveBeenCalled());
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('renders completed status styling', () => {
+ renderWithDialog(
+ ,
+ );
+ expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
+ expect(screen.getByText('已完成 · 可回顾这次安排')).toBeTruthy();
+ });
+});
diff --git a/frontend/__tests__/features/schedule/domain/scheduleOrdering.test.ts b/frontend/__tests__/features/schedule/domain/scheduleOrdering.test.ts
new file mode 100644
index 0000000..a7c14d6
--- /dev/null
+++ b/frontend/__tests__/features/schedule/domain/scheduleOrdering.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { makeSchedule } from '@test/fixtures';
+
+import { compareSchedules } from '@/features/schedule/domain/scheduleOrdering';
+
+describe('compareSchedules', () => {
+ it('orders by start_time when both have one', () => {
+ const earlier = makeSchedule({
+ id: 'a',
+ start_time: new Date(2026, 6, 29, 8, 0).toISOString(),
+ });
+ const later = makeSchedule({
+ id: 'b',
+ start_time: new Date(2026, 6, 29, 10, 0).toISOString(),
+ });
+ expect(compareSchedules(earlier, later)).toBeLessThan(0);
+ expect(compareSchedules(later, earlier)).toBeGreaterThan(0);
+ });
+
+ it('puts timed schedules before location-only ones', () => {
+ const timed = makeSchedule({ id: 't', start_time: new Date(2026, 6, 29, 9, 0).toISOString() });
+ const locationOnly = makeSchedule({ id: 'l', start_time: null });
+ expect(compareSchedules(timed, locationOnly)).toBe(-1);
+ expect(compareSchedules(locationOnly, timed)).toBe(1);
+ });
+
+ it('falls back to created_at when neither has start_time', () => {
+ const older = makeSchedule({
+ id: 'old',
+ start_time: null,
+ created_at: new Date(2026, 6, 1).toISOString(),
+ });
+ const newer = makeSchedule({
+ id: 'new',
+ start_time: null,
+ created_at: new Date(2026, 6, 20).toISOString(),
+ });
+ expect(compareSchedules(older, newer)).toBeGreaterThan(0);
+ });
+});
diff --git a/frontend/__tests__/features/schedule/editor/DateTimeField.test.tsx b/frontend/__tests__/features/schedule/editor/DateTimeField.test.tsx
new file mode 100644
index 0000000..b730369
--- /dev/null
+++ b/frontend/__tests__/features/schedule/editor/DateTimeField.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+jest.mock('@/shared/components/DatePickerSheet', () => ({
+ DatePickerSheet: ({
+ visible,
+ onSelect,
+ onClose,
+ }: {
+ visible: boolean;
+ onSelect: (date: Date) => void;
+ onClose: () => void;
+ }) => {
+ const { Pressable, Text } = require('react-native');
+ if (!visible) return null;
+ return (
+ {
+ onSelect(new Date(2026, 7, 1));
+ onClose();
+ }}
+ >
+ mock-date
+
+ );
+ },
+}));
+
+jest.mock('@/shared/components/TimePickerSheet', () => ({
+ TimePickerSheet: ({
+ visible,
+ onSelect,
+ onClose,
+ }: {
+ visible: boolean;
+ onSelect: (value: string) => void;
+ onClose: () => void;
+ }) => {
+ const { Pressable, Text } = require('react-native');
+ if (!visible) return null;
+ return (
+ {
+ onSelect('15:30');
+ onClose();
+ }}
+ >
+ mock-time
+
+ );
+ },
+}));
+
+import { DateTimeField } from '@/features/schedule/editor/DateTimeField';
+
+describe('DateTimeField', () => {
+ it('opens the date sheet and formats the selection', () => {
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByLabelText('日期'));
+ fireEvent.press(screen.getByLabelText('mock-date-select'));
+ expect(onChange).toHaveBeenCalledWith('2026 / 08 / 01');
+ });
+
+ it('opens the time sheet and returns HH:mm', () => {
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+ expect(screen.getByText('09:00')).toBeTruthy();
+ fireEvent.press(screen.getByLabelText('时间'));
+ fireEvent.press(screen.getByLabelText('mock-time-select'));
+ expect(onChange).toHaveBeenCalledWith('15:30');
+ });
+});
diff --git a/frontend/__tests__/features/schedule/editor/StandardCreateSheet.test.tsx b/frontend/__tests__/features/schedule/editor/StandardCreateSheet.test.tsx
new file mode 100644
index 0000000..d369d72
--- /dev/null
+++ b/frontend/__tests__/features/schedule/editor/StandardCreateSheet.test.tsx
@@ -0,0 +1,190 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+
+import { StandardCreateSheet } from '@/features/schedule/editor/StandardCreateSheet';
+import type { ScheduleUpsertPayload as ScheduleDraft } from '@/contracts';
+
+function pressPrimaryAction(label: string) {
+ const matches = screen.getAllByText(label);
+ fireEvent.press(matches[matches.length - 1]!);
+}
+
+describe('StandardCreateSheet', () => {
+ const baseProps = {
+ onClose: jest.fn(),
+ onSave: jest.fn(async (_draft: ScheduleDraft) => undefined),
+ onUpsertLocation: jest.fn(),
+ savedLocations: [] as [],
+ };
+
+ it('rejects an empty title', async () => {
+ render();
+ pressPrimaryAction('添加日程');
+ expect(await screen.findByText('请填写日程标题。')).toBeTruthy();
+ expect(baseProps.onSave).not.toHaveBeenCalled();
+ });
+
+ it('creates a time schedule with a future start', async () => {
+ const onSave = jest.fn(async (_draft: ScheduleDraft) => undefined);
+
+ render();
+ fireEvent.changeText(screen.getByPlaceholderText('请输入日程标题'), '项目评审');
+ pressPrimaryAction('添加日程');
+
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const draft = onSave.mock.calls[0]![0];
+ expect(draft.title).toBe('项目评审');
+ expect(draft.schedule_type).toBe('time');
+ expect(draft.start_time).toBeTruthy();
+ });
+
+ it('rejects a start time that is not in the future', async () => {
+ const past = new Date(Date.now() - 120_000);
+ render(
+ ,
+ );
+ pressPrimaryAction('保存修改');
+ expect(await screen.findByText('开始时间需晚于当前分钟,请选择下一分钟及以后。')).toBeTruthy();
+ });
+
+ it('surfaces save errors from onSave', async () => {
+ const onSave = jest.fn(async () => {
+ throw new Error('网络异常');
+ });
+ render();
+ fireEvent.changeText(screen.getByPlaceholderText('请输入日程标题'), '会失败');
+ pressPrimaryAction('添加日程');
+ expect(await screen.findByText('网络异常')).toBeTruthy();
+ });
+
+ it('surfaces non-Error save failures', async () => {
+ const onSave = jest.fn(async () => {
+ throw 'boom';
+ });
+ render();
+ fireEvent.changeText(screen.getByPlaceholderText('请输入日程标题'), '会失败');
+ pressPrimaryAction('添加日程');
+ expect(await screen.findByText('保存失败,请稍后重试。')).toBeTruthy();
+ });
+
+ it('saves a location-only schedule', async () => {
+ const onSave = jest.fn(async (_draft: ScheduleDraft) => undefined);
+ const location = {
+ id: 'loc_1',
+ address: '南京东路1号',
+ latitude: 31.2,
+ longitude: 121.5,
+ name: '办公室',
+ };
+ render(
+ ,
+ );
+ // Clearing the date also clears start/end, so the schedule becomes location-only.
+ fireEvent.press(screen.getByLabelText('清除日期'));
+ pressPrimaryAction('添加日程');
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ expect(onSave.mock.calls[0]![0].schedule_type).toBe('location');
+ });
+
+ it('rejects end time earlier than start', async () => {
+ const future = new Date(Date.now() + 3_600_000);
+ const later = new Date(Date.now() + 7_200_000);
+ render(
+ ,
+ );
+ pressPrimaryAction('保存修改');
+ expect(await screen.findByText('结束时间不能早于开始时间。')).toBeTruthy();
+ });
+
+ it('rejects a negative remind offset', async () => {
+ const future = new Date(Date.now() + 3_600_000);
+ render(
+ ,
+ );
+ fireEvent.changeText(screen.getByLabelText('提前提醒分钟数'), '-1');
+ pressPrimaryAction('保存修改');
+ expect(await screen.findByText('提前提醒分钟数必须是非负整数。')).toBeTruthy();
+ });
+
+ it('rejects invalid geofence radius for location schedules', async () => {
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByLabelText('清除日期'));
+ fireEvent.changeText(screen.getByLabelText('地理围栏半径'), '0');
+ pressPrimaryAction('添加日程');
+ expect(await screen.findByText('地理围栏半径必须是大于 0 的整数。')).toBeTruthy();
+ });
+});
diff --git a/frontend/__tests__/features/schedule/editor/datetime.test.ts b/frontend/__tests__/features/schedule/editor/datetime.test.ts
new file mode 100644
index 0000000..10bfce8
--- /dev/null
+++ b/frontend/__tests__/features/schedule/editor/datetime.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from '@jest/globals';
+
+import {
+ currentTimezone,
+ dateAndTimeFromIso,
+ defaultCreateDateAndTime,
+ formatDateValue,
+ isoFromDateAndTime,
+ optionalNumber,
+ parseDateValue,
+ parsePickerValue,
+ parseTimeValue,
+} from '@/features/schedule/editor/datetime';
+import { formatTimeValue } from '@/shared/utils/date';
+
+describe('parseDateValue', () => {
+ it('round-trips the format the field renders', () => {
+ expect(formatDateValue(parseDateValue('2026 / 07 / 29'))).toBe('2026 / 07 / 29');
+ });
+
+ it('accepts any non-digit separator', () => {
+ expect(formatDateValue(parseDateValue('2026-7-29'))).toBe('2026 / 07 / 29');
+ });
+
+ it('falls back to today when the input is incomplete', () => {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ expect(parseDateValue('2026 / 07').getTime()).toBe(today.getTime());
+ });
+});
+
+describe('parseTimeValue', () => {
+ it('reads hours and minutes', () => {
+ expect(formatTimeValue(parseTimeValue('09:05'))).toBe('09:05');
+ });
+
+ it('accepts a single-digit hour', () => {
+ expect(formatTimeValue(parseTimeValue('9:05'))).toBe('09:05');
+ });
+});
+
+describe('isoFromDateAndTime', () => {
+ it('combines the two fields into one instant', () => {
+ expect(isoFromDateAndTime('2026 / 07 / 29', '09:05')).toBe(
+ new Date(2026, 6, 29, 9, 5).toISOString(),
+ );
+ });
+
+ it('returns null when the time does not parse', () => {
+ expect(isoFromDateAndTime('2026 / 07 / 29', '9am')).toBeNull();
+ });
+
+ it('returns null when the date is incomplete', () => {
+ expect(isoFromDateAndTime('2026 / 07', '09:05')).toBeNull();
+ });
+});
+
+describe('defaultCreateDateAndTime', () => {
+ it('defaults to the next whole minute', () => {
+ expect(defaultCreateDateAndTime(new Date(2026, 6, 30, 13, 58, 40, 123))).toEqual({
+ date: '2026 / 07 / 30',
+ time: '13:59',
+ });
+ });
+
+ it('rolls to the next day near midnight', () => {
+ expect(defaultCreateDateAndTime(new Date(2026, 6, 30, 23, 59, 10))).toEqual({
+ date: '2026 / 07 / 31',
+ time: '00:00',
+ });
+ });
+});
+
+describe('dateAndTimeFromIso', () => {
+ it('returns empty strings for a missing value', () => {
+ expect(dateAndTimeFromIso(null)).toEqual({ date: '', time: '' });
+ });
+
+ it('returns empty strings for an unparseable value', () => {
+ expect(dateAndTimeFromIso('not-a-date')).toEqual({ date: '', time: '' });
+ });
+
+ it('splits an ISO string back into the two fields', () => {
+ expect(dateAndTimeFromIso(new Date(2026, 6, 29, 9, 5).toISOString())).toEqual({
+ date: '2026 / 07 / 29',
+ time: '09:05',
+ });
+ });
+});
+
+describe('optionalNumber', () => {
+ it('treats blank input as absent rather than zero', () => {
+ expect(optionalNumber('')).toBeNull();
+ expect(optionalNumber(' ')).toBeNull();
+ });
+
+ it('rejects non-numeric input', () => {
+ expect(optionalNumber('abc')).toBeNull();
+ });
+
+ it('keeps negative and decimal values', () => {
+ expect(optionalNumber('-31.2451')).toBe(-31.2451);
+ expect(optionalNumber('0')).toBe(0);
+ });
+});
+
+describe('parsePickerValue', () => {
+ it('delegates to date or time parsers by mode', () => {
+ expect(formatDateValue(parsePickerValue('2026 / 07 / 29', 'date'))).toBe('2026 / 07 / 29');
+ expect(formatTimeValue(parsePickerValue('09:05', 'time'))).toBe('09:05');
+ });
+});
+
+describe('parseTimeValue fallback', () => {
+ it('keeps the current clock when the string does not match', () => {
+ const parsed = parseTimeValue('bad');
+ expect(parsed.getSeconds()).toBe(0);
+ expect(parsed.getMilliseconds()).toBe(0);
+ });
+});
+
+describe('currentTimezone', () => {
+ it('returns the runtime timezone when Intl is available', () => {
+ expect(typeof currentTimezone()).toBe('string');
+ });
+});
diff --git a/frontend/__tests__/features/schedule/location/AddressEditorSheet.test.tsx b/frontend/__tests__/features/schedule/location/AddressEditorSheet.test.tsx
new file mode 100644
index 0000000..5a19bb7
--- /dev/null
+++ b/frontend/__tests__/features/schedule/location/AddressEditorSheet.test.tsx
@@ -0,0 +1,39 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+jest.mock('@/features/schedule/location/MapPicker', () => ({
+ MapPicker: () => null,
+}));
+
+import { AddressEditorSheet } from '@/features/schedule/location/AddressEditorSheet';
+
+describe('AddressEditorSheet', () => {
+ it('requires a map location before save', () => {
+ const onSave = jest.fn();
+ render();
+ fireEvent.press(screen.getByLabelText('保存地点'));
+ expect(screen.getByText('请选择一个地图位置')).toBeTruthy();
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('saves with an optional name', () => {
+ const onSave = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.changeText(screen.getByLabelText('地点名称'), '办公室');
+ fireEvent.press(screen.getByLabelText('保存地点'));
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ address: '南京东路1号',
+ name: '办公室',
+ }),
+ );
+ });
+});
diff --git a/frontend/__tests__/features/schedule/location/LocationPickerSheet.test.tsx b/frontend/__tests__/features/schedule/location/LocationPickerSheet.test.tsx
new file mode 100644
index 0000000..71a22cf
--- /dev/null
+++ b/frontend/__tests__/features/schedule/location/LocationPickerSheet.test.tsx
@@ -0,0 +1,100 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+jest.mock('@/features/schedule/location/AddressEditorSheet', () => ({
+ AddressEditorSheet: ({
+ visible,
+ onSave,
+ onClose,
+ }: {
+ visible: boolean;
+ onSave: (location: {
+ address: string;
+ latitude: number;
+ longitude: number;
+ name?: string;
+ }) => void;
+ onClose: () => void;
+ }) => {
+ const { Pressable, Text } = require('react-native');
+ if (!visible) return null;
+ return (
+ <>
+
+ onSave({ address: '新地址', latitude: 31.1, longitude: 121.1, name: '新地点' })
+ }
+ >
+ mock-save
+
+
+ mock-close
+
+ >
+ );
+ },
+}));
+
+import { LocationPickerSheet } from '@/features/schedule/location/LocationPickerSheet';
+
+const office = {
+ id: 'loc_1',
+ address: '南京东路1号',
+ latitude: 31.2,
+ longitude: 121.5,
+ name: '办公室',
+};
+
+describe('LocationPickerSheet', () => {
+ it('shows empty state', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('还没有常用地点')).toBeTruthy();
+ });
+
+ it('selects a location and closes', () => {
+ const onSelect = jest.fn();
+ const onClose = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByLabelText('选择地点 办公室'));
+ expect(onSelect).toHaveBeenCalledWith(office);
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('opens the editor and upserts a new location', () => {
+ const onUpsert = jest.fn();
+ const onSelect = jest.fn();
+ const onClose = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByLabelText('添加地点'));
+ fireEvent.press(screen.getByLabelText('mock-save-location'));
+ expect(onUpsert).toHaveBeenCalled();
+ expect(onSelect).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/__tests__/features/schedule/location/MapPicker/MapPicker.native.test.tsx b/frontend/__tests__/features/schedule/location/MapPicker/MapPicker.native.test.tsx
new file mode 100644
index 0000000..113de65
--- /dev/null
+++ b/frontend/__tests__/features/schedule/location/MapPicker/MapPicker.native.test.tsx
@@ -0,0 +1,149 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+
+jest.mock('@/features/schedule/location/MapPicker/baidu', () => ({
+ BAIDU_MAP_AK: 'test-ak',
+ createCoordinateLocation: (latitude: number, longitude: number) => ({
+ address: `坐标 ${latitude},${longitude}`,
+ latitude,
+ longitude,
+ }),
+ buildBaiduMapDocument: () => '',
+}));
+
+jest.mock('@/features/schedule/location/MapPicker/Overlay', () => ({
+ MapPickerOverlay: ({
+ onCancel,
+ onConfirm,
+ onLocate,
+ onSearch,
+ mapReady,
+ selection,
+ }: {
+ onCancel: () => void;
+ onConfirm: () => void;
+ onLocate: () => void;
+ onSearch: (query: string) => Promise;
+ mapReady: boolean;
+ selection: { address: string } | null;
+ }) => {
+ const { Pressable, Text } = require('react-native');
+ return (
+ <>
+ {mapReady ? 'ready' : 'loading'}
+ {selection?.address ?? 'no-selection'}
+
+ cancel
+
+
+ confirm
+
+
+ locate
+
+ {
+ void onSearch('外滩');
+ }}
+ >
+ search
+
+ >
+ );
+ },
+}));
+
+import { MapPicker } from '@/features/schedule/location/MapPicker/MapPicker.native';
+
+describe('MapPicker.native', () => {
+ it('handles bridge messages and confirms the selection', async () => {
+ const onConfirm = jest.fn();
+ const onCancel = jest.fn();
+ const { UNSAFE_getByType } = render(
+ ,
+ );
+
+ const WebView = require('react-native-webview').WebView;
+ const webview = UNSAFE_getByType(WebView);
+
+ await act(async () => {
+ webview.props.onMessage({
+ nativeEvent: { data: JSON.stringify({ type: 'map-ready' }) },
+ });
+ });
+ expect(screen.getByText('ready')).toBeTruthy();
+
+ await act(async () => {
+ webview.props.onMessage({
+ nativeEvent: {
+ data: JSON.stringify({ type: 'selecting', latitude: 31.2, longitude: 121.5 }),
+ },
+ });
+ });
+ expect(screen.getByText('坐标 31.2,121.5')).toBeTruthy();
+
+ await act(async () => {
+ webview.props.onMessage({
+ nativeEvent: {
+ data: JSON.stringify({
+ type: 'selected',
+ location: { address: '外滩', latitude: 31.2, longitude: 121.5 },
+ }),
+ },
+ });
+ });
+ expect(screen.getByText('外滩')).toBeTruthy();
+
+ fireEvent.press(screen.getByLabelText('mock-confirm'));
+ expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ address: '外滩' }));
+ fireEvent.press(screen.getByLabelText('mock-cancel'));
+ expect(onCancel).toHaveBeenCalled();
+ });
+
+ it('surfaces map errors from the bridge', async () => {
+ const { UNSAFE_getByType } = render(
+ ,
+ );
+ const WebView = require('react-native-webview').WebView;
+ const webview = UNSAFE_getByType(WebView);
+ await act(async () => {
+ webview.props.onMessage({
+ nativeEvent: { data: JSON.stringify({ type: 'map-error', message: '坏了' }) },
+ });
+ });
+ expect(screen.getByText('loading')).toBeTruthy();
+ });
+
+ it('handles location errors from the bridge', async () => {
+ const { UNSAFE_getByType } = render(
+ ,
+ );
+ const WebView = require('react-native-webview').WebView;
+ const webview = UNSAFE_getByType(WebView);
+
+ await act(async () => {
+ webview.props.onMessage({
+ nativeEvent: { data: JSON.stringify({ type: 'map-ready' }) },
+ });
+ });
+
+ await act(async () => {
+ webview.props.onMessage({
+ nativeEvent: {
+ data: JSON.stringify({ type: 'location-error', message: '无定位' }),
+ },
+ });
+ });
+
+ fireEvent.press(screen.getByLabelText('mock-locate'));
+
+ await act(async () => {
+ webview.props.onMessage({ nativeEvent: { data: 'not-json' } });
+ });
+ });
+});
diff --git a/frontend/__tests__/features/schedule/location/MapPicker/Overlay.test.tsx b/frontend/__tests__/features/schedule/location/MapPicker/Overlay.test.tsx
new file mode 100644
index 0000000..6ab1054
--- /dev/null
+++ b/frontend/__tests__/features/schedule/location/MapPicker/Overlay.test.tsx
@@ -0,0 +1,92 @@
+import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+
+import { MapPickerOverlay } from '@/features/schedule/location/MapPicker/Overlay';
+
+async function flushDebouncedSearch() {
+ await act(async () => {
+ jest.advanceTimersByTime(320);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+describe('MapPickerOverlay', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ const base = {
+ mapError: null as string | null,
+ mapReady: true,
+ locating: false,
+ locationError: null as string | null,
+ onCancel: jest.fn(),
+ onLocate: jest.fn(),
+ onConfirm: jest.fn(),
+ onSearch: jest.fn(async () => [
+ { address: '外滩 · 中山东一路', latitude: 31.24, longitude: 121.49 },
+ ]),
+ onSelectSearchResult: jest.fn(),
+ selection: {
+ address: '南京东路',
+ latitude: 31.23,
+ longitude: 121.48,
+ },
+ };
+
+ it('debounces search and lists results', async () => {
+ render();
+ fireEvent.changeText(screen.getByLabelText('搜索地点'), '外滩');
+ await flushDebouncedSearch();
+ expect(base.onSearch).toHaveBeenCalledWith('外滩');
+ expect(screen.getByText('外滩 · 中山东一路')).toBeTruthy();
+ fireEvent.press(screen.getByLabelText('选择 外滩 · 中山东一路'));
+ expect(base.onSelectSearchResult).toHaveBeenCalled();
+ });
+
+ it('confirms and cancels', () => {
+ render();
+ fireEvent.press(screen.getByLabelText('确认选中的地址'));
+ expect(base.onConfirm).toHaveBeenCalled();
+ fireEvent.press(screen.getByLabelText('退出地图选点'));
+ expect(base.onCancel).toHaveBeenCalled();
+ });
+
+ it('shows map errors and locating state', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('地图加载失败')).toBeTruthy();
+ expect(screen.getByText('定位失败')).toBeTruthy();
+ expect(screen.getByText('正在获取当前位置...')).toBeTruthy();
+ });
+
+ it('surfaces search failures', async () => {
+ const onSearch = jest.fn(async () => {
+ throw new Error('qps');
+ });
+ render();
+ fireEvent.changeText(screen.getByLabelText('搜索地点'), '失败');
+ await flushDebouncedSearch();
+ expect(screen.getByText('搜索暂时不可用,请直接在地图上选点')).toBeTruthy();
+ });
+
+ it('shows empty search results', async () => {
+ const onSearch = jest.fn(async () => []);
+ render();
+ fireEvent.changeText(screen.getByLabelText('搜索地点'), '空');
+ await flushDebouncedSearch();
+ expect(screen.getByText('没有找到相关地点')).toBeTruthy();
+ });
+});
diff --git a/frontend/__tests__/features/schedule/location/MapPicker/baidu/baiduMapWebView.test.ts b/frontend/__tests__/features/schedule/location/MapPicker/baidu/baiduMapWebView.test.ts
new file mode 100644
index 0000000..fdb0007
--- /dev/null
+++ b/frontend/__tests__/features/schedule/location/MapPicker/baidu/baiduMapWebView.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { buildBaiduMapDocument } from '@/features/schedule/location/MapPicker/baidu/baiduMapWebView';
+
+describe('buildBaiduMapDocument', () => {
+ it('embeds the AK and default Shanghai center when no initial location', () => {
+ const html = buildBaiduMapDocument('test-ak', null);
+ expect(html).toContain(encodeURIComponent('test-ak'));
+ expect(html).toContain('31.236305');
+ expect(html).toContain('121.480237');
+ expect(html).toContain('null');
+ });
+
+ it('embeds the provided initial location', () => {
+ const html = buildBaiduMapDocument('ak', {
+ address: '办公室',
+ latitude: 31.1,
+ longitude: 121.2,
+ name: '办公室',
+ });
+ expect(html).toContain('31.1');
+ expect(html).toContain('121.2');
+ expect(html).toContain('办公室');
+ });
+
+ it('escapes initial location data for the inline script context', () => {
+ const html = buildBaiduMapDocument('ak', {
+ address: '',
+ latitude: 31.1,
+ longitude: 121.2,
+ });
+
+ expect(html).not.toContain('
+
+
+
+
+
+