Skip to content
Closed
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
16 changes: 14 additions & 2 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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
55 changes: 48 additions & 7 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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*
15 changes: 13 additions & 2 deletions frontend/App.tsx
Original file line number Diff line number Diff line change
@@ -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 <AppRoot />;
}

export default function App() {
return <HomeScreen />;
return (
<AppProviders>
<Root />
</AppProviders>
);
}
254 changes: 254 additions & 0 deletions frontend/__tests__/app/AppRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<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('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();
});
});
Loading
Loading