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*
61 changes: 61 additions & 0 deletions frontend/__tests__/app/overlay/OverlayProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Text>{stack.map((entry) => entry.kind).join(',')}</Text>
<View>
<Pressable accessibilityLabel="push-standard" onPress={() => add('standardCreate')} />
<Pressable accessibilityLabel="push-assistant" onPress={() => add('assistant')} />
<Pressable accessibilityLabel="pop" onPress={pop} />
<Pressable accessibilityLabel="pop-standard" onPress={() => popKind('standardCreate')} />
</View>
</>
);
}

function renderHarness(onClose: () => void) {
return render(
<StrictMode>
<OverlayProvider>
<Harness onClose={onClose} />
</OverlayProvider>
</StrictMode>,
);
}

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();
});
});
28 changes: 28 additions & 0 deletions frontend/__tests__/app/session/sessionEndpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
135 changes: 135 additions & 0 deletions frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Original file line number Diff line number Diff line change
@@ -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(
<AssistantChatSheet
messages={[]}
onAction={jest.fn()}
onClose={onClose}
onVoiceEnd={jest.fn()}
visible
/>,
);
expect(screen.getByText('等你说第一句话')).toBeTruthy();
fireEvent.press(screen.getAllByLabelText('关闭语音助手')[0]!);
expect(onClose).toHaveBeenCalled();
});

it('renders user, draft and assistant messages', () => {
const onAction = jest.fn();
render(
<AssistantChatSheet
visible
onAction={onAction}
onClose={jest.fn()}
onVoiceEnd={jest.fn()}
messages={[
{ id: 'u1', role: 'user', createdAt: 1, text: '明天下午开会' },
{
id: 'd1',
role: 'assistant',
createdAt: 2,
draft: { title: '开会', whenLabel: '明天 15:00', state: 'pending' },
actions: [{ id: 'ok', kind: 'confirm', label: '加入' }],
},
{ id: 'a1', role: 'assistant', createdAt: 3, text: '已记下' },
]}
/>,
);
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(
<AssistantChatSheet
messages={[]}
onAction={jest.fn()}
onClose={jest.fn()}
onVoiceEnd={jest.fn()}
visible={false}
/>,
);
expect(screen.queryByText('语音助手')).toBeNull();
});

it('shows a processing state after recording is released', () => {
render(
<AssistantChatSheet
isProcessing
messages={[]}
onAction={jest.fn()}
onClose={jest.fn()}
onVoiceEnd={jest.fn()}
visible
/>,
);
expect(screen.getByText('正在整理录音…')).toBeTruthy();
});
});
Loading
Loading