同步移动端源码并修复小程序嵌入标题
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

@@ -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 () => {