From 8892a3e89538969303e6628c6efb90e2493948e8 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 10:01:10 +0800 Subject: [PATCH 1/7] feat(reminder): add audio playback and interval time adapters Part of #263. ExpoAudioPlayback (implements ReminderDeliveryPort's audio side) + audioDataUri helper, and IntervalTimeListener (implements the time port with a plain setInterval). Both only import from application interfaces already on main -- no dependency on the other adapter PRs in this stack (notifications/location/data layer), can be reviewed and merged independently of them. Removes MockAudioPlayback. --- .../src/app/composition/createAppServices.ts | 4 +- .../infrastructure/audio/ExpoAudioPlayback.ts | 147 ++++++++++++++++++ .../infrastructure/audio/MockAudioPlayback.ts | 32 ---- .../src/infrastructure/audio/audioDataUri.ts | 39 +++++ frontend/src/infrastructure/audio/index.ts | 3 +- .../time/IntervalTimeListener.ts | 38 +++++ frontend/src/infrastructure/time/index.ts | 1 + frontend/src/types/expo-audio.d.ts | 16 ++ 8 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 frontend/src/infrastructure/audio/ExpoAudioPlayback.ts delete mode 100644 frontend/src/infrastructure/audio/MockAudioPlayback.ts create mode 100644 frontend/src/infrastructure/audio/audioDataUri.ts create mode 100644 frontend/src/infrastructure/time/IntervalTimeListener.ts create mode 100644 frontend/src/types/expo-audio.d.ts diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 2aefb22a..e43f41f1 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -10,7 +10,7 @@ import { MockReminderDispositionSync, MockReminderStateStore, } from '../../features/reminder/data/local'; -import { MockAudioPlayback } from '../../infrastructure/audio'; +import { ExpoAudioPlayback } from '../../infrastructure/audio'; import { MockLocationMonitor } from '../../infrastructure/location'; import { MockPopup, @@ -48,7 +48,7 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe location: new MockLocationMonitor(), alarms: new NativeAlarmScheduler(), delivery: new MockReminderDelivery(), - audio: new MockAudioPlayback(), + audio: new ExpoAudioPlayback(), device: new NativeDeviceCapability(), presenter: new MockReminderPresenter(), systemNotification: new MockSystemNotification(), diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts new file mode 100644 index 00000000..548922e2 --- /dev/null +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -0,0 +1,147 @@ +import type { + AudioPlaybackPort, + AudioPlaybackReceipt, + AudioPlaybackRequest, +} from '../../features/reminder/application/interfaces'; +import { buildAudioDataUri } from './audioDataUri'; + +// Metro 资源 id;高强度本地兜底铃声(与原生 AlarmSoundService 同源)。 +const LOCAL_ALARM_SOUND = require('../../../assets/sounds/alarm_prompt.mp3') as number; + +type AudioPlayerLike = { + pause: () => void; + replace: (source: string | number) => void; + play: () => void; + volume: number; + loop: boolean; +}; + +type ExpoAudioModule = { + createAudioPlayer: (source?: string | number | null) => AudioPlayerLike; + setAudioModeAsync: (mode: Record) => Promise; +}; + +/** + * 音频播放适配器: + * - TTS 有字节时播放并循环,直到 stop + * - 否则播放打包的 alarm_prompt.mp3(高强度本地兜底) + */ +export class ExpoAudioPlayback implements AudioPlaybackPort { + private player: AudioPlayerLike | null = null; + private activeScheduleId: string | null = null; + private modeReady: Promise | null = null; + + async isTtsAvailable(): Promise { + // TTS 字节管线尚未接入;有 data 时由 playTts 直接播放。 + return false; + } + + async playTts(request: AudioPlaybackRequest): Promise { + if (request.data == null || request.data.byteLength === 0) { + return { + playback_id: `tts-empty-${request.schedule_id}`, + played: false, + used_local_fallback: false, + }; + } + const played = await this.playBytes(request.schedule_id, request.data, request.format ?? 'wav'); + return { + playback_id: `tts-${request.schedule_id}`, + played, + used_local_fallback: false, + }; + } + + async playLocalFallback(request: AudioPlaybackRequest): Promise { + if (request.data != null && request.data.byteLength > 0) { + const played = await this.playBytes( + request.schedule_id, + request.data, + request.format ?? 'wav', + ); + return { + playback_id: `local-${request.schedule_id}`, + played, + used_local_fallback: true, + }; + } + + const played = await this.playBundledAlarm(request.schedule_id); + return { + playback_id: `local-bundled-${request.schedule_id}`, + played, + used_local_fallback: true, + }; + } + + async stop(scheduleId: string): Promise { + if (this.activeScheduleId !== scheduleId) return; + if (this.player != null) { + this.player.loop = false; + this.player.pause(); + } + this.activeScheduleId = null; + } + + private async playBytes(scheduleId: string, data: Uint8Array, format: string): Promise { + const expoAudio = await loadExpoAudio(); + if (expoAudio == null) return false; + + await this.ensureAudioMode(expoAudio); + const player = this.ensurePlayer(expoAudio); + player.pause(); + player.replace(buildAudioDataUri(data, format)); + player.loop = true; + player.volume = 1; + this.activeScheduleId = scheduleId; + player.play(); + return true; + } + + private async playBundledAlarm(scheduleId: string): Promise { + const expoAudio = await loadExpoAudio(); + if (expoAudio == null) return false; + + await this.ensureAudioMode(expoAudio); + const player = this.ensurePlayer(expoAudio); + player.pause(); + player.replace(LOCAL_ALARM_SOUND); + player.loop = true; + player.volume = 1; + this.activeScheduleId = scheduleId; + player.play(); + return true; + } + + private ensurePlayer(expoAudio: ExpoAudioModule): AudioPlayerLike { + if (this.player == null) { + this.player = expoAudio.createAudioPlayer(null); + } + return this.player; + } + + private async ensureAudioMode(expoAudio: ExpoAudioModule): Promise { + if (this.modeReady == null) { + this.modeReady = expoAudio + .setAudioModeAsync({ + allowsRecording: false, + interruptionMode: 'doNotMix', + playsInSilentMode: true, + shouldPlayInBackground: true, + shouldRouteThroughEarpiece: false, + }) + .catch(() => undefined); + } + await this.modeReady; + } +} + +async function loadExpoAudio(): Promise { + try { + const mod = await import('expo-audio'); + if (typeof mod.createAudioPlayer !== 'function') return null; + return mod as unknown as ExpoAudioModule; + } catch { + return null; + } +} diff --git a/frontend/src/infrastructure/audio/MockAudioPlayback.ts b/frontend/src/infrastructure/audio/MockAudioPlayback.ts deleted file mode 100644 index f59131bc..00000000 --- a/frontend/src/infrastructure/audio/MockAudioPlayback.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - AudioPlaybackPort, - AudioPlaybackReceipt, - AudioPlaybackRequest, -} from '../../features/reminder/application/interfaces'; - -/** 固定音频能力:远程语音合成不可用,本地兜底音效可用。 */ -export class MockAudioPlayback implements AudioPlaybackPort { - async isTtsAvailable(): Promise { - return false; - } - - async playTts(_request: AudioPlaybackRequest): Promise { - return { - playback_id: 'mock-tts-playback-001', - played: false, - used_local_fallback: false, - }; - } - - async playLocalFallback(_request: AudioPlaybackRequest): Promise { - return { - playback_id: 'mock-local-sound-001', - played: true, - used_local_fallback: true, - }; - } - - async stop(_scheduleId: string): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/infrastructure/audio/audioDataUri.ts b/frontend/src/infrastructure/audio/audioDataUri.ts new file mode 100644 index 00000000..94976c5d --- /dev/null +++ b/frontend/src/infrastructure/audio/audioDataUri.ts @@ -0,0 +1,39 @@ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const MIME_BY_FORMAT: Record = { + aac: 'audio/aac', + m4a: 'audio/mp4', + mp3: 'audio/mpeg', + mpeg: 'audio/mpeg', + oga: 'audio/ogg', + ogg: 'audio/ogg', + wav: 'audio/wav', + wave: 'audio/wav', +}; + +export function encodeBase64(bytes: Uint8Array): string { + let output = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const hasSecond = index + 1 < bytes.length; + const hasThird = index + 2 < bytes.length; + const second = hasSecond ? bytes[index + 1]! : 0; + const third = hasThird ? bytes[index + 2]! : 0; + const value = (first << 16) | (second << 8) | third; + + output += BASE64_ALPHABET[(value >>> 18) & 63]; + output += BASE64_ALPHABET[(value >>> 12) & 63]; + output += hasSecond ? BASE64_ALPHABET[(value >>> 6) & 63] : '='; + output += hasThird ? BASE64_ALPHABET[value & 63] : '='; + } + return output; +} + +export function buildAudioDataUri(bytes: Uint8Array, audioFormat: string): string { + const normalizedFormat = audioFormat.trim().toLowerCase().replace(/^\./, ''); + if (!/^[a-z0-9][a-z0-9.+-]{0,31}$/.test(normalizedFormat)) { + throw new Error('Unsupported reminder audio format'); + } + const mime = MIME_BY_FORMAT[normalizedFormat] ?? `audio/${normalizedFormat}`; + return `data:${mime};base64,${encodeBase64(bytes)}`; +} diff --git a/frontend/src/infrastructure/audio/index.ts b/frontend/src/infrastructure/audio/index.ts index 8cc9460f..48529eb5 100644 --- a/frontend/src/infrastructure/audio/index.ts +++ b/frontend/src/infrastructure/audio/index.ts @@ -1 +1,2 @@ -export { MockAudioPlayback } from './MockAudioPlayback'; +export { ExpoAudioPlayback } from './ExpoAudioPlayback'; +export { buildAudioDataUri, encodeBase64 } from './audioDataUri'; diff --git a/frontend/src/infrastructure/time/IntervalTimeListener.ts b/frontend/src/infrastructure/time/IntervalTimeListener.ts new file mode 100644 index 00000000..5099d98e --- /dev/null +++ b/frontend/src/infrastructure/time/IntervalTimeListener.ts @@ -0,0 +1,38 @@ +import type { + LocalTimeTick, + TimeListenerHandle, + TimeListenerOptions, + TimeListenerPort, +} from '../../features/reminder/application/interfaces'; + +const DEFAULT_INTERVAL_MS = 30_000; + +/** 进程内周期时间观测,驱动 handleTime。 */ +export class IntervalTimeListener implements TimeListenerPort { + private readonly timers = new Map>(); + private sequence = 0; + + constructor(private readonly intervalMs: number = DEFAULT_INTERVAL_MS) {} + + async start( + listener: (tick: LocalTimeTick) => void, + _options?: TimeListenerOptions, + ): Promise { + const listenerId = `interval-time-listener-${++this.sequence}`; + const emit = () => { + listener({ observed_at: new Date().toISOString() }); + }; + // 不在 start 同步打首 tick,交给调用方完成 rebuild 后再自然周期触发。 + const timer = setInterval(emit, this.intervalMs); + this.timers.set(listenerId, timer); + return { listener_id: listenerId }; + } + + async stop(listenerId: string): Promise { + const timer = this.timers.get(listenerId); + if (timer != null) { + clearInterval(timer); + this.timers.delete(listenerId); + } + } +} diff --git a/frontend/src/infrastructure/time/index.ts b/frontend/src/infrastructure/time/index.ts index 6d0bf332..110b158c 100644 --- a/frontend/src/infrastructure/time/index.ts +++ b/frontend/src/infrastructure/time/index.ts @@ -1 +1,2 @@ export { MockTimeListener } from './MockTimeListener'; +export { IntervalTimeListener } from './IntervalTimeListener'; diff --git a/frontend/src/types/expo-audio.d.ts b/frontend/src/types/expo-audio.d.ts new file mode 100644 index 00000000..6638cb05 --- /dev/null +++ b/frontend/src/types/expo-audio.d.ts @@ -0,0 +1,16 @@ +declare module 'expo-audio' { + export function createAudioPlayer(source?: string | number | null): { + pause: () => void; + replace: (source: string | number) => void; + play: () => void; + volume: number; + loop: boolean; + }; + + export function setAudioModeAsync(mode: Record): Promise; +} + +declare module '*.mp3' { + const asset: number; + export default asset; +} From d973f405ba05f7dd69cef12810a38f4d66583567 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 16:54:11 +0800 Subject: [PATCH 2/7] fix(reminder): retry audio mode setup after a failed attempt Code review (PR #267): ensureAudioMode() cached setAudioModeAsync()'s promise unconditionally, including on rejection (the .catch swallowed it into a resolved-undefined promise). A transient failure on the first reminder -- e.g. called before the native audio module finishes initializing after app launch -- would permanently skip playsInSilentMode/shouldPlayInBackground configuration for every reminder afterward, for the rest of the app session. Reset modeReady to null in the catch so the next call retries instead. --- frontend/src/infrastructure/audio/ExpoAudioPlayback.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts index 548922e2..95b02a62 100644 --- a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -130,7 +130,11 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { shouldPlayInBackground: true, shouldRouteThroughEarpiece: false, }) - .catch(() => undefined); + // 失败别永久缓存住:清掉 modeReady,让下一次响铃重试,而不是这次失败 + // 之后整个 App 生命周期都跳过静音模式/后台播放配置。 + .catch(() => { + this.modeReady = null; + }); } await this.modeReady; } From 3c3b2500101843022c7ba5fb15c8f2385adbe677 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 14:44:35 +0800 Subject: [PATCH 3/7] fix(reminder): wire IntervalTimeListener into the composition root createAppServices() still constructed MockTimeListener, whose start() explicitly never fires the listener. LocalReminderApplication.handleTime() therefore never received periodic ticks, so foreground due/overdue reminders were never triggered via the JS time channel even though this PR's own IntervalTimeListener was sitting right there, exported but unused. Code review (PR #267, fennoai bot). --- frontend/src/app/composition/createAppServices.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index e43f41f1..068a1f60 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 '../../infrastructure/time'; +import { IntervalTimeListener } from '../../infrastructure/time'; import { MockReminderPresenter } from '../../features/reminder/presentation'; import { ScheduleViewStore } from '../../features/schedule/presentation'; @@ -44,7 +44,7 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe const auth = createAuthRuntime(options.auth); const reminderPorts: ReminderApplicationDependencies = { schedules: new MockLocalScheduleReader(), - time: new MockTimeListener(), + time: new IntervalTimeListener(), location: new MockLocationMonitor(), alarms: new NativeAlarmScheduler(), delivery: new MockReminderDelivery(), From e718dcfac41909ae27cf56c738a3b925a3729b8b Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 14:44:53 +0800 Subject: [PATCH 4/7] test(reminder): cover the audio and interval-time adapters Patch coverage was 6.86% on this PR (Codecov, 95 lines missing) -- ExpoAudioPlayback.ts, audioDataUri.ts, and IntervalTimeListener.ts had no tests at all. audioDataUri.ts and IntervalTimeListener.ts are straightforward to test directly (fake timers for the interval, known base64 vectors for the encoder). ExpoAudioPlayback.ts was not: its dynamic import('expo-audio') throws in this Jest environment without --experimental-vm-modules, so jest.mock('expo-audio', ...) can never be reached -- the try/catch around the import swallows that TypeError the same way it would swallow a real "native module unavailable" failure, making the class's actual play/pause/ mode-setup logic structurally unreachable from a test. Added a constructor seam (loadExpoAudioModule, defaulting to the real loadExpoAudio) so tests can inject a fake module and exercise the real logic instead of only ever hitting the fallback branch. The sole production call site (createAppServices.ts) still does `new ExpoAudioPlayback()` unchanged. Patch coverage on the three files is now 94-100%. --- .../infrastructure/audio/ExpoAudioPlayback.ts | 7 +- .../audio/ExpoAudioPlayback.test.ts | 74 ++++++++++++ .../ExpoAudioPlayback.withPlayer.test.ts | 114 ++++++++++++++++++ .../infrastructure/audio/audioDataUri.test.ts | 58 +++++++++ .../time/intervalTimeListener.test.ts | 76 ++++++++++++ 5 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts create mode 100644 frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts create mode 100644 frontend/tests/unit/infrastructure/audio/audioDataUri.test.ts create mode 100644 frontend/tests/unit/infrastructure/time/intervalTimeListener.test.ts diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts index 95b02a62..1d558672 100644 --- a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -31,6 +31,9 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { private activeScheduleId: string | null = null; private modeReady: Promise | null = null; + /** 默认走真实的动态 import;测试注入一个假实现,绕开 expo-audio 这个原生模块。 */ + constructor(private readonly loadExpoAudioModule: () => Promise = loadExpoAudio) {} + async isTtsAvailable(): Promise { // TTS 字节管线尚未接入;有 data 时由 playTts 直接播放。 return false; @@ -84,7 +87,7 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { } private async playBytes(scheduleId: string, data: Uint8Array, format: string): Promise { - const expoAudio = await loadExpoAudio(); + const expoAudio = await this.loadExpoAudioModule(); if (expoAudio == null) return false; await this.ensureAudioMode(expoAudio); @@ -99,7 +102,7 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { } private async playBundledAlarm(scheduleId: string): Promise { - const expoAudio = await loadExpoAudio(); + const expoAudio = await this.loadExpoAudioModule(); if (expoAudio == null) return false; await this.ensureAudioMode(expoAudio); diff --git a/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts new file mode 100644 index 00000000..a368cbfd --- /dev/null +++ b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from '@jest/globals'; + +import { ExpoAudioPlayback } from '../../../../src/infrastructure/audio/ExpoAudioPlayback'; + +/** + * expo-audio 是原生模块,Jest 环境里没有真实原生绑定,`import('expo-audio')` + * 会自然失败——不额外 mock,正好覆盖 loadExpoAudio() 的失败分支(played: false)。 + * 成功播放路径(真的调用 createAudioPlayer)见同目录下 + * ExpoAudioPlayback.withPlayer.test.ts。 + */ +describe('ExpoAudioPlayback (no native audio module available)', () => { + it('reports TTS as unavailable', async () => { + const audio = new ExpoAudioPlayback(); + await expect(audio.isTtsAvailable()).resolves.toBe(false); + }); + + it('playTts resolves played: false without touching native audio when data is empty', async () => { + const audio = new ExpoAudioPlayback(); + await expect( + audio.playTts({ schedule_id: 'sch-1', data: new Uint8Array() }), + ).resolves.toEqual({ + playback_id: 'tts-empty-sch-1', + played: false, + used_local_fallback: false, + }); + }); + + it('playTts resolves played: false without data at all', async () => { + const audio = new ExpoAudioPlayback(); + await expect(audio.playTts({ schedule_id: 'sch-1' })).resolves.toEqual({ + playback_id: 'tts-empty-sch-1', + played: false, + used_local_fallback: false, + }); + }); + + it('playTts resolves played: false when native audio cannot load, even with data', async () => { + const audio = new ExpoAudioPlayback(); + const receipt = await audio.playTts({ + schedule_id: 'sch-1', + data: new Uint8Array([1, 2, 3]), + format: 'wav', + }); + expect(receipt).toEqual({ playback_id: 'tts-sch-1', played: false, used_local_fallback: false }); + }); + + it('playLocalFallback resolves played: false with data when native audio cannot load', async () => { + const audio = new ExpoAudioPlayback(); + const receipt = await audio.playLocalFallback({ + schedule_id: 'sch-1', + data: new Uint8Array([1, 2, 3]), + }); + expect(receipt).toEqual({ + playback_id: 'local-sch-1', + played: false, + used_local_fallback: true, + }); + }); + + it('playLocalFallback falls back to the bundled alarm when there is no data, still played: false', async () => { + const audio = new ExpoAudioPlayback(); + const receipt = await audio.playLocalFallback({ schedule_id: 'sch-1' }); + expect(receipt).toEqual({ + playback_id: 'local-bundled-sch-1', + played: false, + used_local_fallback: true, + }); + }); + + it('stop() resolves without an active schedule', async () => { + const audio = new ExpoAudioPlayback(); + await expect(audio.stop('sch-1')).resolves.toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts new file mode 100644 index 00000000..5c519ce3 --- /dev/null +++ b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { ExpoAudioPlayback } from '../../../../src/infrastructure/audio/ExpoAudioPlayback'; + +/** + * expo-audio 的动态 import() 在这个 Jest 环境下天然会抛错(没有 + * --experimental-vm-modules),jest.mock('expo-audio', ...) 拦不住它——见 + * ExpoAudioPlayback.ts 构造函数上新加的注入口子。这里直接注入一个假的 + * loadExpoAudioModule,绕开真的动态 import,测真正的播放逻辑。 + */ +describe('ExpoAudioPlayback (fake native audio module injected)', () => { + const player = { + pause: jest.fn(), + replace: jest.fn(), + play: jest.fn(), + volume: 0, + loop: false, + }; + const setAudioModeAsync = jest.fn<() => Promise>().mockResolvedValue(undefined); + const createAudioPlayer = jest.fn(() => player); + const loadExpoAudioModule = jest.fn(async () => ({ createAudioPlayer, setAudioModeAsync })); + + beforeEach(() => { + jest.clearAllMocks(); + setAudioModeAsync.mockResolvedValue(undefined); + player.volume = 0; + player.loop = false; + }); + + it('playTts configures audio mode once, plays the decoded bytes looped, and marks played: true', async () => { + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + const receipt = await audio.playTts({ + schedule_id: 'sch-1', + data: new Uint8Array([1, 2, 3]), + format: 'wav', + }); + + expect(receipt).toEqual({ playback_id: 'tts-sch-1', played: true, used_local_fallback: false }); + expect(setAudioModeAsync).toHaveBeenCalledTimes(1); + expect(createAudioPlayer).toHaveBeenCalledTimes(1); + expect(player.replace).toHaveBeenCalledWith(expect.stringContaining('data:audio/wav;base64,')); + expect(player.loop).toBe(true); + expect(player.volume).toBe(1); + expect(player.play).toHaveBeenCalledTimes(1); + }); + + it('playLocalFallback plays the bundled alarm sound when there is no data', async () => { + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + const receipt = await audio.playLocalFallback({ schedule_id: 'sch-1' }); + + expect(receipt).toEqual({ + playback_id: 'local-bundled-sch-1', + played: true, + used_local_fallback: true, + }); + // 打包的兜底音效是一个 Metro 资源 id(number),不是 data URI 字符串。 + expect(player.replace).toHaveBeenCalledWith(expect.any(Number)); + expect(player.play).toHaveBeenCalledTimes(1); + }); + + it('reuses the same underlying player across repeated plays instead of recreating it', async () => { + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + await audio.playTts({ schedule_id: 'sch-1', data: new Uint8Array([1]), format: 'wav' }); + await audio.playTts({ schedule_id: 'sch-2', data: new Uint8Array([2]), format: 'wav' }); + + expect(createAudioPlayer).toHaveBeenCalledTimes(1); + expect(player.pause).toHaveBeenCalledTimes(2); + }); + + it('sets up the audio mode only once across multiple plays', async () => { + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + await audio.playTts({ schedule_id: 'sch-1', data: new Uint8Array([1]), format: 'wav' }); + await audio.playTts({ schedule_id: 'sch-2', data: new Uint8Array([2]), format: 'wav' }); + + expect(setAudioModeAsync).toHaveBeenCalledTimes(1); + }); + + it('stop() pauses and un-loops the player only when the schedule is the active one', async () => { + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + await audio.playTts({ schedule_id: 'sch-1', data: new Uint8Array([1]), format: 'wav' }); + + await audio.stop('sch-2'); + expect(player.pause).toHaveBeenCalledTimes(1); // only the initial play-time pause() so far + + await audio.stop('sch-1'); + expect(player.pause).toHaveBeenCalledTimes(2); + expect(player.loop).toBe(false); + }); + + it('retries setAudioModeAsync on the next play after a failed attempt instead of caching the failure', async () => { + setAudioModeAsync.mockRejectedValueOnce(new Error('mode setup failed')); + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + + const first = await audio.playTts({ + schedule_id: 'sch-1', + data: new Uint8Array([1]), + format: 'wav', + }); + expect(first.played).toBe(true); // ensureAudioMode awaits but doesn't propagate the rejection + + await audio.playTts({ schedule_id: 'sch-2', data: new Uint8Array([2]), format: 'wav' }); + expect(setAudioModeAsync).toHaveBeenCalledTimes(2); + }); + + it('treats a module without createAudioPlayer as unavailable', async () => { + const audio = new ExpoAudioPlayback(async () => null); + const receipt = await audio.playTts({ + schedule_id: 'sch-1', + data: new Uint8Array([1]), + format: 'wav', + }); + expect(receipt.played).toBe(false); + }); +}); diff --git a/frontend/tests/unit/infrastructure/audio/audioDataUri.test.ts b/frontend/tests/unit/infrastructure/audio/audioDataUri.test.ts new file mode 100644 index 00000000..d8fa3aeb --- /dev/null +++ b/frontend/tests/unit/infrastructure/audio/audioDataUri.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from '@jest/globals'; + +import { buildAudioDataUri, encodeBase64 } from '../../../../src/infrastructure/audio/audioDataUri'; + +describe('encodeBase64', () => { + it('encodes an empty array to an empty string', () => { + expect(encodeBase64(new Uint8Array())).toBe(''); + }); + + it('encodes a length divisible by 3 without padding', () => { + // "Man" -> "TWFu" is the classic RFC 4648 base64 test vector. + const bytes = new Uint8Array([0x4d, 0x61, 0x6e]); + expect(encodeBase64(bytes)).toBe('TWFu'); + }); + + it('pads with one "=" when one byte is left over', () => { + // "Ma" -> "TWE=" + const bytes = new Uint8Array([0x4d, 0x61]); + expect(encodeBase64(bytes)).toBe('TWE='); + }); + + it('pads with two "=" when two bytes are left over', () => { + // "M" -> "TQ==" + const bytes = new Uint8Array([0x4d]); + expect(encodeBase64(bytes)).toBe('TQ=='); + }); +}); + +describe('buildAudioDataUri', () => { + it('maps a known extension to its registered MIME type', () => { + const uri = buildAudioDataUri(new Uint8Array([1, 2, 3]), 'mp3'); + expect(uri.startsWith('data:audio/mpeg;base64,')).toBe(true); + }); + + it('normalizes a leading dot, whitespace, and case before lookup', () => { + const uri = buildAudioDataUri(new Uint8Array([1, 2, 3]), ' .WAV '); + expect(uri.startsWith('data:audio/wav;base64,')).toBe(true); + }); + + it('falls back to audio/ for an unregistered but valid format', () => { + const uri = buildAudioDataUri(new Uint8Array([1, 2, 3]), 'opus'); + expect(uri.startsWith('data:audio/opus;base64,')).toBe(true); + }); + + it('embeds the base64-encoded bytes after the comma', () => { + const uri = buildAudioDataUri(new Uint8Array([0x4d, 0x61, 0x6e]), 'wav'); + expect(uri).toBe('data:audio/wav;base64,TWFu'); + }); + + it('rejects a format that fails the allowed-character pattern', () => { + expect(() => buildAudioDataUri(new Uint8Array([1]), '')).toThrow( + 'Unsupported reminder audio format', + ); + expect(() => buildAudioDataUri(new Uint8Array([1]), '../etc')).toThrow( + 'Unsupported reminder audio format', + ); + }); +}); diff --git a/frontend/tests/unit/infrastructure/time/intervalTimeListener.test.ts b/frontend/tests/unit/infrastructure/time/intervalTimeListener.test.ts new file mode 100644 index 00000000..92e9bd06 --- /dev/null +++ b/frontend/tests/unit/infrastructure/time/intervalTimeListener.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { IntervalTimeListener } from '../../../../src/infrastructure/time/IntervalTimeListener'; + +describe('IntervalTimeListener', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves start() with a handle carrying a listener_id, without firing synchronously', async () => { + const listener = jest.fn(); + const time = new IntervalTimeListener(); + const handle = await time.start(listener); + expect(handle.listener_id).toEqual(expect.any(String)); + expect(listener).not.toHaveBeenCalled(); + }); + + it('fires the listener with an observed_at timestamp on every interval tick', async () => { + const listener = jest.fn(); + const time = new IntervalTimeListener(1_000); + await time.start(listener); + + jest.advanceTimersByTime(1_000); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ observed_at: expect.any(String) }); + + jest.advanceTimersByTime(2_000); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it('hands out a distinct listener_id per start() call and ticks them independently', async () => { + const first = jest.fn(); + const second = jest.fn(); + const time = new IntervalTimeListener(1_000); + const firstHandle = await time.start(first); + const secondHandle = await time.start(second); + expect(firstHandle.listener_id).not.toBe(secondHandle.listener_id); + + jest.advanceTimersByTime(1_000); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('stop() clears the interval so the listener stops receiving ticks', async () => { + const listener = jest.fn(); + const time = new IntervalTimeListener(1_000); + const handle = await time.start(listener); + + jest.advanceTimersByTime(1_000); + expect(listener).toHaveBeenCalledTimes(1); + + await time.stop(handle.listener_id); + jest.advanceTimersByTime(5_000); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('stop() resolves without needing a matching start()', async () => { + const time = new IntervalTimeListener(); + await expect(time.stop('unknown-listener')).resolves.toBeUndefined(); + }); + + it('uses the default 30s interval when none is supplied', async () => { + const listener = jest.fn(); + const time = new IntervalTimeListener(); + await time.start(listener); + + jest.advanceTimersByTime(29_999); + expect(listener).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); From e6275ea808eb8098f1224fa9bda0002161c87031 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 14:52:31 +0800 Subject: [PATCH 5/7] style(reminder): run prettier on ExpoAudioPlayback and its test CI format:check failure after e718dcf. --- frontend/src/infrastructure/audio/ExpoAudioPlayback.ts | 4 +++- .../infrastructure/audio/ExpoAudioPlayback.test.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts index 1d558672..10852869 100644 --- a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -32,7 +32,9 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { private modeReady: Promise | null = null; /** 默认走真实的动态 import;测试注入一个假实现,绕开 expo-audio 这个原生模块。 */ - constructor(private readonly loadExpoAudioModule: () => Promise = loadExpoAudio) {} + constructor( + private readonly loadExpoAudioModule: () => Promise = loadExpoAudio, + ) {} async isTtsAvailable(): Promise { // TTS 字节管线尚未接入;有 data 时由 playTts 直接播放。 diff --git a/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts index a368cbfd..72f7a0f8 100644 --- a/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts +++ b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts @@ -16,9 +16,7 @@ describe('ExpoAudioPlayback (no native audio module available)', () => { it('playTts resolves played: false without touching native audio when data is empty', async () => { const audio = new ExpoAudioPlayback(); - await expect( - audio.playTts({ schedule_id: 'sch-1', data: new Uint8Array() }), - ).resolves.toEqual({ + await expect(audio.playTts({ schedule_id: 'sch-1', data: new Uint8Array() })).resolves.toEqual({ playback_id: 'tts-empty-sch-1', played: false, used_local_fallback: false, @@ -41,7 +39,11 @@ describe('ExpoAudioPlayback (no native audio module available)', () => { data: new Uint8Array([1, 2, 3]), format: 'wav', }); - expect(receipt).toEqual({ playback_id: 'tts-sch-1', played: false, used_local_fallback: false }); + expect(receipt).toEqual({ + playback_id: 'tts-sch-1', + played: false, + used_local_fallback: false, + }); }); it('playLocalFallback resolves played: false with data when native audio cannot load', async () => { From 237a1694de697263cf929048593fae75110a33e7 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 15:16:32 +0800 Subject: [PATCH 6/7] fix(reminder): report played: false when audio mode setup fails ensureAudioMode()'s .catch(() => { this.modeReady = null; }) turned a rejected setAudioModeAsync() into a resolved promise -- the rejection never propagated past ensureAudioMode(), so playBytes()/playBundledAlarm() always proceeded to call player.play() and returned played: true regardless of whether silent-mode/background playback was actually configured. LocalReminderApplication trusts played: true and won't fall back to another delivery channel, so a mode setup failure could make a reminder silent with no fallback ever triggered. ensureAudioMode() now returns whether the mode is actually ready; the two callers bail out with played: false when it isn't, instead of proceeding to a play() that's unlikely to be heard. Retry-on-next-attempt behavior is unchanged: a failure still clears modeReady so the next play tries again. Code review (PR #267, Wintercom). --- .../infrastructure/audio/ExpoAudioPlayback.ts | 39 ++++++++++-------- .../ExpoAudioPlayback.withPlayer.test.ts | 40 ++++++++++++++++++- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts index 10852869..f489fb65 100644 --- a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -91,8 +91,8 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { private async playBytes(scheduleId: string, data: Uint8Array, format: string): Promise { const expoAudio = await this.loadExpoAudioModule(); if (expoAudio == null) return false; + if (!(await this.ensureAudioMode(expoAudio))) return false; - await this.ensureAudioMode(expoAudio); const player = this.ensurePlayer(expoAudio); player.pause(); player.replace(buildAudioDataUri(data, format)); @@ -106,8 +106,8 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { private async playBundledAlarm(scheduleId: string): Promise { const expoAudio = await this.loadExpoAudioModule(); if (expoAudio == null) return false; + if (!(await this.ensureAudioMode(expoAudio))) return false; - await this.ensureAudioMode(expoAudio); const player = this.ensurePlayer(expoAudio); player.pause(); player.replace(LOCAL_ALARM_SOUND); @@ -125,23 +125,28 @@ export class ExpoAudioPlayback implements AudioPlaybackPort { return this.player; } - private async ensureAudioMode(expoAudio: ExpoAudioModule): Promise { + /** + * 返回 false 时调用方必须放弃这次播放,不能假装播放成功——静音模式/后台播放 + * 没配置成功,即使真的调用 play() 用户也大概率听不到。不永久缓存失败:清掉 + * modeReady 让下一次响铃重新尝试,而不是这次失败后整个 App 生命周期都跳过。 + */ + private async ensureAudioMode(expoAudio: ExpoAudioModule): Promise { if (this.modeReady == null) { - this.modeReady = expoAudio - .setAudioModeAsync({ - allowsRecording: false, - interruptionMode: 'doNotMix', - playsInSilentMode: true, - shouldPlayInBackground: true, - shouldRouteThroughEarpiece: false, - }) - // 失败别永久缓存住:清掉 modeReady,让下一次响铃重试,而不是这次失败 - // 之后整个 App 生命周期都跳过静音模式/后台播放配置。 - .catch(() => { - this.modeReady = null; - }); + this.modeReady = expoAudio.setAudioModeAsync({ + allowsRecording: false, + interruptionMode: 'doNotMix', + playsInSilentMode: true, + shouldPlayInBackground: true, + shouldRouteThroughEarpiece: false, + }); + } + try { + await this.modeReady; + return true; + } catch { + this.modeReady = null; + return false; } - await this.modeReady; } } diff --git a/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts index 5c519ce3..44b4364a 100644 --- a/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts +++ b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts @@ -87,6 +87,23 @@ describe('ExpoAudioPlayback (fake native audio module injected)', () => { expect(player.loop).toBe(false); }); + it('reports played: false and never calls play() when audio mode setup fails', async () => { + setAudioModeAsync.mockRejectedValueOnce(new Error('mode setup failed')); + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + + const receipt = await audio.playTts({ + schedule_id: 'sch-1', + data: new Uint8Array([1]), + format: 'wav', + }); + expect(receipt).toEqual({ + playback_id: 'tts-sch-1', + played: false, + used_local_fallback: false, + }); + expect(player.play).not.toHaveBeenCalled(); + }); + it('retries setAudioModeAsync on the next play after a failed attempt instead of caching the failure', async () => { setAudioModeAsync.mockRejectedValueOnce(new Error('mode setup failed')); const audio = new ExpoAudioPlayback(loadExpoAudioModule); @@ -96,10 +113,29 @@ describe('ExpoAudioPlayback (fake native audio module injected)', () => { data: new Uint8Array([1]), format: 'wav', }); - expect(first.played).toBe(true); // ensureAudioMode awaits but doesn't propagate the rejection + expect(first.played).toBe(false); - await audio.playTts({ schedule_id: 'sch-2', data: new Uint8Array([2]), format: 'wav' }); + const second = await audio.playTts({ + schedule_id: 'sch-2', + data: new Uint8Array([2]), + format: 'wav', + }); + expect(second.played).toBe(true); expect(setAudioModeAsync).toHaveBeenCalledTimes(2); + expect(player.play).toHaveBeenCalledTimes(1); + }); + + it('falls back to the bundled alarm sound reporting played: false when mode setup fails', async () => { + setAudioModeAsync.mockRejectedValueOnce(new Error('mode setup failed')); + const audio = new ExpoAudioPlayback(loadExpoAudioModule); + + const receipt = await audio.playLocalFallback({ schedule_id: 'sch-1' }); + expect(receipt).toEqual({ + playback_id: 'local-bundled-sch-1', + played: false, + used_local_fallback: true, + }); + expect(player.play).not.toHaveBeenCalled(); }); it('treats a module without createAudioPlayer as unavailable', async () => { From b385082a9ee9b83524d886aa0bc9a01d96e206e0 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 15:16:53 +0800 Subject: [PATCH 7/7] fix(reminder): wire LocalReminderApplication into the composition root createAppServices() still constructed MockReminderApplication, whose start()/handleTime()/deliver() are all no-ops. Wiring IntervalTimeListener and ExpoAudioPlayback into reminderPorts (earlier commits on this PR) therefore had no effect in production: nothing ever called time.start() or audio.playTts(), because the engine that's supposed to call them was a stub that never touches its dependencies. Swapped in the real LocalReminderApplication (#266). The other ports this PR doesn't own (schedules, location, notifications, state, disposition sync) stay on their existing Mocks -- LocalReminderApplication works against the ReminderApplicationDependencies interface regardless of which side of each port is real, same as the equivalent swap already done for #270. Added an assertion to the existing createAppServices test that starting the runtime actually calls through to reminderPorts.time.start(), proving the composition root wires an engine that consumes its ports instead of one that ignores them. Code review (PR #267, Wintercom). --- frontend/src/app/composition/createAppServices.ts | 4 ++-- frontend/tests/unit/app/createAppServices.test.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 068a1f60..8ad840d6 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -4,9 +4,9 @@ import type { ReminderApplicationDependencies, ReminderApplicationPort, } from '../../features/reminder/application/interfaces'; +import { LocalReminderApplication } from '../../features/reminder/application'; import { MockLocalScheduleReader, - MockReminderApplication, MockReminderDispositionSync, MockReminderStateStore, } from '../../features/reminder/data/local'; @@ -58,7 +58,7 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe state: new MockReminderStateStore(), dispositionSync: new MockReminderDispositionSync(), }; - const reminder = new MockReminderApplication(reminderPorts); + const reminder = new LocalReminderApplication(reminderPorts); const scheduleView = new ScheduleViewStore(); const runtime = new AppRuntime([ { diff --git a/frontend/tests/unit/app/createAppServices.test.ts b/frontend/tests/unit/app/createAppServices.test.ts index 2773b98f..6608c367 100644 --- a/frontend/tests/unit/app/createAppServices.test.ts +++ b/frontend/tests/unit/app/createAppServices.test.ts @@ -12,6 +12,7 @@ describe('createAppServices', () => { }, }); const stopReminder = jest.spyOn(services.reminder, 'stop'); + const startTimeListener = jest.spyOn(services.reminderPorts.time, 'start'); services.scheduleView.replace( { accountId: 'acc_001', selectedDate: '2026-08-12', timezone: 'Asia/Shanghai' }, [], @@ -29,6 +30,10 @@ describe('createAppServices', () => { timezone: null, }); expect(stopReminder).toHaveBeenCalledTimes(1); + // reminder 是真的 LocalReminderApplication,不是 Mock:start() 应该真的调用 + // 到 reminderPorts.time.start(),证明组合根接的是会消费这些端口的引擎, + // 不是一个 start()/handleTime() 全是空操作的桩。 + expect(startTimeListener).toHaveBeenCalledTimes(1); }); });