import { describe, expect, it, vi } from 'vitest' import type { GuideStopInfo } 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 => ({ available: true, targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN', title: 'Guide stop', galleryUrls: [], imageStatus: 'MISSING', linkedExhibits: [ { id: 'exhibit-1', name: 'Linked exhibit', coverImageUrl: '/linked-exhibit.jpg' } ], playTargetType: 'STOP', playTargetId: 'stop-1', hasAudio: false, hasText: false, supportedLanguages: [], languageVariants: {}, audioStatus: 'MISSING', ...overrides }) const createUseCase = (stopInfo: GuideStopInfo) => { 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) } describe('ExplainUseCase stop image policy', () => { it('uses stop-info first 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 await new ExplainUseCase(explain, audio).enterExplainDetail({ exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', hallName: '宇宙厅' }) expect(audio.getStopInfo).toHaveBeenCalledTimes(1) expect(explain.getExhibitById).not.toHaveBeenCalled() expect(explain.listExplainExhibits).not.toHaveBeenCalled() 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' }) expect(detail.image).toBeUndefined() expect(detail.galleryUrls).toEqual([]) expect(detail.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' }) expect(detail.image).toBe('/guide-stop.jpg') expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg']) }) it('uses stop-info language variants locally without requesting play-info or text-info', async () => { const audio = { getStopInfo: vi.fn().mockResolvedValue(createStopInfo({ hasAudio: true, hasText: true, supportedLanguages: ['zh-CN', 'yue-HK'], languageVariants: { 'zh-CN': { lang: 'zh-CN', enabled: true, playable: true, playUrl: '/audio/zh.mp3', duration: 50, hasText: true, textAvailable: true, text: '普通话讲解词', fallback: false, audioStatus: 'READY' }, 'yue-HK': { lang: 'yue-HK', enabled: true, playable: true, playUrl: '/audio/yue.mp3', duration: 52, hasText: true, textAvailable: true, text: '粤语通道使用普通话文案', fallback: false, audioStatus: 'READY' } } as GuideStopInfo['languageVariants'] })), getPlayInfo: vi.fn(), getTextInfo: vi.fn() } as unknown as AudioPlayInfoRepository const explain = { getExhibitById: vi.fn(), listExplainExhibits: vi.fn() } as unknown as ExplainRepository const useCase = new ExplainUseCase(explain, audio) const detail = await useCase.enterExplainDetail({ exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN' }) const cantonese = useCase.selectExplainDetailLanguage(detail, 'yue-HK') expect(detail.guideText).toBe('普通话讲解词') expect(detail.audioUrl).toBe('/audio/zh.mp3') expect(cantonese.guideText).toBe('粤语通道使用普通话文案') expect(cantonese.audioUrl).toBe('/audio/yue.mp3') expect(cantonese.audioDuration).toBe(52) expect((audio as any).getPlayInfo).not.toHaveBeenCalled() expect((audio as any).getTextInfo).not.toHaveBeenCalled() }) })