diff --git a/frontend/index.ts b/frontend/index.ts index 5600fa08..d3f0c0f2 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -1,3 +1,6 @@ +// 必须在根组件注册前加载,确保 TaskManager.defineTask 进入顶层作用域。 +import './src/infrastructure/location/geofenceTask'; + import { registerRootComponent } from 'expo'; import App from './App'; diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 8ad840d6..4140dee7 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -11,7 +11,7 @@ import { MockReminderStateStore, } from '../../features/reminder/data/local'; import { ExpoAudioPlayback } from '../../infrastructure/audio'; -import { MockLocationMonitor } from '../../infrastructure/location'; +import { ExpoLocationMonitor } from '../../infrastructure/location'; import { MockPopup, MockReminderRecovery, @@ -45,7 +45,7 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe const reminderPorts: ReminderApplicationDependencies = { schedules: new MockLocalScheduleReader(), time: new IntervalTimeListener(), - location: new MockLocationMonitor(), + location: new ExpoLocationMonitor(), alarms: new NativeAlarmScheduler(), delivery: new MockReminderDelivery(), audio: new ExpoAudioPlayback(), diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts new file mode 100644 index 00000000..079e60ee --- /dev/null +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -0,0 +1,275 @@ +import * as Location from 'expo-location'; +import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationMonitorPort, + LocationRebuildTarget, + LocationWatchHandle, + LocationWatchRequest, +} from '../../features/reminder/application/interfaces'; +import type { LocationSample } from '../../features/reminder/domain'; + +import { + GEOFENCE_TASK_NAME, + drainPendingGeofenceEvents, + subscribeGeofenceTaskEvents, + type GeofenceTaskPayload, +} from './geofenceTask'; +import type { LocationProvider } from './LocationProvider'; + +type ActiveWatch = { + listener_id: string; + request: LocationWatchRequest; + listener: (event: LocationMonitorEvent) => void; +}; + +/** ≈1.1km,足够超出任何合理的围栏半径,用来给 exit 事件合成一个"明显在圈外"的采样点。 */ +const OUTSIDE_OFFSET_DEGREES = 0.01; + +/** + * 基于 expo-location 系统原生地理围栏(Android GeofencingClient / iOS + * CLCircularRegion)的适配器:不依赖百度账号/Key,围栏进出判断交给系统,比 + * NativeLocationMonitor 那套连续定位轮询 + 应用侧 Haversine 更省电、后台也更可靠。 + * + * 系统只会在真正穿越边界时回调 enter/exit,不会在注册那一刻告诉你当前在圈内还是圈 + * 外,所以 watch() 里仍然主动取一次当前定位、强制送一条初始采样,让 + * LocalReminderApplication 的 armed 状态机能定出正确的起始值;之后的进出全靠 + * geofenceTask 的系统回调,不再轮询。 + * + * LocalReminderApplication.applyLocationSample() 不看这里传的 phase,只用 sample + * 坐标自己重新跑一遍 Haversine 判断,所以 enter/exit 时合成的坐标(区域中心 / + * 中心外一段距离)足够,不需要为了拿"真实"坐标再多发一次定位请求。 + */ +export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvider { + private readonly watches = new Map(); + private readonly scheduleToListener = new Map(); + private lastSample: LocationSample | null = null; + private syncChain: Promise = Promise.resolve(); + private unsubscribeTask: (() => void) | null; + private readonly appStateSub: NativeEventSubscription; + + constructor() { + this.unsubscribeTask = subscribeGeofenceTaskEvents(this.handleTaskEvent); + this.appStateSub = AppState.addEventListener('change', this.handleAppState); + } + + async watch( + request: LocationWatchRequest, + listener: (event: LocationMonitorEvent) => void, + ): Promise { + const existingId = this.scheduleToListener.get(request.schedule_id); + if (existingId != null) { + await this.removeWatch(existingId, false); + } + + const listener_id = `location-${request.schedule_id}`; + this.watches.set(listener_id, { listener_id, request: { ...request }, listener }); + this.scheduleToListener.set(request.schedule_id, listener_id); + await this.chainSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + listener({ schedule_id: request.schedule_id, sample, phase: 'inside' }); + } + await this.replayPendingEvents(); + + return { listener_id, schedule_id: request.schedule_id }; + } + + async unwatch(listenerId: string): Promise { + await this.removeWatch(listenerId, true); + } + + async rebuild( + targets: readonly LocationRebuildTarget[], + listener: (event: LocationMonitorEvent) => void, + ): Promise { + for (const listenerId of [...this.watches.keys()]) { + await this.removeWatch(listenerId, false); + } + + // 直接灌 Map,不逐个调用 watch():watch() 自己的 chainSync()+getCurrentSample() + // 是为单条增量注册设计的,N 个 target 各跑一次会把 startGeofencingAsync 的区域 + // 列表越注册越长(O(N²) 总区域数)、定位请求也打 N 次;这里全部灌完只同步一次、 + // 取一次定位,再扇给每个刚注册的 watch。 + const handles: LocationWatchHandle[] = []; + for (const target of targets) { + const listener_id = `location-${target.schedule_id}`; + this.watches.set(listener_id, { + listener_id, + request: { + schedule_id: target.schedule_id, + center: target.center, + radius_meters: target.radius_meters, + mode: target.mode, + background: target.background, + }, + listener, + }); + this.scheduleToListener.set(target.schedule_id, listener_id); + handles.push({ listener_id, schedule_id: target.schedule_id }); + } + await this.chainSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + for (const handle of handles) { + listener({ schedule_id: handle.schedule_id, sample, phase: 'inside' }); + } + } + + // App 进程被杀掉期间,headless task 攒下的围栏事件在这里补上——watches/ + // scheduleToListener 刚灌好,handleTaskEvent 才查得到对应的 watch。 + await this.replayPendingEvents(); + + return handles; + } + + async getLastSample(): Promise { + return this.lastSample; + } + + async getCurrentSample(): Promise { + try { + const { status } = await Location.getForegroundPermissionsAsync(); + if (status !== 'granted') { + console.warn('[geofence] getCurrentSample skipped: foreground permission not granted'); + return this.lastSample; + } + const position = await Location.getCurrentPositionAsync({}); + const sample = toSample(position); + this.lastSample = sample; + return sample; + } catch (error) { + console.warn('[geofence] getCurrentSample failed', error); + return this.lastSample; + } + } + + dispose(): void { + this.unsubscribeTask?.(); + this.unsubscribeTask = null; + this.appStateSub.remove(); + void Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME) + .then((started) => (started ? Location.stopGeofencingAsync(GEOFENCE_TASK_NAME) : undefined)) + .catch(() => undefined); + } + + private readonly handleAppState = (state: AppStateStatus): void => { + if (state !== 'active' || this.watches.size === 0) return; + void this.chainSync(); + }; + + /** 补上 headless task 期间(没有订阅者时)攒下的围栏事件,按正常路径重放一遍。 */ + private async replayPendingEvents(): Promise { + const pending = await drainPendingGeofenceEvents(); + for (const payload of pending) { + this.handleTaskEvent(payload); + } + } + + private readonly handleTaskEvent = (payload: GeofenceTaskPayload): void => { + const listenerId = this.scheduleToListener.get(payload.schedule_id); + const watch = listenerId != null ? this.watches.get(listenerId) : undefined; + if (watch == null) return; + + const sample: LocationSample = + payload.event === 'enter' + ? { + latitude: payload.latitude, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + } + : { + latitude: payload.latitude + OUTSIDE_OFFSET_DEGREES, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + }; + this.lastSample = sample; + watch.listener({ + schedule_id: watch.request.schedule_id, + sample, + phase: payload.event === 'enter' ? 'entered' : 'left', + }); + }; + + private async removeWatch(listenerId: string, sync: boolean): Promise { + const watch = this.watches.get(listenerId); + if (watch == null) return; + this.watches.delete(listenerId); + this.scheduleToListener.delete(watch.request.schedule_id); + if (sync) { + await this.chainSync(); + } + } + + /** 把一次区域同步接到 syncChain 末尾,保证上一次真正执行完(不管成功与否)才轮到 + * 这一次——不是排队等着处理各自不同的输入,每次都是重新读 this.watches 当前 + * 状态,纯粹为了不让 syncRegions() 并发跑,参照 AssistantContinuousConversationService + * 的 chainPlayback()/playbackChain 同一个模式。 */ + private chainSync(): Promise { + this.syncChain = this.syncChain.then( + () => this.syncRegions(), + () => this.syncRegions(), + ); + return this.syncChain; + } + + private async syncRegions(): Promise { + if (this.watches.size === 0) { + const started = await Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME).catch( + () => false, + ); + if (started) { + await Location.stopGeofencingAsync(GEOFENCE_TASK_NAME).catch(() => undefined); + } + return; + } + + let { status: foreground } = await Location.getForegroundPermissionsAsync(); + if (foreground !== 'granted') { + ({ status: foreground } = await Location.requestForegroundPermissionsAsync()); + } + if (foreground !== 'granted') { + console.warn('[geofence] syncRegions skipped: foreground permission not granted'); + return; + } + // Android 要求先拿到前台权限才能申请后台权限,所以这两步不能对调顺序。 + let { status: background } = await Location.getBackgroundPermissionsAsync(); + if (background !== 'granted') { + ({ status: background } = await Location.requestBackgroundPermissionsAsync()); + } + if (background !== 'granted') { + console.warn('[geofence] syncRegions skipped: background permission not granted'); + return; + } + + const regions: Location.LocationRegion[] = [...this.watches.values()].map((watch) => ({ + identifier: watch.request.schedule_id, + latitude: watch.request.center.latitude, + longitude: watch.request.center.longitude, + radius: watch.request.radius_meters, + notifyOnEnter: true, + notifyOnExit: true, + })); + + try { + await Location.startGeofencingAsync(GEOFENCE_TASK_NAME, regions); + } catch (error) { + // 系统围栏注册失败(比如权限被收回);下次 watch()/rebuild() 会再重试。 + console.warn('[geofence] startGeofencingAsync failed', error); + } + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy_meters: position.coords.accuracy ?? 0, + observed_at: new Date(position.timestamp).toISOString(), + }; +} diff --git a/frontend/src/infrastructure/location/ExpoLocationProvider.ts b/frontend/src/infrastructure/location/ExpoLocationProvider.ts index f9907df1..c0ce1b77 100644 --- a/frontend/src/infrastructure/location/ExpoLocationProvider.ts +++ b/frontend/src/infrastructure/location/ExpoLocationProvider.ts @@ -4,27 +4,76 @@ import type { LocationSample } from '../../features/reminder/domain'; import type { LocationProvider } from './LocationProvider'; +const LAST_KNOWN_MAX_AGE_MS = 60_000; +const LAST_KNOWN_REQUIRED_ACCURACY_METERS = 200; + /** - * 真实定位实现。权限被拒绝或定位失败都返回 null 而不是抛错——调用方(目前是 - * session.hello 的位置字段)拿不到就不带这个字段,不应该因为定位失败连不上。 + * 真实定位实现。语音握手优先使用短时间内的缓存位置,避免等待 GPS 冷启动;同时 + * 在后台刷新当前位置,让下一次连接获得更新的位置。权限被拒绝或定位失败都返回 + * null 而不是抛错,调用方拿不到就不带 session.hello 的位置字段,不影响连通性。 */ export class ExpoLocationProvider implements LocationProvider { + private freshSampleInFlight: Promise | null = null; + async getCurrentSample(): Promise { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { + console.warn('[location-search] foreground location permission is unavailable', { status }); return null; } + const cached = await this.getRecentSample(); + if (cached !== null) { + void this.getFreshSample(); + return cached; + } + + return this.getFreshSample(); + } + + private async getRecentSample(): Promise { try { - const position = await Location.getCurrentPositionAsync({}); - return { - accuracy_meters: position.coords.accuracy ?? 0, - latitude: position.coords.latitude, - longitude: position.coords.longitude, - observed_at: new Date(position.timestamp).toISOString(), - }; - } catch { + const position = await Location.getLastKnownPositionAsync({ + maxAge: LAST_KNOWN_MAX_AGE_MS, + requiredAccuracy: LAST_KNOWN_REQUIRED_ACCURACY_METERS, + }); + return position === null ? null : toSample(position); + } catch (error) { + console.warn('[location-search] failed to read cached location', { + errorType: error instanceof Error ? error.name : typeof error, + }); return null; } } + + private getFreshSample(): Promise { + if (this.freshSampleInFlight !== null) { + return this.freshSampleInFlight; + } + + const request = Location.getCurrentPositionAsync({}) + .then(toSample) + .catch((error) => { + console.warn('[location-search] failed to acquire current location', { + errorType: error instanceof Error ? error.name : typeof error, + }); + return null; + }); + this.freshSampleInFlight = request; + void request.finally(() => { + if (this.freshSampleInFlight === request) { + this.freshSampleInFlight = null; + } + }); + return request; + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + accuracy_meters: position.coords.accuracy ?? 0, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + observed_at: new Date(position.timestamp).toISOString(), + }; } diff --git a/frontend/src/infrastructure/location/LocationProvider.ts b/frontend/src/infrastructure/location/LocationProvider.ts index 84933700..723c463a 100644 --- a/frontend/src/infrastructure/location/LocationProvider.ts +++ b/frontend/src/infrastructure/location/LocationProvider.ts @@ -1,6 +1,6 @@ -import type { LocationSample } from '../../features/reminder/domain'; +import type { LocationObservation } from '../../contracts/reminder'; /** 获取一次当前位置样本的平台适配器。 */ export interface LocationProvider { - getCurrentSample(): Promise; + getCurrentSample(): Promise; } diff --git a/frontend/src/infrastructure/location/MockLocationMonitor.ts b/frontend/src/infrastructure/location/MockLocationMonitor.ts deleted file mode 100644 index 8c3aaa9a..00000000 --- a/frontend/src/infrastructure/location/MockLocationMonitor.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { - LocationMonitorEvent, - LocationMonitorPort, - LocationRebuildTarget, - LocationWatchHandle, - LocationWatchRequest, -} from '../../features/reminder/application/interfaces'; -import type { LocationSample } from '../../features/reminder/domain'; - -import type { LocationProvider } from './LocationProvider'; - -const MOCK_SAMPLE: LocationSample = { - latitude: 31.2304, - longitude: 121.4737, - accuracy_meters: 12, - observed_at: '2026-08-07T01:00:00.000Z', -}; - -/** 固定定位能力适配器,不访问平台定位接口。 */ -export class MockLocationMonitor implements LocationMonitorPort, LocationProvider { - async watch( - request: LocationWatchRequest, - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return { - listener_id: `mock-location-listener-${request.schedule_id}`, - schedule_id: request.schedule_id, - }; - } - - async unwatch(_listenerId: string): Promise { - return Promise.resolve(); - } - - async rebuild( - targets: readonly LocationRebuildTarget[], - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return targets.map((target) => ({ - listener_id: `mock-location-listener-${target.schedule_id}`, - schedule_id: target.schedule_id, - })); - } - - async getLastSample(): Promise { - return { ...MOCK_SAMPLE }; - } - - async getCurrentSample(): Promise { - return { ...MOCK_SAMPLE }; - } -} - -export { MOCK_SAMPLE as MOCK_LOCATION_SAMPLE }; diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts new file mode 100644 index 00000000..211a25c9 --- /dev/null +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -0,0 +1,279 @@ +import * as Location from 'expo-location'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import * as TaskManager from 'expo-task-manager'; + +export const GEOFENCE_TASK_NAME = 'timeflow-geofence'; + +export type GeofenceTaskPayload = { + schedule_id: string; + event: 'enter' | 'exit'; + latitude: number; + longitude: number; + radius: number; + observed_at: string; +}; + +type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void; + +const listeners = new Set(); + +/** 没有订阅者时(App 进程已死,Expo 只把 JS 引擎拉起来跑这一个 headless task, + * AppRoot/ExpoLocationMonitor 那套 React 生命周期根本没启动),通知失败的事件落这里, + * 等真正的会话起来后由 drainPendingGeofenceEvents() 取走重放,而不是直接丢掉。 */ +const PENDING_EVENTS_KEY = 'timeflow-pending-geofence-events'; +const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; + +type HeadlessScheduleRow = { + id: string; + title: string; + location_name: string | null; + schedule_type: string; + reminder_type: string | null; + reminder_disposition_state: string | null; + snoozed_until: string | null; + geofence_armed: number; + status: string; +}; + +/** 订阅系统围栏任务回调;须在应用入口尽早 import 本模块以完成 defineTask。 */ +export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** 懒加载:expo-sqlite/kv-store 的默认导出是模块加载时就构造的单例,顶层 import + * 会在测试环境(没有真实原生模块)里直接抛错——这个仓库里原生相关的按需依赖 + * 一律走动态 import,参照 ExpoAudioPlayback.ts 的 loadExpoAudio()。 */ +async function loadStorage(): Promise { + try { + const mod = await import('expo-sqlite/kv-store'); + // istanbul ignore next -- unreachable in this Jest env: the import above always throws + // first (no --experimental-vm-modules, see the file header), so this line never runs. + return mod.Storage; + } catch { + return null; + } +} + +/** 取出并清空 headless 期间攒下的围栏事件;调用方负责按订阅时的逻辑重放它们。 */ +export async function drainPendingGeofenceEvents(): Promise { + const storage = await loadStorage(); + if (storage == null) return []; + // istanbul ignore next -- storage is only non-null with a real expo-sqlite/kv-store, which + // loadStorage() can never resolve in this Jest env (see the file header); unreachable here. + { + const raw = await storage.getItem(PENDING_EVENTS_KEY); + if (raw == null) return []; + await storage.removeItem(PENDING_EVENTS_KEY); + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as GeofenceTaskPayload[]) : []; + } catch { + return []; + } + } +} + +async function persistPendingEvent(payload: GeofenceTaskPayload): Promise { + const storage = await loadStorage(); + if (storage == null) return; + // istanbul ignore next -- same as drainPendingGeofenceEvents() above: unreachable without a + // real expo-sqlite/kv-store. + { + const raw = await storage.getItem(PENDING_EVENTS_KEY); + const pending: GeofenceTaskPayload[] = []; + if (raw != null) { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) pending.push(...(parsed as GeofenceTaskPayload[])); + } catch { + // 上一份坏了就当没有,不阻塞这次事件的持久化。 + } + } + pending.push(payload); + await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending)); + } +} + +async function emit(payload: GeofenceTaskPayload): Promise { + if (listeners.size === 0) { + let delivered = false; + try { + delivered = await deliverHeadlessGeofenceEvent(payload); + } catch { + delivered = false; + } + if (!delivered) { + await persistPendingEvent(payload); + } + return; + } + for (const listener of listeners) { + listener(payload); + } +} + +/** + * Deliver a notification while Expo has only started this headless task. + * + * AppRoot/LocalReminderApplication is not alive in this path, so the task must keep + * the same edge-triggered armed state in SQLite instead of notifying on every enter. + * Returning false leaves the event in the existing pending queue for replay when the + * normal application session starts. + */ +async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promise { + const database = await openHeadlessDatabase(); + if (database == null) return false; + + // istanbul ignore next -- database is only non-null with a real expo-sqlite, which + // openHeadlessDatabase() can never resolve in this Jest env (see the file header); + // unreachable here. + { + if (payload.event === 'exit') { + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 1 + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 0`, + payload.schedule_id, + ); + return true; + } + + const schedule = await database.getFirstAsync( + `SELECT id, title, location_name, schedule_type, reminder_type, + reminder_disposition_state, snoozed_until, geofence_armed, status + FROM local_schedules + WHERE id = ?`, + payload.schedule_id, + ); + if ( + schedule == null || + schedule.status !== 'active' || + schedule.schedule_type !== 'location' || + schedule.geofence_armed !== 1 || + schedule.reminder_disposition_state === 'pending' || + schedule.reminder_disposition_state === 'confirmed' || + (schedule.reminder_disposition_state === 'snoozed' && + schedule.snoozed_until != null && + Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at)) + ) { + return true; + } + + const notifications = await loadNotifications(); + if (notifications == null) return false; + await ensureAndroidChannel(notifications); + notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); + + const notificationId = `reminder-${schedule.id}`; + await notifications.scheduleNotificationAsync({ + identifier: notificationId, + content: { + title: schedule.title || '日程提醒', + body: + schedule.reminder_type === 'return_to_recorded_location' + ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` + : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, + sound: 'default', + data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, + }, + trigger: null, + }); + + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 0, + reminder_disposition_state = 'pending', + next_trigger_at = NULL, + disposition_updated_at = ?, + sync_status = 'pending' + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 1 + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + payload.observed_at, + schedule.id, + ); + return true; + } +} + +async function openHeadlessDatabase(): Promise { + try { + const { openDatabaseAsync } = await import('expo-sqlite'); + // istanbul ignore next -- unreachable in this Jest env: the import above always throws + // first (no --experimental-vm-modules, see the file header), so this line never runs. + return await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + } catch { + return null; + } +} + +// istanbul ignore next -- only ever called from the unreachable branch of +// deliverHeadlessGeofenceEvent() above (see the file header), so never entered here. +async function loadNotifications(): Promise { + try { + return await import('expo-notifications'); + } catch { + return null; + } +} + +// istanbul ignore next -- only ever called from the unreachable branch of +// deliverHeadlessGeofenceEvent() above (see the file header). +async function ensureAndroidChannel( + notifications: typeof import('expo-notifications'), +): Promise { + await notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + }); +} + +if (!TaskManager.isTaskDefined(GEOFENCE_TASK_NAME)) { + TaskManager.defineTask(GEOFENCE_TASK_NAME, async ({ data, error }) => { + if (error) return; + + const payload = data as + | { + eventType?: Location.GeofencingEventType; + region?: Location.LocationRegion; + } + | undefined; + const region = payload?.region; + if (region?.identifier == null) return; + + const eventType = payload?.eventType; + const event = + eventType === Location.GeofencingEventType.Enter + ? 'enter' + : eventType === Location.GeofencingEventType.Exit + ? 'exit' + : null; + if (event == null) return; + + await emit({ + schedule_id: region.identifier, + event, + latitude: region.latitude, + longitude: region.longitude, + radius: region.radius, + observed_at: new Date().toISOString(), + }); + }); +} diff --git a/frontend/src/infrastructure/location/index.ts b/frontend/src/infrastructure/location/index.ts index ef2d2b9d..7389433a 100644 --- a/frontend/src/infrastructure/location/index.ts +++ b/frontend/src/infrastructure/location/index.ts @@ -1,2 +1,2 @@ -export { MockLocationMonitor, MOCK_LOCATION_SAMPLE } from './MockLocationMonitor'; +export { ExpoLocationMonitor } from './ExpoLocationMonitor'; export type { LocationProvider } from './LocationProvider'; diff --git a/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts new file mode 100644 index 00000000..e66c2ed8 --- /dev/null +++ b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts @@ -0,0 +1,483 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import * as Location from 'expo-location'; +import { AppState } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationWatchRequest, +} from '../../../../src/features/reminder/application/interfaces'; +import { ExpoLocationMonitor } from '../../../../src/infrastructure/location/ExpoLocationMonitor'; +import { + drainPendingGeofenceEvents, + subscribeGeofenceTaskEvents, +} from '../../../../src/infrastructure/location/geofenceTask'; + +jest.mock('expo-location', () => ({ + getForegroundPermissionsAsync: jest.fn(), + requestForegroundPermissionsAsync: jest.fn(), + getBackgroundPermissionsAsync: jest.fn(), + requestBackgroundPermissionsAsync: jest.fn(), + getCurrentPositionAsync: jest.fn(), + hasStartedGeofencingAsync: jest.fn(), + stopGeofencingAsync: jest.fn(), + startGeofencingAsync: jest.fn(), +})); + +jest.mock('../../../../src/infrastructure/location/geofenceTask', () => ({ + GEOFENCE_TASK_NAME: 'timeflow-geofence', + subscribeGeofenceTaskEvents: jest.fn(), + drainPendingGeofenceEvents: jest.fn(), +})); + +const getForeground = Location.getForegroundPermissionsAsync as jest.MockedFunction< + typeof Location.getForegroundPermissionsAsync +>; +const requestForeground = Location.requestForegroundPermissionsAsync as jest.MockedFunction< + typeof Location.requestForegroundPermissionsAsync +>; +const getBackground = Location.getBackgroundPermissionsAsync as jest.MockedFunction< + typeof Location.getBackgroundPermissionsAsync +>; +const requestBackground = Location.requestBackgroundPermissionsAsync as jest.MockedFunction< + typeof Location.requestBackgroundPermissionsAsync +>; +const getCurrentPosition = Location.getCurrentPositionAsync as jest.MockedFunction< + typeof Location.getCurrentPositionAsync +>; +const hasStartedGeofencing = Location.hasStartedGeofencingAsync as jest.MockedFunction< + typeof Location.hasStartedGeofencingAsync +>; +const stopGeofencing = Location.stopGeofencingAsync as jest.MockedFunction< + typeof Location.stopGeofencingAsync +>; +const startGeofencing = Location.startGeofencingAsync as jest.MockedFunction< + typeof Location.startGeofencingAsync +>; +const subscribeTaskEvents = subscribeGeofenceTaskEvents as jest.MockedFunction< + typeof subscribeGeofenceTaskEvents +>; +const drainPending = drainPendingGeofenceEvents as jest.MockedFunction< + typeof drainPendingGeofenceEvents +>; + +function granted(): Location.LocationPermissionResponse { + return { + status: 'granted' as Location.PermissionStatus, + canAskAgain: true, + granted: true, + expires: 'never', + }; +} + +function denied(canAskAgain = true): Location.LocationPermissionResponse { + return { + status: 'denied' as Location.PermissionStatus, + canAskAgain, + granted: false, + expires: 'never', + }; +} + +function request(overrides: Partial = {}): LocationWatchRequest { + return { + schedule_id: 'schedule-1', + center: { latitude: 31.2, longitude: 121.5 }, + radius_meters: 200, + mode: 'arrive', + background: true, + ...overrides, + }; +} + +function position(overrides: Partial = {}) { + return { + coords: { + latitude: 31.2, + longitude: 121.5, + accuracy: 12, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + ...overrides, + }, + timestamp: Date.parse('2026-08-19T08:00:00.000Z'), + }; +} + +describe('ExpoLocationMonitor', () => { + let taskListener: ((payload: unknown) => void) | undefined; + let appStateHandler: ((state: string) => void) | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + taskListener = undefined; + appStateHandler = undefined; + + subscribeTaskEvents.mockImplementation((listener) => { + taskListener = listener as (payload: unknown) => void; + return jest.fn(); + }); + drainPending.mockResolvedValue([]); + + jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, handler) => { + appStateHandler = handler as (state: string) => void; + return { remove: jest.fn() } as unknown as ReturnType; + }); + + getForeground.mockResolvedValue(granted()); + getBackground.mockResolvedValue(granted()); + requestForeground.mockResolvedValue(granted()); + requestBackground.mockResolvedValue(granted()); + getCurrentPosition.mockResolvedValue(position()); + hasStartedGeofencing.mockResolvedValue(false); + stopGeofencing.mockResolvedValue(undefined); + startGeofencing.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('subscribes to geofence task events and app-state changes on construction', () => { + new ExpoLocationMonitor(); + expect(subscribeTaskEvents).toHaveBeenCalledTimes(1); + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + describe('watch()', () => { + it('registers a watch, syncs regions, and delivers an initial inside sample', async () => { + const monitor = new ExpoLocationMonitor(); + const events: LocationMonitorEvent[] = []; + const handle = await monitor.watch(request(), (event) => events.push(event)); + + expect(handle).toEqual({ listener_id: 'location-schedule-1', schedule_id: 'schedule-1' }); + expect(startGeofencing).toHaveBeenCalledWith( + 'timeflow-geofence', + expect.arrayContaining([expect.objectContaining({ identifier: 'schedule-1' })]), + ); + expect(events).toEqual([ + { + schedule_id: 'schedule-1', + sample: { + latitude: 31.2, + longitude: 121.5, + accuracy_meters: 12, + observed_at: '2026-08-19T08:00:00.000Z', + }, + phase: 'inside', + }, + ]); + }); + + it('does not deliver an initial sample when the current position is unavailable', async () => { + getForeground.mockResolvedValue(denied(false)); + requestForeground.mockResolvedValue(denied(false)); + const monitor = new ExpoLocationMonitor(); + const listener = jest.fn(); + await monitor.watch(request(), listener); + expect(listener).not.toHaveBeenCalled(); + }); + + it('replaces an existing watch for the same schedule_id instead of stacking two', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + await monitor.watch(request({ radius_meters: 500 }), jest.fn()); + + const lastCall = startGeofencing.mock.calls.at(-1); + const regions = lastCall?.[1] as { identifier: string; radius: number }[]; + expect(regions).toHaveLength(1); + expect(regions[0]).toMatchObject({ identifier: 'schedule-1', radius: 500 }); + }); + + it('replays events queued while the app process was headless', async () => { + drainPending.mockResolvedValue([ + { + schedule_id: 'schedule-1', + event: 'enter', + latitude: 31.21, + longitude: 121.51, + radius: 200, + observed_at: '2026-08-19T09:00:00.000Z', + }, + ]); + const monitor = new ExpoLocationMonitor(); + const events: LocationMonitorEvent[] = []; + await monitor.watch(request(), (event) => events.push(event)); + + expect(events.at(-1)).toEqual({ + schedule_id: 'schedule-1', + sample: { + latitude: 31.21, + longitude: 121.51, + accuracy_meters: 200, + observed_at: '2026-08-19T09:00:00.000Z', + }, + phase: 'entered', + }); + }); + }); + + describe('unwatch()', () => { + it('stops the system geofence once the last watch is removed', async () => { + hasStartedGeofencing.mockResolvedValue(true); + const monitor = new ExpoLocationMonitor(); + const handle = await monitor.watch(request(), jest.fn()); + await monitor.unwatch(handle.listener_id); + + expect(stopGeofencing).toHaveBeenCalledWith('timeflow-geofence'); + }); + + it('does nothing for an unknown listener id', async () => { + const monitor = new ExpoLocationMonitor(); + await expect(monitor.unwatch('does-not-exist')).resolves.toBeUndefined(); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + }); + + describe('rebuild()', () => { + it('replaces every watch with one sync and fans the sample out to all handles', async () => { + const monitor = new ExpoLocationMonitor(); + const events: LocationMonitorEvent[] = []; + const handles = await monitor.rebuild( + [ + { + schedule_id: 'a', + center: { latitude: 1, longitude: 2 }, + radius_meters: 100, + mode: 'arrive', + background: true, + }, + { + schedule_id: 'b', + center: { latitude: 3, longitude: 4 }, + radius_meters: 150, + mode: 'return', + background: false, + }, + ], + (event) => events.push(event), + ); + + expect(handles).toEqual([ + { listener_id: 'location-a', schedule_id: 'a' }, + { listener_id: 'location-b', schedule_id: 'b' }, + ]); + expect(startGeofencing).toHaveBeenCalledTimes(1); + expect(events.map((e) => e.schedule_id)).toEqual(['a', 'b']); + }); + + it('stops geofencing when rebuilt with no targets', async () => { + hasStartedGeofencing.mockResolvedValue(true); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + stopGeofencing.mockClear(); + + await monitor.rebuild([], jest.fn()); + expect(stopGeofencing).toHaveBeenCalledWith('timeflow-geofence'); + }); + }); + + describe('getCurrentSample() / getLastSample()', () => { + it('returns null before any sample has ever been taken', async () => { + const monitor = new ExpoLocationMonitor(); + await expect(monitor.getLastSample()).resolves.toBeNull(); + }); + + it('falls back to the last known sample when foreground permission is missing', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + const cached = await monitor.getLastSample(); + + getForeground.mockResolvedValue(denied(false)); + requestForeground.mockResolvedValue(denied(false)); + await expect(monitor.getCurrentSample()).resolves.toEqual(cached); + expect(getCurrentPosition).toHaveBeenCalledTimes(1); // only from the watch() call above + }); + + it('falls back to the last known sample when the position read throws', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + const cached = await monitor.getLastSample(); + + getCurrentPosition.mockRejectedValue(new Error('gps unavailable')); + await expect(monitor.getCurrentSample()).resolves.toEqual(cached); + }); + + it('defaults accuracy to 0 when the platform does not report it', async () => { + getCurrentPosition.mockResolvedValue(position({ accuracy: null })); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + await expect(monitor.getLastSample()).resolves.toMatchObject({ accuracy_meters: 0 }); + }); + }); + + describe('dispose()', () => { + it('unsubscribes from task events, removes the app-state listener, and stops an active geofence', async () => { + const removeAppState = jest.fn(); + jest.spyOn(AppState, 'addEventListener').mockReturnValue({ + remove: removeAppState, + } as unknown as ReturnType); + const unsubscribeTask = jest.fn(); + subscribeTaskEvents.mockReturnValue(unsubscribeTask); + hasStartedGeofencing.mockResolvedValue(true); + + const monitor = new ExpoLocationMonitor(); + monitor.dispose(); + await Promise.resolve(); + await Promise.resolve(); + + expect(unsubscribeTask).toHaveBeenCalledTimes(1); + expect(removeAppState).toHaveBeenCalledTimes(1); + expect(stopGeofencing).toHaveBeenCalledWith('timeflow-geofence'); + }); + + it('does not stop geofencing when it was never started', async () => { + hasStartedGeofencing.mockResolvedValue(false); + const monitor = new ExpoLocationMonitor(); + monitor.dispose(); + await Promise.resolve(); + await Promise.resolve(); + + expect(stopGeofencing).not.toHaveBeenCalled(); + }); + + it('does not throw when checking geofence status fails during disposal', async () => { + hasStartedGeofencing.mockRejectedValue(new Error('native module torn down')); + const monitor = new ExpoLocationMonitor(); + expect(() => monitor.dispose()).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + }); + }); + + describe('app-state resync', () => { + it('resyncs regions when the app becomes active with active watches', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + startGeofencing.mockClear(); + + appStateHandler?.('active'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(startGeofencing).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the app becomes active with no watches', () => { + new ExpoLocationMonitor(); + appStateHandler?.('active'); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + + it('ignores background/inactive transitions', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + startGeofencing.mockClear(); + + appStateHandler?.('background'); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + }); + + describe('geofence task event routing', () => { + it('ignores an event for a schedule with no active watch', async () => { + const monitor = new ExpoLocationMonitor(); + const listener = jest.fn(); + await monitor.watch(request(), listener); + listener.mockClear(); + + taskListener?.({ + schedule_id: 'unknown-schedule', + event: 'enter', + latitude: 1, + longitude: 2, + radius: 100, + observed_at: '2026-08-19T10:00:00.000Z', + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('reports an exit sample offset outside the fence, not the raw region center', async () => { + const monitor = new ExpoLocationMonitor(); + const listener = jest.fn(); + await monitor.watch(request(), listener); + listener.mockClear(); + + taskListener?.({ + schedule_id: 'schedule-1', + event: 'exit', + latitude: 31.2, + longitude: 121.5, + radius: 200, + observed_at: '2026-08-19T10:00:00.000Z', + }); + + expect(listener).toHaveBeenCalledWith({ + schedule_id: 'schedule-1', + sample: { + latitude: 31.21, + longitude: 121.5, + accuracy_meters: 200, + observed_at: '2026-08-19T10:00:00.000Z', + }, + phase: 'left', + }); + }); + }); + + describe('syncRegions() permission requesting', () => { + it('requests foreground permission when not already granted, then proceeds', async () => { + getForeground.mockResolvedValue(denied()); + requestForeground.mockResolvedValue(granted()); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestForeground).toHaveBeenCalledTimes(1); + expect(startGeofencing).toHaveBeenCalled(); + }); + + it('skips registration when foreground permission is still denied after requesting', async () => { + getForeground.mockResolvedValue(denied(false)); + requestForeground.mockResolvedValue(denied(false)); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestBackground).not.toHaveBeenCalled(); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + + it('requests background permission only after foreground is already granted', async () => { + getBackground.mockResolvedValue(denied()); + requestBackground.mockResolvedValue(granted()); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestForeground).not.toHaveBeenCalled(); + expect(requestBackground).toHaveBeenCalledTimes(1); + expect(startGeofencing).toHaveBeenCalled(); + }); + + it('skips registration when background permission is still denied after requesting', async () => { + getBackground.mockResolvedValue(denied(false)); + requestBackground.mockResolvedValue(denied(false)); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(startGeofencing).not.toHaveBeenCalled(); + }); + + it('does not re-request permission that is already granted', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestForeground).not.toHaveBeenCalled(); + expect(requestBackground).not.toHaveBeenCalled(); + }); + + it('warns and does not throw when startGeofencingAsync itself fails', async () => { + startGeofencing.mockRejectedValue(new Error('system geofence limit reached')); + const monitor = new ExpoLocationMonitor(); + await expect(monitor.watch(request(), jest.fn())).resolves.toBeDefined(); + }); + }); +}); diff --git a/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts new file mode 100644 index 00000000..f26f2e7b --- /dev/null +++ b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +/** + * deliverHeadlessGeofenceEvent()/persistPendingEvent()/drainPendingGeofenceEvents() all + * reach expo-sqlite(/kv-store)/expo-notifications through a dynamic `import()` (deliberate: + * see the "懒加载" comment on loadStorage() in the source — the top-level default export of + * those native modules would throw immediately in a test environment without a real native + * module, so the whole codebase's convention is to defer that behind a dynamic import). + * This project's Jest config has no `--experimental-vm-modules`, so a bare `await import(...)` + * throws "A dynamic import callback was invoked without --experimental-vm-modules" here — that + * failure is swallowed by the source's own try/catch (by design, for a genuinely-unavailable + * native module), which makes it indistinguishable from "no database file yet" in this suite. + * So the DB/notification branches inside deliverHeadlessGeofenceEvent are not reachable from a + * unit test in this project without changing the Jest runtime flags project-wide; only the + * synchronous routing in front of that boundary is covered here. + */ + +type TaskExecutor = (args: { data: unknown; error: unknown }) => Promise; + +const mockIsTaskDefined = jest.fn<(name: string) => boolean>(); +const mockDefineTask = jest.fn<(name: string, executor: TaskExecutor) => void>(); + +jest.mock('expo-task-manager', () => ({ + isTaskDefined: (name: string) => mockIsTaskDefined(name), + defineTask: (name: string, executor: TaskExecutor) => mockDefineTask(name, executor), +})); + +jest.mock('expo-location', () => ({ + GeofencingEventType: { Enter: 1, Exit: 2 }, +})); + +/** 每个测试都重新 require 模块,拿到一份干净的 listeners 集合和被捕获的 task executor。 */ +function loadModule(): { + module: typeof import('../../../../src/infrastructure/location/geofenceTask'); + executor: TaskExecutor; +} { + mockIsTaskDefined.mockReturnValue(false); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- fresh module registry per test needs require(), not static import + const required = require('../../../../src/infrastructure/location/geofenceTask'); + const module: typeof import('../../../../src/infrastructure/location/geofenceTask') = required; + const executor = mockDefineTask.mock.calls.at(-1)?.[1] as TaskExecutor; + return { module, executor }; +} + +describe('geofenceTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('defines the task only once, keyed by GEOFENCE_TASK_NAME', () => { + const { module } = loadModule(); + expect(module.GEOFENCE_TASK_NAME).toBe('timeflow-geofence'); + expect(mockDefineTask).toHaveBeenCalledWith('timeflow-geofence', expect.any(Function)); + }); + + it('does not redefine the task if it is already defined', () => { + mockIsTaskDefined.mockReturnValue(true); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../../../../src/infrastructure/location/geofenceTask'); + expect(mockDefineTask).not.toHaveBeenCalled(); + }); + + describe('subscribeGeofenceTaskEvents()', () => { + it('delivers task events straight to a subscribed listener, bypassing headless delivery', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + + await executor({ + data: { + eventType: 1, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(listener).toHaveBeenCalledWith({ + schedule_id: 'schedule-1', + event: 'enter', + latitude: 1, + longitude: 2, + radius: 100, + observed_at: expect.any(String), + }); + }); + + it('maps GeofencingEventType.Exit to the "exit" event string', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + + await executor({ + data: { + eventType: 2, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ event: 'exit' })); + }); + + it('stops delivering to a listener once unsubscribed', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + const unsubscribe = module.subscribeGeofenceTaskEvents(listener); + unsubscribe(); + + await executor({ + data: { + eventType: 1, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('supports multiple concurrent listeners', async () => { + const { module, executor } = loadModule(); + const first = jest.fn(); + const second = jest.fn(); + module.subscribeGeofenceTaskEvents(first); + module.subscribeGeofenceTaskEvents(second); + + await executor({ + data: { + eventType: 1, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + }); + + describe('task executor input handling', () => { + it('ignores the callback when TaskManager reports an error', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ data: undefined, error: new Error('boom') }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ignores a payload with no region identifier', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ data: { eventType: 1, region: undefined }, error: null }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ignores a payload with no data at all', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ data: undefined, error: null }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ignores an unrecognized event type', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ + data: { + eventType: 99, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('drainPendingGeofenceEvents()', () => { + it('resolves to an empty array when the kv-store backing it is unreachable', async () => { + // loadStorage() 内部动态 import 失败即返回 null,这条路径本身就是设计要覆盖的 + // "原生模块不可用" 分支,跟测试环境无法提供真实 expo-sqlite/kv-store 是同一种情况。 + const { module } = loadModule(); + await expect(module.drainPendingGeofenceEvents()).resolves.toEqual([]); + }); + }); +}); diff --git a/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts b/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts deleted file mode 100644 index 42d2a3f8..00000000 --- a/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, jest } from '@jest/globals'; - -import { MockLocationMonitor } from '../../../../src/infrastructure/location/MockLocationMonitor'; - -describe('MockLocationMonitor', () => { - it('watch() resolves a handle keyed off the request schedule_id', async () => { - const monitor = new MockLocationMonitor(); - const handle = await monitor.watch( - { - schedule_id: 's1', - center: { latitude: 1, longitude: 2 }, - radius_meters: 100, - mode: 'arrive', - background: false, - }, - jest.fn(), - ); - expect(handle).toEqual({ listener_id: 'mock-location-listener-s1', schedule_id: 's1' }); - }); - - it('unwatch() resolves without needing a matching watch()', async () => { - const monitor = new MockLocationMonitor(); - await expect(monitor.unwatch('unknown-listener')).resolves.toBeUndefined(); - }); - - it('rebuild() maps each target straight to a handle, no filtering', async () => { - const monitor = new MockLocationMonitor(); - const handles = await monitor.rebuild( - [ - { - schedule_id: 's1', - center: { latitude: 1, longitude: 2 }, - radius_meters: 100, - mode: 'arrive', - background: false, - }, - { - schedule_id: 's2', - center: { latitude: 3, longitude: 4 }, - radius_meters: 200, - mode: 'return', - background: true, - }, - ], - jest.fn(), - ); - expect(handles).toEqual([ - { listener_id: 'mock-location-listener-s1', schedule_id: 's1' }, - { listener_id: 'mock-location-listener-s2', schedule_id: 's2' }, - ]); - }); - - it('rebuild() resolves an empty list for no targets', async () => { - const monitor = new MockLocationMonitor(); - await expect(monitor.rebuild([], jest.fn())).resolves.toEqual([]); - }); - - it('getLastSample()/getCurrentSample() resolve the fixed mock sample', async () => { - const monitor = new MockLocationMonitor(); - const sample = { latitude: 31.2304, longitude: 121.4737, accuracy_meters: 12 }; - await expect(monitor.getLastSample()).resolves.toMatchObject(sample); - await expect(monitor.getCurrentSample()).resolves.toMatchObject(sample); - }); -});