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 }}
+
@@ -75,31 +77,6 @@
-
-
-
-
-
- {{ unit.name }}
-
- {{ unit.guideStopCount }} 个讲解
-
-
-
- ›
-
-
-
{{ emptyTitle }}
{{ emptyDescription }}
+
+ 正在加载更多讲解对象
+
+ {{ loadMoreError }}
+
+
+ 已加载全部讲解对象
+
@@ -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 @@
-
+
@@ -21,147 +20,158 @@
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'])
+ })
+})