/** * OmniCard AI (CRMmp) - 微官网发音语音条与多媒体播放器测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD 3.1、5.1 设计编写 */ import * as fs from 'fs'; import * as path from 'path'; const AUDIO_PLAYER_UTS = path.resolve( __dirname, '../../../mpuniapp/pages/portal/useAudioPlayer.uts' ); const AUDIO_PRONOUNCE_COMPONENT = path.resolve( __dirname, '../../../mpuniapp/components/audio-pronounce/audio-pronounce.uvue' ); const PORTAL_PAGE = path.resolve( __dirname, '../../../mpuniapp/pages/portal/index.uvue' ); type MockAudioContext = { src: string; duration: number; currentTime: number; paused: boolean; play: jest.Mock; pause: jest.Mock; stop: jest.Mock; destroy: jest.Mock; onPlay: jest.Mock; onEnded: jest.Mock; onError: jest.Mock; onTimeUpdate: jest.Mock; _onPlayCb: (() => void) | null; _onEndedCb: (() => void) | null; _onErrorCb: ((err: { errMsg: string; errCode?: number }) => void) | null; _onTimeUpdateCb: (() => void) | null; }; const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u; describe('OmniCard AI 移动微官网双语发音语音播放器驱动层测试 (useAudioPlayer.uts)', () => { let audioContextMock: MockAudioContext; let mockFactoryCalls: number; function createMockAudio(): MockAudioContext { mockFactoryCalls++; const ctx: MockAudioContext = { src: '', duration: 3.5, currentTime: 0, paused: true, play: jest.fn(() => { ctx.paused = false; if (ctx._onPlayCb) { ctx._onPlayCb(); } }), pause: jest.fn(() => { ctx.paused = true; }), stop: jest.fn(() => { ctx.paused = true; }), destroy: jest.fn(() => { ctx.paused = true; }), onPlay: jest.fn((cb: () => void) => { ctx._onPlayCb = cb; }), onEnded: jest.fn((cb: () => void) => { ctx._onEndedCb = cb; }), onError: jest.fn((cb: (err: { errMsg: string; errCode?: number }) => void) => { ctx._onErrorCb = cb; }), onTimeUpdate: jest.fn((cb: () => void) => { ctx._onTimeUpdateCb = cb; }), _onPlayCb: null, _onEndedCb: null, _onErrorCb: null, _onTimeUpdateCb: null, }; return ctx; } beforeEach(() => { jest.resetModules(); mockFactoryCalls = 0; audioContextMock = createMockAudio(); (global as unknown as { uni: Record }).uni = { createInnerAudioContext: () => audioContextMock, showToast: jest.fn(), }; }); afterEach(() => { delete (global as unknown as { uni?: Record }).uni; }); function loadAudioPlayerModule() { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../../mpuniapp/pages/portal/useAudioPlayer.uts'); } describe('1. 初始状态与契约 (createAudioPlayerState)', () => { it('1.1 初始状态所有指示均为静止与默认零值', () => { const { createAudioPlayerState } = loadAudioPlayerModule(); const state = createAudioPlayerState(); expect(state).toBeDefined(); expect(state.isPlaying).toBe(false); expect(state.currentDuration).toBe(0); expect(state.currentTime).toBe(0); expect(state.waveformActive).toBe(false); expect(state.currentUrl).toBe(''); expect(state.errorMessage).toBe(''); }); }); describe('2. 正常播放逻辑 (playAudio)', () => { it('2.1 提供合法链接时设置 src 并播放,更新 isPlaying 和 waveformActive', () => { const { createAudioPlayerState, playAudio } = loadAudioPlayerModule(); const state = createAudioPlayerState(); const testUrl = 'https://cdn.omnicard.ai/audio/alex-name.mp3'; const success = playAudio(state, testUrl); expect(success).toBe(true); expect(audioContextMock.src).toBe(testUrl); expect(audioContextMock.play).toHaveBeenCalled(); expect(state.isPlaying).toBe(true); expect(state.waveformActive).toBe(true); expect(state.currentUrl).toBe(testUrl); expect(state.errorMessage).toBe(''); }); it('2.2 播放不同音频时,自动停止上一首并载入新音频播放', () => { const { createAudioPlayerState, playAudio } = loadAudioPlayerModule(); const state = createAudioPlayerState(); const url1 = 'https://cdn.omnicard.ai/audio/alex-1.mp3'; const url2 = 'https://cdn.omnicard.ai/audio/alex-2.mp3'; playAudio(state, url1); expect(state.currentUrl).toBe(url1); playAudio(state, url2); expect(audioContextMock.stop).toHaveBeenCalled(); expect(audioContextMock.src).toBe(url2); expect(state.currentUrl).toBe(url2); expect(state.isPlaying).toBe(true); }); }); describe('3. 停止播放 (stopAudio)', () => { it('3.1 停止后复位 isPlaying 与 waveformActive', () => { const { createAudioPlayerState, playAudio, stopAudio } = loadAudioPlayerModule(); const state = createAudioPlayerState(); playAudio(state, 'https://cdn.omnicard.ai/audio/alex.mp3'); stopAudio(state); expect(audioContextMock.stop).toHaveBeenCalled(); expect(state.isPlaying).toBe(false); expect(state.waveformActive).toBe(false); }); }); describe('4. 切换控制 (toggleAudio)', () => { it('4.1 未播放时点击触发播放;正在播放同一音频时点击触发停止', () => { const { createAudioPlayerState, toggleAudio } = loadAudioPlayerModule(); const state = createAudioPlayerState(); const url = 'https://cdn.omnicard.ai/audio/alex.mp3'; // 第一次点击:播放 const firstResult = toggleAudio(state, url); expect(firstResult).toBe(true); expect(state.isPlaying).toBe(true); expect(state.waveformActive).toBe(true); // 第二次点击同一链接:暂停/停止 const secondResult = toggleAudio(state, url); expect(secondResult).toBe(false); expect(state.isPlaying).toBe(false); expect(state.waveformActive).toBe(false); }); }); describe('5. 播放结束回调 (onEnded)', () => { it('5.1 底层播放完毕触发 onEnded 时,自动恢复未播放状态与停止波形动效', () => { const { createAudioPlayerState, playAudio } = loadAudioPlayerModule(); const state = createAudioPlayerState(); playAudio(state, 'https://cdn.omnicard.ai/audio/alex.mp3'); expect(state.isPlaying).toBe(true); expect(state.waveformActive).toBe(true); // 触发 onEnded 回调 audioContextMock._onEndedCb?.(); expect(state.isPlaying).toBe(false); expect(state.waveformActive).toBe(false); expect(state.currentTime).toBe(0); }); }); describe('6. 异常音频链接与错误处理优雅降级 (onError)', () => { it('6.1 空链接或纯空格链接安全防卫退出,不调用 play 并记录提示', () => { const { createAudioPlayerState, playAudio } = loadAudioPlayerModule(); const state = createAudioPlayerState(); const emptyRes = playAudio(state, ''); expect(emptyRes).toBe(false); expect(audioContextMock.play).not.toHaveBeenCalled(); expect(state.isPlaying).toBe(false); const blankRes = playAudio(state, ' '); expect(blankRes).toBe(false); expect(state.isPlaying).toBe(false); }); it('6.2 底层触发 onError 时优雅降级,重置状态并记录错误信息,不抛崩溃', () => { const { createAudioPlayerState, playAudio } = loadAudioPlayerModule(); const state = createAudioPlayerState(); playAudio(state, 'https://cdn.omnicard.ai/audio/broken.mp3'); // 触发 onError audioContextMock._onErrorCb?.({ errMsg: 'MEDIA_ELEMENT_ERROR: Format not supported', errCode: 10004, }); expect(state.isPlaying).toBe(false); expect(state.waveformActive).toBe(false); expect(state.errorMessage).toContain('Format not supported'); }); }); describe('7. 单例音频释放与资源清理 (releaseAudioPlayer)', () => { it('7.1 调用 releaseAudioPlayer 释放底层实例与重置状态', () => { const { createAudioPlayerState, playAudio, releaseAudioPlayer } = loadAudioPlayerModule(); const state = createAudioPlayerState(); playAudio(state, 'https://cdn.omnicard.ai/audio/alex.mp3'); releaseAudioPlayer(state); expect(audioContextMock.destroy).toHaveBeenCalled(); expect(state.isPlaying).toBe(false); expect(state.waveformActive).toBe(false); expect(state.currentUrl).toBe(''); }); }); }); describe('OmniCard AI 语音组件契约与设计规范校验', () => { it('组件与 Composable 源码存在并遵循强类型与无 emoji 规范', () => { expect(fs.existsSync(AUDIO_PLAYER_UTS)).toBe(true); expect(fs.existsSync(AUDIO_PRONOUNCE_COMPONENT)).toBe(true); expect(fs.existsSync(PORTAL_PAGE)).toBe(true); const playerSource = fs.readFileSync(AUDIO_PLAYER_UTS, 'utf8'); const compSource = fs.readFileSync(AUDIO_PRONOUNCE_COMPONENT, 'utf8'); const portalSource = fs.readFileSync(PORTAL_PAGE, 'utf8'); const testSource = fs.readFileSync(__filename, 'utf8'); // 严禁 emoji expect(EMOJI_RE.test(playerSource)).toBe(false); expect(EMOJI_RE.test(compSource)).toBe(false); expect(EMOJI_RE.test(testSource)).toBe(false); // 严禁 any expect(playerSource.includes(': any') || playerSource.includes(' as any')).toBe(false); expect(compSource.includes(': any') || compSource.includes(' as any')).toBe(false); // 设计 Token: 主色 #0B5CFF、脉冲动画、prefers-reduced-motion expect(compSource.includes('#0B5CFF')).toBe(true); expect(compSource.includes('prefers-reduced-motion')).toBe(true); // 门户首页 Hero 区关联 audio-pronounce 组件 expect(portalSource.includes('audio-pronounce') || portalSource.includes('AudioPronounce')).toBe(true); }); });