Skip to content
Merged
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
12 changes: 10 additions & 2 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
# Android emulator: 10.0.2.2 reaches the host machine.
# Android emulator talks to the host machine via 10.0.2.2
EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1
# Baidu Maps browser-side AK with JavaScript API v4 enabled.

# Baidu Maps browser-side AK with JavaScript API v4 enabled
EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak

# Real backend WebSocket URL. Leave empty to use the in-process fake transport in development.
# Example: ws://10.0.2.2:8000/ws/v1
EXPO_PUBLIC_WS_URL=

# Force the fake WebSocket even if EXPO_PUBLIC_WS_URL is set (dev only).
# EXPO_PUBLIC_USE_FAKE_WS=true
95 changes: 95 additions & 0 deletions frontend/__tests__/app/session/SessionProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { act, render } from '@testing-library/react-native';
import { View } from 'react-native';

import { SessionProvider } from '@/app/session/SessionProvider';
import type { DeviceIdStore } from '@/infrastructure/storage/deviceIdStore';

class TestWebSocket {
static readonly OPEN = 1;
static instances: TestWebSocket[] = [];

binaryType = '';
readyState = 0;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;

constructor(_url: string) {
TestWebSocket.instances.push(this);
}

send(_data: string) {}

close() {
this.readyState = 3;
this.onclose?.();
}

open() {
this.readyState = TestWebSocket.OPEN;
this.onopen?.();
}
}

const deviceIdStore: DeviceIdStore = {
get: async () => 'device_test',
set: async () => undefined,
};

describe('SessionProvider reconnect lifecycle', () => {
const originalWebSocket = globalThis.WebSocket;
const originalUrl = process.env.EXPO_PUBLIC_WS_URL;

beforeEach(() => {
jest.useFakeTimers();
TestWebSocket.instances = [];
process.env.EXPO_PUBLIC_WS_URL = 'ws://test.invalid/ws';
Object.defineProperty(globalThis, 'WebSocket', {
configurable: true,
value: TestWebSocket,
writable: true,
});
});

afterEach(() => {
jest.useRealTimers();
if (originalUrl === undefined) delete process.env.EXPO_PUBLIC_WS_URL;
else process.env.EXPO_PUBLIC_WS_URL = originalUrl;
Object.defineProperty(globalThis, 'WebSocket', {
configurable: true,
value: originalWebSocket,
writable: true,
});
});

it('schedules only one first retry after the session handshake times out', async () => {
const view = render(
<SessionProvider deviceIdStore={deviceIdStore}>
<View />
</SessionProvider>,
);

await act(async () => undefined);
expect(TestWebSocket.instances).toHaveLength(1);

await act(async () => {
TestWebSocket.instances[0]?.open();
await Promise.resolve();
});

act(() => {
jest.advanceTimersByTime(10_000);
jest.advanceTimersByTime(999);
});
expect(TestWebSocket.instances).toHaveLength(1);

act(() => {
jest.advanceTimersByTime(1);
});
expect(TestWebSocket.instances).toHaveLength(2);

view.unmount();
});
});
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);
});
});
30 changes: 30 additions & 0 deletions frontend/__tests__/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Schedule } from '@/contracts';

export function makeSchedule(overrides: Partial<Schedule> = {}): Schedule {
return {
id: 'schedule_test',
user_id: 'default_user',
source_mode: 'manual',
schedule_type: 'time',
status: 'scheduled',
title: '测试日程',
notes: null,
start_time: new Date(2026, 6, 29, 9, 5).toISOString(),
end_time: null,
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,
};
}
41 changes: 41 additions & 0 deletions frontend/__tests__/infrastructure/storage/deviceIdStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';

const mockGetInfoAsync = jest.fn(async () => ({ exists: true }));
const mockReadAsStringAsync = jest.fn(async () => ' device_existing ');
const mockWriteAsStringAsync = jest.fn(async () => undefined);
const mockFileSystem = {
documentDirectory: 'file:///documents/',
getInfoAsync: mockGetInfoAsync,
readAsStringAsync: mockReadAsStringAsync,
writeAsStringAsync: mockWriteAsStringAsync,
};

jest.mock('react-native', () => ({
Platform: { OS: 'android' },
}));

jest.mock('expo', () => ({
requireOptionalNativeModule: jest.fn(() => mockFileSystem),
}));

import { createDeviceIdStore } from '@/infrastructure/storage/deviceIdStore';

describe('native deviceIdStore', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetInfoAsync.mockResolvedValue({ exists: true });
mockReadAsStringAsync.mockResolvedValue(' device_existing ');
});

it('passes the options arguments required by Expo SDK 57 native methods', async () => {
const store = await createDeviceIdStore();

await expect(store.get()).resolves.toBe('device_existing');
await store.set('device_updated');

const path = 'file:///documents/.timeflow-device-id';
expect(mockGetInfoAsync).toHaveBeenCalledWith(path, {});
expect(mockReadAsStringAsync).toHaveBeenCalledWith(path, {});
expect(mockWriteAsStringAsync).toHaveBeenCalledWith(path, 'device_updated', {});
});
});
Loading
Loading