同步 sgs-frontend-mobile 源码
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-27 11:26:01 +08:00
parent 575dca430f
commit 9ea1bfab71
181 changed files with 24395 additions and 5212 deletions

View File

@@ -9,8 +9,6 @@ const mocks = vi.hoisted(() => ({
onHideHandler: null as null | (() => void),
onUnloadHandler: null as null | (() => void),
enterExplainDetail: vi.fn(),
selectExplainDetailLanguage: vi.fn(),
selectExplainDetailAudioOption: vi.fn(),
loadExplainDetailText: vi.fn(),
selectAudioForExplainDetail: vi.fn(),
play: vi.fn(),
@@ -18,8 +16,8 @@ const mocks = vi.hoisted(() => ({
resume: vi.fn(),
close: vi.fn(),
seekToPercent: vi.fn(),
cyclePlaybackRate: vi.fn(),
toggleMuted: vi.fn()
setPlaybackRate: vi.fn(),
toggleMute: vi.fn()
}))
const audioState = vi.hoisted(() => ({
@@ -49,8 +47,6 @@ vi.mock('@dcloudio/uni-app', () => ({
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
enterExplainDetail: mocks.enterExplainDetail,
selectExplainDetailLanguage: mocks.selectExplainDetailLanguage,
selectExplainDetailAudioOption: mocks.selectExplainDetailAudioOption,
loadExplainDetailText: mocks.loadExplainDetailText,
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
}
@@ -65,19 +61,15 @@ vi.mock('@/usecases/guideUseCase', () => ({
vi.mock('@/composables/useGlobalAudioPlayer', () => ({
useGlobalAudioPlayer: () => ({
...audioState,
isCurrentSource: (source: { targetType?: string, targetId?: string, lang?: string }) => (
audioState.currentSource.value?.targetType === source.targetType
&& audioState.currentSource.value?.targetId === source.targetId
&& audioState.currentSource.value?.lang === source.lang
),
isCurrentSource: () => false,
play: mocks.play,
pause: mocks.pause,
resume: mocks.resume,
switchLanguage: vi.fn(),
close: mocks.close,
seekToPercent: mocks.seekToPercent,
cyclePlaybackRate: mocks.cyclePlaybackRate,
toggleMuted: mocks.toggleMuted
setPlaybackRate: mocks.setPlaybackRate,
toggleMute: mocks.toggleMute
})
}))
@@ -126,11 +118,6 @@ describe('讲解详情音频优先布局', () => {
audioState.playbackRate.value = 1
audioState.muted.value = false
mocks.enterExplainDetail.mockResolvedValue(exhibit)
mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({
...source,
audioLanguage: lang
}))
mocks.selectExplainDetailAudioOption.mockImplementation((source, channelCode) => source)
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
mocks.selectAudioForExplainDetail.mockResolvedValue({
playable: true,
@@ -146,7 +133,6 @@ describe('讲解详情音频优先布局', () => {
mocks.play.mockResolvedValue(undefined)
vi.stubGlobal('uni', {
showToast: vi.fn(),
showActionSheet: vi.fn(),
navigateBack: vi.fn(),
redirectTo: vi.fn(),
reLaunch: vi.fn()
@@ -168,34 +154,38 @@ describe('讲解详情音频优先布局', () => {
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.get('.detail-audio-subtitle').text()).toBe('0:00 / 1:21')
expect(wrapper.get('.detail-audio-rate').text()).toBe('1.0')
expect(wrapper.find('.detail-audio-mute').exists()).toBe(true)
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')
expect(wrapper.get('.detail-audio-time').text()).toBe('0:00 / 1:21')
expect(wrapper.get('.detail-audio-speed').attributes('aria-label')).toBe('播放速度 1.0倍')
})
it('正常音频底栏可切换播放速率和静音', async () => {
it('详情加载失败时使用统一的系统反馈状态和紧凑操作按钮', async () => {
mocks.enterExplainDetail.mockRejectedValue(new Error('请检查网络后重试。'))
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
await wrapper.get('.detail-audio-rate').trigger('tap')
await wrapper.get('.detail-audio-mute').trigger('tap')
expect(mocks.cyclePlaybackRate).toHaveBeenCalledTimes(1)
expect(mocks.toggleMuted).toHaveBeenCalledTimes(1)
const state = wrapper.get('[data-testid="exhibit-detail-state"]')
expect(state.classes()).toContain('guide-feedback-state')
expect(state.text()).toContain('讲解对象加载失败')
expect(state.text()).toContain('请检查网络后重试。')
expect(state.get('.feedback-action.primary').text()).toBe('重新加载')
expect(state.get('.feedback-action.secondary').text()).toBe('返回讲解列表')
await state.get('.feedback-action.primary').trigger('tap')
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(2)
})
it('仅展示 stop-info 声明支持的语言按钮', async () => {
@@ -230,125 +220,7 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
})
it('仅在当前语言同时存在男女声时展示声线按钮并重播所选音频', async () => {
const voiceExhibit = {
...exhibit,
audioOptions: [
{
channelCode: 'standard.en-US.female',
displayName: '英文女声',
languageCode: 'en-US',
gender: 'female',
audioUrl: '/assets/astrolabe-en-female.mp3',
duration: 81,
isDefault: true
},
{
channelCode: 'standard.en-US.male',
displayName: '英文男声',
languageCode: 'en-US',
gender: 'male',
audioUrl: '/assets/astrolabe-en-male.mp3',
duration: 82,
isDefault: false
},
{
channelCode: 'standard.yue-HK.female',
displayName: '粤语女声',
languageCode: 'yue-HK',
gender: 'female',
audioUrl: '/assets/astrolabe-yue-female.mp3',
duration: 83,
isDefault: true
}
],
audioChannelCode: 'standard.en-US.female',
audioVoiceGender: 'female',
audioVoiceDisplayName: '英文女声',
audioUrl: '/assets/astrolabe-en-female.mp3'
}
const maleVoiceExhibit = {
...voiceExhibit,
audioUrl: '/assets/astrolabe-en-male.mp3',
audioDuration: 82,
audioChannelCode: 'standard.en-US.male',
audioVoiceGender: 'male',
audioVoiceDisplayName: '英文男声'
}
mocks.enterExplainDetail.mockResolvedValue(voiceExhibit)
mocks.selectExplainDetailAudioOption.mockReturnValue(maleVoiceExhibit)
mocks.selectAudioForExplainDetail.mockImplementation((source) => Promise.resolve({
playable: true,
exhibit: source,
media: {
id: source.audioChannelCode,
url: source.audioUrl,
duration: source.audioDuration,
language: source.audioLanguage
},
playInfo: { title: source.name }
}))
audioState.currentAudio.value = { id: 'audio-1', audioUrl: voiceExhibit.audioUrl }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
audioState.playing.value = true
mocks.close.mockImplementation(() => {
audioState.currentAudio.value = null
audioState.currentSource.value = null
audioState.playing.value = false
})
vi.mocked(uni.showActionSheet).mockImplementation(({ success }) => success?.({ tapIndex: 1 } as any))
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-voice').text()).toBe('女声')
await wrapper.get('.detail-audio-voice').trigger('tap')
await flushPromises()
expect(uni.showActionSheet).toHaveBeenCalledWith(expect.objectContaining({
itemList: ['英文女声', '英文男声']
}))
expect(mocks.selectExplainDetailAudioOption).toHaveBeenCalledWith(voiceExhibit, 'standard.en-US.male')
expect(wrapper.get('.detail-audio-voice').text()).toBe('男声')
expect(mocks.close).toHaveBeenCalledTimes(1)
expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({
audioUrl: '/assets/astrolabe-en-male.mp3'
}), expect.any(Object))
})
it('粤语只有女声时保留禁用的声线按钮', async () => {
mocks.enterExplainDetail.mockResolvedValue({
...exhibit,
audioLanguage: 'yue-HK',
audioVoiceGender: 'female',
audioOptions: [
{
channelCode: 'standard.yue-HK.female',
displayName: '粤语女声',
languageCode: 'yue-HK',
gender: 'female',
audioUrl: '/assets/astrolabe-yue-female.mp3',
duration: 83,
isDefault: true
}
]
})
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-voice').text()).toBe('女声')
expect(wrapper.get('.detail-audio-voice').attributes('disabled')).toBeDefined()
})
it('深链语言不受支持时本地回退至中文并规范化地址', async () => {
it('深链语言不受支持时回退至中文并规范化地址', async () => {
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
...exhibit,
audioLanguage: request.lang,
@@ -362,9 +234,8 @@ describe('讲解详情音频优先布局', () => {
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.enterExplainDetail).toHaveBeenCalledWith(expect.objectContaining({ lang: 'en-US' }))
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'zh-CN')
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'))
@@ -397,39 +268,11 @@ describe('讲解详情音频优先布局', () => {
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.find('.detail-audio-status').exists()).toBe(false)
expect(wrapper.find('.detail-audio-progress').exists()).toBe(true)
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
})
it('切换语言时使用初次详情响应中的变体,不重复请求详情或正文', async () => {
const localizedExhibit = {
...exhibit,
guideText: '粤语通道使用普通话文案。',
audioUrl: '/assets/astrolabe-yue.mp3',
audioDuration: 83,
audioLanguage: 'yue-HK',
audioStatus: 'READY',
audioHasText: true,
supportedLanguages: ['zh-CN', 'yue-HK', 'en-US']
}
mocks.selectExplainDetailLanguage.mockReturnValue(localizedExhibit)
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.findAll('.language-option').find((node) => node.text() === '粤语')?.trigger('tap')
await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.loadExplainDetailText).not.toHaveBeenCalled()
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'yue-HK')
expect(wrapper.get('.section-text').text()).toContain('粤语通道使用普通话文案')
expect(wrapper.get('.detail-audio-subtitle').text()).toBe('0:00 / 1:23')
})
it('无音频时禁用播放按钮并显示原因', async () => {
mocks.enterExplainDetail.mockResolvedValue({
...exhibit,
@@ -449,7 +292,7 @@ describe('讲解详情音频优先布局', () => {
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('当前语言暂无语音讲解。')
expect(wrapper.find('.detail-audio-status').exists()).toBe(false)
})
it('播放失败显示错误并通过既有刷新链路重试', async () => {
@@ -469,9 +312,9 @@ describe('讲解详情音频优先布局', () => {
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('重试')
expect(wrapper.find('.detail-audio-retry').exists()).toBe(false)
await wrapper.get('.detail-audio-retry').trigger('tap')
await wrapper.get('.detail-audio-play').trigger('tap')
await flushPromises()
expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.any(Object), { refreshPlayInfo: true })
expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({
@@ -480,6 +323,42 @@ describe('讲解详情音频优先布局', () => {
}))
})
it('播放器单行提供倍速循环和男女声切换', async () => {
mocks.enterExplainDetail.mockResolvedValue({
...exhibit,
audioOptions: [
{
channelCode: 'standard.en-US.male',
languageCode: 'en-US',
gender: 'male',
playUrl: '/assets/astrolabe-en-male.mp3'
},
{
channelCode: 'standard.en-US.female',
languageCode: 'en-US',
gender: 'female',
playUrl: '/assets/astrolabe-en-female.mp3'
}
]
})
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-speed').text()).toBe('1.0')
expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为女声讲解')
await wrapper.get('.detail-audio-speed').trigger('tap')
await wrapper.get('.detail-audio-voice').trigger('tap')
expect(mocks.setPlaybackRate).toHaveBeenCalledWith(1.25)
expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为男声讲解')
expect(mocks.toggleMute).not.toHaveBeenCalled()
})
it('上一页是讲解对象列表时使用页面栈返回', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/guide-stop-list', options: {} },
@@ -528,6 +407,9 @@ describe('讲解详情音频优先布局', () => {
})
await flushPromises()
vi.mocked(uni.navigateBack).mockImplementationOnce((options: any) => {
options.fail?.()
})
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
@@ -536,6 +418,6 @@ describe('讲解详情音频优先布局', () => {
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()
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
})
})