From cf6ad0231879f6517c58868978e32d818bb15932 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Fri, 17 Jul 2026 18:27:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=B2=E8=A7=A3=E5=AF=B9=E8=B1=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=8E=A5=E5=85=A5=E5=B1=95=E5=8E=85=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/explain/ExplainHallSelect.vue | 146 +++++------ .../backendExplainContentProvider.ts | 103 ++++++-- .../providers/staticMuseumContentProvider.ts | 16 ++ src/domain/museum.ts | 8 + src/pages.json | 10 +- src/pages/explain/business-unit-list.vue | 137 ----------- src/pages/explain/guide-stop-list.vue | 232 +++++++++--------- src/pages/explain/list.vue | 9 +- src/pages/index/index.vue | 9 +- src/repositories/ExplainRepository.ts | 33 ++- src/usecases/explainUseCase.ts | 14 +- src/utils/explainNavigation.ts | 4 + tests/e2e/explain-hall-page.spec.ts | 40 +++ .../BackendExplainContentProvider.spec.ts | 57 +++++ .../ExplainGuideStopListNavigation.spec.ts | 122 +++++++++ tests/unit/ExplainHallSelect.spec.ts | 36 ++- tests/unit/ExplainNavigation.spec.ts | 13 + tests/unit/ExplainUseCase.spec.ts | 102 ++++++++ 18 files changed, 682 insertions(+), 409 deletions(-) delete mode 100644 src/pages/explain/business-unit-list.vue create mode 100644 src/utils/explainNavigation.ts create mode 100644 tests/e2e/explain-hall-page.spec.ts create mode 100644 tests/unit/BackendExplainContentProvider.spec.ts create mode 100644 tests/unit/ExplainGuideStopListNavigation.spec.ts create mode 100644 tests/unit/ExplainNavigation.spec.ts create mode 100644 tests/unit/ExplainUseCase.spec.ts diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue index 890a1c0..4344cfd 100644 --- a/src/components/explain/ExplainHallSelect.vue +++ b/src/components/explain/ExplainHallSelect.vue @@ -12,10 +12,11 @@ class="explain-scroll" :class="{ 'hall-stage': stage === 'hall', - 'unit-stage': stage === 'unit', 'stop-stage': stage === 'stop' }" scroll-y + @scroll="handleScroll" + @scrolltolower="handleScrollToLower" :show-scrollbar="false" > @@ -24,8 +25,9 @@ - 讲解内容加载失败 + 讲解对象加载失败 {{ error }} + @@ -147,9 +132,6 @@ - - diff --git a/src/pages/explain/guide-stop-list.vue b/src/pages/explain/guide-stop-list.vue index 4bbfd17..22ea435 100644 --- a/src/pages/explain/guide-stop-list.vue +++ b/src/pages/explain/guide-stop-list.vue @@ -1,19 +1,18 @@ diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue index 7e77c26..1695d4d 100644 --- a/src/pages/explain/list.vue +++ b/src/pages/explain/list.vue @@ -38,6 +38,7 @@ import { type GuideTopTab } from '@/utils/guideTopTabs' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' +import { explainGuideStopListUrl } from '@/utils/explainNavigation' const explainHallItems = ref([]) const explainLoading = ref(false) @@ -56,7 +57,6 @@ const buildExplainHallItems = ( floorLabel: hall.floorLabel, image: hall.image, explainCount: hall.exhibitCount || 0, - businessUnitCount: summaries[hall.id]?.businessUnitCount, guideStopCount: summaries[hall.id]?.guideStopCount, searchText: normalizeExplainKeyword([ hall.name, @@ -225,13 +225,8 @@ const handleTopTabChange = (tab: GuideTopTab) => { const handleExplainHallClick = (hallId: string) => { const hall = explainHallItems.value.find((item) => item.id === hallId) - const params = new URLSearchParams({ - hallId, - hallName: hall?.name || '讲解' - }) - uni.navigateTo({ - url: `/pages/explain/business-unit-list?${params.toString()}` + url: explainGuideStopListUrl(hallId, hall?.name || '讲解') }) } diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 0a685ee..f7986a4 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -302,6 +302,7 @@ import type { import ExplainHallSelect, { type ExplainHallSelectItem } from '@/components/explain/ExplainHallSelect.vue' +import { explainGuideStopListUrl } from '@/utils/explainNavigation' import { getLastGuideLocationPreviewUrl, isGuideTopTab, @@ -990,7 +991,6 @@ const buildExplainHallItems = ( floorLabel: hall.floorLabel, image: hall.image, explainCount: hall.exhibitCount || 0, - businessUnitCount: summaries[hall.id]?.businessUnitCount, guideStopCount: summaries[hall.id]?.guideStopCount, searchText: normalizeExplainKeyword(keywordParts.join(' ')) } @@ -1725,13 +1725,8 @@ const guideSearchText = computed(() => { // 讲解页面处理 const handleExplainHallClick = async (hallId: string) => { const hall = explainHallItems.value.find((item) => item.id === hallId) - const params = new URLSearchParams({ - hallId, - hallName: hall?.name || '讲解' - }) - uni.navigateTo({ - url: `/pages/explain/business-unit-list?${params.toString()}` + url: explainGuideStopListUrl(hallId, hall?.name || '讲解') }) } diff --git a/src/repositories/ExplainRepository.ts b/src/repositories/ExplainRepository.ts index 217ae48..793805a 100644 --- a/src/repositories/ExplainRepository.ts +++ b/src/repositories/ExplainRepository.ts @@ -1,6 +1,7 @@ import type { ExplainBusinessUnit, ExplainGuideStop, + ExplainGuideStopPage, ExplainTrack, MuseumExhibit, MuseumHall, @@ -27,6 +28,7 @@ export interface ExplainRepository { listHalls(): Promise getHallById(id: string): Promise listGuideStopsByHall(hallId: string): Promise + listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise listTemporaryBusinessUnitsByHall(hallId: string): Promise listTracks(): Promise @@ -73,7 +75,36 @@ export class DefaultExplainRepository implements ExplainRepository { } async listGuideStopsByHall(hallId: string) { - return this.explainContent.listGuideStopsByHall?.(hallId) || [] + const pageSize = 100 + const items: ExplainGuideStop[] = [] + const seen = new Set() + for (let pageNo = 1; ; pageNo += 1) { + const page = await this.listGuideStopsPageByHall(hallId, pageNo, pageSize) + page.items.forEach((item) => { + if (!seen.has(item.id)) { + seen.add(item.id) + items.push(item) + } + }) + if (!page.hasMore || items.length >= page.total) return items + } + } + + async listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number) { + if (this.explainContent.listGuideStopsPageByHall) { + return this.explainContent.listGuideStopsPageByHall(hallId, pageNo, pageSize) + } + const items = await this.explainContent.listGuideStopsByHall?.(hallId) || [] + const normalizedPageNo = Math.max(1, Math.floor(pageNo) || 1) + const normalizedPageSize = Math.max(1, Math.floor(pageSize) || 20) + const offset = (normalizedPageNo - 1) * normalizedPageSize + return { + items: items.slice(offset, offset + normalizedPageSize), + total: items.length, + pageNo: normalizedPageNo, + pageSize: normalizedPageSize, + hasMore: offset + normalizedPageSize < items.length + } } async listGuideStopsByBusinessUnit(hallId: string, unitId: string) { diff --git a/src/usecases/explainUseCase.ts b/src/usecases/explainUseCase.ts index a6c072a..4babe86 100644 --- a/src/usecases/explainUseCase.ts +++ b/src/usecases/explainUseCase.ts @@ -2,6 +2,7 @@ import type { AudioPlayTargetType, ExplainBusinessUnit, ExplainGuideStop, + ExplainGuideStopPage, ExplainTrack, MediaAsset, MuseumExhibit, @@ -67,7 +68,6 @@ export interface ExhibitDetailAudioOptions { export interface ExplainHallSummary { hallId: string - businessUnitCount: number guideStopCount: number } @@ -487,24 +487,20 @@ export class ExplainUseCase { if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) { return [hallId, { hallId, - businessUnitCount: hall.outlineCount || 0, guideStopCount: hall.stopCount || 0 }] as const } try { - const units = await this.loadTemporaryBusinessUnitsByHall(hallId) - const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0) + const page = await this.listGuideStopsPageByHall(hallId, 1, 1) return [hallId, { hallId, - businessUnitCount: units.length, - guideStopCount + guideStopCount: page.total }] as const } catch (error) { console.warn('讲解展厅统计加载失败:', hallId, error) return [hallId, { hallId, - businessUnitCount: 0, guideStopCount: 0 }] as const } @@ -513,6 +509,10 @@ export class ExplainUseCase { return Object.fromEntries(entries) } + listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise { + return this.explain.listGuideStopsPageByHall(hallId, pageNo, pageSize) + } + getHallById(id: string) { return this.explain.getHallById(id) } diff --git a/src/utils/explainNavigation.ts b/src/utils/explainNavigation.ts new file mode 100644 index 0000000..8d416f2 --- /dev/null +++ b/src/utils/explainNavigation.ts @@ -0,0 +1,4 @@ +export const explainGuideStopListUrl = (hallId: string, hallName = '讲解') => { + const params = new URLSearchParams({ hallId, hallName }) + return `/pages/explain/guide-stop-list?${params.toString()}` +} diff --git a/tests/e2e/explain-hall-page.spec.ts b/tests/e2e/explain-hall-page.spec.ts new file mode 100644 index 0000000..5b1d822 --- /dev/null +++ b/tests/e2e/explain-hall-page.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test' + +const stops = Array.from({ length: 21 }, (_, index) => ({ + stopId: `stop-${index + 1}`, + name: `讲解对象${index + 1}`, + imageStatus: index === 0 ? 'READY' : 'MISSING', + coverImageUrl: index === 0 ? '/one.jpg' : undefined, + playTargetType: 'STOP' as const, + playTargetId: `target-${index + 1}` +})) + +test('hall goes directly to paged guide objects without outline requests', async ({ page }) => { + const requests: string[] = [] + await page.route('**/app-api/gis/guide/catalog/**', async (route) => { + const url = route.request().url() + requests.push(url) + if (url.includes('/catalog/halls?')) { + await route.fulfill({ json: { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', floorId: 'L1', stopCount: 21 }] } }) + return + } + if (url.includes('/halls/hall-1/stops/page')) { + const pageNo = new URL(url).searchParams.get('pageNo') + await route.fulfill({ json: { code: 0, data: { total: 21, list: pageNo === '1' ? stops.slice(0, 20) : stops.slice(20) } } }) + return + } + await route.fulfill({ status: 404, json: { code: 404, msg: 'unexpected request' } }) + }) + + await page.goto('/#/pages/explain/list') + await page.getByText('恐龙厅', { exact: true }).click() + await expect(page).toHaveURL(/guide-stop-list\?hallId=hall-1/) + await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible() + await page.locator('.explain-scroll').hover() + await page.mouse.wheel(0, 10000) + await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible() + await page.getByText('讲解对象1', { exact: true }).click() + await expect(page).toHaveURL(/pages\/exhibit\/detail\?.*targetType=STOP.*targetId=target-1/) + expect(requests.some((url) => url.includes('/outlines'))).toBe(false) + expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2']) +}) diff --git a/tests/unit/BackendExplainContentProvider.spec.ts b/tests/unit/BackendExplainContentProvider.spec.ts new file mode 100644 index 0000000..cf5c046 --- /dev/null +++ b/tests/unit/BackendExplainContentProvider.spec.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BackendExplainContentProvider } from '@/data/providers/backendExplainContentProvider' + +const installRequest = (handler: (url: string) => unknown) => { + vi.stubGlobal('uni', { + request: ({ url, success }: { url: string, success: (response: unknown) => void }) => { + success({ statusCode: 200, data: handler(url) }) + } + }) +} + +describe('BackendExplainContentProvider hall stop paging', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('uses the paged hall endpoint and maps independent pages without outlines', async () => { + const requests: string[] = [] + installRequest((url) => { + requests.push(url) + if (url.includes('/catalog/halls?')) return { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', floorId: 'f1' }] } + const pageNo = new URL(url, 'http://localhost').searchParams.get('pageNo') + return { + code: 0, + 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' }] + } + } + }) + const provider = new BackendExplainContentProvider() + const [first, second] = await Promise.all([ + provider.listGuideStopsPageByHall('hall-1', 1, 20), + provider.listGuideStopsPageByHall('hall-1', 2, 20) + ]) + + expect(first).toMatchObject({ total: 30, pageNo: 1, pageSize: 20, hasMore: true }) + expect(first.items[0]).toMatchObject({ id: 'stop-1', hallName: '恐龙厅', playTargetId: 'target-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) + expect(requests.find((url) => url.includes('pageNo=1') && url.includes('pageSize=20') && url.includes('lang=zh-CN'))).toBeTruthy() + }) + + it('clears failed inflight requests so the page can be retried', async () => { + let attempts = 0 + installRequest((url) => { + if (url.includes('/catalog/halls?')) return { code: 0, data: [{ id: 'hall-1', name: '恐龙厅' }] } + attempts += 1 + return attempts === 1 ? { code: 500, msg: 'temporary failure' } : { code: 0, data: { list: [], total: 0 } } + }) + const provider = new BackendExplainContentProvider() + await expect(provider.listGuideStopsPageByHall('hall-1', 1, 20)).rejects.toThrow('temporary failure') + await expect(provider.listGuideStopsPageByHall('hall-1', 1, 20)).resolves.toMatchObject({ items: [], total: 0, hasMore: false }) + expect(attempts).toBe(2) + }) +}) diff --git a/tests/unit/ExplainGuideStopListNavigation.spec.ts b/tests/unit/ExplainGuideStopListNavigation.spec.ts new file mode 100644 index 0000000..d8c50b1 --- /dev/null +++ b/tests/unit/ExplainGuideStopListNavigation.spec.ts @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom + +import { defineComponent } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ExplainGuideStopListPage from '@/pages/explain/guide-stop-list.vue' + +const testState = vi.hoisted(() => ({ + onLoadHandler: null as null | ((options?: Record) => void) +})) + +vi.mock('@dcloudio/uni-app', () => ({ + onLoad: (handler: (options?: Record) => void) => { + testState.onLoadHandler = handler + } +})) + +vi.mock('@/usecases/explainUseCase', () => ({ + explainUseCase: { + listGuideStopsPageByHall: vi.fn().mockResolvedValue({ + items: [], total: 0, pageNo: 1, pageSize: 20, hasMore: false + }) + } +})) + +const GuidePageFrameStub = defineComponent({ + name: 'GuidePageFrame', + emits: ['back'], + template: '
' +}) + +const ExplainHallSelectStub = defineComponent({ + name: 'ExplainHallSelect', + emits: ['back'], + template: '' +}) + +const mountGuideStopList = () => mount(ExplainGuideStopListPage, { + global: { + stubs: { + GuidePageFrame: GuidePageFrameStub, + ExplainHallSelect: ExplainHallSelectStub + } + } +}) + +beforeEach(() => { + testState.onLoadHandler = null + vi.stubGlobal('uni', { + navigateBack: vi.fn(), + reLaunch: vi.fn(), + navigateTo: vi.fn(), + setNavigationBarTitle: vi.fn() + }) + vi.stubGlobal('getCurrentPages', vi.fn(() => [])) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('讲解对象列表返回', () => { + it('上一页是独立展厅列表时回到该列表', async () => { + vi.stubGlobal('getCurrentPages', vi.fn(() => [ + { route: 'pages/explain/list', options: {} }, + { route: 'pages/explain/guide-stop-list', options: {} } + ])) + const wrapper = mountGuideStopList() + testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '恐龙厅' }) + await flushPromises() + + await wrapper.get('[data-testid="frame-back"]').trigger('click') + + expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) + expect(uni.reLaunch).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('上一页是首页讲解 Tab 时回到首页中的展厅列表', async () => { + vi.stubGlobal('getCurrentPages', vi.fn(() => [ + { route: 'pages/index/index', options: { tab: 'explain' } }, + { route: 'pages/explain/guide-stop-list', options: {} } + ])) + const wrapper = mountGuideStopList() + + await wrapper.get('[data-testid="list-back"]').trigger('click') + + expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) + expect(uni.reLaunch).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('深链或不相关上一页直接回退至独立展厅列表', async () => { + const wrapper = mountGuideStopList() + + await wrapper.get('[data-testid="list-back"]').trigger('click') + + expect(uni.navigateBack).not.toHaveBeenCalled() + expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/explain/list' }) + wrapper.unmount() + }) + + it('页面栈返回失败时回退至独立展厅列表', async () => { + vi.stubGlobal('getCurrentPages', vi.fn(() => [ + { route: 'pages/explain/list', options: {} }, + { route: 'pages/explain/guide-stop-list', options: {} } + ])) + vi.stubGlobal('uni', { + navigateBack: vi.fn((options: { fail?: () => void }) => options.fail?.()), + reLaunch: vi.fn(), + navigateTo: vi.fn(), + setNavigationBarTitle: vi.fn() + }) + const wrapper = mountGuideStopList() + + await wrapper.get('[data-testid="list-back"]').trigger('click') + + expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/explain/list' }) + wrapper.unmount() + }) +}) diff --git a/tests/unit/ExplainHallSelect.spec.ts b/tests/unit/ExplainHallSelect.spec.ts index 433ae56..a55b012 100644 --- a/tests/unit/ExplainHallSelect.spec.ts +++ b/tests/unit/ExplainHallSelect.spec.ts @@ -9,38 +9,30 @@ vi.mock('@/utils/hostEnvironment', () => ({ })) describe('讲解展厅选择列表', () => { - it('业务单元卡片不显示业务单元标签,并保留讲解数量和点击事件', async () => { + it('展厅卡片只显示讲解对象数量,不暴露业务单元契约', async () => { const wrapper = mount(ExplainHallSelect, { props: { - stage: 'unit', - businessUnits: [ + stage: 'hall', + halls: [ { - id: 'unit-evolution', - name: '生命演化', - hallId: 'hall-evolution', - guideStopCount: 6 - }, - { - id: 'unit-dinosaur', - name: '恐龙世界', - hallId: 'hall-dinosaur', - guideStopCount: 4 + id: 'hall-evolution', + name: '生命演化厅', + explainCount: 6, + guideStopCount: 6, + searchText: '生命演化厅' } ] } }) - const cards = wrapper.findAll('.unit-card') - expect(cards).toHaveLength(2) - expect(wrapper.text()).toContain('生命演化') - expect(wrapper.text()).toContain('恐龙世界') - expect(wrapper.text()).toContain('6 个讲解') - expect(wrapper.text()).toContain('4 个讲解') - expect(cards.map((card) => card.text()).join('')).not.toContain('业务单元') - expect(cards[0]?.find('.floor-badge').exists()).toBe(false) + const cards = wrapper.findAll('.hall-card') + expect(cards).toHaveLength(1) + expect(wrapper.text()).toContain('生命演化厅') + expect(wrapper.text()).toContain('6 个讲解对象') + expect(wrapper.text()).not.toContain('业务单元') await cards[0]?.trigger('tap') - expect(wrapper.emitted('businessUnitClick')).toEqual([['unit-evolution']]) + expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']]) }) it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => { diff --git a/tests/unit/ExplainNavigation.spec.ts b/tests/unit/ExplainNavigation.spec.ts new file mode 100644 index 0000000..10f19e3 --- /dev/null +++ b/tests/unit/ExplainNavigation.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { explainGuideStopListUrl } from '@/utils/explainNavigation' + +describe('explain navigation', () => { + it('builds the hall-only guide-object URL', () => { + const url = explainGuideStopListUrl('hall-1', '恐龙厅') + expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list') + const params = new URLSearchParams(url.split('?')[1]) + expect(params.get('hallId')).toBe('hall-1') + expect(params.get('hallName')).toBe('恐龙厅') + expect(params.has('unitId')).toBe(false) + }) +}) diff --git a/tests/unit/ExplainUseCase.spec.ts b/tests/unit/ExplainUseCase.spec.ts new file mode 100644 index 0000000..3a07dd4 --- /dev/null +++ b/tests/unit/ExplainUseCase.spec.ts @@ -0,0 +1,102 @@ +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: [], + 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']) + }) +})