From 5ebd50447a6ab9c1b39b52647a8ea9f9eb6b66ca Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 08:08:58 +0800 Subject: [PATCH 1/7] feat(reminder): add reminder engine core (domain + application) LocalReminderApplication and its ports (AlarmSchedulerPort, LocationMonitorPort, DeviceCapabilityPort, NotificationChannels, ReminderDeliveryPort, ReminderApplicationPort), plus the domain layer (reminder.ts, strengthDelivery.ts) that drives arm/fire/confirm state transitions and recurring-schedule advancement. Pure logic layer: no device-specific adapters yet, nothing wired into the app. shared/time gains format.ts (used by the delivery strength calc); MockClock/MockTimeListener are removed since nothing in this stack still needs a fake clock once the real engine lands. --- .../application/LocalReminderApplication.ts | 192 +++++++++++++++--- .../features/reminder/application/index.ts | 8 + .../interfaces/AlarmSchedulerPort.ts | 21 ++ .../interfaces/DeviceCapabilityPort.ts | 2 + .../interfaces/LocationMonitorPort.ts | 16 +- .../interfaces/NotificationChannels.ts | 20 ++ .../interfaces/ReminderApplicationPort.ts | 20 +- .../interfaces/ReminderDeliveryPort.ts | 2 + .../reminder/application/interfaces/index.ts | 12 +- .../src/features/reminder/domain/index.ts | 2 + .../src/features/reminder/domain/reminder.ts | 7 +- .../reminder/domain/strengthDelivery.ts | 35 ++++ frontend/src/shared/time/MockClock.ts | 12 -- frontend/src/shared/time/MockTimeListener.ts | 20 -- frontend/src/shared/time/format.ts | 19 ++ frontend/src/shared/time/index.ts | 4 +- 16 files changed, 304 insertions(+), 88 deletions(-) create mode 100644 frontend/src/features/reminder/domain/strengthDelivery.ts delete mode 100644 frontend/src/shared/time/MockClock.ts delete mode 100644 frontend/src/shared/time/MockTimeListener.ts create mode 100644 frontend/src/shared/time/format.ts diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index e8904c8a..0de4509f 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(); + 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,83 @@ 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 { + 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', + }); + } + + private async hydrateNativeDispositions(): Promise { + const rows = await this.dependencies.alarms.consumeNativeDispositions?.(); + if (rows == null || rows.length === 0) return; + + for (const row of rows) { + if (row.state === 'confirmed') { + await this.confirm(row.schedule_id, row.updated_at); + continue; + } + if (row.state === 'snoozed') { + await this.snooze({ schedule_id: row.schedule_id, snooze_until: null }); + continue; + } + await this.acknowledgeNativeFire(row.schedule_id, row.updated_at); + } + } + + 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..b7bb86fc 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -12,9 +12,30 @@ 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; + /** 拉取并清空进程外写入的处置状态(冷启动补水)。 */ + consumeNativeDispositions?(): 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/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/MockTimeListener.ts b/frontend/src/shared/time/MockTimeListener.ts deleted file mode 100644 index e77e36ec..00000000 --- a/frontend/src/shared/time/MockTimeListener.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - LocalTimeTick, - TimeListenerHandle, - TimeListenerOptions, - TimeListenerPort, -} from '../../features/reminder/application/interfaces'; - -/** 固定时间监听边界,替换前不会发出平台事件。 */ -export class MockTimeListener implements TimeListenerPort { - async start( - _listener: (tick: LocalTimeTick) => void, - _options?: TimeListenerOptions, - ): Promise { - return { listener_id: 'mock-time-listener-001' }; - } - - async stop(_listenerId: string): Promise { - return Promise.resolve(); - } -} 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'; From 7868ab1992a1d39f55fae9a03ed641395e3fab05 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 15:47:17 +0800 Subject: [PATCH 2/7] fix(reminder): close native/JS double-fire race in acknowledgeNativeFire Code review (PR #266): acknowledgeNativeFire() only checked the persisted disposition_state (confirmed/pending) before proceeding, not the in-memory activeDeliveries/deliverLocks sets. Those sets are exactly what canDeliver() checks on the JS-driven handleTime path to skip a schedule the native alarm already owns -- but the guard only worked in that one direction. If handleTime was already mid-flight for a schedule (added to activeDeliveries, not yet persisted any disposition change) at the moment the native alarm for the same schedule fired, acknowledgeNativeFire would fall through and the two channels could both run -- the exact double-fire the surrounding comment says this mechanism eliminates. Added the activeDeliveries check as an additional early-return, matching canDeliver()'s guard. --- .../reminder/application/LocalReminderApplication.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index 0de4509f..b02de1c3 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -728,6 +728,13 @@ export class LocalReminderApplication implements ReminderApplicationPort { * 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' || From a057e5e8da3d486eac6d8fbdc277ce9407c8856b Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 18 Aug 2026 17:08:48 +0800 Subject: [PATCH 3/7] fix(reminder): peek/ack native dispositions, close CI gap after rebase Two changes on top of the rebase onto upstream/main's native alarm module (#264/#265): - AlarmSchedulerPort.consumeNativeDispositions (single-phase, clears the native buffer on read) is now peekNativeDispositions + ackNativeDispositions (two-phase), matching the split #265 already made on the native side. hydrateNativeDispositions() now acks only after the whole batch persists successfully, so a mid-batch failure leaves the un-acked rows in the native buffer for retry instead of losing them. - This PR's own interface changes (DeviceCapabilityPort.onAppActive, LocationMonitorPort.rebuild's new LocationRebuildTarget param, dropping MockTimeListener from shared/time) were never propagated to their implementers, so `npm run check` failed standalone -- expected for a mid-stack PR per its own description, but not acceptable for merging. Added the minimal implementations needed to close the gap: onAppActive on Native/MockDeviceCapability, MockLocationMonitor.rebuild's signature, and a new infrastructure/time/MockTimeListener placeholder. #269 replaces all of these with real adapters; this just keeps #266 green on its own. Co-Authored-By: Claude Sonnet 5 --- .../src/app/composition/createAppServices.ts | 2 +- .../application/LocalReminderApplication.ts | 6 ++++- .../interfaces/AlarmSchedulerPort.ts | 6 +++-- .../location/MockLocationMonitor.ts | 15 +++++------- .../notifications/MockDeviceCapability.ts | 4 ++++ .../notifications/NativeDeviceCapability.ts | 9 +++++++- .../infrastructure/time/MockTimeListener.ts | 23 +++++++++++++++++++ frontend/src/infrastructure/time/index.ts | 1 + .../useReminderPermissionsOnLaunch.test.ts | 1 + 9 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 frontend/src/infrastructure/time/MockTimeListener.ts create mode 100644 frontend/src/infrastructure/time/index.ts 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 b02de1c3..44096496 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -765,7 +765,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { } private async hydrateNativeDispositions(): Promise { - const rows = await this.dependencies.alarms.consumeNativeDispositions?.(); + const rows = await this.dependencies.alarms.peekNativeDispositions?.(); if (rows == null || rows.length === 0) return; for (const row of rows) { @@ -779,6 +779,10 @@ export class LocalReminderApplication implements ReminderApplicationPort { } 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 { diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index b7bb86fc..ceb20d4b 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -36,6 +36,8 @@ export interface AlarmSchedulerPort { stopRinging?(): Promise; /** 订阅原生响铃/停铃事件;无原生桥时可不实现。 */ subscribe?(listener: (event: AlarmNativeEvent) => void): () => void; - /** 拉取并清空进程外写入的处置状态(冷启动补水)。 */ - consumeNativeDispositions?(): Promise; + /** 只读取进程外写入的处置状态(冷启动补水),不清空原生缓冲区。 */ + peekNativeDispositions?(): Promise; + /** 确认对应 schedule_id 已经在 JS 侧落盘成功,原生缓冲区才真正删除这批记录。 */ + ackNativeDispositions?(scheduleIds: readonly string[]): Promise; } 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/infrastructure/time/MockTimeListener.ts b/frontend/src/infrastructure/time/MockTimeListener.ts new file mode 100644 index 00000000..b2a97fbe --- /dev/null +++ b/frontend/src/infrastructure/time/MockTimeListener.ts @@ -0,0 +1,23 @@ +import type { + LocalTimeTick, + TimeListenerHandle, + TimeListenerOptions, + TimeListenerPort, +} from '../../features/reminder/application/interfaces'; + +let nextListenerId = 0; + +/** 不产生任何 tick 的固定实现——真实的周期计时器接入前占位用。 */ +export class MockTimeListener implements TimeListenerPort { + async start( + _listener: (tick: LocalTimeTick) => void, + _options?: TimeListenerOptions, + ): Promise { + nextListenerId += 1; + return { listener_id: `mock-time-listener-${nextListenerId}` }; + } + + async stop(_listenerId: string): Promise { + return Promise.resolve(); + } +} 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/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; } From ba17aa9dc23e89d9ef7856fac6dfdab455b89f33 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 18 Aug 2026 17:31:31 +0800 Subject: [PATCH 4/7] fix(reminder): break cold-start deadlock in hydrateNativeDispositions, add adapter tests hydrateNativeDispositions() runs inside startInternal(), which is itself the task currently occupying opChain. It was calling the public confirm()/snooze() wrappers, which re-enqueue onto that same opChain -- but that chain can't advance until startInternal() (the caller) returns, and startInternal() won't return until those re-enqueued calls resolve. Any cold start with a confirmed/snoozed row sitting in the native disposition buffer deadlocked permanently. Fixed by calling confirmInternal()/snoozeInternal() directly, since hydrateNativeDispositions() is already running as the sole active opChain task and doesn't need to re-enqueue. Also closes the Codecov patch-coverage gap on the adapter changes from the previous commit: onAppActive on Native/MockDeviceCapability, and the MockLocationMonitor/MockTimeListener mocks. Co-Authored-By: Claude Sonnet 5 --- .../application/LocalReminderApplication.ts | 15 +++-- .../location/mockLocationMonitor.test.ts | 64 +++++++++++++++++++ .../mockDeviceCapability.test.ts | 42 ++++++++++++ .../nativeDeviceCapability.test.ts | 44 ++++++++++++- .../time/mockTimeListener.test.ts | 25 ++++++++ 5 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts create mode 100644 frontend/tests/unit/infrastructure/notifications/mockDeviceCapability.test.ts create mode 100644 frontend/tests/unit/infrastructure/time/mockTimeListener.test.ts diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index 44096496..56b3b449 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -185,7 +185,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { return; } - await this.hydrateNativeDispositions(); + await this.hydrateNativeDispositions(generation); if (!this.isLive(generation)) { await this.stopInternal(); return; @@ -764,17 +764,24 @@ export class LocalReminderApplication implements ReminderApplicationPort { }); } - private async hydrateNativeDispositions(): Promise { + /** + * 只能在 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.confirm(row.schedule_id, row.updated_at); + await this.confirmInternal(row.schedule_id, row.updated_at, generation); continue; } if (row.state === 'snoozed') { - await this.snooze({ schedule_id: row.schedule_id, snooze_until: null }); + await this.snoozeInternal({ schedule_id: row.schedule_id, snooze_until: null }, generation); continue; } await this.acknowledgeNativeFire(row.schedule_id, row.updated_at); 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(); + }); +}); From 7dbcf50ab8cc1da5e19857b357a769061024fe70 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 18 Aug 2026 22:23:16 +0800 Subject: [PATCH 5/7] test(reminder): add application-layer coverage for LocalReminderApplication Covers what review flagged as missing: cold-start peek/ack (batch-level ack only after every row persists, none on partial failure), hydration for each native disposition state (confirmed/snoozed/pending), the native/JS dual-channel race guard, alarm rescheduling after confirm/snooze, stop/ restart cleanup, and recurring-reminder re-arming across two occurrences (demonstrating the next_trigger_at signal / state-layer-computes-the-next- occurrence split with actual test evidence, not just an explanation). Co-Authored-By: Claude Sonnet 5 --- .../LocalReminderApplication.test.ts | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts 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..9d85baa7 --- /dev/null +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -0,0 +1,565 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import { LocalReminderApplication } from '../../../../../src/features/reminder/application/LocalReminderApplication'; +import type { + AlarmNativeDisposition, + 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'; + +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', + }); + }); + }); +}); From ffe7207c82a7bc7d08c1d27cfaa596ab5e02e867 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 09:37:58 +0800 Subject: [PATCH 6/7] test(reminder): cover location triggering and strength delivery plans Codecov flagged patch coverage at 66% after the application-layer test commit -- LocalReminderApplication.ts's location-schedule path (arm on leave, deliver on re-entry, no re-fire while still armed) and strengthDelivery.ts's low/high branches had zero coverage. Both were only exercised indirectly (or not at all) by the existing tests, which focused on time-triggered schedules. Co-Authored-By: Claude Sonnet 5 --- .../LocalReminderApplication.test.ts | 82 +++++++++++++++++++ .../reminder/domain/strengthDelivery.test.ts | 32 ++++++++ 2 files changed, 114 insertions(+) create mode 100644 frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index 9d85baa7..9ed6ffeb 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -562,4 +562,86 @@ describe('LocalReminderApplication', () => { }); }); }); + + 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(); + }); + }); }); 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, + }); + }); +}); From 55937d560773dac354e0bb1af39e947d34ed3c1a Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 09:55:09 +0800 Subject: [PATCH 7/7] test(reminder): cover delivery channels by strength and native alarm event routing Codecov target for this PR's patch is 91.16%, was at 76.78%. Adds: - low strength delivery: system notification only, no popup/vibration/audio - high strength delivery: popup + vibration + tts, falls back to local audio when tts fails - native "snoozed"/"dismissed" events routed through alarms.subscribe() to snooze()/confirm(), including unsubscribe on stop() --- .../LocalReminderApplication.test.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index 9ed6ffeb..9df83613 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, jest } from '@jest/globals'; import { LocalReminderApplication } from '../../../../../src/features/reminder/application/LocalReminderApplication'; import type { AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -19,6 +20,15 @@ import type { 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, @@ -644,4 +654,123 @@ describe('LocalReminderApplication', () => { 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(); + }); + }); });