diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 2aefb22a..8ad840d6 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -4,13 +4,13 @@ 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'; -import { MockAudioPlayback } from '../../infrastructure/audio'; +import { ExpoAudioPlayback } from '../../infrastructure/audio'; import { MockLocationMonitor } from '../../infrastructure/location'; import { MockPopup, @@ -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,11 +44,11 @@ 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(), - audio: new MockAudioPlayback(), + audio: new ExpoAudioPlayback(), device: new NativeDeviceCapability(), presenter: new MockReminderPresenter(), systemNotification: new MockSystemNotification(), @@ -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/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts new file mode 100644 index 00000000..f489fb65 --- /dev/null +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -0,0 +1,161 @@ +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; + + /** 默认走真实的动态 import;测试注入一个假实现,绕开 expo-audio 这个原生模块。 */ + constructor( + private readonly loadExpoAudioModule: () => Promise = loadExpoAudio, + ) {} + + 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 this.loadExpoAudioModule(); + if (expoAudio == null) return false; + if (!(await this.ensureAudioMode(expoAudio))) return false; + + 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 this.loadExpoAudioModule(); + if (expoAudio == null) return false; + if (!(await this.ensureAudioMode(expoAudio))) return false; + + 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; + } + + /** + * 返回 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, + }); + } + try { + await this.modeReady; + return true; + } catch { + this.modeReady = null; + return false; + } + } +} + +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; +} 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); }); }); 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..72f7a0f8 --- /dev/null +++ b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.test.ts @@ -0,0 +1,76 @@ +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..44b4364a --- /dev/null +++ b/frontend/tests/unit/infrastructure/audio/ExpoAudioPlayback.withPlayer.test.ts @@ -0,0 +1,150 @@ +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('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); + + const first = await audio.playTts({ + schedule_id: 'sch-1', + data: new Uint8Array([1]), + format: 'wav', + }); + expect(first.played).toBe(false); + + 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 () => { + 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); + }); +});