同步移动端源码并修复小程序嵌入标题
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-28 18:37:40 +08:00
parent 06ad609e1f
commit 540bb8627f
62 changed files with 4196 additions and 1410 deletions

View File

@@ -66,7 +66,7 @@ test('hall goes directly to paged guide objects without outline requests', async
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
})
test('embedded direct hall list delegates browser return to the host mini program', async ({ page }) => {
test('embedded hall list exposes an H5 return control that keeps host markers', async ({ page }) => {
await page.route('**/app-api/gis/guide/catalog/**', async (route) => {
const url = route.request().url()
if (url.includes('/catalog/halls?')) {
@@ -76,38 +76,31 @@ test('embedded direct hall list delegates browser return to the host mini progra
await route.fulfill({ json: { code: 0, data: {} } })
})
await page.addInitScript(() => {
document.documentElement.dataset.wechatNavigateBackCount = '0'
;(window as Window & { wx?: unknown }).wx = {
miniProgram: {
navigateBack: () => {
const count = Number(document.documentElement.dataset.wechatNavigateBackCount || '0')
document.documentElement.dataset.wechatNavigateBackCount = String(count + 1)
}
}
}
})
await page.goto('/#/pages/explain/list?embedded=wechat-mini-program&weapp=1')
await expect(page.locator('.header-back')).toHaveCount(0)
const back = page.locator('.header-back')
await expect(back).toHaveCount(1)
await expect(back).toBeVisible()
await expect(page.locator('.hall-overview-card')).toHaveCount(1)
for (const width of [375, 390, 430]) {
await page.setViewportSize({ width, height: 844 })
const layout = await page.locator('.explain-hall-select').evaluate((container) => {
const header = container.querySelector<HTMLElement>('.explain-page-header')
const card = container.querySelector<HTMLElement>('.hall-overview-card')
if (!card) throw new Error('missing hall card')
if (!header || !card) throw new Error('missing header or hall card')
const headerRect = header.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
return {
clientWidth: container.clientWidth,
scrollWidth: container.scrollWidth,
headerBottom: headerRect.bottom,
cardTop: cardRect.top
}
})
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
expect(layout.cardTop).toBeGreaterThanOrEqual(0)
expect(layout.cardTop).toBeGreaterThanOrEqual(layout.headerBottom)
}
await page.evaluate(() => window.history.back())
await expect.poll(() => page.locator('html').getAttribute('data-wechat-navigate-back-count')).toBe('1')
await back.click()
await expect(page).toHaveURL(/#\/\?tab=guide&embedded=wechat-mini-program&weapp=1$/)
})
test('ordinary H5 hall list return reaches the guide home', async ({ page }) => {

View File

@@ -1,84 +1,92 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ArrivalPanel from '@/components/navigation/ArrivalPanel.vue'
import { ARRIVAL_TARGETS } from '@/data/arrivalSearchTargets'
import { openExternalNavigation } from '@/services/MapNavigationService'
import { openWechatMiniProgramLocation } from '@/services/WechatMiniProgramBridgeService'
vi.mock('@/services/MapNavigationService', () => ({ openExternalNavigation: vi.fn() }))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({ openWechatMiniProgramLocation: vi.fn() }))
const selectedTarget = ARRIVAL_TARGETS.find((target) => target.type === 'parking')!
const selectedTarget = ARRIVAL_TARGETS[1]
const defaultUserAgent = window.navigator.userAgent
const mountArrivalPanel = () => mount(ArrivalPanel, {
const mountArrivalPanel = (overrides: Record<string, unknown> = {}) => mount(ArrivalPanel, {
props: {
visible: true,
activeType: selectedTarget.type,
targets: [selectedTarget],
selectedTargetId: selectedTarget.id,
selectedTarget
mode: 'browse',
activeType: null,
targets: [],
selectedTargetId: '',
selectedTarget: null,
loading: false,
error: '',
...overrides
}
})
beforeEach(() => {
vi.mocked(openExternalNavigation).mockResolvedValue(true)
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'not-embedded' })
vi.stubGlobal('uni', { showToast: vi.fn(), showActionSheet: vi.fn() })
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Linux; Android 14)' })
})
describe('来馆目的地选择', () => {
it('初始只显示分类入口,默认不选中也不请求或展示附近点位', async () => {
const wrapper = mountArrivalPanel()
const tabs = wrapper.findAll('.arrival-tab')
afterEach(() => {
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: defaultUserAgent })
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
vi.clearAllMocks()
vi.unstubAllGlobals()
})
expect(tabs.every((tab) => !tab.classes('active'))).toBe(true)
expect(wrapper.find('.arrival-target-row').exists()).toBe(false)
await tabs[0].trigger('tap')
describe('来馆面板 H5 导航分流', () => {
it('嵌入小程序 web-view 时只向宿主页面导航,且完整传递当前选中目标', async () => {
Object.assign(window, { __wxjs_environment: 'miniprogram' })
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 MicroMessenger/8.0.50' })
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'opened' })
expect(wrapper.emitted('typeChange')).toEqual([['bus']])
expect(wrapper.find('.arrival-primary').exists()).toBe(false)
})
it('选择列表点位后向父级交付完整的地图目标', async () => {
const wrapper = mountArrivalPanel({
activeType: selectedTarget.type,
targets: [selectedTarget]
})
await wrapper.get('.arrival-target-row').trigger('tap')
expect(wrapper.emitted('selectTarget')).toEqual([[selectedTarget]])
})
it('详情态不展示冗余标题,关闭后回到分类查询', async () => {
const wrapper = mountArrivalPanel({
mode: 'detail',
selectedTargetId: selectedTarget.id,
selectedTarget
})
expect(wrapper.text()).toContain(selectedTarget.title)
expect(wrapper.text()).not.toContain('目的地详情')
await wrapper.get('.arrival-primary').trigger('tap')
await wrapper.get('.arrival-close').trigger('tap')
expect(wrapper.emitted('navigate')).toEqual([[selectedTarget]])
expect(wrapper.emitted('back')).toHaveLength(1)
})
it('分类查询态不显示关闭按钮', () => {
const wrapper = mountArrivalPanel()
expect(wrapper.get('.arrival-primary-text').text()).toBe('打开地图导航')
await wrapper.get('.arrival-primary').trigger('tap')
expect(openWechatMiniProgramLocation).toHaveBeenCalledTimes(1)
expect(openWechatMiniProgramLocation).toHaveBeenCalledWith({
latitude: selectedTarget.latitude,
longitude: selectedTarget.longitude,
name: selectedTarget.title,
address: selectedTarget.subtitle
})
expect(openExternalNavigation).not.toHaveBeenCalled()
expect((uni as unknown as { showActionSheet: ReturnType<typeof vi.fn> }).showActionSheet).not.toHaveBeenCalled()
expect(wrapper.find('.arrival-close').exists()).toBe(false)
expect(wrapper.find('.arrival-collapse').exists()).toBe(false)
})
it('普通 H5 仅回退至第三方地图,并使用当前选中目标', async () => {
const wrapper = mountArrivalPanel()
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
await wrapper.get('.arrival-primary').trigger('tap')
expect(openExternalNavigation).toHaveBeenCalledWith({
latitude: selectedTarget.latitude,
longitude: selectedTarget.longitude,
name: selectedTarget.title,
address: selectedTarget.subtitle
it('展开分类结果后可收起到初始分类栏', async () => {
const wrapper = mountArrivalPanel({
activeType: selectedTarget.type,
targets: [selectedTarget]
})
await wrapper.get('.arrival-collapse').trigger('tap')
expect(wrapper.emitted('collapse')).toHaveLength(1)
})
it('诊断信息默认隐藏,仅在 debug-build=1 时显示构建边界', () => {
const normalWrapper = mountArrivalPanel()
expect(normalWrapper.find('[data-testid="arrival-build-diagnostics"]').exists()).toBe(false)
normalWrapper.unmount()
it('服务异常时提供原位重试,不展示失真的静态结果', async () => {
const wrapper = mountArrivalPanel({
activeType: selectedTarget.type,
error: '周边点位暂时无法加载'
})
window.location.hash = '#/pages/index/index?tab=guide&debug-build=1'
const debugWrapper = mountArrivalPanel()
expect(debugWrapper.get('[data-testid="arrival-build-diagnostics"]').text()).toContain('构建平台h5')
expect(wrapper.text()).toContain('周边点位暂时无法加载')
await wrapper.get('.arrival-retry').trigger('tap')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})

View File

@@ -6,7 +6,6 @@ import { flushPromises, mount } from '@vue/test-utils'
const mocks = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, unknown>) => Promise<void>),
onBackPressHandler: null as null | (() => boolean),
onHideHandler: null as null | (() => void),
onUnloadHandler: null as null | (() => void),
enterExplainDetail: vi.fn(),
@@ -18,9 +17,7 @@ const mocks = vi.hoisted(() => ({
close: vi.fn(),
seekToPercent: vi.fn(),
setPlaybackRate: vi.fn(),
toggleMute: vi.fn(),
embeddedInWechatMiniProgram: false,
returnToWechatMiniProgram: vi.fn(() => Promise.resolve({ status: 'failed' as const, reason: 'bridge-unavailable' as const }))
toggleMute: vi.fn()
}))
const audioState = vi.hoisted(() => ({
@@ -39,9 +36,6 @@ vi.mock('@dcloudio/uni-app', () => ({
onLoad: (handler: (options?: Record<string, unknown>) => Promise<void>) => {
mocks.onLoadHandler = handler
},
onBackPress: (handler: () => boolean) => {
mocks.onBackPressHandler = handler
},
onHide: (handler: () => void) => {
mocks.onHideHandler = handler
},
@@ -80,11 +74,7 @@ vi.mock('@/composables/useGlobalAudioPlayer', () => ({
}))
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => mocks.embeddedInWechatMiniProgram
}))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({
returnToWechatMiniProgram: () => mocks.returnToWechatMiniProgram()
isEmbeddedInWechatMiniProgram: () => false
}))
import ExhibitDetail from '@/pages/exhibit/detail.vue'
@@ -116,7 +106,6 @@ const exhibit = {
describe('讲解详情音频优先布局', () => {
beforeEach(() => {
mocks.onLoadHandler = null
mocks.onBackPressHandler = null
mocks.onHideHandler = null
mocks.onUnloadHandler = null
audioState.currentAudio.value = null
@@ -142,9 +131,6 @@ describe('讲解详情音频优先布局', () => {
playInfo: { title: exhibit.name }
})
mocks.play.mockResolvedValue(undefined)
mocks.embeddedInWechatMiniProgram = false
mocks.returnToWechatMiniProgram.mockReset()
mocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'failed', reason: 'bridge-unavailable' })
vi.stubGlobal('uni', {
showToast: vi.fn(),
navigateBack: vi.fn(),
@@ -389,58 +375,6 @@ describe('讲解详情音频优先布局', () => {
expect(uni.redirectTo).not.toHaveBeenCalled()
})
it('小程序直达讲解详情时返回宿主,不进入 H5 讲解对象列表', async () => {
mocks.embeddedInWechatMiniProgram = true
mocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({
id: exhibit.id,
targetType: 'STOP',
targetId: 'stop-1',
hallId: 'hall-1',
hallName: '恐龙厅'
})
expect(wrapper.find('.hero-back').exists()).toBe(false)
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
expect(mocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(uni.redirectTo).not.toHaveBeenCalled()
})
it('从讲解对象列表进入时,即使 H5 页面栈缺失也回到对象列表', async () => {
mocks.embeddedInWechatMiniProgram = true
mocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({
id: exhibit.id,
targetType: 'STOP',
targetId: 'stop-1',
hallId: 'hall-1',
hallName: '恐龙厅',
returnToGuideStopList: '1',
returnToHallList: '1'
})
expect(wrapper.find('.hero-back').exists()).toBe(true)
await wrapper.get('.hero-back').trigger('tap')
await flushPromises()
const url = vi.mocked(uni.redirectTo).mock.calls[0]?.[0]?.url || ''
const params = new URLSearchParams(url.split('?')[1])
expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list')
expect(params.get('hallId')).toBe('hall-1')
expect(params.get('returnToHallList')).toBe('1')
expect(mocks.returnToWechatMiniProgram).not.toHaveBeenCalled()
expect(uni.navigateBack).not.toHaveBeenCalled()
})
it('离开详情页时立即关闭当前讲解播放器', async () => {
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
@@ -473,6 +407,9 @@ describe('讲解详情音频优先布局', () => {
})
await flushPromises()
vi.mocked(uni.navigateBack).mockImplementationOnce((options: any) => {
options.fail?.()
})
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
@@ -481,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 }))
})
})

View File

@@ -7,35 +7,15 @@ import ExplainGuideStopListPage from '@/pages/explain/guide-stop-list.vue'
import { explainUseCase } from '@/usecases/explainUseCase'
const testState = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, string>) => void),
onBackPressHandler: null as null | (() => boolean)
}))
const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false
}))
const miniProgramReturnMocks = vi.hoisted(() => ({
returnToWechatMiniProgram: vi.fn(() => Promise.resolve({ status: 'failed' as const, reason: 'bridge-unavailable' as const }))
onLoadHandler: null as null | ((options?: Record<string, string>) => void)
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: (handler: (options?: Record<string, string>) => void) => {
testState.onLoadHandler = handler
},
onBackPress: (handler: () => boolean) => {
testState.onBackPressHandler = handler
}
}))
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
}))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({
returnToWechatMiniProgram: () => miniProgramReturnMocks.returnToWechatMiniProgram()
}))
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
listGuideStopsPageByHall: vi.fn().mockResolvedValue({
@@ -52,7 +32,7 @@ const GuidePageFrameStub = defineComponent({
const ExplainGuideStopCatalogStub = defineComponent({
name: 'ExplainGuideStopCatalog',
props: ['guideStops', 'selectedHallName', 'showEmbeddedBack', 'loading', 'loadingMore', 'hasMore', 'error', 'loadMoreError'],
props: ['guideStops', 'selectedHallName', 'loading', 'loadingMore', 'hasMore', 'error', 'loadMoreError'],
emits: ['back', 'retry', 'retryMore', 'guideStopClick'],
setup(_, { emit }) {
return {
@@ -73,10 +53,6 @@ const mountGuideStopList = () => mount(ExplainGuideStopListPage, {
beforeEach(() => {
testState.onLoadHandler = null
testState.onBackPressHandler = null
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
miniProgramReturnMocks.returnToWechatMiniProgram.mockReset()
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'failed', reason: 'bridge-unavailable' })
vi.stubGlobal('uni', {
navigateBack: vi.fn(),
reLaunch: vi.fn(),
@@ -136,7 +112,6 @@ describe('讲解对象列表返回', () => {
expect(params.get('hallName')).toBe('恐龙厅')
expect(params.get('targetType')).toBe('STOP')
expect(params.get('targetId')).toBe('target-1')
expect(params.get('returnToGuideStopList')).toBe('1')
wrapper.unmount()
})
@@ -180,40 +155,6 @@ describe('讲解对象列表返回', () => {
wrapper.unmount()
})
it('小程序直达展厅时返回宿主,不重启 H5 展厅列表', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
const wrapper = mountGuideStopList()
await wrapper.get('[data-testid="list-back"]').trigger('click')
await flushPromises()
expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('从展厅列表进入时,即使 H5 页面栈缺失也保留内部返回路径', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
const wrapper = mountGuideStopList()
testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '恐龙厅', returnToHallList: '1' })
await flushPromises()
expect(wrapper.getComponent(ExplainGuideStopCatalogStub).props('showEmbeddedBack')).toBe(true)
await wrapper.get('[data-testid="detail-click"]').trigger('click')
const detailUrl = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url || ''
expect(new URLSearchParams(detailUrl.split('?')[1]).get('returnToHallList')).toBe('1')
await wrapper.get('[data-testid="list-back"]').trigger('click')
await flushPromises()
expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/explain/list' })
expect(miniProgramReturnMocks.returnToWechatMiniProgram).not.toHaveBeenCalled()
wrapper.unmount()
})
it('页面栈返回失败时回退至独立展厅列表', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/list', options: {} },

View File

@@ -9,10 +9,6 @@ const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false
}))
const miniProgramReturnMocks = vi.hoisted(() => ({
returnToWechatMiniProgram: vi.fn(() => Promise.resolve({ status: 'failed' as const, reason: 'bridge-unavailable' as const }))
}))
const testState = vi.hoisted(() => ({
onLoadHandler: null as null | (() => void),
onBackPressHandler: null as null | (() => boolean)
@@ -31,10 +27,6 @@ vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
}))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({
returnToWechatMiniProgram: () => miniProgramReturnMocks.returnToWechatMiniProgram()
}))
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
loadExplainHalls: async () => [],
@@ -68,8 +60,6 @@ const mountExplainList = () => mount(ExplainListPage, {
beforeEach(() => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
miniProgramReturnMocks.returnToWechatMiniProgram.mockReset()
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'failed', reason: 'bridge-unavailable' })
testState.onLoadHandler = null
testState.onBackPressHandler = null
window.location.hash = '#/pages/explain/list'
@@ -88,9 +78,9 @@ afterEach(() => {
})
describe('讲解展厅列表返回', () => {
it('首页讲解 Tab 进入时,顶部返回使用 navigateBack 回到项目首页', async () => {
it('首页通过内部页面栈进入时,顶部返回使用 navigateBack 回到导览首页', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/index/index', options: { tab: 'explain' } },
{ route: 'pages/index/index', options: { tab: 'guide' } },
{ route: 'pages/explain/list', options: {} }
]))
const wrapper = mountExplainList()
@@ -104,22 +94,6 @@ describe('讲解展厅列表返回', () => {
wrapper.unmount()
})
it('小程序中的首页讲解 Tab 进入时仍优先回到项目首页', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/index/index', options: { tab: 'explain' } },
{ route: 'pages/explain/list', options: {} }
]))
const wrapper = mountExplainList()
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(miniProgramReturnMocks.returnToWechatMiniProgram).not.toHaveBeenCalled()
wrapper.unmount()
})
it('页面栈只有展厅列表时使用 reLaunch 回到导览首页', async () => {
const wrapper = mountExplainList()
@@ -132,26 +106,24 @@ describe('讲解展厅列表返回', () => {
wrapper.unmount()
})
it('小程序直达独立入口时返回小程序,而不是重启项目首页', async () => {
it('微信嵌入态不强制渲染 H5 页头,并保留宿主识别参数', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1'
window.history.replaceState({}, '', window.location.href)
const wrapper = mountExplainList()
expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(false)
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
await flushPromises()
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled()
expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({
url: '/pages/index/index?tab=guide&embedded=wechat-mini-program&weapp=1'
}))
wrapper.unmount()
})
it('微信嵌入态从非讲解首页进入时返回小程序', async () => {
it('微信嵌入态即使前置页是导览首页也不调用宿主返回', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program'
window.history.replaceState({}, '', window.location.href)
vi.stubGlobal('getCurrentPages', vi.fn(() => [
@@ -161,11 +133,11 @@ describe('讲解展厅列表返回', () => {
const wrapper = mountExplainList()
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
await flushPromises()
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled()
expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({
url: '/pages/index/index?tab=guide&embedded=wechat-mini-program'
}))
wrapper.unmount()
})
@@ -185,11 +157,27 @@ describe('讲解展厅列表返回', () => {
wrapper.unmount()
})
it('首页讲解 Tab 返回失败后重启到项目首页讲解 Tab', async () => {
it('上一页是首页但不是导览 Tab 时重启到导览首页', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/index/index', options: { tab: 'explain' } },
{ route: 'pages/explain/list', options: {} }
]))
const wrapper = mountExplainList()
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({
url: '/pages/index/index?tab=guide'
}))
wrapper.unmount()
})
it('navigateBack 失败后重启到导览首页', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/index/index', options: { tab: 'guide' } },
{ route: 'pages/explain/list', options: {} }
]))
vi.stubGlobal('uni', {
navigateBack: vi.fn((options: { fail?: () => void }) => options.fail?.()),
reLaunch: vi.fn(),
@@ -200,7 +188,7 @@ describe('讲解展厅列表返回', () => {
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({
url: '/pages/index/index?tab=explain'
url: '/pages/index/index?tab=guide'
}))
wrapper.unmount()
})
@@ -222,9 +210,8 @@ describe('讲解展厅列表返回', () => {
wrapper.unmount()
})
it('小程序直达入口的浏览器返回会委托给小程序宿主', async () => {
it('根栈只创建一条历史标记,浏览器返回会回到导览首页', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1'
window.history.replaceState({}, '', window.location.href)
const wrapper = mountExplainList()
@@ -238,8 +225,12 @@ describe('讲解展厅列表返回', () => {
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(pushState).toHaveBeenCalledTimes(1)
expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled()
const url = vi.mocked(uni.reLaunch).mock.calls[0]?.[0]?.url || ''
const query = new URLSearchParams(url.split('?')[1])
expect(url.split('?')[0]).toBe('/pages/index/index')
expect(query.get('tab')).toBe('guide')
expect(query.get('embedded')).toBe('wechat-mini-program')
expect(query.get('weapp')).toBe('1')
wrapper.unmount()
})

View File

@@ -240,6 +240,37 @@ describe('GuideFloorBasemap projection', () => {
expect(await loadGuideFloorBasemapManifest()).toBeNull()
})
it('uses the sole valid floor basemap when the published GLB version is newer', () => {
const basemap = createBasemap()
const manifest = {
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
render: {
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
},
floors: [basemap],
routeAssets: []
}
expect(findGuideFloorBasemap(manifest, 'L1', 'v2')).toEqual(basemap)
})
it('does not guess a basemap when a floor has multiple nonmatching versions', () => {
const older = createBasemap()
const newer = { ...createBasemap(), modelVersion: 'v2' }
const manifest = {
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
render: {
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
},
floors: [older, newer],
routeAssets: []
}
expect(findGuideFloorBasemap(manifest, 'L1', 'v3')).toBeNull()
})
it('requires an orthographic floor entry under the schema 4 render identity', () => {
const basemap = createBasemap()
const manifest = {

View File

@@ -29,7 +29,13 @@ const ThreeMapStub = defineComponent({
default: null
}
},
emits: ['floor-change', 'target-focus', 'auto-switch', 'render-mode-fallback'],
emits: [
'floor-change',
'target-focus',
'auto-switch',
'render-mode-fallback',
'semantic-exterior-exit'
],
setup(_, { expose }) {
expose({
switchFloor: vi.fn(),
@@ -48,6 +54,12 @@ const ThreeMapStub = defineComponent({
template: '<div data-testid="three-map"></div>'
})
const TencentJsMapStub = defineComponent({
name: 'TencentJsMap',
emits: ['semantic-enter-building'],
template: '<div data-testid="tencent-js-map"></div>'
})
const modelSource = {
loadPackage: async () => ({ overviewModelUrl: '', floors: [] }),
loadFloorPois: async () => []
@@ -166,6 +178,54 @@ describe('GuideMapShell 点位过滤契约', () => {
expect(wrapper.emitted('targetFocus')).toEqual([[focusResult]])
})
it('仅在语义缩放启用时转发室内外层级切换事件', async () => {
const indoorWrapper = mount(GuideMapShell, {
props: {
mapType: 'indoor',
indoorModelSource: modelSource,
floors: [{ id: 'L1', label: '1F' }],
activeFloor: 'L1',
semanticZoomEnabled: true
},
global: { stubs: { ThreeMap: ThreeMapStub } }
})
indoorWrapper.getComponent(ThreeMapStub).vm.$emit('semantic-exterior-exit')
await indoorWrapper.vm.$nextTick()
expect(indoorWrapper.emitted('semanticExitOutdoor')).toEqual([[]])
const outdoorWrapper = mount(GuideMapShell, {
props: {
mapType: 'outdoor',
indoorModelSource: modelSource,
floors: [{ id: 'L1', label: '1F' }],
activeFloor: 'L1',
semanticZoomEnabled: true
},
global: { stubs: { TencentJsMap: TencentJsMapStub, TencentMap: true } }
})
const viewport = { latitude: 22.692763, longitude: 114.363572, zoom: 18.5 }
outdoorWrapper.getComponent(TencentJsMapStub).vm.$emit('semantic-enter-building', viewport)
await outdoorWrapper.vm.$nextTick()
expect(outdoorWrapper.emitted('semanticEnterBuilding')).toEqual([[viewport]])
})
it('语义缩放关闭时不转发地图层级切换事件', async () => {
const wrapper = mount(GuideMapShell, {
props: {
mapType: 'indoor',
indoorModelSource: modelSource,
floors: [{ id: 'L1', label: '1F' }],
activeFloor: 'L1',
semanticZoomEnabled: false
},
global: { stubs: { ThreeMap: ThreeMapStub } }
})
wrapper.getComponent(ThreeMapStub).vm.$emit('semantic-exterior-exit')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('semanticExitOutdoor')).toBeUndefined()
})
it('忽略旧渲染版本的 WebP 回退事件', async () => {
const wrapper = mount(GuideMapShell, {
props: {

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
OUTDOOR_BUILDING_EXIT_ZOOM,
OUTDOOR_BUILDING_ENTRY_ZOOM,
canEnterBuildingSemanticMap,
distanceBetweenCoordinatesMeters,
hasReachedOutdoorExitBoundary
} from '@/domain/guideSemanticZoom'
const museum = { latitude: 22.692763, longitude: 114.363572 }
describe('guide semantic zoom', () => {
it('uses a stable outdoor extent below the building-entry threshold', () => {
expect(OUTDOOR_BUILDING_EXIT_ZOOM).toBeLessThan(OUTDOOR_BUILDING_ENTRY_ZOOM)
})
it('starts the outdoor handoff before the 3D camera reaches its hard zoom clamp', () => {
expect(hasReachedOutdoorExitBoundary(92, 100)).toBe(true)
expect(hasReachedOutdoorExitBoundary(91.9, 100)).toBe(false)
expect(hasReachedOutdoorExitBoundary(0, 0)).toBe(false)
})
it('enters the building only when the museum is centered at the entry zoom', () => {
expect(canEnterBuildingSemanticMap({
...museum,
zoom: OUTDOOR_BUILDING_ENTRY_ZOOM
}, museum)).toBe(true)
})
it('does not enter the building when the outdoor map has not been zoomed in enough', () => {
expect(canEnterBuildingSemanticMap({
...museum,
zoom: OUTDOOR_BUILDING_ENTRY_ZOOM - 0.1
}, museum)).toBe(false)
})
it('does not switch for a similarly zoomed but unrelated outdoor location', () => {
expect(canEnterBuildingSemanticMap({
latitude: 22.698,
longitude: 114.37,
zoom: OUTDOOR_BUILDING_ENTRY_ZOOM + 1
}, museum)).toBe(false)
})
it('calculates a usable geographic distance for the focus guard', () => {
expect(distanceBetweenCoordinatesMeters(museum, museum)).toBe(0)
expect(distanceBetweenCoordinatesMeters(museum, {
latitude: 22.693763,
longitude: 114.363572
})).toBeGreaterThan(100)
})
})

View File

@@ -53,6 +53,64 @@ const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false
}))
const outdoorMapMocks = vi.hoisted(() => ({
getOutdoorMapProfile: vi.fn(async () => ({
enabled: true,
h5Enabled: true,
available: true,
state: 'AVAILABLE',
message: '腾讯地图服务探测正常',
quotaStatus: 'NOT_QUERYABLE',
availableModes: ['DRIVING', 'WALKING', 'TRANSIT'],
jsMapEnabled: true,
jsMapKey: 'test-js-key'
})),
refreshOutdoorMapProfile: vi.fn(async () => ({
enabled: true,
h5Enabled: true,
available: true,
state: 'AVAILABLE',
message: '腾讯地图服务探测正常',
quotaStatus: 'NOT_QUERYABLE',
availableModes: ['DRIVING', 'WALKING', 'TRANSIT'],
jsMapEnabled: true,
jsMapKey: 'test-js-key'
})),
subscribeOutdoorMapAvailability: vi.fn(() => () => {}),
searchOutdoorPlaces: vi.fn(async () => ({
items: [{
id: 'bus-stop-museum',
title: '深圳自然博物馆[公交站]',
address: 'M574路',
category: '公交站',
latitude: 22.69258,
longitude: 114.36372,
distance: 80
}]
})),
suggestOutdoorPlaces: vi.fn(async () => ({
items: [{
id: 'manual-start-suggestion',
title: '深圳北站',
address: '深圳市龙华区',
category: '火车站',
latitude: 22.609,
longitude: 114.03
}]
})),
reverseGeocodeOutdoorLocation: vi.fn(async () => ({
address: '深圳市坪山区龙坪路 1 号',
latitude: 22.688,
longitude: 114.359
})),
planOutdoorRoute: vi.fn()
}))
const browserLocationMocks = vi.hoisted(() => ({
getBrowserGcjLocation: vi.fn(),
getBrowserLocationPermissionState: vi.fn()
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: vi.fn(),
onShow: (handler: () => Promise<void> | void) => {
@@ -64,6 +122,21 @@ vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
}))
vi.mock('@/services/outdoor/OutdoorMapAppService', () => outdoorMapMocks)
vi.mock('@/services/outdoor/BrowserLocationService', () => ({
BrowserLocationError: class BrowserLocationError extends Error {
reason: string
constructor(reason: string) {
super(reason)
this.reason = reason
}
},
getBrowserGcjLocation: browserLocationMocks.getBrowserGcjLocation,
getBrowserLocationPermissionState: browserLocationMocks.getBrowserLocationPermissionState
}))
const floors = [
{ id: 'L1', label: '1F', order: 1 },
{ id: 'L2', label: '2F', order: 2 }
@@ -120,13 +193,16 @@ const GuideMapShellStub = defineComponent({
indoorView: { type: String, default: '' },
visiblePoiIds: { type: Array, default: null },
targetFocusRequest: { type: Object, default: null },
mapType: { type: String, default: 'indoor' },
outdoorMarkers: { type: Array, default: () => [] },
activeOutdoorMarkerId: { type: String, default: '' },
showFloor: { type: Boolean, default: false },
floorBottom: { type: String, default: '' },
disableAutoExit: { type: Boolean, default: false },
routeStartSelectionActive: { type: Boolean, default: false },
routeSelectablePoiIds: { type: Array, default: () => [] }
},
emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change', 'auto-switch', 'route-start-candidate', 'route-start-candidate-rejected', 'route-roaming-progress'],
emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change', 'auto-switch', 'semantic-exit-outdoor', 'route-start-candidate', 'route-start-candidate-rejected', 'route-roaming-progress', 'map-tap'],
setup() {
onMounted(() => {
testState.mapMountCount += 1
@@ -199,12 +275,49 @@ const RoutePlannerPanelStub = defineComponent({
template: '<section data-testid="route-planner"></section>'
})
const ArrivalPanelStub = defineComponent({
name: 'ArrivalPanel',
props: {
mode: { type: String, default: 'browse' },
activeType: { type: String, default: null },
targets: { type: Array, default: () => [] },
selectedTarget: { type: Object, default: null }
},
emits: ['type-change', 'select-target', 'navigate', 'back', 'retry', 'close'],
template: '<section data-testid="arrival-panel"></section>'
})
const OutdoorArrivalRoutePanelStub = defineComponent({
name: 'OutdoorArrivalRoutePanel',
props: {
phase: { type: String, default: 'locating' },
locationHelpVisible: { type: Boolean, default: false },
startPoint: { type: Object, default: null },
destination: { type: Object, default: null },
loading: { type: Boolean, default: false },
routeError: { type: String, default: '' },
manualSearchResults: { type: Array, default: () => [] }
},
emits: [
'retry-location',
'manual-start-request',
'manual-map-request',
'manual-start-confirm',
'manual-start-search',
'confirm-start',
'retry-route',
'back'
],
template: '<section data-testid="outdoor-route-panel"></section>'
})
const stubs = {
GuidePageFrame: GuidePageFrameStub,
GuideMapShell: GuideMapShellStub,
PoiSearchPanel: PoiSearchPanelStub,
RoutePlannerPanel: RoutePlannerPanelStub,
ArrivalPanel: true,
ArrivalPanel: ArrivalPanelStub,
OutdoorArrivalRoutePanel: OutdoorArrivalRoutePanelStub,
ExplainHallSelect: true
}
@@ -262,6 +375,12 @@ beforeEach(() => {
testState.hallResolutionPromises = []
testState.explainResults = []
testState.routeTargets = []
outdoorMapMocks.searchOutdoorPlaces.mockClear()
outdoorMapMocks.suggestOutdoorPlaces.mockClear()
outdoorMapMocks.reverseGeocodeOutdoorLocation.mockClear()
outdoorMapMocks.planOutdoorRoute.mockClear()
browserLocationMocks.getBrowserGcjLocation.mockReset()
browserLocationMocks.getBrowserLocationPermissionState.mockReset()
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
vi.stubGlobal('uni', {
navigateTo: vi.fn(),
@@ -371,13 +490,14 @@ describe('首页搜索与地图闭环', () => {
expect(window.location.hash).toBe('#/pages/index/index?tab=guide&embedded=wechat-mini-program')
})
it('首页暂不展示来馆入口,导览快捷按钮文案固定且保留原切换行为', async () => {
it('首页来馆入口默认聚焦博物馆,仅在选择类别后读取并联动附近点位', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
const guideAction = wrapper.findAll('.guide-quick-action')[0]
const arrivalAction = wrapper.findAll('.guide-quick-action')[0]
const guideAction = wrapper.findAll('.guide-quick-action')[1]
expect(wrapper.text()).not.toContain('来馆')
expect(wrapper.findAll('.guide-quick-action').map((item) => item.text())).toEqual([
'来馆',
'导览',
'讲解'
])
@@ -388,12 +508,292 @@ describe('首页搜索与地图闭环', () => {
map.vm.$emit('floor-change', 'L1')
await flushPromises()
expect(map.props('indoorView')).toBe('floor')
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
expect(wrapper.findAll('.guide-quick-action')[1]?.text()).toBe('导览')
await guideAction.trigger('tap')
await flushPromises()
expect(map.props('indoorView')).toBe('overview')
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
expect(wrapper.findAll('.guide-quick-action')[1]?.text()).toBe('导览')
await arrivalAction.trigger('tap')
await flushPromises()
expect(map.props('mapType')).toBe('outdoor')
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
expect(arrivalPanel.props('mode')).toBe('browse')
expect(arrivalPanel.props('activeType')).toBeNull()
expect(arrivalPanel.props('targets')).toEqual([])
expect(map.props('outdoorMarkers')).toEqual([expect.objectContaining({
id: 'museum-destination',
title: '深圳自然博物馆'
})])
expect(map.props('activeOutdoorMarkerId')).toBe('museum-destination')
arrivalPanel.vm.$emit('type-change', 'bus')
await flushPromises()
expect(arrivalPanel.props('activeType')).toBe('bus')
expect(arrivalPanel.props('targets')).toEqual([expect.objectContaining({
id: 'bus-stop-museum',
title: '深圳自然博物馆[公交站]'
})])
expect(map.props('outdoorMarkers')).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'museum-destination', markerKind: 'museum' }),
expect.objectContaining({ id: 'bus-stop-museum', markerKind: 'bus' })
]))
const busTarget = arrivalPanel.props('targets')[0] as { id: string; type: string }
arrivalPanel.vm.$emit('select-target', busTarget)
await flushPromises()
expect(arrivalPanel.props('mode')).toBe('detail')
expect(map.props('activeOutdoorMarkerId')).toBe('bus-stop-museum')
expect(wrapper.find('.guide-quick-actions').classes()).toContain('is-outdoor')
expect(wrapper.findAll('.guide-quick-action').map((item) => item.text())).toEqual([
'来馆',
'导览',
'讲解'
])
expect(wrapper.findAll('.guide-quick-action')[0]?.classes()).toContain('active')
})
it('腾讯地图状态不可用时隐藏来馆入口并保持馆内导览', async () => {
outdoorMapMocks.refreshOutdoorMapProfile.mockResolvedValueOnce({
enabled: true,
h5Enabled: false,
available: false,
state: 'PROVIDER_UNAVAILABLE',
message: '腾讯地图服务暂时不可用',
quotaStatus: 'NOT_QUERYABLE',
availableModes: [],
jsMapEnabled: false,
jsMapKey: null
})
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
expect(wrapper.findAll('.guide-quick-action').map((item) => item.text())).toEqual(['导览', '讲解'])
map.vm.$emit('semantic-exit-outdoor')
await flushPromises()
expect(map.props('mapType')).toBe('indoor')
})
it('地铁和停车场分类同样在地图上显示可点击的高亮标记', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
const arrivalAction = wrapper.findAll('.guide-quick-action')[0]
await arrivalAction.trigger('tap')
await flushPromises()
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
arrivalPanel.vm.$emit('type-change', 'metro')
await flushPromises()
expect(map.props('outdoorMarkers')).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'museum-destination', markerKind: 'museum' }),
expect.objectContaining({ id: 'bus-stop-museum', markerKind: 'metro' })
]))
arrivalPanel.vm.$emit('type-change', 'parking')
await flushPromises()
expect(map.props('outdoorMarkers')).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'museum-destination', markerKind: 'museum' }),
expect.objectContaining({ id: 'bus-stop-museum', markerKind: 'parking' })
]))
})
it('来馆分类在当前页面会复用短时缓存,不重复查询腾讯地点服务', async () => {
const wrapper = await mountIndex()
await wrapper.findAll('.guide-quick-action')[0].trigger('tap')
await flushPromises()
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
arrivalPanel.vm.$emit('type-change', 'bus')
await flushPromises()
arrivalPanel.vm.$emit('type-change', 'metro')
await flushPromises()
arrivalPanel.vm.$emit('type-change', 'bus')
await flushPromises()
expect(outdoorMapMocks.searchOutdoorPlaces).toHaveBeenCalledTimes(2)
})
it('缩小建筑外观切换到来馆时展示初始分类查询栏', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('semantic-exit-outdoor')
await flushPromises()
expect(map.props('mapType')).toBe('outdoor')
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
expect(arrivalPanel.props('mode')).toBe('browse')
expect(arrivalPanel.props('activeType')).toBeNull()
expect(arrivalPanel.props('targets')).toEqual([])
expect(map.props('outdoorMarkers')).toEqual([expect.objectContaining({
id: 'museum-destination',
title: '深圳自然博物馆'
})])
})
it('来馆目的地确认后,实时定位成功即自动调用腾讯路线规划', async () => {
browserLocationMocks.getBrowserGcjLocation.mockResolvedValue({
latitude: 22.688,
longitude: 114.359,
accuracyMeters: 18,
timestamp: Date.now()
})
outdoorMapMocks.planOutdoorRoute.mockResolvedValue({
mode: 'TRANSIT',
distance: 1850,
duration: 840,
polyline: []
})
const wrapper = await mountIndex()
await wrapper.findAll('.guide-quick-action')[0].trigger('tap')
await flushPromises()
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
const destination = {
id: 'bus-stop-museum',
type: 'bus',
title: '深圳自然博物馆[公交站]',
subtitle: 'M574路',
keyword: '深圳自然博物馆[公交站]',
region: '深圳市',
latitude: 22.69258,
longitude: 114.36372
}
arrivalPanel.vm.$emit('navigate', destination)
await flushPromises()
const routePanel = wrapper.getComponent(OutdoorArrivalRoutePanelStub)
expect(routePanel.props('phase')).toBe('route')
expect(routePanel.props('startPoint')).toMatchObject({ name: '我的位置' })
expect(outdoorMapMocks.planOutdoorRoute).toHaveBeenCalledWith(expect.objectContaining({
from: expect.objectContaining({ name: '我的位置' }),
to: expect.objectContaining({ id: 'bus-stop-museum' }),
mode: 'TRANSIT'
}))
})
it('定位不可用时保留重新检测、输入地点和地图选点入口', async () => {
browserLocationMocks.getBrowserGcjLocation.mockRejectedValue(new Error('未获得位置权限'))
const wrapper = await mountIndex()
await wrapper.findAll('.guide-quick-action')[0].trigger('tap')
await flushPromises()
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
const destination = {
id: 'bus-stop-museum',
type: 'bus',
title: '深圳自然博物馆[公交站]',
subtitle: 'M574路',
keyword: '深圳自然博物馆[公交站]',
region: '深圳市',
latitude: 22.69258,
longitude: 114.36372
}
arrivalPanel.vm.$emit('navigate', destination)
await flushPromises()
const routePanel = wrapper.getComponent(OutdoorArrivalRoutePanelStub)
expect(routePanel.props('phase')).toBe('permission-needed')
browserLocationMocks.getBrowserLocationPermissionState.mockResolvedValue('denied')
routePanel.vm.$emit('retry-location')
await flushPromises()
expect(routePanel.props('locationHelpVisible')).toBe(true)
routePanel.vm.$emit('manual-start-request')
await flushPromises()
expect(routePanel.props('phase')).toBe('manual-search')
routePanel.vm.$emit('manual-map-request')
await flushPromises()
expect(routePanel.props('phase')).toBe('manual-map')
routePanel.vm.$emit('manual-start-confirm', {
title: '深圳北站',
latitude: 22.609,
longitude: 114.03
})
await flushPromises()
expect(routePanel.props('phase')).toBe('confirm-start')
expect(routePanel.props('startPoint')).toMatchObject({ name: '深圳北站' })
})
it('手动起点使用输入提示,地图选点会补充可读地址并标记当前位置', async () => {
browserLocationMocks.getBrowserGcjLocation.mockRejectedValue(new Error('未获得位置权限'))
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
await wrapper.findAll('.guide-quick-action')[0].trigger('tap')
await flushPromises()
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
const destination = {
id: 'bus-stop-museum',
type: 'bus',
title: '深圳自然博物馆[公交站]',
subtitle: 'M574路',
keyword: '深圳自然博物馆[公交站]',
region: '深圳市',
latitude: 22.69258,
longitude: 114.36372
}
arrivalPanel.vm.$emit('navigate', destination)
await flushPromises()
const routePanel = wrapper.getComponent(OutdoorArrivalRoutePanelStub)
routePanel.vm.$emit('manual-start-request')
await flushPromises()
routePanel.vm.$emit('manual-start-search', '深圳北')
await flushPromises()
expect(outdoorMapMocks.suggestOutdoorPlaces).toHaveBeenCalledWith(expect.objectContaining({ keyword: '深圳北' }))
routePanel.vm.$emit('manual-map-request')
await flushPromises()
map.vm.$emit('map-tap', { latitude: 22.688, longitude: 114.359 })
await flushPromises()
expect(outdoorMapMocks.reverseGeocodeOutdoorLocation).toHaveBeenCalledWith({ latitude: 22.688, longitude: 114.359 })
expect(routePanel.props('phase')).toBe('confirm-start')
expect(routePanel.props('startPoint')).toMatchObject({ name: '深圳市坪山区龙坪路 1 号' })
expect(map.props('outdoorMarkers')).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'outdoor-route-start', markerKind: 'user' })
]))
})
it('切换手动起点方式后忽略迟到的输入提示结果', async () => {
browserLocationMocks.getBrowserGcjLocation.mockRejectedValue(new Error('未获得位置权限'))
let resolveSuggestion: ((value: { items: Array<Record<string, unknown>> }) => void) | null = null
outdoorMapMocks.suggestOutdoorPlaces.mockImplementationOnce(() => new Promise((resolve) => {
resolveSuggestion = resolve
}))
const wrapper = await mountIndex()
await wrapper.findAll('.guide-quick-action')[0].trigger('tap')
await flushPromises()
const arrivalPanel = wrapper.getComponent(ArrivalPanelStub)
arrivalPanel.vm.$emit('navigate', {
id: 'bus-stop-museum',
type: 'bus',
title: '深圳自然博物馆[公交站]',
subtitle: 'M574路',
keyword: '深圳自然博物馆[公交站]',
region: '深圳市',
latitude: 22.69258,
longitude: 114.36372
})
await flushPromises()
const routePanel = wrapper.getComponent(OutdoorArrivalRoutePanelStub)
routePanel.vm.$emit('manual-start-request')
await flushPromises()
routePanel.vm.$emit('manual-start-search', '深圳北')
await flushPromises()
routePanel.vm.$emit('manual-map-request')
resolveSuggestion?.({ items: [{ id: 'late-result', title: '迟到结果' }] })
await flushPromises()
expect(routePanel.props('phase')).toBe('manual-map')
expect(routePanel.props('manualSearchResults')).toEqual([])
})
it('向搜索面板传入当前楼层,并将分类结果 ID 精确传给地图', async () => {

View File

@@ -0,0 +1,118 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import OutdoorArrivalRoutePanel from '@/components/navigation/OutdoorArrivalRoutePanel.vue'
const destination = {
title: '深圳自然博物馆[公交站]',
address: 'M574路',
latitude: 22.69258,
longitude: 114.36372
}
const start = {
name: '我的位置',
latitude: 22.688,
longitude: 114.359
}
const mountPanel = (overrides: Record<string, unknown> = {}) => mount(OutdoorArrivalRoutePanel, {
props: {
visible: true,
phase: 'permission-needed',
destination,
startPoint: null,
loading: false,
routeError: '',
routeDistance: 0,
routeDuration: 0,
manualSearchResults: [],
manualSearchLoading: false,
...overrides
}
})
describe('来馆路线起点确认', () => {
it('定位不可用时提示重新检测或手动选择,而不显示旧馆外导览界面', async () => {
const wrapper = mountPanel({
locationMessage: '请在微信小程序的设置中开启位置权限后重试'
})
expect(wrapper.text()).toContain('暂未获取实时位置')
expect(wrapper.text()).toContain('重新检测')
expect(wrapper.text()).toContain('手动选择起点')
expect(wrapper.text()).not.toContain('馆外导览')
await wrapper.get('.outdoor-route-action.secondary').trigger('tap')
await wrapper.get('.outdoor-route-action.primary').trigger('tap')
expect(wrapper.emitted('retryLocation')).toHaveLength(1)
expect(wrapper.emitted('manualStartRequest')).toHaveLength(1)
})
it('浏览器已拒绝位置权限时,重新检测区域会给出明确的设置指引', () => {
const wrapper = mountPanel({
locationHelpVisible: true,
locationMessage: '当前位置权限已关闭'
})
expect(wrapper.text()).toContain('请开启位置权限')
expect(wrapper.text()).toContain('微信小程序的设置')
expect(wrapper.text()).toContain('重新检测')
})
it('手动选择的起点保留确认操作,避免误用地图点位', async () => {
const wrapper = mountPanel({
phase: 'confirm-start',
startPoint: { ...start, name: '深圳北站' }
})
expect(wrapper.text()).toContain('深圳北站')
expect(wrapper.text()).toContain('从这里出发')
await wrapper.get('.outdoor-route-action.primary').trigger('tap')
await wrapper.get('.outdoor-route-text-action').trigger('tap')
expect(wrapper.emitted('confirmStart')).toHaveLength(1)
expect(wrapper.emitted('manualStartRequest')).toHaveLength(1)
})
it('手动起点支持输入地点与地图选点两条路径', async () => {
const wrapper = mountPanel({ phase: 'manual-search' })
await wrapper.get('.outdoor-route-switch + .outdoor-route-switch').trigger('tap')
expect(wrapper.emitted('manualMapRequest')).toHaveLength(1)
await wrapper.setProps({
manualSearchResults: [{
id: 'ping-shan-station',
title: '深圳坪山站',
address: '深圳市坪山区',
category: '火车站',
latitude: 22.699,
longitude: 114.347
}]
})
await wrapper.get('.outdoor-route-search-result').trigger('tap')
expect(wrapper.emitted('manualStartConfirm')).toEqual([[expect.objectContaining({
id: 'ping-shan-station',
title: '深圳坪山站'
})]])
})
it('路线已生成时仅展示路线结果,不重复显示出行方式选择', () => {
const wrapper = mountPanel({
phase: 'route',
startPoint: start,
routeDistance: 1850,
routeDuration: 14
})
expect(wrapper.text()).toContain('1.9 公里')
expect(wrapper.text()).toContain('约 14 分钟')
expect(wrapper.text()).toContain('路线已显示在地图上')
expect(wrapper.find('.outdoor-route-mode-list').exists()).toBe(false)
})
})

View File

@@ -13,6 +13,7 @@ describe('shared POI icon registry', () => {
name: '售票机'
})).toBe('ticket-office')
expect(getPoiIconHref('ticket-office')).toBe(getPoiCategoryIconHref('ticket-office'))
expect(getPoiIconHref('ticket-office')).toBe('#poi-ticket-office')
})
it.each([

View File

@@ -421,7 +421,7 @@ describe('WeakNetworkGuideFallback', () => {
const marker = wrapper.get('[data-poi-id="ticket"]')
expect(marker.attributes('data-poi-icon')).toBe('ticket-office')
expect(marker.get('.weak-network-marker-label-icon use').attributes('href'))
.toBe('/static/icons/poi/shortcut-icons.svg#poi-ticket-office')
.toBe('#poi-ticket-office')
expect(marker.get('.weak-network-marker-label').classes()).toContain('weak-network-marker-label')
})
@@ -557,6 +557,39 @@ describe('WeakNetworkGuideFallback', () => {
})
})
it('reserves two exterior zoom-out steps before reporting the Tencent Map handoff boundary', async () => {
const wrapper = mount(WeakNetworkGuideFallback, {
props: {
floorId: '2065808919904776194',
sceneView: 'overview',
floorModelVersion: 'model-v1',
basemapManifest: exteriorBasemapManifest,
presentation: 'two-dimensional',
pois: []
}
})
const renderer = wrapper.vm as unknown as {
zoomCamera: (direction: 'in' | 'out') => void
}
renderer.zoomCamera('out')
renderer.zoomCamera('out')
await wrapper.vm.$nextTick()
const intents = wrapper.emitted('zoomIntent') || []
expect(intents).toHaveLength(2)
expect(intents[0][0]).toMatchObject({
direction: 'out',
outdoorExitReached: false,
viewport: { visibleWorldSpan: 125 }
})
expect(intents[1][0]).toMatchObject({
direction: 'out',
outdoorExitReached: true,
viewport: { visibleWorldSpan: 135 }
})
})
it('routes native H5 wheel input through the semantic viewport zoom', async () => {
const wrapper = mount(WeakNetworkGuideFallback, {
props: {
@@ -651,7 +684,7 @@ describe('WeakNetworkGuideFallback', () => {
expect(wrapper.findAll('.weak-network-route-line')).toHaveLength(1)
})
it('falls back to the neutral grid when the floor model version has no basemap', () => {
it('keeps the sole valid floor basemap when the model version has advanced', () => {
const wrapper = mount(WeakNetworkGuideFallback, {
props: {
floorId: 'L1',
@@ -661,8 +694,8 @@ describe('WeakNetworkGuideFallback', () => {
}
})
expect(wrapper.find('[data-testid="weak-network-basemap"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="weak-network-neutral-grid"]').exists()).toBe(true)
expect(wrapper.get('[data-testid="weak-network-basemap"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="weak-network-neutral-grid"]').exists()).toBe(false)
})
it('keeps map-based route-start selection available in the two-dimensional guide', async () => {

View File

@@ -4,11 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const target = { latitude: 22.604321, longitude: 114.058765, name: '停车场 A&B', address: '龙华区 #1' }
const setEmbeddedRuntime = () => Object.assign(window, { __wxjs_environment: 'miniprogram' })
const addWechatSdkMarker = () => {
const script = document.createElement('script')
script.dataset.wechatJsSdk = 'true'
document.head.appendChild(script)
}
const loadService = async () => {
vi.resetModules()
@@ -38,7 +33,6 @@ describe('微信小程序 web-view 桥接', () => {
it('等待延迟注入的 bridge 后导航到编码后的宿主页', async () => {
setEmbeddedRuntime()
addWechatSdkMarker()
const { openWechatMiniProgramLocation } = await loadService()
const result = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(100)
@@ -90,45 +84,4 @@ describe('微信小程序 web-view 桥接', () => {
await expect(first).resolves.toEqual({ status: 'opened' })
expect(navigateTo).toHaveBeenCalledTimes(1)
})
it('等待延迟 bridge 后返回宿主小程序', async () => {
setEmbeddedRuntime()
addWechatSdkMarker()
const { returnToWechatMiniProgram } = await loadService()
const result = returnToWechatMiniProgram()
await vi.advanceTimersByTimeAsync(100)
const navigateBack = vi.fn(({ success }) => success())
Object.assign(window, {
wx: {
miniProgram: {
getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }),
navigateBack
}
}
})
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toEqual({ status: 'returned' })
expect(navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
})
it('原生返回失败时委托宿主页执行安全回退', async () => {
setEmbeddedRuntime()
const { MINI_PROGRAM_RETURN_MESSAGE, returnToWechatMiniProgram } = await loadService()
const postMessage = vi.fn()
Object.assign(window, {
wx: {
miniProgram: {
getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }),
navigateBack: ({ fail }: { fail?: (error: { errMsg?: string }) => void }) => fail?.({ errMsg: 'navigateBack:fail page stack is empty' }),
postMessage
}
}
})
await expect(returnToWechatMiniProgram()).resolves.toEqual({ status: 'returned' })
expect(postMessage).toHaveBeenCalledWith({
data: { type: MINI_PROGRAM_RETURN_MESSAGE }
})
})
})

View File

@@ -1,7 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
isEmbeddedInWechatMiniProgram
} from '@/utils/hostEnvironment'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const createWindow = (overrides: Record<string, unknown> = {}) => ({
location: { search: '', hash: '' },