241 lines
8.4 KiB
TypeScript
241 lines
8.4 KiB
TypeScript
// @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<string, unknown>) => Promise<void>),
|
|
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<string, unknown>) => Promise<void>) => {
|
|
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: '<div data-testid="guide-page-frame"><slot /></div>'
|
|
})
|
|
|
|
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.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)
|
|
})
|
|
|
|
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 }))
|
|
})
|
|
})
|