// @vitest-environment happy-dom import { defineComponent } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' const mocks = vi.hoisted(() => ({ onLoadHandler: null as null | ((options?: Record) => Promise), enterExplainDetail: vi.fn(), loadExplainDetailText: vi.fn(), selectAudioForExplainDetail: vi.fn(), play: vi.fn(), pause: vi.fn(), resume: vi.fn(), close: vi.fn(), seekToPercent: vi.fn() })) const audioState = vi.hoisted(() => ({ currentAudio: { value: null as any }, currentSource: { value: null as any }, playing: { value: false }, loading: { value: false }, error: { value: '' }, currentTime: { value: 0 }, duration: { value: 0 } })) vi.mock('@dcloudio/uni-app', () => ({ onLoad: (handler: (options?: Record) => Promise) => { mocks.onLoadHandler = handler }, onUnload: () => undefined })) vi.mock('@/usecases/explainUseCase', () => ({ explainUseCase: { enterExplainDetail: mocks.enterExplainDetail, loadExplainDetailText: mocks.loadExplainDetailText, selectAudioForExplainDetail: mocks.selectAudioForExplainDetail } })) vi.mock('@/usecases/guideUseCase', () => ({ guideUseCase: { resolveContentLocationPreviewTarget: vi.fn() } })) vi.mock('@/composables/useGlobalAudioPlayer', () => ({ useGlobalAudioPlayer: () => ({ ...audioState, isCurrentSource: () => false, play: mocks.play, pause: mocks.pause, resume: mocks.resume, switchLanguage: vi.fn(), close: mocks.close, seekToPercent: mocks.seekToPercent }) })) vi.mock('@/utils/hostEnvironment', () => ({ isEmbeddedInWechatMiniProgram: () => false })) import ExhibitDetail from '@/pages/exhibit/detail.vue' const GuidePageFrameStub = defineComponent({ name: 'GuidePageFrameStub', emits: ['back'], template: '
' }) const exhibit = { id: 'exhibit-1', name: '[Classical Astrolabe] 标准解说', hallName: 'Universe Hall', floorLabel: 'B2', image: '/assets/astrolabe.jpg', description: 'A detailed English narration for this exhibit.', guideText: 'A detailed English narration for this exhibit.', audioUrl: '/assets/astrolabe.mp3', audioDuration: 81, audioStatus: 'READY', audioLanguage: 'en-US', audioHasText: false, playTargetType: 'STOP', playTargetId: 'stop-1' } describe('讲解详情音频优先布局', () => { beforeEach(() => { mocks.onLoadHandler = null audioState.currentAudio.value = null audioState.currentSource.value = null audioState.playing.value = false audioState.loading.value = false audioState.error.value = '' audioState.currentTime.value = 0 audioState.duration.value = 0 mocks.enterExplainDetail.mockResolvedValue(exhibit) mocks.loadExplainDetailText.mockResolvedValue({ available: false }) mocks.selectAudioForExplainDetail.mockResolvedValue({ playable: true, exhibit, media: { id: 'audio-1', url: exhibit.audioUrl, duration: exhibit.audioDuration, language: 'en-US' }, playInfo: { title: exhibit.name } }) mocks.play.mockResolvedValue(undefined) vi.stubGlobal('uni', { showToast: vi.fn(), navigateBack: vi.fn(), redirectTo: vi.fn(), reLaunch: vi.fn() }) }) afterEach(() => { vi.clearAllMocks() vi.unstubAllGlobals() }) it('正常状态渲染固定底部播放器、语言栏和英文正文', async () => { const wrapper = mount(ExhibitDetail, { global: { stubs: { GuidePageFrame: GuidePageFrameStub } } }) await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) await flushPromises() expect(wrapper.get('.detail-title').text()).toBe('Classical Astrolabe') expect(wrapper.get('.detail-audio-title').text()).toBe('Classical Astrolabe') expect(wrapper.get('.detail-meta').text()).toContain('Universe Hall · B2') expect(wrapper.get('.detail-chip').text()).toBe('标准解说') expect(wrapper.find('.detail-audio-dock').exists()).toBe(true) expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-playable') expect(wrapper.find('.language-switch').text()).toContain('English') expect(wrapper.find('.detail-language-bar').exists()).toBe(true) expect(wrapper.find('.content .language-switch').exists()).toBe(false) expect(wrapper.get('.content-section').classes()).toContain('is-english') expect(wrapper.find('.section-title').exists()).toBe(false) expect(wrapper.get('.section-text').text()).toContain('detailed English narration') }) it('通过同一全局音频播放器开始播放,不创建页面播放器', async () => { const wrapper = mount(ExhibitDetail, { global: { stubs: { GuidePageFrame: GuidePageFrameStub } } }) await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) await flushPromises() await wrapper.get('.detail-audio-play').trigger('tap') expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ audioUrl: exhibit.audioUrl, language: 'en-US' }), expect.objectContaining({ displayMode: 'mini' })) }) it('加载中使用同一高度底栏和不确定进度', async () => { audioState.currentAudio.value = { id: 'audio-1' } audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' } audioState.loading.value = true const wrapper = mount(ExhibitDetail, { global: { stubs: { GuidePageFrame: GuidePageFrameStub } } }) await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) await flushPromises() expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading') expect(wrapper.find('.audio-loading-spinner').exists()).toBe(true) expect(wrapper.find('.detail-audio-progress.is-indeterminate').exists()).toBe(true) expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined() }) it('无音频时禁用播放按钮并显示原因', async () => { mocks.enterExplainDetail.mockResolvedValue({ ...exhibit, audioUrl: undefined, audioDuration: undefined, audioStatus: 'MISSING', audioLanguage: 'yue-HK', audioUnavailableReason: '当前语言暂无语音讲解。' }) const wrapper = mount(ExhibitDetail, { global: { stubs: { GuidePageFrame: GuidePageFrameStub } } }) await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', targetType: 'STOP', targetId: 'stop-1' }) await flushPromises() expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-unavailable') expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined() expect(wrapper.get('.detail-audio-subtitle').text()).toContain('当前语言暂无语音讲解。') }) it('播放失败显示错误并通过既有刷新链路重试', async () => { audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl } audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' } audioState.error.value = '音频暂时无法播放' mocks.selectAudioForExplainDetail.mockResolvedValue({ playable: true, exhibit, media: { id: 'audio-refresh', url: exhibit.audioUrl, duration: exhibit.audioDuration, language: 'en-US' }, playInfo: { title: exhibit.name } }) const wrapper = mount(ExhibitDetail, { global: { stubs: { GuidePageFrame: GuidePageFrameStub } } }) await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) await flushPromises() expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-failed') expect(wrapper.get('.detail-audio-retry').text()).toBe('重试') await wrapper.get('.detail-audio-retry').trigger('tap') await flushPromises() expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.any(Object), { refreshPlayInfo: true }) expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({ retryOnError: expect.any(Function), displayMode: 'mini' })) }) it('顶部返回统一使用页面栈,以便导览首页 onShow 消费复位上下文', async () => { const wrapper = mount(ExhibitDetail, { global: { stubs: { GuidePageFrame: GuidePageFrameStub } } }) wrapper.getComponent(GuidePageFrameStub).vm.$emit('back') await flushPromises() expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) }) })