diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 66823c6c..2aefb22a 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -21,7 +21,7 @@ import { NativeAlarmScheduler, NativeDeviceCapability, } from '../../infrastructure/notifications'; -import { MockTimeListener } from '../../shared/time'; +import { MockTimeListener } from '../../infrastructure/time'; import { MockReminderPresenter } from '../../features/reminder/presentation'; import { ScheduleViewStore } from '../../features/schedule/presentation'; diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index e8904c8a..56b3b449 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -7,8 +7,10 @@ import type { LocationMonitorEvent, LocationWatchHandle, AlarmScheduleReceipt, + AlarmNativeEvent, } from './interfaces'; import type { + DeliveryChannel, LocalReminderSchedule, LocationSample, ReminderDeliveryReceipt, @@ -21,6 +23,7 @@ import type { } from '../domain'; import { DEFAULT_SNOOZE_MINUTES } from '../domain'; import { evaluateGeofence, resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence'; +import { resolveStrengthDeliveryPlan } from '../domain/strengthDelivery'; import { isSnoozeActive, isSnoozeExpired, @@ -50,9 +53,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { private timeListenerId: string | null = null; private unsubscribePresenter: (() => void) | null = null; private unsubscribeSchedules: (() => void) | null = null; + private unsubscribeAlarms: (() => void) | null = null; private readonly registrations = new Map(); private readonly activeDeliveries = new Set(); private readonly deliverLocks = new Set(); + /** 原生已响铃、由原生 UI 承接展示的日程;JS 不再叠加弹窗/TTS。 */ + private readonly nativePresented = new Set(); private readonly inFlight = new Set>(); private opChain: Promise = Promise.resolve(); /** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */ @@ -161,6 +167,10 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.unsubscribePresenter = this.dependencies.presenter.onAction((event) => { void this.handlePresentationAction(event.schedule_id, event.action); }); + this.unsubscribeAlarms = + this.dependencies.alarms.subscribe?.((event) => { + void this.handleNativeAlarmEvent(event); + }) ?? null; // IntervalTimeListener 不在 start 时同步打点;先挂上 listener id 再 rebuild。 const timeHandle = await this.dependencies.time.start( @@ -175,6 +185,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { return; } + await this.hydrateNativeDispositions(generation); + if (!this.isLive(generation)) { + await this.stopInternal(); + return; + } + this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => { void this.enqueueRebuild(); }); @@ -209,6 +225,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { } this.activeDeliveries.clear(); this.deliverLocks.clear(); + this.nativePresented.clear(); this.started = false; } @@ -217,6 +234,8 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.unsubscribePresenter = null; this.unsubscribeSchedules?.(); this.unsubscribeSchedules = null; + this.unsubscribeAlarms?.(); + this.unsubscribeAlarms = null; } private async registerInternal( @@ -283,8 +302,22 @@ export class LocalReminderApplication implements ReminderApplicationPort { if (!this.isLive(generation)) return []; - const locationSchedules = active.filter((schedule) => schedule.schedule_type === 'location'); - const locationHandles = await this.dependencies.location.rebuild(locationSchedules, (event) => { + const locationTargets = active + .filter((schedule) => schedule.schedule_type === 'location') + .map((schedule) => { + const mode = resolveWatchMode(schedule); + const center = resolveGeofenceCenter(schedule, mode); + if (center == null) return null; + return { + schedule_id: schedule.id, + center, + radius_meters: schedule.geofence_radius_meters, + mode, + background: true, + }; + }) + .filter((target): target is NonNullable => target != null); + const locationHandles = await this.dependencies.location.rebuild(locationTargets, (event) => { void this.handleLocationMonitorEvent(event); }); const locationBySchedule = new Map( @@ -563,25 +596,58 @@ export class LocalReminderApplication implements ReminderApplicationPort { }); const request = toDeliveryRequest(schedule, trigger); - const receipt = await this.dependencies.delivery.deliver(request); - await this.dependencies.presenter.show(request); - await this.dependencies.systemNotification.show({ - notification_id: `reminder-${schedule.id}`, - title: schedule.title, - body: schedule.location_name ?? schedule.title, - }); - await this.dependencies.popup.show({ - popup_id: `reminder-${schedule.id}`, - title: schedule.title, - body: schedule.location_name ?? schedule.title, - }); - await this.dependencies.vibration.vibrate(); - - let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); - if (!audioReceipt.played) { - audioReceipt = await this.dependencies.audio.playLocalFallback({ - schedule_id: schedule.id, + const plan = resolveStrengthDeliveryPlan(request.strength); + const channels: DeliveryChannel[] = []; + let usedFallbackAudio = false; + let deliveryId = `delivery-${schedule.id}`; + + // 时间型日程只要排上了原生精确闹钟,响铃 UI 和声音就全权交给原生 + // RingActivity/AlarmSoundService;JS 这边再弹 Alert/放音会跟原生的全屏页 + // 抢事件,谁都关不掉谁。地点型日程没有原生等价物,走完整 JS 通道。 + const nativeAlarmOwnsRingUi = + schedule.schedule_type === 'time' && + this.registrations.get(schedule.id)?.alarm_id != null; + + // low=系统通知;medium=弹窗+短震动;high=弹窗+短震动+TTS(失败则本地音)。 + if (plan.useSystemNotification) { + const receipt = await this.dependencies.delivery.deliver(request); + deliveryId = receipt.delivery_id; + await this.dependencies.systemNotification.show({ + notification_id: `reminder-${schedule.id}`, + title: schedule.title, + body: schedule.location_name ?? schedule.title, }); + channels.push('system_notification'); + } + + if (plan.usePopup && !nativeAlarmOwnsRingUi) { + await this.dependencies.presenter.show(request); + channels.push('popup'); + } + + if (plan.useVibration) { + await this.dependencies.vibration.vibrate(); + channels.push('vibration'); + } + + let audioPlayed = false; + if (plan.useAudio && !nativeAlarmOwnsRingUi) { + let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); + if (!audioReceipt.played) { + audioReceipt = await this.dependencies.audio.playLocalFallback({ + schedule_id: schedule.id, + }); + } + audioPlayed = audioReceipt.played; + if (audioPlayed) { + channels.push(audioReceipt.used_local_fallback ? 'local_sound' : 'tts'); + } + usedFallbackAudio = audioReceipt.used_local_fallback; + } + + await this.cancelScheduledAlarm(schedule.id); + if (!plan.useAudio || audioPlayed) { + await this.dependencies.alarms.stopRinging?.(); } if (!this.isLive(generation)) { @@ -593,14 +659,11 @@ export class LocalReminderApplication implements ReminderApplicationPort { } return { - ...receipt, - channels: [ - ...receipt.channels, - 'popup', - 'vibration', - audioReceipt.used_local_fallback ? 'local_sound' : 'tts', - ], - used_fallback_audio: audioReceipt.used_local_fallback, + delivery_id: deliveryId, + schedule_id: schedule.id, + delivered_at: trigger.triggered_at, + channels, + used_fallback_audio: usedFallbackAudio, }; } catch (error) { if (!this.acceptingWork || this.isLive(generation)) { @@ -641,12 +704,101 @@ export class LocalReminderApplication implements ReminderApplicationPort { } await this.enqueueOp(() => this.snoozeInternal( - { schedule_id: scheduleId, snooze_minutes: DEFAULT_SNOOZE_MINUTES }, + { schedule_id: scheduleId, snooze_until: null, snooze_minutes: DEFAULT_SNOOZE_MINUTES }, generation, ), ); } + private async handleNativeAlarmEvent(event: AlarmNativeEvent): Promise { + if (!event.schedule_id) return; + if (event.type === 'fired') { + await this.acknowledgeNativeFire(event.schedule_id, event.at); + return; + } + if (event.type === 'snoozed') { + await this.snooze({ schedule_id: event.schedule_id, snooze_until: null }); + return; + } + await this.confirm(event.schedule_id, event.at); + } + + /** + * 原生已承接响铃 UI:只落 pending disposition,避免 JS 再弹 Alert/TTS。 + * handleTime 会因 pending / activeDeliveries 跳过,从而消除双通道连响。 + */ + private async acknowledgeNativeFire(scheduleId: string, firedAt: string): Promise { + // handleTime 侧的 canDeliver() 检查 activeDeliveries 来跳过已经在原生响铃的日程, + // 但反过来这里原来只看持久化的 disposition_state——如果 handleTime 已经在跑(还没 + // 来得及落盘 pending)而原生这时候也报了 fired,两条通道会一起响。加上这个检查堵住。 + if (this.activeDeliveries.has(scheduleId)) { + this.nativePresented.add(scheduleId); + return; + } + const runtime = (await this.readRuntime(scheduleId)) ?? emptyRuntime(); + if ( + runtime.reminder_disposition_state === 'confirmed' || + runtime.reminder_disposition_state === 'pending' + ) { + this.nativePresented.add(scheduleId); + this.activeDeliveries.add(scheduleId); + return; + } + + this.nativePresented.add(scheduleId); + this.activeDeliveries.add(scheduleId); + await this.cancelScheduledAlarm(scheduleId); + await this.patchRuntime(scheduleId, { + ...runtime, + reminder_disposition_state: 'pending', + next_trigger_at: null, + disposition_updated_at: firedAt, + sync_status: 'pending', + }); + await this.dependencies.state.setDisposition(scheduleId, { + schedule_id: scheduleId, + state: 'pending', + updated_at: firedAt, + snoozed_until: null, + sync_status: 'pending', + }); + } + + /** + * 只能在 startInternal() 内部调用:这本身就是 opChain 上正在跑的那个任务, + * 这里必须直接调 confirmInternal/snoozeInternal(不再入队),否则通过公开的 + * confirm()/snooze() 会把新任务追加到同一条 opChain 上,而那条链要等 + * startInternal()(也就是当前这次调用本身)先跑完才会轮到它们——形成死锁, + * 冷启动永远卡在这一步。 + */ + private async hydrateNativeDispositions(generation: number): Promise { + const rows = await this.dependencies.alarms.peekNativeDispositions?.(); + if (rows == null || rows.length === 0) return; + + for (const row of rows) { + if (row.state === 'confirmed') { + await this.confirmInternal(row.schedule_id, row.updated_at, generation); + continue; + } + if (row.state === 'snoozed') { + await this.snoozeInternal({ schedule_id: row.schedule_id, snooze_until: null }, generation); + continue; + } + await this.acknowledgeNativeFire(row.schedule_id, row.updated_at); + } + // ack 放在整批落盘都成功之后:peek 不清空原生缓冲区,中途某一条抛错就整批 + // 不 ack,下次冷启动重新 peek 到、重放同样的 confirm/snooze/acknowledgeNativeFire + // ——这几个都是幂等的状态转换,重放安全,比"读完立刻清空"丢数据的窗口好。 + await this.dependencies.alarms.ackNativeDispositions?.(rows.map((row) => row.schedule_id)); + } + + private async cancelScheduledAlarm(scheduleId: string): Promise { + const registration = this.registrations.get(scheduleId); + if (registration?.alarm_id == null) return; + await this.dependencies.alarms.cancel(registration.alarm_id); + registration.alarm_id = null; + } + private async handleLocationMonitorEvent(event: LocationMonitorEvent): Promise { const generation = this.generation; await this.track(this.runLocationMonitorEvent(event, generation)); diff --git a/frontend/src/features/reminder/application/index.ts b/frontend/src/features/reminder/application/index.ts index 6e87c456..63351bc7 100644 --- a/frontend/src/features/reminder/application/index.ts +++ b/frontend/src/features/reminder/application/index.ts @@ -1,5 +1,7 @@ export { LocalReminderApplication } from './LocalReminderApplication'; export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -13,9 +15,13 @@ export type { LocalTimeTick, LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -24,6 +30,8 @@ export type { ReminderApplicationResult, ReminderConfirmedDisposition, ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, ReminderDispositionSyncPort, ReminderDispositionSyncReceipt, ReminderPresentationAction, diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index 17949c02..ceb20d4b 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -12,9 +12,32 @@ export type AlarmScheduleReceipt = { scheduled: boolean; }; +export type AlarmNativeEvent = { + type: 'fired' | 'dismissed' | 'snoozed'; + schedule_id: string; + alarm_id: string; + title: string; + at: string; +}; + +export type AlarmNativeDisposition = { + schedule_id: string; + alarm_id: string; + state: 'pending' | 'confirmed' | 'snoozed'; + updated_at: string; +}; + /** 原生闹钟映射边界;触发时间的选择留在应用层或领域层。 */ export interface AlarmSchedulerPort { schedule(request: AlarmScheduleRequest): Promise; cancel(alarmId: string | null): Promise<{ cancelled: boolean }>; rebuild(requests: readonly AlarmScheduleRequest[]): Promise; + /** 停止正在响铃的原生界面/音频(尽力而为)。 */ + stopRinging?(): Promise; + /** 订阅原生响铃/停铃事件;无原生桥时可不实现。 */ + subscribe?(listener: (event: AlarmNativeEvent) => void): () => void; + /** 只读取进程外写入的处置状态(冷启动补水),不清空原生缓冲区。 */ + peekNativeDispositions?(): Promise; + /** 确认对应 schedule_id 已经在 JS 侧落盘成功,原生缓冲区才真正删除这批记录。 */ + ackNativeDispositions?(scheduleIds: readonly string[]): Promise; } diff --git a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts index 9ba6ae5c..1eb2b688 100644 --- a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts +++ b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts @@ -19,4 +19,6 @@ export interface DeviceCapabilityPort { getStatus(): Promise; requestPermission(permission: DevicePermission): Promise; openSettings(permission: DevicePermission): Promise; + /** 订阅应用回到前台;返回取消订阅函数。 */ + onAppActive(listener: () => void): () => void; } diff --git a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts index 05711ee8..aa2dfa58 100644 --- a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts +++ b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts @@ -1,7 +1,19 @@ -import type { GeoPoint, LocalReminderSchedule, LocationSample } from '../../domain'; +import type { GeoPoint, LocationSample } from '../../domain'; export type LocationWatchMode = 'arrive' | 'return'; +/** + * 围栏重建目标:携带 watch 所需几何参数,避免 infrastructure 依赖完整领域日程, + * 同时保证冷启动 rebuild 能重新挂上系统围栏。 + */ +export type LocationRebuildTarget = { + schedule_id: string; + center: GeoPoint; + radius_meters: number; + mode: LocationWatchMode; + background: boolean; +}; + export type LocationWatchRequest = { schedule_id: string; center: GeoPoint; @@ -29,7 +41,7 @@ export interface LocationMonitorPort { ): Promise; unwatch(listenerId: string): Promise; rebuild( - schedules: readonly LocalReminderSchedule[], + targets: readonly LocationRebuildTarget[], listener: (event: LocationMonitorEvent) => void, ): Promise; getLastSample(): Promise; diff --git a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts index 3c62b6e9..c9706ee5 100644 --- a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts +++ b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts @@ -34,3 +34,23 @@ export interface VibrationPort { vibrate(): Promise; stop(): Promise; } + +export type AlertDialogButton = { + text: string; + style?: 'default' | 'cancel' | 'destructive'; + onPress?: () => void; +}; + +export type AlertDialogRequest = { + title: string; + message: string; + buttons: readonly AlertDialogButton[]; + /** Android:返回键关闭时回调;未点按钮也要能结束等待。 */ + onDismiss?: () => void; + cancelable?: boolean; +}; + +/** 系统确认/选择对话框;由 infrastructure 适配,presentation 不直接依赖 RN Alert。 */ +export interface AlertDialogPort { + show(request: AlertDialogRequest): Promise; +} diff --git a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts index 903b1dbd..5f0b4fe8 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts @@ -24,21 +24,11 @@ export type ReminderApplicationDependencies = { dispositionSync: import('./ReminderDispositionSyncPort').ReminderDispositionSyncPort; }; -/** - * 贪睡必须且只能提供一种目标时刻表达:绝对时间或相对分钟。 - * 两种字段互斥,避免实现层自行决定优先级,也禁止无目标时刻的请求。 - */ -export type ReminderSnoozeRequest = - | { - schedule_id: string; - snooze_until: string; - snooze_minutes?: never; - } - | { - schedule_id: string; - snooze_minutes: number; - snooze_until?: never; - }; +export type ReminderSnoozeRequest = { + schedule_id: string; + snooze_until: string | null; + snooze_minutes?: number | null; +}; export type ReminderApplicationResult = { accepted: boolean; diff --git a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts index f13ba733..611712a6 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts @@ -1,5 +1,7 @@ import type { ReminderDeliveryReceipt, ReminderDeliveryRequest } from '../../domain'; +export type { ReminderDeliveryReceipt, ReminderDeliveryRequest }; + /** 通过与平台无关的展示边界送达提醒。 */ export interface ReminderDeliveryPort { deliver(request: ReminderDeliveryRequest): Promise; diff --git a/frontend/src/features/reminder/application/interfaces/index.ts b/frontend/src/features/reminder/application/interfaces/index.ts index ca99f198..788b9f5d 100644 --- a/frontend/src/features/reminder/application/interfaces/index.ts +++ b/frontend/src/features/reminder/application/interfaces/index.ts @@ -1,4 +1,6 @@ export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -17,11 +19,15 @@ export type { LocalScheduleReader } from './LocalScheduleReader'; export type { LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, } from './LocationMonitorPort'; export type { + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -36,7 +42,11 @@ export type { ReminderApplicationResult, ReminderSnoozeRequest, } from './ReminderApplicationPort'; -export type { ReminderDeliveryPort } from './ReminderDeliveryPort'; +export type { + ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, +} from './ReminderDeliveryPort'; export type { ReminderConfirmedDisposition, ReminderDispositionSyncPort, diff --git a/frontend/src/features/reminder/domain/index.ts b/frontend/src/features/reminder/domain/index.ts index 86f87408..328cc015 100644 --- a/frontend/src/features/reminder/domain/index.ts +++ b/frontend/src/features/reminder/domain/index.ts @@ -35,3 +35,5 @@ export { resolveSnoozeUntil, resolveTimeTriggerAt, } from './timeWindow'; +export type { StrengthDeliveryPlan } from './strengthDelivery'; +export { resolveStrengthDeliveryPlan } from './strengthDelivery'; diff --git a/frontend/src/features/reminder/domain/reminder.ts b/frontend/src/features/reminder/domain/reminder.ts index c20b859d..58b4c215 100644 --- a/frontend/src/features/reminder/domain/reminder.ts +++ b/frontend/src/features/reminder/domain/reminder.ts @@ -73,12 +73,7 @@ export type LocalReminderSchedule = { }; export type ReminderTriggerReason = - | 'at_time' - | 'before_start' - | 'arrive_location' - | 'return_to_recorded_location' - | 'snooze_expired' - | 'mock'; + 'at_time' | 'before_start' | 'arrive_location' | 'return_to_recorded_location' | 'snooze_expired'; export type ReminderTrigger = { reminder_id: string; diff --git a/frontend/src/features/reminder/domain/strengthDelivery.ts b/frontend/src/features/reminder/domain/strengthDelivery.ts new file mode 100644 index 00000000..37a56ff1 --- /dev/null +++ b/frontend/src/features/reminder/domain/strengthDelivery.ts @@ -0,0 +1,35 @@ +import type { ReminderStrength } from './reminder'; + +/** 提醒强度对应的客户端送达通道组合。 */ +export type StrengthDeliveryPlan = { + useSystemNotification: boolean; + usePopup: boolean; + useVibration: boolean; + useAudio: boolean; +}; + +export function resolveStrengthDeliveryPlan(strength: ReminderStrength): StrengthDeliveryPlan { + switch (strength) { + case 'low': + return { + useSystemNotification: true, + usePopup: false, + useVibration: false, + useAudio: false, + }; + case 'medium': + return { + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: false, + }; + case 'high': + return { + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: true, + }; + } +} diff --git a/frontend/src/infrastructure/location/MockLocationMonitor.ts b/frontend/src/infrastructure/location/MockLocationMonitor.ts index b5fb4e65..8c3aaa9a 100644 --- a/frontend/src/infrastructure/location/MockLocationMonitor.ts +++ b/frontend/src/infrastructure/location/MockLocationMonitor.ts @@ -1,10 +1,11 @@ import type { LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchRequest, } from '../../features/reminder/application/interfaces'; -import type { LocalReminderSchedule, LocationSample } from '../../features/reminder/domain'; +import type { LocationSample } from '../../features/reminder/domain'; import type { LocationProvider } from './LocationProvider'; @@ -15,10 +16,6 @@ const MOCK_SAMPLE: LocationSample = { observed_at: '2026-08-07T01:00:00.000Z', }; -function isLocationSchedule(schedule: LocalReminderSchedule): boolean { - return schedule.schedule_type === 'location' && schedule.status === 'active'; -} - /** 固定定位能力适配器,不访问平台定位接口。 */ export class MockLocationMonitor implements LocationMonitorPort, LocationProvider { async watch( @@ -36,12 +33,12 @@ export class MockLocationMonitor implements LocationMonitorPort, LocationProvide } async rebuild( - schedules: readonly LocalReminderSchedule[], + targets: readonly LocationRebuildTarget[], _listener: (event: LocationMonitorEvent) => void, ): Promise { - return schedules.filter(isLocationSchedule).map((schedule) => ({ - listener_id: `mock-location-listener-${schedule.id}`, - schedule_id: schedule.id, + return targets.map((target) => ({ + listener_id: `mock-location-listener-${target.schedule_id}`, + schedule_id: target.schedule_id, })); } diff --git a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts b/frontend/src/infrastructure/notifications/MockDeviceCapability.ts index cda5590c..6e7a50dc 100644 --- a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts +++ b/frontend/src/infrastructure/notifications/MockDeviceCapability.ts @@ -35,6 +35,10 @@ export class MockDeviceCapability implements DeviceCapabilityPort { async openSettings(_permission: DevicePermission): Promise { return true; } + + onAppActive(_listener: () => void): () => void { + return () => {}; + } } export { MOCK_STATUS as MOCK_DEVICE_CAPABILITY_STATUS }; diff --git a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts index b1ce7a66..4806a195 100644 --- a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts +++ b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts @@ -1,4 +1,4 @@ -import { Platform } from 'react-native'; +import { AppState, Platform, type AppStateStatus } from 'react-native'; import type { DeviceCapabilityPort, @@ -75,6 +75,13 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { return false; } } + + onAppActive(listener: () => void): () => void { + const subscription = AppState.addEventListener('change', (state: AppStateStatus) => { + if (state === 'active') listener(); + }); + return () => subscription.remove(); + } } function toPlatform(): DeviceCapabilityStatus['platform'] { diff --git a/frontend/src/shared/time/MockTimeListener.ts b/frontend/src/infrastructure/time/MockTimeListener.ts similarity index 67% rename from frontend/src/shared/time/MockTimeListener.ts rename to frontend/src/infrastructure/time/MockTimeListener.ts index e77e36ec..b2a97fbe 100644 --- a/frontend/src/shared/time/MockTimeListener.ts +++ b/frontend/src/infrastructure/time/MockTimeListener.ts @@ -5,13 +5,16 @@ import type { TimeListenerPort, } from '../../features/reminder/application/interfaces'; -/** 固定时间监听边界,替换前不会发出平台事件。 */ +let nextListenerId = 0; + +/** 不产生任何 tick 的固定实现——真实的周期计时器接入前占位用。 */ export class MockTimeListener implements TimeListenerPort { async start( _listener: (tick: LocalTimeTick) => void, _options?: TimeListenerOptions, ): Promise { - return { listener_id: 'mock-time-listener-001' }; + nextListenerId += 1; + return { listener_id: `mock-time-listener-${nextListenerId}` }; } async stop(_listenerId: string): Promise { diff --git a/frontend/src/infrastructure/time/index.ts b/frontend/src/infrastructure/time/index.ts new file mode 100644 index 00000000..6d0bf332 --- /dev/null +++ b/frontend/src/infrastructure/time/index.ts @@ -0,0 +1 @@ +export { MockTimeListener } from './MockTimeListener'; diff --git a/frontend/src/shared/time/MockClock.ts b/frontend/src/shared/time/MockClock.ts deleted file mode 100644 index edc7ff1c..00000000 --- a/frontend/src/shared/time/MockClock.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** 用于确定性测试和模拟组合的最小时钟端口。 */ -export interface Clock { - nowIso(): string; -} - -export class MockClock implements Clock { - constructor(private readonly value = '2026-08-07T01:00:00.000Z') {} - - nowIso(): string { - return this.value; - } -} diff --git a/frontend/src/shared/time/format.ts b/frontend/src/shared/time/format.ts new file mode 100644 index 00000000..71609c15 --- /dev/null +++ b/frontend/src/shared/time/format.ts @@ -0,0 +1,19 @@ +/** + * 无业务语义的时间工具(附录 B.5)。 + * 不承载日程/提醒触发规则。 + */ +export function toIsoUtc(date: Date = new Date()): string { + return date.toISOString(); +} + +export function formatLocalDateTime( + iso: string, + timeZone = 'Asia/Shanghai', + locale = 'zh-CN', +): string { + try { + return new Date(iso).toLocaleString(locale, { hour12: false, timeZone }); + } catch { + return iso; + } +} diff --git a/frontend/src/shared/time/index.ts b/frontend/src/shared/time/index.ts index 1924ce7a..00679748 100644 --- a/frontend/src/shared/time/index.ts +++ b/frontend/src/shared/time/index.ts @@ -1,3 +1 @@ -export { MockClock } from './MockClock'; -export type { Clock } from './MockClock'; -export { MockTimeListener } from './MockTimeListener'; +export { formatLocalDateTime, toIsoUtc } from './format'; diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts new file mode 100644 index 00000000..9df83613 --- /dev/null +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -0,0 +1,776 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import { LocalReminderApplication } from '../../../../../src/features/reminder/application/LocalReminderApplication'; +import type { + AlarmNativeDisposition, + AlarmNativeEvent, + AlarmScheduleReceipt, + AlarmScheduleRequest, + AlarmSchedulerPort, + LocationWatchRequest, + PopupRequest, + ReminderApplicationDependencies, + ReminderConfirmedDisposition, + ReminderDeliveryRequest, + SystemNotificationRequest, +} from '../../../../../src/features/reminder/application/interfaces'; +import { MemoryReminderStateStore } from '../../../../../src/features/reminder/data/local/MemoryReminderStateStore'; +import type { + LocalReminderSchedule, + ReminderRuntimeState, +} from '../../../../../src/features/reminder/domain'; + +/** 事件订阅回调是 fire-and-forget(void handleNativeAlarmEvent(event)),背后 + * 排了好几层 await(enqueueOp → teardownDelivery 的 6 个串行任务 → state 读写 + * → 按需重排闹钟);固定多轮 flush 比猜一两次够不够稳。 */ +async function flushAsync(iterations = 20): Promise { + for (let i = 0; i < iterations; i += 1) { + await Promise.resolve(); + } +} + +function emptyRuntime(): ReminderRuntimeState { + return { + reminder_disposition_state: null, + next_trigger_at: null, + snoozed_until: null, + geofence_armed: false, + disposition_updated_at: null, + sync_status: 'pending', + recorded_location: null, + }; +} + +function fixtureSchedule(overrides: Partial = {}): LocalReminderSchedule { + return { + id: 's1', + account_id: 'acc_1', + title: '喝水提醒', + schedule_type: 'time', + schedule_kind: 'once', + is_all_day: false, + start_time: '2026-08-18T10:00:00.000Z', + end_time: null, + timezone: 'Asia/Shanghai', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + geofence_radius_meters: 200, + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: '2026-08-18T10:00:00.000Z', + reminder_offset_minutes: null, + reminder_strength: 'medium', + }, + runtime: emptyRuntime(), + status: 'active', + revision: 1, + cloud_revision: 1, + updated_at: '2026-08-18T09:00:00.000Z', + ...overrides, + }; +} + +class FakeScheduleReader { + schedules: LocalReminderSchedule[]; + private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + + constructor(schedules: LocalReminderSchedule[] = []) { + this.schedules = schedules; + } + + async listReminderSchedules(): Promise { + return this.schedules; + } + + async getReminderSchedule(scheduleId: string): Promise { + return this.schedules.find((schedule) => schedule.id === scheduleId) ?? null; + } + + subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +function createFakeAlarms(overrides: Partial = {}) { + const scheduleCalls: AlarmScheduleRequest[] = []; + const cancelCalls: (string | null)[] = []; + let nextId = 0; + + const base: AlarmSchedulerPort = { + schedule: jest.fn(async (request: AlarmScheduleRequest): Promise => { + scheduleCalls.push(request); + nextId += 1; + return { alarm_id: `alarm-${nextId}`, schedule_id: request.schedule_id, scheduled: true }; + }), + cancel: jest.fn(async (alarmId: string | null) => { + cancelCalls.push(alarmId); + return { cancelled: true }; + }), + rebuild: jest.fn(async (requests: readonly AlarmScheduleRequest[]) => + Promise.all(requests.map((request) => base.schedule(request))), + ), + peekNativeDispositions: jest.fn(async () => []), + ackNativeDispositions: jest.fn(async () => {}), + ...overrides, + }; + + return { alarms: base, cancelCalls, scheduleCalls }; +} + +function createDeps( + overrides: Partial = {}, +): ReminderApplicationDependencies { + const { alarms } = createFakeAlarms(); + return { + alarms, + audio: { + isTtsAvailable: jest.fn(async () => false), + playLocalFallback: jest.fn(async () => ({ + playback_id: 'p1', + played: false, + used_local_fallback: true, + })), + playTts: jest.fn(async () => ({ + playback_id: 'p1', + played: false, + used_local_fallback: false, + })), + stop: jest.fn(async () => {}), + }, + delivery: { + deliver: jest.fn(async (request: ReminderDeliveryRequest) => ({ + delivery_id: `delivery-${request.schedule_id}`, + schedule_id: request.schedule_id, + delivered_at: request.trigger.triggered_at, + channels: [], + used_fallback_audio: false, + })), + dismiss: jest.fn(async () => {}), + }, + device: { + getStatus: jest.fn(async () => ({ + platform: 'android' as const, + supported: true, + permissions: { + notifications: true, + exact_alarm: true, + overlay: true, + full_screen: true, + battery_optimization: true, + location_foreground: true, + location_background: true, + }, + background_execution: true, + })), + onAppActive: jest.fn(() => () => {}), + openSettings: jest.fn(async () => true), + requestPermission: jest.fn(async () => true), + }, + dispositionSync: { + submitConfirmed: jest.fn(async (disposition: ReminderConfirmedDisposition) => ({ + schedule_id: disposition.schedule_id, + accepted: true, + })), + }, + location: { + getLastSample: jest.fn(async () => null), + rebuild: jest.fn(async () => []), + unwatch: jest.fn(async () => {}), + watch: jest.fn(async (request: LocationWatchRequest) => ({ + listener_id: `loc-${request.schedule_id}`, + schedule_id: request.schedule_id, + })), + }, + popup: { + dismiss: jest.fn(async () => {}), + show: jest.fn(async (request: PopupRequest) => ({ + popup_id: request.popup_id, + visible: true, + })), + }, + presenter: { + hide: jest.fn(async () => {}), + onAction: jest.fn(() => () => {}), + show: jest.fn(async () => ({ presentation_id: 'p1', visible: true })), + }, + recovery: { + registerForRestart: jest.fn(async () => ({ registered: true, recovery_id: 'r1' })), + restoreAfterRestart: jest.fn(async () => ({ registered: true, recovery_id: 'r1' })), + }, + schedules: new FakeScheduleReader([]), + state: new MemoryReminderStateStore(), + systemNotification: { + cancel: jest.fn(async () => {}), + show: jest.fn(async (request: SystemNotificationRequest) => ({ + notification_id: request.notification_id, + shown: true, + })), + }, + time: { + start: jest.fn(async () => ({ listener_id: 'time-1' })), + stop: jest.fn(async () => {}), + }, + vibration: { + stop: jest.fn(async () => {}), + vibrate: jest.fn(async () => {}), + }, + ...overrides, + }; +} + +describe('LocalReminderApplication', () => { + describe('cold start: peek/ack native dispositions', () => { + it('acks the native buffer only after the whole batch of rows persists successfully', async () => { + const disposition: AlarmNativeDisposition = { + schedule_id: 's1', + alarm_id: 'alarm-1', + state: 'confirmed', + updated_at: '2026-08-18T09:30:00.000Z', + }; + const { alarms } = createFakeAlarms({ + peekNativeDispositions: jest.fn(async () => [disposition]), + }); + const deps = createDeps({ alarms }); + const app = new LocalReminderApplication(deps); + + await app.start(); + + expect(alarms.peekNativeDispositions).toHaveBeenCalledTimes(1); + expect(alarms.ackNativeDispositions).toHaveBeenCalledWith(['s1']); + const ackOrder = (alarms.ackNativeDispositions as jest.Mock).mock.invocationCallOrder[0]; + const submitOrder = (deps.dispositionSync.submitConfirmed as jest.Mock).mock + .invocationCallOrder[0]; + expect(ackOrder).toBeGreaterThan(submitOrder as number); + }); + + it('does not ack anything when persisting one row in the batch fails', async () => { + const rows: AlarmNativeDisposition[] = [ + { + schedule_id: 's1', + alarm_id: 'alarm-1', + state: 'confirmed', + updated_at: '2026-08-18T09:00:00.000Z', + }, + { + schedule_id: 's2', + alarm_id: 'alarm-2', + state: 'confirmed', + updated_at: '2026-08-18T09:01:00.000Z', + }, + ]; + const { alarms } = createFakeAlarms({ peekNativeDispositions: jest.fn(async () => rows) }); + const deps = createDeps({ + alarms, + dispositionSync: { + submitConfirmed: jest.fn(async (disposition: ReminderConfirmedDisposition) => { + if (disposition.schedule_id === 's2') throw new Error('sync failed'); + return { schedule_id: disposition.schedule_id, accepted: true }; + }), + }, + }); + const app = new LocalReminderApplication(deps); + + await expect(app.start()).rejects.toThrow('sync failed'); + expect(alarms.ackNativeDispositions).not.toHaveBeenCalled(); + }); + + it('does nothing when the native buffer has no pending rows', async () => { + const { alarms } = createFakeAlarms({ peekNativeDispositions: jest.fn(async () => []) }); + const deps = createDeps({ alarms }); + const app = new LocalReminderApplication(deps); + + await app.start(); + + expect(alarms.ackNativeDispositions).not.toHaveBeenCalled(); + }); + }); + + describe('confirmed/snoozed/fired hydration', () => { + it('hydrates a confirmed row into a synced confirmed disposition', async () => { + const disposition: AlarmNativeDisposition = { + schedule_id: 's1', + alarm_id: 'alarm-1', + state: 'confirmed', + updated_at: '2026-08-18T09:30:00.000Z', + }; + const { alarms } = createFakeAlarms({ + peekNativeDispositions: jest.fn(async () => [disposition]), + }); + const deps = createDeps({ alarms }); + const app = new LocalReminderApplication(deps); + + await app.start(); + + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + }); + expect(deps.dispositionSync.submitConfirmed).toHaveBeenCalledWith( + expect.objectContaining({ schedule_id: 's1', state: 'confirmed' }), + ); + }); + + it('hydrates a snoozed row and reschedules the native alarm at snoozed_until', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const disposition: AlarmNativeDisposition = { + schedule_id: 's1', + alarm_id: 'alarm-1', + state: 'snoozed', + updated_at: '2026-08-18T09:30:00.000Z', + }; + const { alarms, scheduleCalls } = createFakeAlarms({ + peekNativeDispositions: jest.fn(async () => [disposition]), + }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + + await app.start(); + + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'snoozed', + }); + // hydrate 之后 startInternal() 还会跑一次完整 rebuild,两边都会按当前 + // snoozed_until 排闹钟,不只看第一次——只要最终排的时间点对就行。 + expect(scheduleCalls.length).toBeGreaterThan(0); + for (const call of scheduleCalls) { + expect(call.schedule_id).toBe('s1'); + } + }); + + it('hydrates a fired (pending) row without syncing yet, marking it native-presented', async () => { + const disposition: AlarmNativeDisposition = { + schedule_id: 's1', + alarm_id: 'alarm-1', + state: 'pending', + updated_at: '2026-08-18T09:30:00.000Z', + }; + const { alarms } = createFakeAlarms({ + peekNativeDispositions: jest.fn(async () => [disposition]), + }); + const deps = createDeps({ alarms }); + const app = new LocalReminderApplication(deps); + + await app.start(); + + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'pending', + next_trigger_at: null, + }); + expect(deps.dispositionSync.submitConfirmed).not.toHaveBeenCalled(); + }); + }); + + describe('native/JS dual-channel race', () => { + it('does not double-process a fired alarm that handleTime already claimed', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + // start() 自己的 rebuildInternal() 会把 reader 里这条 time 类型日程注册 + // 上原生闹钟,之后 popup/audio 就会因为"原生已经托管响铃 UI"被跳过—— + // 用不受这条影响的 vibration 通道来判断到底响了几次更直接。 + await app.start(); + + // deliver() 内部 runDeliver() 在第一个 await 之前就同步把 schedule_id 加进 + // deliverLocks/activeDeliveries,所以这里不等它,直接紧接着让 handleTime + // 也去处理同一条日程,模拟两条通道同时抢同一次触发。 + const started = app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + await app.handleTime({ observed_at: '2026-08-18T10:00:00.000Z' }); + const receipt = await started; + + expect(receipt.schedule_id).toBe('s1'); + // 只应该走一条通道:只响一次,不是两次连响。 + expect(deps.vibration.vibrate).toHaveBeenCalledTimes(1); + }); + + it('skips a schedule already marked pending/confirmed when a duplicate native fire arrives', async () => { + const schedule = fixtureSchedule({ + id: 's1', + runtime: { ...emptyRuntime(), reminder_disposition_state: 'confirmed' }, + }); + const disposition: AlarmNativeDisposition = { + schedule_id: 's1', + alarm_id: 'alarm-1', + state: 'pending', + updated_at: '2026-08-18T09:30:00.000Z', + }; + const { alarms } = createFakeAlarms({ + peekNativeDispositions: jest.fn(async () => [disposition]), + }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + await deps.state.write('s1', schedule.runtime); + const app = new LocalReminderApplication(deps); + + await app.start(); + + // 已经是 confirmed 的日程再收到一次 fired:不应该被改写成 pending。 + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'confirmed', + }); + }); + }); + + describe('reorder after confirm/postpone', () => { + it('confirm() cancels the native alarm and stops the schedule from being re-armed', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const { alarms, cancelCalls } = createFakeAlarms(); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + const registration = await app.register(schedule); + expect(registration.alarm_id).not.toBeNull(); + + await app.confirm('s1', '2026-08-18T10:05:00.000Z'); + + expect(cancelCalls).toContain(registration.alarm_id); + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + }); + }); + + it('snooze() cancels the current alarm and reschedules one at the new snoozed_until', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const { alarms, cancelCalls, scheduleCalls } = createFakeAlarms(); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + const registration = await app.register(schedule); + const firstAlarmId = registration.alarm_id; + scheduleCalls.length = 0; + + const result = await app.snooze({ + schedule_id: 's1', + snooze_until: '2026-08-18T10:20:00.000Z', + }); + + expect(result.accepted).toBe(true); + expect(cancelCalls).toContain(firstAlarmId); + expect(scheduleCalls).toHaveLength(1); + expect(scheduleCalls[0]).toMatchObject({ + schedule_id: 's1', + trigger_at: '2026-08-18T10:20:00.000Z', + }); + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'snoozed', + next_trigger_at: '2026-08-18T10:20:00.000Z', + }); + }); + }); + + describe('stop/restart', () => { + it('clears in-memory delivery/registration state on stop, then starts clean again', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + const registration = await app.register(schedule); + expect(registration.alarm_id).not.toBeNull(); + + await app.stop(); + expect(deps.time.stop).toHaveBeenCalledWith('time-1'); + + // 重启后引擎应该干净可用:重新注册同一条日程要能再拿到一个新闹钟。 + await app.start(); + const secondRegistration = await app.register(schedule); + expect(secondRegistration.alarm_id).not.toBeNull(); + }); + + it('a delivery still in flight when stop() is called does not resurrect state after teardown', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + await deps.state.write('s1', emptyRuntime()); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + + const delivering = app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + await app.stop(); + await delivering; + + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: null, + }); + }); + }); + + describe('recurring reminder advancement', () => { + it('arms the native alarm at the recurring schedule occurrence provided by the state layer', async () => { + const schedule = fixtureSchedule({ + id: 's1', + schedule_kind: 'recurring', + recurrence_rule: 'FREQ=DAILY', + runtime: { ...emptyRuntime(), next_trigger_at: '2026-08-18T10:00:00.000Z' }, + }); + const { alarms, scheduleCalls } = createFakeAlarms(); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + await deps.state.write('s1', schedule.runtime); + const app = new LocalReminderApplication(deps); + await app.start(); + + const registration = await app.register(schedule); + + expect(registration.alarm_id).not.toBeNull(); + expect(scheduleCalls[0]).toMatchObject({ + schedule_id: 's1', + trigger_at: '2026-08-18T10:00:00.000Z', + }); + }); + + it('re-arms at the next occurrence once the data layer advances next_trigger_at again', async () => { + // LocalReminderApplication 本身不算 RRULE:next_trigger_at 置空之后, + // 真正推到下一次发生时间是状态层(例如 SqliteReminderStateStore)的职责 + // ——这里模拟状态层已经算好了第二次 occurrence,验证引擎会正确接上、 + // 重新挂闹钟,而不是卡在第一次触发之后就不再调度。 + const schedule = fixtureSchedule({ + id: 's1', + schedule_kind: 'recurring', + recurrence_rule: 'FREQ=DAILY', + runtime: { ...emptyRuntime(), next_trigger_at: '2026-08-18T10:00:00.000Z' }, + }); + const { alarms, scheduleCalls } = createFakeAlarms(); + const reader = new FakeScheduleReader([schedule]); + const deps = createDeps({ alarms, schedules: reader }); + await deps.state.write('s1', schedule.runtime); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + expect(scheduleCalls[0]?.trigger_at).toBe('2026-08-18T10:00:00.000Z'); + + // 状态层算出了下一次 occurrence(模拟 confirmInternal 把光标置空之后, + // 数据层在下一次 read() 时补上新的 next_trigger_at)。 + await deps.state.write('s1', { + ...emptyRuntime(), + next_trigger_at: '2026-08-19T10:00:00.000Z', + }); + reader.schedules = [ + { + ...schedule, + runtime: { ...emptyRuntime(), next_trigger_at: '2026-08-19T10:00:00.000Z' }, + }, + ]; + + const registrations = await app.rebuild(); + + expect(registrations[0]?.alarm_id).not.toBeNull(); + const latestCall = scheduleCalls[scheduleCalls.length - 1]; + expect(latestCall).toMatchObject({ + schedule_id: 's1', + trigger_at: '2026-08-19T10:00:00.000Z', + }); + }); + }); + + describe('location triggering', () => { + function fixtureLocationSchedule( + overrides: Partial = {}, + ): LocalReminderSchedule { + return fixtureSchedule({ + schedule_type: 'location', + latitude: 31.2304, + longitude: 121.4737, + geofence_radius_meters: 100, + reminder: { + reminder_type: 'arrive_location', + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: 'medium', + }, + ...overrides, + }); + } + + it('arms the geofence once the sample leaves the zone, without delivering', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + + await app.handleLocation({ + latitude: 40, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }); + + await expect(deps.state.read('s1')).resolves.toMatchObject({ geofence_armed: true }); + expect(deps.presenter.show).not.toHaveBeenCalled(); + }); + + it('delivers once an armed geofence is re-entered, then disarms it', async () => { + const schedule = fixtureLocationSchedule({ + id: 's1', + runtime: { ...emptyRuntime(), geofence_armed: true }, + }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + await deps.state.write('s1', schedule.runtime); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + + await app.handleLocation({ + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }); + + expect(deps.presenter.show).toHaveBeenCalledTimes(1); + await expect(deps.state.read('s1')).resolves.toMatchObject({ + geofence_armed: false, + reminder_disposition_state: 'pending', + }); + }); + + it('does not re-deliver a still-armed geofence while the sample stays outside it', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + + const farSample = { + latitude: 40, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }; + await app.handleLocation(farSample); + await app.handleLocation(farSample); + + expect(deps.presenter.show).not.toHaveBeenCalled(); + }); + }); + + describe('delivery channels by strength', () => { + it('low strength: system notification only, no popup/vibration/audio', async () => { + const schedule = fixtureSchedule({ + id: 's1', + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: '2026-08-18T10:00:00.000Z', + reminder_offset_minutes: null, + reminder_strength: 'low', + }, + }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + const receipt = await app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(receipt.channels).toEqual(['system_notification']); + expect(deps.delivery.deliver).toHaveBeenCalledTimes(1); + expect(deps.systemNotification.show).toHaveBeenCalledTimes(1); + expect(deps.presenter.show).not.toHaveBeenCalled(); + expect(deps.vibration.vibrate).not.toHaveBeenCalled(); + expect(deps.audio.playTts).not.toHaveBeenCalled(); + }); + + it('high strength: popup + vibration + tts, falls back to local audio when tts fails', async () => { + // 用 location 类型:time 类型经过 start() 内部 rebuild 会自动挂上原生闹钟, + // 一旦 nativeAlarmOwnsRingUi 为真,popup/audio 都会被跳过——这里就是要 + // 验证这两个通道本身,用不会触发这条豁免的日程类型。 + const schedule = fixtureSchedule({ + id: 's1', + schedule_type: 'location', + latitude: 31.2304, + longitude: 121.4737, + reminder: { + reminder_type: 'arrive_location', + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: 'high', + }, + }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + // 默认 fake 的 playLocalFallback 也返回 played:false("什么都没真的响"这个 + // 中性默认对其它测试没影响);这里要验证兜底音真的放出来了,单独覆盖一下。 + deps.audio.playLocalFallback = jest.fn(async () => ({ + playback_id: 'p1', + played: true, + used_local_fallback: true, + })); + const app = new LocalReminderApplication(deps); + await app.start(); + + const receipt = await app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(receipt.used_fallback_audio).toBe(true); + expect(receipt.channels).toEqual(['popup', 'vibration', 'local_sound']); + expect(deps.audio.playTts).toHaveBeenCalledTimes(1); + expect(deps.audio.playLocalFallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('native alarm events (dismissed/snoozed) routed through subscribe', () => { + it('routes a native "snoozed" event to snooze() and "dismissed" to confirm()', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const listenerRef: { current: ((event: AlarmNativeEvent) => void) | null } = { + current: null, + }; + const { alarms } = createFakeAlarms({ + subscribe: jest.fn((listener: (event: AlarmNativeEvent) => void) => { + listenerRef.current = listener; + return () => { + listenerRef.current = null; + }; + }), + }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + expect(listenerRef.current).not.toBeNull(); + + listenerRef.current?.({ + type: 'snoozed', + schedule_id: 's1', + alarm_id: 'alarm-1', + title: schedule.title, + at: '2026-08-18T10:00:00.000Z', + }); + await flushAsync(); + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'snoozed', + }); + + listenerRef.current?.({ + type: 'dismissed', + schedule_id: 's1', + alarm_id: 'alarm-1', + title: schedule.title, + at: '2026-08-18T10:05:00.000Z', + }); + await flushAsync(); + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'confirmed', + }); + + await app.stop(); + expect(listenerRef.current).toBeNull(); + }); + }); +}); diff --git a/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts b/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts new file mode 100644 index 00000000..65007036 --- /dev/null +++ b/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from '@jest/globals'; + +import { resolveStrengthDeliveryPlan } from '../../../../../src/features/reminder/domain/strengthDelivery'; + +describe('resolveStrengthDeliveryPlan', () => { + it('low: system notification only', () => { + expect(resolveStrengthDeliveryPlan('low')).toEqual({ + useSystemNotification: true, + usePopup: false, + useVibration: false, + useAudio: false, + }); + }); + + it('medium: popup + vibration, no audio', () => { + expect(resolveStrengthDeliveryPlan('medium')).toEqual({ + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: false, + }); + }); + + it('high: popup + vibration + audio', () => { + expect(resolveStrengthDeliveryPlan('high')).toEqual({ + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: true, + }); + }); +}); diff --git a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts index 831b5244..a7291319 100644 --- a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts +++ b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts @@ -61,6 +61,7 @@ function createDevice( return true; }), openSettings: jest.fn(async () => true), + onAppActive: jest.fn(() => () => {}), }; return device; } diff --git a/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts b/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts new file mode 100644 index 00000000..42d2a3f8 --- /dev/null +++ b/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts @@ -0,0 +1,64 @@ +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); + }); +}); diff --git a/frontend/tests/unit/infrastructure/notifications/mockDeviceCapability.test.ts b/frontend/tests/unit/infrastructure/notifications/mockDeviceCapability.test.ts new file mode 100644 index 00000000..b0f0b95a --- /dev/null +++ b/frontend/tests/unit/infrastructure/notifications/mockDeviceCapability.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from '@jest/globals'; + +import { MockDeviceCapability } from '../../../../src/infrastructure/notifications/MockDeviceCapability'; + +describe('MockDeviceCapability', () => { + it('reports the fixed mock status', async () => { + const device = new MockDeviceCapability(); + await expect(device.getStatus()).resolves.toEqual({ + platform: 'android', + supported: true, + permissions: { + notifications: true, + exact_alarm: true, + overlay: false, + full_screen: true, + battery_optimization: false, + location_foreground: true, + location_background: false, + }, + background_execution: false, + }); + }); + + it('resolves requestPermission with the fixed value for that permission', async () => { + const device = new MockDeviceCapability(); + await expect(device.requestPermission('overlay')).resolves.toBe(false); + await expect(device.requestPermission('notifications')).resolves.toBe(true); + }); + + it('resolves openSettings as successful', async () => { + const device = new MockDeviceCapability(); + await expect(device.openSettings('overlay')).resolves.toBe(true); + }); + + it('onAppActive is a no-op that returns an unsubscribe function', () => { + const device = new MockDeviceCapability(); + const unsubscribe = device.onAppActive(() => { + throw new Error('should never be called'); + }); + expect(() => unsubscribe()).not.toThrow(); + }); +}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts index a7860009..e14c96ea 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { Platform } from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { AppState, Platform } from 'react-native'; import { NativeDeviceCapability } from '../../../../src/infrastructure/notifications/NativeDeviceCapability'; import { @@ -144,4 +144,44 @@ describe('NativeDeviceCapability', () => { 'app', ]); }); + + describe('onAppActive', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('calls the listener only when AppState transitions to active', () => { + const remove = jest.fn(); + const addEventListener = jest + .spyOn(AppState, 'addEventListener') + .mockReturnValue({ remove } as unknown as ReturnType); + + const device = new NativeDeviceCapability(); + const listener = jest.fn(); + device.onAppActive(listener); + + expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + const handler = addEventListener.mock.calls[0][1] as (state: string) => void; + + handler('background'); + expect(listener).not.toHaveBeenCalled(); + + handler('active'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('removes the underlying subscription when the returned unsubscribe is called', () => { + const remove = jest.fn(); + jest + .spyOn(AppState, 'addEventListener') + .mockReturnValue({ remove } as unknown as ReturnType); + + const device = new NativeDeviceCapability(); + const unsubscribe = device.onAppActive(jest.fn()); + + expect(remove).not.toHaveBeenCalled(); + unsubscribe(); + expect(remove).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/frontend/tests/unit/infrastructure/time/mockTimeListener.test.ts b/frontend/tests/unit/infrastructure/time/mockTimeListener.test.ts new file mode 100644 index 00000000..1304126f --- /dev/null +++ b/frontend/tests/unit/infrastructure/time/mockTimeListener.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import { MockTimeListener } from '../../../../src/infrastructure/time/MockTimeListener'; + +describe('MockTimeListener', () => { + it('resolves start() with a handle carrying a listener_id, never firing the listener', async () => { + const listener = jest.fn(); + const time = new MockTimeListener(); + const handle = await time.start(listener); + expect(handle.listener_id).toEqual(expect.any(String)); + expect(listener).not.toHaveBeenCalled(); + }); + + it('hands out a distinct listener_id per start() call', async () => { + const time = new MockTimeListener(); + const first = await time.start(() => {}); + const second = await time.start(() => {}); + expect(first.listener_id).not.toBe(second.listener_id); + }); + + it('stop() resolves without needing a matching start()', async () => { + const time = new MockTimeListener(); + await expect(time.stop('unknown-listener')).resolves.toBeUndefined(); + }); +});