Files
frontend-miniapp/tests/unit/ExhibitDetailAudioFirst.spec.ts
lyf f4e7a5f147
Some checks failed
CI / verify (push) Has been cancelled
修复讲解详情返回对象列表
2026-07-17 18:46:07 +08:00

329 lines
12 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',
supportedLanguages: ['zh-CN', 'yue-HK', 'en-US']
}
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()
})
vi.stubGlobal('getCurrentPages', 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('仅展示 stop-info 声明支持的语言按钮', async () => {
mocks.enterExplainDetail.mockResolvedValue({
...exhibit,
supportedLanguages: ['zh-CN', 'en-US']
})
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
const labels = wrapper.findAll('.language-option-text').map((node) => node.text())
expect(labels).toEqual(['中文', 'English'])
expect(labels).not.toContain('粤语')
})
it('无支持语言时不显示语言栏', async () => {
mocks.enterExplainDetail.mockResolvedValue({
...exhibit,
supportedLanguages: []
})
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
})
it('深链语言不受支持时回退至中文并规范化地址', async () => {
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
...exhibit,
audioLanguage: request.lang,
supportedLanguages: ['zh-CN']
}))
const replaceState = vi.spyOn(window.history, 'replaceState')
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(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' }))
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(2, expect.objectContaining({ lang: 'zh-CN' }))
expect(wrapper.get('.language-option').classes()).toContain('active')
expect(wrapper.get('.language-option-text').text()).toBe('中文')
expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN'))
})
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('上一页是讲解对象列表时使用页面栈返回', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/guide-stop-list', options: {} },
{ route: 'pages/exhibit/detail', options: {} }
]))
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.redirectTo).not.toHaveBeenCalled()
})
it('深链详情按展厅上下文回退至讲解对象列表', async () => {
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({
id: exhibit.id,
lang: 'en-US',
targetType: 'STOP',
targetId: 'stop-1',
hallId: 'hall-1',
hallName: '%2525E6%252581%252590%2525E9%2525BE%252599%2525E5%25258E%252585'
})
await flushPromises()
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
const url = vi.mocked(uni.redirectTo).mock.calls[0]?.[0]?.url || ''
const params = new URLSearchParams(url.split('?')[1])
expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list')
expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅')
expect(uni.navigateBack).not.toHaveBeenCalled()
})
})