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..cac6d00 --- /dev/null +++ b/frontend/__tests__/app/AppRoot.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react-native'; + +jest.mock('@/app/AppShell', () => { + const { Text } = require('react-native'); + return { AppShell: () => connected-app-shell }; +}); + +import { AppRoot } from '@/app/AppRoot'; + +describe('AppRoot', () => { + it('mounts the connected application shell', () => { + render(); + expect(screen.getByText('connected-app-shell')).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/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( +