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/.gitignore b/frontend/.gitignore index 91d1261..650b43f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,12 +1,15 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + # Expo .expo/ +dist/ web-build/ expo-env.d.ts -# Metro -.metro-health-check* - -# Native signing +# Native .kotlin/ *.orig.* *.jks @@ -15,8 +18,46 @@ expo-env.d.ts *.key *.mobileprovision -# generated native folders -# Leading slash keeps these to this directory only — a bare `android/` would -# match a directory of that name at any depth. +# Metro +.metro-health-check* + +# debug +*.log +npm-debug.* +yarn-debug.* +yarn-error.* + +# test coverage +coverage/ + +# macOS / Windows +.DS_Store +Thumbs.db +*.pem + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# local env files +.env*.local +.env +!.env.example + +# typescript +*.tsbuildinfo + +# generated native folders — the repo root does not cover these +# Leading slash is required: a bare `android/` matches a directory of that +# name at any depth, not just the prebuild output sitting here. /ios /android + +# local build artifacts & previews +*.apk +alarm-ring-preview.html + +# local source backups +_backup* diff --git a/frontend/App.tsx b/frontend/App.tsx index ac3548e..d491a66 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,5 +1,16 @@ -import { HomeScreen } from './src/screens/HomeScreen'; +import { AppRoot } from '@/app/AppRoot'; +import { AppProviders } from '@/app/providers'; +import { useAlarmPermissionsOnLaunch } from '@/features/reminder'; + +function Root() { + useAlarmPermissionsOnLaunch(); + return ; +} export default function App() { - return ; + return ( + + + + ); } diff --git a/frontend/__tests__/app/AppRoot.test.tsx b/frontend/__tests__/app/AppRoot.test.tsx new file mode 100644 index 0000000..94e6840 --- /dev/null +++ b/frontend/__tests__/app/AppRoot.test.tsx @@ -0,0 +1,254 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { BackHandler } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +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 ( + <> + {scheduleItems[0]?.title} + + create + + onEditSchedule(scheduleItems[0]!)}> + edit + + + ); + }, + StandardCreateModal: ({ + onClose, + onSave, + onUpsertLocation, + initialDraft, + visible, + }: { + onClose: () => void; + onSave: (draft: unknown) => void | Promise; + 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 ( + <> + {initialDraft ? `editing:${initialDraft.title}` : 'creating'} + {saveError ? {saveError} : null} + { + 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 : '保存失败'); + }); + }} + > + save + + + onUpsertLocation({ + id: 'loc_x', + address: 'A', + latitude: 1, + longitude: 2, + }) + } + > + loc + + + close + + + ); + }, +})); + +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( + + + , + ); +} + +describe('AppRoot', () => { + 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(); + }); +}); 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/providers.test.tsx b/frontend/__tests__/app/providers.test.tsx new file mode 100644 index 0000000..19587db --- /dev/null +++ b/frontend/__tests__/app/providers.test.tsx @@ -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( + + child + , + ); + expect(getByText('child')).toBeTruthy(); + }); + + it('wraps web children in the desktop frame', () => { + (Platform as { OS: string }).OS = 'web'; + render( + + web-child + , + ); + expect(screen.getByText('web-child')).toBeTruthy(); + (Platform as { OS: string }).OS = 'ios'; + }); +}); 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/assistant/components/AssistantChatSheet.test.tsx b/frontend/__tests__/features/assistant/components/AssistantChatSheet.test.tsx new file mode 100644 index 0000000..50ae6d7 --- /dev/null +++ b/frontend/__tests__/features/assistant/components/AssistantChatSheet.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { AssistantChatSheet } from '@/features/assistant/components/AssistantChatSheet'; + +describe('AssistantChatSheet', () => { + it('shows empty state and closes', () => { + const onClose = jest.fn(); + render( + , + ); + expect(screen.getByText('等你说第一句话')).toBeTruthy(); + fireEvent.press(screen.getAllByLabelText('关闭语音助手')[0]!); + expect(onClose).toHaveBeenCalled(); + }); + + it('renders user, draft and assistant messages', () => { + const onAction = jest.fn(); + render( + , + ); + expect(screen.getByText('明天下午开会')).toBeTruthy(); + expect(screen.getByText('开会')).toBeTruthy(); + expect(screen.getByText('已记下')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('加入')); + expect(onAction).toHaveBeenCalledWith('d1', { id: 'ok', kind: 'confirm', label: '加入' }); + }); + + it('hides when not visible', () => { + render( + , + ); + expect(screen.queryByText('语音助手')).toBeNull(); + }); + + it('shows a processing state after recording is released', () => { + render( + , + ); + expect(screen.getByText('正在整理录音…')).toBeTruthy(); + }); +}); diff --git a/frontend/__tests__/features/assistant/components/AssistantDock.test.tsx b/frontend/__tests__/features/assistant/components/AssistantDock.test.tsx new file mode 100644 index 0000000..553fe6c --- /dev/null +++ b/frontend/__tests__/features/assistant/components/AssistantDock.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { AssistantDock } from '@/features/assistant/components/AssistantDock'; + +jest.mock('@/features/assistant/components/VoiceHoldButton', () => ({ + VoiceHoldButton: ({ onPress }: { onPress?: () => void }) => { + const { Pressable, Text } = require('react-native'); + return ( + + voice-hold + + ); + }, +})); + +describe('AssistantDock', () => { + it('hides when requested', () => { + const { queryByText } = render( +