适配H5讲解统一详情接口
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-08-05 14:52:36 +08:00
parent c287ef3a2a
commit 2edb6bd9f2
25 changed files with 946 additions and 1148 deletions

View File

@@ -1,47 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DefaultAudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
const stopInfoResponse = {
const detailResponse = {
code: 0,
data: {
available: true,
targetType: 'STOP',
targetId: 'stop-1',
resolvedStopId: 'stop-1',
lang: 'zh-CN',
title: '测试讲解点',
description: '测试简介',
imageStatus: 'READY',
imageSource: 'STOP',
galleryUrls: '[]',
playTargetType: 'STOP',
playTargetId: 'stop-1',
hasAudio: false,
id: '865546647764037632',
version: 'standard',
name: '测试讲解点',
imageStatus: 'MISSING',
textVariants: [{ version: 'standard', languageCode: 'zh-CN', text: '测试正文' }],
audioTracks: [{
version: 'standard', languageCode: 'zh-CN', gender: 'female',
playUrl: '/museum-assets/audio/zh-female.mp3'
}],
hasText: true,
supportedLanguages: ['zh-CN'],
audioStatus: 'MISSING',
audioOptions: []
hasAudio: true
}
}
describe('DefaultAudioPlayInfoRepository stop-info loading mode', () => {
describe('DefaultAudioPlayInfoRepository unified stop detail', () => {
afterEach(() => vi.unstubAllGlobals())
it('defaults H5 detail requests to lightweight mode and keeps its cache separate from legacy mode', async () => {
it('requests the versioned endpoint and caches each detail version independently', async () => {
const requestUrls: string[] = []
vi.stubGlobal('uni', {
request: ({ url, success }: { url: string, success: (response: unknown) => void }) => {
requestUrls.push(url)
success({ statusCode: 200, data: stopInfoResponse })
success({
statusCode: 200,
data: {
...detailResponse,
data: {
...detailResponse.data,
version: String(url).includes('version=extended') ? 'extended' : 'standard'
}
}
})
}
})
const repository = new DefaultAudioPlayInfoRepository()
await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN' })
await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN', lightweight: false })
const first = await repository.getStopDetail('865546647764037632')
const second = await repository.getStopDetail('865546647764037632')
const extended = await repository.getStopDetail('865546647764037632', { version: 'extended' })
expect(requestUrls).toHaveLength(2)
expect(new URL(requestUrls[0], 'http://localhost').searchParams.get('lightweight')).toBe('true')
expect(new URL(requestUrls[1], 'http://localhost').searchParams.get('lightweight')).toBe('false')
expect(first.id).toBe('865546647764037632')
expect(second).toBe(first)
expect(extended).not.toBe(first)
expect(extended.version).toBe('extended')
expect(requestUrls).toEqual([
'/app-api/gis/guide/stops/865546647764037632?version=standard',
'/app-api/gis/guide/stops/865546647764037632?version=extended'
])
expect(first.audioTracks[0]?.channelCode).toBe('standard.zh-CN.female')
})
it('bypasses a completed entry only when refresh is explicitly requested', async () => {
const request = vi.fn(({ success }: { success: (response: unknown) => void }) => {
success({ statusCode: 200, data: detailResponse })
})
vi.stubGlobal('uni', { request })
const repository = new DefaultAudioPlayInfoRepository()
await repository.getStopDetail('stop-1')
await repository.getStopDetail('stop-1', { refresh: true })
expect(request).toHaveBeenCalledTimes(2)
})
it('surfaces a business missing-detail response as a detail error', async () => {
vi.stubGlobal('uni', {
request: ({ success }: { success: (response: unknown) => void }) => {
success({ statusCode: 200, data: { code: 1020005000, msg: '导览讲解点不存在' } })
}
})
const repository = new DefaultAudioPlayInfoRepository()
await expect(repository.getStopDetail('not-found')).rejects.toThrow('导览讲解点不存在')
})
})

View File

@@ -10,7 +10,10 @@ const installRequest = (handler: (url: string) => unknown) => {
}
describe('BackendExplainContentProvider hall stop paging', () => {
afterEach(() => vi.unstubAllGlobals())
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('uses the paged hall endpoint and maps independent pages without outlines', async () => {
const requests: string[] = []
@@ -23,8 +26,8 @@ describe('BackendExplainContentProvider hall stop paging', () => {
data: {
total: 30,
list: pageNo === '1'
? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', playTargetType: 'STOP', playTargetId: 'target-1' }]
: [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', playTargetType: 'STOP', playTargetId: 'target-2' }]
? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', outlineId: 'outline-1', linkedExhibits: [] }]
: [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', outlineId: 'outline-1', linkedExhibits: [] }]
}
}
})
@@ -35,7 +38,7 @@ describe('BackendExplainContentProvider hall stop paging', () => {
])
expect(first).toMatchObject({ total: 30, pageNo: 1, pageSize: 20, hasMore: true })
expect(first.items[0]).toMatchObject({ id: 'stop-1', hallName: '恐龙厅', playTargetId: 'target-1' })
expect(first.items[0]).toMatchObject({ id: 'stop-1', stopId: 'stop-1', hallName: '恐龙厅', outlineId: 'outline-1' })
expect(second.items.map((item) => item.id)).toEqual(['stop-2'])
expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(2)
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
@@ -79,4 +82,25 @@ describe('BackendExplainContentProvider hall stop paging', () => {
expect(requests.filter((url) => url.includes('/catalog/halls?'))).toHaveLength(1)
expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(1)
})
it('warns when a hall has guide stops but every compatibility outline reports zero', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
installRequest((url) => {
if (url.includes('/catalog/halls?')) {
return { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', stopCount: 2 }] }
}
if (url.includes('/catalog/halls/hall-1/outlines?')) {
return { code: 0, data: [{ id: 'outline-1', name: '单元一', hallId: 'hall-1', stopCount: 0 }] }
}
throw new Error(`Unexpected request: ${url}`)
})
const units = await new BackendExplainContentProvider().listTemporaryBusinessUnitsByHall('hall-1')
expect(units).toHaveLength(1)
expect(warn).toHaveBeenCalledWith(
'一级单元与讲解点关联异常,一级单元仅作为兼容入口:',
expect.objectContaining({ hallId: 'hall-1', hallStopCount: 2 })
)
})
})

View File

@@ -295,7 +295,7 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.find('.detail-audio-status').exists()).toBe(false)
})
it('播放失败显示错误并通过既有刷新链路重试', async () => {
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 = '音频暂时无法播放'
@@ -316,7 +316,10 @@ describe('讲解详情音频优先布局', () => {
await wrapper.get('.detail-audio-play').trigger('tap')
await flushPromises()
expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.any(Object), { refreshPlayInfo: true })
expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.objectContaining({
id: exhibit.id,
guideVersion: 'standard'
}))
expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({
retryOnError: expect.any(Function),
displayMode: 'mini'
@@ -349,13 +352,13 @@ describe('讲解详情音频优先布局', () => {
await flushPromises()
expect(wrapper.get('.detail-audio-speed').text()).toBe('1.0')
expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为声讲解')
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(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为声讲解')
expect(mocks.toggleMute).not.toHaveBeenCalled()
})
@@ -407,9 +410,6 @@ describe('讲解详情音频优先布局', () => {
})
await flushPromises()
vi.mocked(uni.navigateBack).mockImplementationOnce((options: any) => {
options.fail?.()
})
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
@@ -418,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).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.navigateBack).not.toHaveBeenCalled()
})
})

View File

@@ -22,7 +22,7 @@ describe('ExplainGuideStopCatalog', () => {
expect(wrapper.findAll('.stop-status')).toHaveLength(3)
expect(wrapper.text()).toContain('讲解')
expect(wrapper.text()).toContain('图文')
expect(wrapper.text()).toContain('暂无内容')
expect(wrapper.text()).not.toContain('暂无内容')
expect(wrapper.find('.hall-arrow').exists()).toBe(false)
expect(wrapper.find('.stop-name').classes()).toContain('stop-name')
})

View File

@@ -110,8 +110,7 @@ describe('讲解对象列表返回', () => {
expect(url.split('?')[0]).toBe('/pages/exhibit/detail')
expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅')
expect(params.get('targetType')).toBe('STOP')
expect(params.get('targetId')).toBe('target-1')
expect(params.get('stopId')).toBe('stop-1')
wrapper.unmount()
})

View File

@@ -1,102 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import type { GuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
import type { GuideStopDetail } from '@/data/adapters/guideStopInfoAdapter'
import type { AudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
import type { ExplainRepository } from '@/repositories/ExplainRepository'
import { ExplainUseCase } from '@/usecases/explainUseCase'
import { dataSourceConfig } from '@/config/dataSource'
const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo => ({
available: true,
targetType: 'STOP',
targetId: 'stop-1',
lang: 'zh-CN',
title: 'Guide stop',
const createDetail = (overrides: Partial<GuideStopDetail> = {}): GuideStopDetail => ({
id: 'stop-1',
version: 'standard',
name: 'Guide stop',
galleryUrls: [],
imageStatus: 'MISSING',
linkedExhibits: [
{
id: 'exhibit-1',
name: 'Linked exhibit',
coverImageUrl: '/linked-exhibit.jpg'
}
linkedExhibits: [{ id: 'exhibit-1', name: 'Linked exhibit', coverImageUrl: '/linked-exhibit.jpg' }],
isSharedStop: false,
textVariants: [
{ version: 'standard', languageCode: 'zh-CN', text: '普通话正文' },
{ version: 'standard', languageCode: 'en-US', text: 'English text' }
],
playTargetType: 'STOP',
playTargetId: 'stop-1',
hasAudio: false,
hasText: false,
supportedLanguages: [],
audioStatus: 'MISSING',
audioTracks: [
{ channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/zh-female.mp3' },
{ channelCode: 'standard.yue-HK.female', version: 'standard', languageCode: 'yue-HK', gender: 'female', playUrl: '/yue-female.mp3' }
],
hasText: true,
hasAudio: true,
textVariantCount: 2,
audioTrackCount: 2,
...overrides
})
const createUseCase = (stopInfo: GuideStopInfo) => {
const createUseCase = (detail: GuideStopDetail) => {
const explain = {
getExhibitById: vi.fn().mockResolvedValue(null),
listExplainExhibits: vi.fn().mockResolvedValue([])
} as unknown as ExplainRepository
const audioPlayInfo = {
getStopInfo: vi.fn().mockResolvedValue(stopInfo)
} as unknown as AudioPlayInfoRepository
return new ExplainUseCase(explain, audioPlayInfo)
const audio = { getStopDetail: vi.fn().mockResolvedValue(detail) } as unknown as AudioPlayInfoRepository
return { useCase: new ExplainUseCase(explain, audio), explain, audio }
}
describe('ExplainUseCase stop image policy', () => {
it('uses stop-info first in remote mode without requesting a catalog fallback', async () => {
describe('ExplainUseCase unified stop detail', () => {
it('uses stopId once in remote mode without requesting a catalog fallback', async () => {
const originalMode = dataSourceConfig.explainContentMode
Object.assign(dataSourceConfig, { explainContentMode: 'remote' })
const explain = {
getExhibitById: vi.fn(),
listExplainExhibits: vi.fn(),
listExplainExhibitsByHall: vi.fn()
} as unknown as ExplainRepository
const audio = { getStopInfo: vi.fn().mockResolvedValue(createStopInfo()) } as unknown as AudioPlayInfoRepository
const { useCase, explain, audio } = createUseCase(createDetail())
await new ExplainUseCase(explain, audio).enterExplainDetail({
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', hallName: '宇宙厅'
})
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', hallName: '宇宙厅' })
expect(audio.getStopInfo).toHaveBeenCalledTimes(1)
expect(audio.getStopDetail).toHaveBeenCalledWith('stop-1', { version: 'standard' })
expect(explain.getExhibitById).not.toHaveBeenCalled()
expect(explain.listExplainExhibits).not.toHaveBeenCalled()
expect(result.hallName).toBe('宇宙厅')
Object.assign(dataSourceConfig, { explainContentMode: originalMode })
})
it('delegates hall exhibits and business-unit stops without loading full exhibits', async () => {
const explain = {
listExplainExhibitsByHall: vi.fn().mockResolvedValue([]),
listGuideStopsByBusinessUnit: vi.fn().mockResolvedValue([])
} as unknown as ExplainRepository
const useCase = new ExplainUseCase(explain, {} as AudioPlayInfoRepository)
await useCase.listExhibitsByHallId('hall-1')
await useCase.listGuideStopsByBusinessUnit('hall-1', 'outline-1')
expect(explain.listExplainExhibitsByHall).toHaveBeenCalledWith('hall-1')
expect(explain.listGuideStopsByBusinessUnit).toHaveBeenCalledWith('hall-1', 'outline-1')
})
it('does not use linked exhibit images when stop-info reports MISSING', async () => {
const detail = await createUseCase(createStopInfo()).enterExplainDetail({
exhibitId: 'stop-1',
targetType: 'STOP',
targetId: 'stop-1'
})
it('does not use linked exhibit images when detail reports MISSING', async () => {
const { useCase } = createUseCase(createDetail())
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1' })
expect(detail.image).toBeUndefined()
expect(detail.galleryUrls).toEqual([])
expect(detail.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg')
expect(result.image).toBeUndefined()
expect(result.galleryUrls).toEqual([])
expect(result.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg')
})
it('uses the stop image when stop-info reports READY', async () => {
const detail = await createUseCase(createStopInfo({
imageStatus: 'READY',
coverImageUrl: '/guide-stop.jpg',
galleryUrls: ['/guide-stop-gallery.jpg']
})).enterExplainDetail({
exhibitId: 'stop-1',
targetType: 'STOP',
targetId: 'stop-1'
})
it('uses local standard text and audio variants, with Chinese text fallback for Cantonese', async () => {
const { useCase, audio } = createUseCase(createDetail())
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', lang: 'yue-HK' })
const text = await useCase.loadExplainDetailText(result)
const selection = await useCase.selectAudioForExplainDetail(result, { voiceGender: 'male' })
expect(detail.image).toBe('/guide-stop.jpg')
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
expect(result.guideText).toBe('普通话正文')
expect(text.textInfo.text).toBe('普通话正文')
expect(selection.media?.url).toBe('/yue-female.mp3')
expect(audio.getStopDetail).toHaveBeenCalledTimes(1)
})
it('uses the recommended track for the current detail version by default', async () => {
const { useCase } = createUseCase(createDetail({
recommendedTrackCode: 'standard.zh-CN.female',
audioTracks: [
{ channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/zh-female.mp3' },
{ channelCode: 'standard.zh-CN.male', version: 'standard', languageCode: 'zh-CN', gender: 'male', playUrl: '/zh-male.mp3' }
]
}))
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1' })
const selection = await useCase.selectAudioForExplainDetail(result)
expect(selection.media?.url).toBe('/zh-female.mp3')
})
})

View File

@@ -1,18 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const repositoryMocks = vi.hoisted(() => ({
getPlayInfo: vi.fn()
}))
vi.mock('@/repositories/AudioPlayInfoRepository', () => ({
audioPlayInfoRepository: {
getPlayInfo: repositoryMocks.getPlayInfo
},
audioReasonToText: () => '当前语言暂无语音讲解'
}))
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
describe('全局讲解播放器语言切换', () => {
@@ -20,10 +8,7 @@ describe('全局讲解播放器语言切换', () => {
beforeEach(() => {
player.close()
repositoryMocks.getPlayInfo.mockReset()
vi.stubGlobal('uni', {
showToast: vi.fn()
})
vi.stubGlobal('uni', { showToast: vi.fn() })
vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined)
@@ -35,18 +20,9 @@ describe('全局讲解播放器语言切换', () => {
vi.unstubAllGlobals()
})
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => {
repositoryMocks.getPlayInfo.mockResolvedValue({
playable: true,
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'yue-HK',
audioId: 'cantonese-audio',
playUrl: '/museum-assets/audio/cantonese.mp3',
hasText: true,
fallback: false
})
it('切换到粤语时只从当前统一详情的音轨数组选择,不产生额外 HTTP 请求', async () => {
const request = vi.fn()
vi.stubGlobal('uni', { request, showToast: vi.fn() })
await player.play({
id: 'mandarin-audio',
name: '测试讲解',
@@ -54,28 +30,25 @@ describe('全局讲解播放器语言切换', () => {
language: 'zh-CN'
}, {
source: {
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'zh-CN'
stopId: '1823450596800289',
lang: 'zh-CN',
voiceGender: 'male',
audioOptions: [
{ channelCode: 'standard.zh-CN.male', version: 'standard', languageCode: 'zh-CN', gender: 'male', playUrl: '/museum-assets/audio/mandarin.mp3' },
{ channelCode: 'standard.yue-HK.female', version: 'standard', languageCode: 'yue-HK', gender: 'female', playUrl: '/museum-assets/audio/cantonese.mp3' }
]
}
})
const pauseSpy = vi.mocked(window.HTMLMediaElement.prototype.pause)
pauseSpy.mockClear()
const switching = player.switchLanguage('yue-HK')
expect(pauseSpy).toHaveBeenCalledTimes(1)
expect(player.playing.value).toBe(false)
expect(player.currentTime.value).toBe(0)
expect(repositoryMocks.getPlayInfo).toHaveBeenCalledWith(expect.objectContaining({
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'yue-HK',
refresh: true
}))
await expect(switching).resolves.toBe(true)
expect(player.currentSource.value?.lang).toBe('yue-HK')
expect(request).not.toHaveBeenCalled()
expect(player.currentSource.value).toEqual(expect.objectContaining({
stopId: '1823450596800289',
lang: 'yue-HK',
voiceGender: 'female'
}))
expect(player.currentAudio.value).toEqual(expect.objectContaining({
language: 'yue-HK',
audioUrl: '/museum-assets/audio/cantonese.mp3'

View File

@@ -1,137 +1,67 @@
import { describe, expect, it } from 'vitest'
import { toGuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
import { toGuideStopDetail } from '@/data/adapters/guideStopInfoAdapter'
describe('guide stop-info adapter', () => {
it('normalizes aggregated language variants and exposes only displayable languages', () => {
const result = toGuideStopInfo({
available: true,
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN',
const languages = ['zh-CN', 'en-US', 'yue-HK'] as const
describe('unified guide stop detail adapter', () => {
it('preserves snowflake IDs, synthesizes stable track codes, and ignores images marked missing', () => {
const textVariants = languages.map((languageCode) => ({
version: 'standard',
languageCode,
text: `standard-${languageCode}`
}))
const audioTracks = languages.flatMap((languageCode) => (
languageCode === 'yue-HK'
? [{ version: 'standard', languageCode, gender: 'female', playUrl: `/standard-${languageCode}-female.mp3` }]
: ['female', 'male'].map((gender) => ({ version: 'standard', languageCode, gender, playUrl: `/standard-${languageCode}-${gender}.mp3` }))
))
const detail = toGuideStopDetail({
id: '865546647764037632',
version: 'standard',
outlineId: '7467940240901013505',
linkedExhibits: [{ id: '865546647764037633', name: '关联展品' }],
name: '青铜神树',
coverImageUrl: '/should-not-be-used.webp',
galleryUrls: '["/should-not-be-used-gallery.webp"]',
imageStatus: 'MISSING',
recommendedTrackCode: 'standard.zh-CN.female',
textVariants,
audioTracks,
textVariantCount: 3,
audioTrackCount: 5
})
expect(detail.id).toBe('865546647764037632')
expect(detail.version).toBe('standard')
expect(detail.outlineId).toBe('7467940240901013505')
expect(detail.linkedExhibits[0]?.id).toBe('865546647764037633')
expect(detail.coverImageUrl).toBeUndefined()
expect(detail.galleryUrls).toEqual([])
expect(detail.textVariants).toHaveLength(3)
expect(detail.audioTracks).toHaveLength(5)
expect(detail.recommendedTrackCode).toBe('standard.zh-CN.female')
expect(detail.audioTracks.find((track) => track.channelCode === 'standard.zh-CN.female')?.playUrl)
.toBe('/standard-zh-CN-female.mp3')
})
it('drops unusable tracks while retaining a Chinese text variant for Cantonese fallback', () => {
const detail = toGuideStopDetail({
id: 'stop-1',
version: 'extended',
name: '测试',
imageStatus: 'READY',
coverImageUrl: '/museum-assets/cover.webp',
languageVariants: [
{
lang: 'zh',
enabled: true,
playable: true,
audioStatus: 'READY',
playUrl: '/museum-assets/audio/zh.mp3',
duration: '50',
audioId: 12,
hasText: true,
textAvailable: true,
text: '普通话讲解词',
textLength: '6',
textHash: 'zh-hash'
},
{
lang: 'yue-HK',
enabled: true,
playable: false,
audioStatus: 'MISSING',
textAvailable: true,
text: '粤语使用普通话文案',
reason: 'NO_PUBLISHED_AUDIO'
},
{
lang: 'en-US',
enabled: false,
playable: true,
playUrl: '/museum-assets/audio/en.mp3'
}
textVariants: [{ version: 'extended', languageCode: 'zh-CN', text: '普通话正文' }],
audioTracks: [
{ version: 'extended', languageCode: 'yue-HK', gender: 'female', playUrl: '/cantonese.mp3' },
{ version: 'extended', languageCode: 'zh-CN', gender: 'female' }
]
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'yue-HK'])
expect(result.languageVariants['zh-CN']).toMatchObject({
playable: true,
playUrl: '/museum-assets/audio/zh.mp3',
duration: 50,
audioId: '12',
text: '普通话讲解词',
textLength: 6
})
expect(result.languageVariants['yue-HK']).toMatchObject({
playable: false,
textAvailable: true,
reason: 'NO_PUBLISHED_AUDIO'
})
})
it('keeps supportedLanguages as the compatibility fallback when variants are absent', () => {
const result = toGuideStopInfo({
supportedLanguages: ['zh-CN', 'en-US']
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
expect(result.languageVariants).toEqual({})
})
it('uses playable audio options as the primary language and voice contract', () => {
const result = toGuideStopInfo({
supportedLanguages: ['zh-CN', 'yue-HK'],
audioOptions: [
{
channelCode: 'standard.zh-CN.male',
displayName: '普通话男声',
languageCode: 'zh-CN',
gender: 'male',
playUrl: '/museum-assets/audio/zh-male.mp3',
duration: '51',
isDefault: false,
sortOrder: 11
},
{
channelCode: 'standard.en-US.female',
displayName: '英文女声',
languageCode: 'en-US',
gender: 'female',
playUrl: '/museum-assets/audio/en-female.mp3',
duration: 49,
isDefault: true,
sortOrder: 20
},
{
channelCode: 'standard.zh-CN.female',
displayName: '普通话女声',
languageCode: 'zh-CN',
gender: 'female',
playUrl: '/museum-assets/audio/zh-female.mp3',
isDefault: true,
sortOrder: 10
},
{
channelCode: 'invalid',
languageCode: 'zh-CN',
gender: 'female'
}
]
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
expect(result.audioOptions.map((option) => option.channelCode)).toEqual([
'standard.zh-CN.female',
'standard.zh-CN.male',
'standard.en-US.female'
])
expect(result.audioOptions[0]).toMatchObject({
displayName: '普通话女声',
gender: 'female',
playUrl: '/museum-assets/audio/zh-female.mp3',
isDefault: true
})
expect(detail.version).toBe('extended')
expect(detail.textVariants[0]?.text).toBe('普通话正文')
expect(detail.audioTracks).toEqual([expect.objectContaining({
channelCode: 'extended.yue-HK.female',
gender: 'female'
})])
})
})

View File

@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { explainUseCase } from '@/usecases/explainUseCase'
import { PoiDetailUseCase } from '@/usecases/poiDetailUseCase'
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
getExhibitById: vi.fn(),
listHalls: vi.fn()
}
}))
describe('PoiDetailUseCase', () => {
beforeEach(() => {
vi.mocked(explainUseCase.getExhibitById).mockReset()
})
it('resolves a POI exhibit reference to a guide stop before opening the unified detail route', async () => {
vi.mocked(explainUseCase.getExhibitById).mockResolvedValue({
id: 'stop-865546647764037632',
resolvedStopId: '865546647764037632'
} as never)
const target = await new PoiDetailUseCase().resolve({
id: 'poi-1',
name: '青铜神树',
floorId: 'floor-1',
exhibitId: 'exhibit-1'
})
const params = new URLSearchParams(target.url.split('?')[1])
expect(explainUseCase.getExhibitById).toHaveBeenCalledWith('exhibit-1')
expect(target.detailId).toBe('865546647764037632')
expect(target.stopId).toBe('865546647764037632')
expect(params.get('id')).toBe('exhibit-1')
expect(params.get('stopId')).toBe('865546647764037632')
})
})