From 122013ccfbd317371fe081552949bf6a6b9533c1 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Fri, 17 Jul 2026 11:54:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E8=A7=88=E6=A5=BC?= =?UTF-8?q?=E5=B1=82=E9=BB=98=E8=AE=A4=E8=BF=9B=E5=85=A5=E4=B8=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=A4=8D=E4=BD=8D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/ThreeMap.vue | 79 +++++++++-- src/components/navigation/GuideMapShell.vue | 9 +- src/composables/useGuideModelState.ts | 49 ++++++- src/pages/index/index.vue | 85 ++++++++---- tests/e2e/three-map-camera-stability.spec.ts | 131 +++++++++++++++---- tests/unit/GuideModelStateReset.spec.ts | 34 +++++ tests/unit/IndexPoiSearchFlow.spec.ts | 89 ++++++++++++- 7 files changed, 407 insertions(+), 69 deletions(-) create mode 100644 tests/unit/GuideModelStateReset.spec.ts diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 77498dc..9d26664 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -3430,6 +3430,8 @@ const restoreCameraSnapshot = (snapshot: CameraSnapshot) => { } } +export type ResetViewBaselineResult = 'applied' | 'not-ready' | 'invalid-target' | 'failed' | 'stale' + const applyReferenceOverviewCameraState = () => { restoreCameraSnapshot(referenceOverviewCameraState) } @@ -3652,6 +3654,49 @@ const getVisualFocusReport = () => { } } +const getVisiblePoiScreenPositions = () => { + if (!renderer || !camera) return [] + + const rect = renderer.domElement.getBoundingClientRect() + return getPoiSprites().flatMap((sprite) => { + const poi = sprite.userData.poi as RenderPoi | undefined + const screen = getProjectedScreenPosition(sprite) + let ancestor: THREE.Object3D | null = sprite + while (ancestor) { + if (!ancestor.visible) return [] + ancestor = ancestor.parent + } + if ( + !poi + || !screen + || screen.x < 0 + || screen.x > rect.width + || screen.y < 0 + || screen.y > rect.height + ) return [] + + // Match the production fallback hit-test so diagnostics only expose coordinates + // that a tap can resolve to this exact current marker. + const hit = findNearestPoiMarkerByScreenPoint({ + clientX: rect.left + screen.x, + clientY: rect.top + screen.y + } as PointerEvent, rect) + if (hit !== sprite) return [] + + return [{ + poiId: poi.id, + floorId: poi.floorId, + name: poi.name, + kind: poi.kind, + primaryCategory: poi.primaryCategory, + screen: { + x: rect.left + screen.x, + y: rect.top + screen.y + } + }] + }) +} + const getVisualStabilityReport = () => ({ camera: serializeCameraSnapshot(captureCameraSnapshot()), modelRoot: getModelRootTransformAudit(activeModel), @@ -3674,6 +3719,8 @@ const installVisualStabilityDiagnostics = () => { } diagnosticsWindow.__GUIDE_3D_VISUAL_STABILITY__ = { getReport: getVisualStabilityReport, + isInitialModelReady: () => initialModelSettled && Boolean(initialGuideState && activeModel), + getVisiblePoiScreenPositions, getFloors: () => floorIndex.value.map((floor) => ({ floorId: floor.floorId, label: floor.label, @@ -3696,6 +3743,8 @@ const installVisualStabilityDiagnostics = () => { poiId: poi.id, floorId: poi.floorId, name: poi.name, + kind: poi.kind, + primaryCategory: poi.primaryCategory, positionGltf: poi.positionGltf! })) }, @@ -5610,6 +5659,18 @@ const showMultiFloor = async () => { // Restores business and camera state on the existing scene. It never initializes WebGL or // reloads the model package, so closing a detail page cannot remount ThreeMap. const resetToViewBaseline = async (options: ResetViewBaselineOptions) => { + // Do not invalidate the first model load while the renderer is still being initialized. + // The page-level guide model state will replay only its latest not-ready request. + const baseline = initialGuideState + if (!baseline || !scene || !camera || !controls) return 'not-ready' as const + if (loadError.value) return 'failed' as const + + if (options.view === 'floor') { + if (!options.floorId || !floorIndex.value.some((item) => item.floorId === options.floorId)) { + return 'invalid-target' as const + } + } + clearTargetFocus() invalidateModelLoads() adjacentPreloadSeq += 1 @@ -5627,25 +5688,22 @@ const resetToViewBaseline = async (options: ResetViewBaselineOptions) => { autoSwitchStateMachine.reset(options.view) resetInteractionGateState() - const baseline = initialGuideState - if (!baseline || !scene || !camera || !controls || loadError.value) return false - isLoading.value = true try { if (options.view === 'floor') { const floorId = options.floorId - if (!floorId) return false + if (!floorId) return 'invalid-target' as const const floor = floorIndex.value.find((item) => item.floorId === floorId) - if (!floor) return false + if (!floor) return 'invalid-target' as const if (activeView.value !== 'floor' || currentFloor.value !== floorId || activeModel?.userData.floorId !== floorId) { await loadFloor(floorId, { applyFloorBaseline: true, detachPoiBeforeLoad: true }) } else { const floorBaseline = getFloorViewBaseline(floorId, activeModel, floor.modelUrl) - if (!floorBaseline) return false + if (!floorBaseline) return 'failed' as const restoreCameraSnapshot(floorBaseline.camera) } - if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false + if (!scene || !isCurrentModelLoad(modelLoadVersion)) return 'stale' as const activeView.value = 'floor' currentFloor.value = floorId autoSwitchStateMachine.reset('floor') @@ -5654,7 +5712,7 @@ const resetToViewBaseline = async (options: ResetViewBaselineOptions) => { if (activeView.value !== 'overview') { await loadOverview({ cameraSnapshot: baseline.camera }) } - if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false + if (!scene || !isCurrentModelLoad(modelLoadVersion)) return 'stale' as const restoreCameraSnapshot(baseline.camera) currentFloor.value = baseline.floorId activeView.value = 'overview' @@ -5665,12 +5723,13 @@ const resetToViewBaseline = async (options: ResetViewBaselineOptions) => { autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance()) } refreshPoiVisibilityByDistance() - return true + return 'applied' as const } catch (error) { + if (isStaleModelLoadError(error)) return 'stale' as const if (!isStaleModelLoadError(error)) { console.error('恢复馆内导览初始状态失败:', error) } - return false + return 'failed' as const } finally { isLoading.value = false } diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 84d65e7..db86624 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -240,6 +240,7 @@ import { computed, ref, watch } from 'vue' import TencentMap from '@/components/map/TencentMap.vue' // #ifdef H5 import ThreeMap from '@/components/map/ThreeMap.vue' +import type { ResetViewBaselineResult } from '@/components/map/ThreeMap.vue' // #endif import type { GuideModelSource, @@ -464,8 +465,8 @@ const indoorRendererRef = ref<{ view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' - }) => Promise | boolean - resetToInitialState?: () => Promise | boolean + }) => Promise | ResetViewBaselineResult + resetToInitialState?: () => Promise | ResetViewBaselineResult disableAutoSwitchTemporarily?: (durationMs: number) => void } | null>(null) @@ -790,8 +791,8 @@ defineExpose({ view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' - }) => indoorRendererRef.value?.resetToViewBaseline?.(options) || false, - resetToInitialState: () => indoorRendererRef.value?.resetToInitialState?.() || false + }) => indoorRendererRef.value?.resetToViewBaseline?.(options) || 'not-ready', + resetToInitialState: () => indoorRendererRef.value?.resetToInitialState?.() || 'not-ready' }) diff --git a/src/composables/useGuideModelState.ts b/src/composables/useGuideModelState.ts index c2a971c..09c46f5 100644 --- a/src/composables/useGuideModelState.ts +++ b/src/composables/useGuideModelState.ts @@ -24,6 +24,14 @@ export type PoiDetailReturnContext = Readonly<{ requestId: number }> +export type GuideModelResetReason = 'poi-detail-close' | 'floor-reset' + +export type GuideModelResetRequest = Readonly<{ + floorId: string + requestId: number + rendererReason: GuideModelResetReason +}> + type GuideModelReadyState = Readonly<{ view: 'overview' | 'floor' | 'multi' floorId: string @@ -52,6 +60,7 @@ const initialState = ref(GUIDE_MODEL_INITIAL_STATE) const pendingModelResetAfterPoiDetail = ref(null) const confirmedPoiDetailRequestId = ref(null) const requestGeneration = ref(0) +const pendingViewBaselineReset = ref(null) let poiDetailRequestId = 0 export const useGuideModelState = () => { @@ -107,15 +116,53 @@ export const useGuideModelState = () => { return requestGeneration.value } + const beginViewBaselineReset = ({ floorId, rendererReason }: { + floorId: string + rendererReason: GuideModelResetReason + }) => Object.freeze({ + floorId, + rendererReason, + requestId: invalidatePendingRequests() + }) + + const isCurrentRequest = (requestId: number) => requestGeneration.value === requestId + + // The renderer may not yet exist while the page has already received a return event. + // Keep exactly one latest request here so page lifecycle hooks cannot race each other. + const queueViewBaselineReset = (request: GuideModelResetRequest) => { + if (!isCurrentRequest(request.requestId)) return false + pendingViewBaselineReset.value = request + return true + } + + const completeViewBaselineReset = (request: GuideModelResetRequest) => { + if (!isCurrentRequest(request.requestId)) return false + pendingViewBaselineReset.value = null + return true + } + + const consumePendingViewBaselineReset = () => { + const request = pendingViewBaselineReset.value + if (!request || !isCurrentRequest(request.requestId)) return null + pendingViewBaselineReset.value = null + return request + } + return { initialState: readonly(initialState), pendingModelResetAfterPoiDetail: readonly(pendingModelResetAfterPoiDetail), requestGeneration: readonly(requestGeneration), + pendingViewBaselineReset: readonly(pendingViewBaselineReset), initializeModel, beginPoiDetailReturn, confirmPoiDetailReturn, cancelPoiDetailReturn, consumePoiDetailReturn, - invalidatePendingRequests + invalidatePendingRequests, + beginViewBaselineReset, + isCurrentRequest, + queueViewBaselineReset, + completeViewBaselineReset, + consumePendingViewBaselineReset } } diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 8d22a68..1069e71 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -93,7 +93,7 @@ 收起 - + × @@ -327,7 +327,12 @@ import type { import { type TargetPoiFocusRequestViewModel } from '@/view-models/guideViewModels' -import { useGuideModelState, type PoiDetailReturnContext } from '@/composables/useGuideModelState' +import { + useGuideModelState, + type GuideModelResetReason, + type GuideModelResetRequest +} from '@/composables/useGuideModelState' +import type { ResetViewBaselineResult } from '@/components/map/ThreeMap.vue' import { ARRIVAL_TARGETS_BY_TYPE, type ArrivalSearchTarget, @@ -422,7 +427,7 @@ const guideMapShellRef = ref<{ view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' - }) => Promise | boolean + }) => Promise | ResetViewBaselineResult resetToInitialState?: () => Promise | boolean } | null>(null) const homeSearchPanelRef = ref<{ @@ -435,8 +440,6 @@ const homeSearchExpanded = ref(false) const homeCategoryModeActive = ref(false) const searchVisiblePoiIds = ref(null) const searchTargetFocusRequest = ref(null) -let pendingGuideModelResetUntilReady: PoiDetailReturnContext | null = null - type PoiSearchOverlayHistoryState = { museumGuideOverlay?: 'poi-search' [key: string]: unknown @@ -578,6 +581,14 @@ const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl() const indoorModelSource = guideUseCase.getModelSource() const guideFloors = ref([]) const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId) +const getPreferredInitialGuideFloorId = (floors: MuseumFloor[]) => ( + floors.find((floor) => ( + normalizeGuideFloorId(floor.id) === 'L1' + || normalizeGuideFloorId(floor.label) === 'L1' + ))?.id + || floors[0]?.id + || '' +) const getGuideFloorById = (floorId: string) => ( guideFloors.value.find((floor) => floor.id === floorId) ) @@ -923,7 +934,7 @@ const loadGuideFloors = async () => { ? normalizeGuideFloorId(activeGuideFloor.value) : '' activeGuideFloor.value = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id - || floors[0]?.id + || getPreferredInitialGuideFloorId(floors) || activeGuideFloor.value renderedFloorId.value = activeGuideFloor.value } catch (error) { @@ -1048,10 +1059,13 @@ const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: strin guideModelState.initializeModel({ view: event.view, floorId: readyFloorId }) } settleLaunchOverlayByModel('ready', event) - if (pendingGuideModelResetUntilReady) { - const context = pendingGuideModelResetUntilReady - pendingGuideModelResetUntilReady = null - void resetGuideModelToInitial({ reason: 'queued-poi-detail-close', floorId: context.floorId }) + const context = guideModelState.consumePendingViewBaselineReset() + if (context) { + void resetGuideModelToFloorBaseline({ + rendererReason: context.rendererReason, + floorId: context.floorId, + request: context + }) } } @@ -1086,6 +1100,19 @@ const handleGuideSelectionClear = () => { isPoiCardCollapsed.value = false } +const handleGuidePoiCardClose = () => { + const floorId = selectedGuidePoi.value?.floorId + if (!floorId) { + handleGuideSelectionClear() + return + } + + void resetGuideModelToFloorBaseline({ + rendererReason: 'floor-reset', + floorId + }) +} + const collapsePoiCard = () => { isPoiCardCollapsed.value = true } @@ -1507,15 +1534,17 @@ const closeHomeSearchDock = () => { homeSearchExpanded.value = false } -const resetGuideModelToInitial = async ({ - reason, - floorId +const resetGuideModelToFloorBaseline = async ({ + rendererReason, + floorId, + request }: { - reason: 'poi-detail-close' | 'queued-poi-detail-close' + rendererReason: GuideModelResetReason floorId: string + request?: GuideModelResetRequest }) => { const initial = guideModelState.initialState.value - const resetRequestId = guideModelState.invalidatePendingRequests() + const resetRequest = request || guideModelState.beginViewBaselineReset({ floorId, rendererReason }) searchTargetFocusRequest.value = null searchVisiblePoiIds.value = null homeCategoryModeActive.value = false @@ -1537,32 +1566,32 @@ const resetGuideModelToInitial = async ({ activeGuideFloor.value = floorId || initial.defaultActiveFloorId || activeGuideFloor.value closeArrivalPanel() await homeSearchPanelRef.value?.resetSearchState?.() + if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return closeHomeSearchDock() - const resetApplied = await guideMapShellRef.value?.resetToViewBaseline?.({ + const resetResult = await guideMapShellRef.value?.resetToViewBaseline?.({ view: 'floor', floorId: activeGuideFloor.value, - reason: 'poi-detail-close' + reason: rendererReason }) - if (resetApplied) { + if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return + if (resetResult === 'applied') { renderedFloorId.value = activeGuideFloor.value - pendingGuideModelResetUntilReady = null - } else { - // The renderer is still becoming ready; retain only the latest requested reset. - pendingGuideModelResetUntilReady = Object.freeze({ - floorId: activeGuideFloor.value, - resetMode: 'floor-baseline', - requestId: resetRequestId - }) + guideModelState.completeViewBaselineReset(resetRequest) + console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason }) + } else if (resetResult === 'not-ready' || resetResult === undefined) { + guideModelState.queueViewBaselineReset(resetRequest) } - console.info('馆内三维模型已恢复统一初始状态:', { reason }) } onShow(async () => { await nextTick() const detailReturnContext = guideModelState.consumePoiDetailReturn() if (detailReturnContext) { - await resetGuideModelToInitial({ reason: 'poi-detail-close', floorId: detailReturnContext.floorId }) + await resetGuideModelToFloorBaseline({ + rendererReason: 'poi-detail-close', + floorId: detailReturnContext.floorId + }) return } diff --git a/tests/e2e/three-map-camera-stability.spec.ts b/tests/e2e/three-map-camera-stability.spec.ts index 7179184..dd35e3c 100644 --- a/tests/e2e/three-map-camera-stability.spec.ts +++ b/tests/e2e/three-map-camera-stability.spec.ts @@ -52,11 +52,23 @@ interface PoiFocusCandidate { poiId: string floorId: string name: string + kind?: string + primaryCategory: string positionGltf: [number, number, number] } +interface VisiblePoiScreenPosition { + poiId: string + floorId: string + name: string + kind?: string + primaryCategory: string + screen: { x: number; y: number } +} + interface VisualStabilityApi { getReport: () => VisualReport + isInitialModelReady: () => boolean getFloors: () => FloorReport[] switchFloor: (floorId: string) => Promise showOverview: () => Promise @@ -64,10 +76,11 @@ interface VisualStabilityApi { view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' - }) => Promise - resetToInitialState: () => Promise + }) => Promise<'applied' | 'not-ready' | 'invalid-target' | 'failed' | 'stale'> + resetToInitialState: () => Promise<'applied' | 'not-ready' | 'invalid-target' | 'failed' | 'stale'> getFloorBaseline: (floorId: string) => CameraReport | null getFloorPois: (floorId: string) => Promise + getVisiblePoiScreenPositions: () => VisiblePoiScreenPosition[] focusTargetPoi: (request: { requestId: number | string poiId: string @@ -103,6 +116,20 @@ const getReport = async (page: Page) => { return state!.report } +const openGuide = async (page: Page) => { + await page.goto('/') + await page.waitForURL(/tab=guide/, { timeout: 30_000 }) + await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true) + await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }) + .toBeGreaterThan(0) + await expect.poll(async () => page.evaluate(() => ( + (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) + .__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false + )), { timeout: 180_000 }).toBe(true) + await expect.poll(async () => (await getApiState(page))?.report.modelRoot !== null, { timeout: 180_000 }).toBe(true) + await expect.poll(async () => (await getApiState(page))?.report.anchors.length ?? 0, { timeout: 180_000 }).toBe(3) +} + const getMaximumCameraDelta = (before: CameraReport, after: CameraReport) => Math.max( Math.hypot( before.position.x - after.position.x, @@ -263,6 +290,13 @@ const getFloorPois = async (page: Page, floorId: string) => { return pois } +const getVisiblePoiScreenPositions = async (page: Page) => page.evaluate(() => { + const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) + .__GUIDE_3D_VISUAL_STABILITY__ + if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable') + return api.getVisiblePoiScreenPositions() +}) + const getVectorDelta = (left: { x: number; y: number; z: number }, right: [number, number, number]) => ( Math.hypot(left.x - right[0], left.y - right[1], left.z - right[2]) ) @@ -290,12 +324,7 @@ const waitForPoiFocusToSettle = async (page: Page, testInfo: TestInfo, name: str } test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => { - await page.goto('/') - await Promise.all([ - page.waitForURL(/tab=guide/, { timeout: 30_000 }), - page.getByText('馆内', { exact: true }).click() - ]) - await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true) + await openGuide(page) await expect.poll(async () => (await getApiState(page))?.report.anchors.length ?? 0, { timeout: 180_000 }).toBe(3) @@ -337,12 +366,7 @@ test('ordinary exterior and floor switches preserve the GLB_METER camera project }) test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and does not rebound', async ({ page }, testInfo) => { - await page.goto('/') - await Promise.all([ - page.waitForURL(/tab=guide/, { timeout: 30_000 }), - page.getByText('馆内', { exact: true }).click() - ]) - await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true) + await openGuide(page) const state = await getApiState(page) expect(state).not.toBeNull() @@ -410,12 +434,7 @@ test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and do }) test('reset invalidates an in-flight cross-floor focus before it can overwrite the initial state', async ({ page }) => { - await page.goto('/') - await Promise.all([ - page.waitForURL(/tab=guide/, { timeout: 30_000 }), - page.getByText('馆内', { exact: true }).click() - ]) - await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true) + await openGuide(page) await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0) const state = await getApiState(page) expect(state).not.toBeNull() @@ -440,11 +459,7 @@ test('reset invalidates an in-flight cross-floor focus before it can overwrite t }) test('floor view baseline is deterministic across repeated resets', async ({ page }) => { - await page.goto('/') - await Promise.all([ - page.waitForURL(/tab=guide/, { timeout: 30_000 }), - page.getByText('馆内', { exact: true }).click() - ]) + await openGuide(page) await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0) const state = await getApiState(page) expect(state).not.toBeNull() @@ -478,3 +493,69 @@ test('floor view baseline is deterministic across repeated resets', async ({ pag expect(getMaximumCameraDelta(baseline!, second.camera)).toBeLessThan(1e-6) } }) + +test.describe('mobile POI card close restores the floor baseline', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('real canvas clicks on a facility and hall close back to their floor baselines', async ({ page }) => { + await openGuide(page) + const state = await getApiState(page) + expect(state).not.toBeNull() + + const floorId = state!.report.floorId + await page.evaluate(async (targetFloorId) => { + const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) + .__GUIDE_3D_VISUAL_STABILITY__ + if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable') + await api.resetToViewBaseline({ view: 'floor', floorId: targetFloorId, reason: 'floor-reset' }) + }, floorId) + await expect.poll(async () => getVisiblePoiScreenPositions(page), { timeout: 30_000 }) + .not.toHaveLength(0) + const visibleMarkers = await getVisiblePoiScreenPositions(page) + const facility = visibleMarkers.find((poi) => ( + poi.kind !== 'hall' + && !poi.primaryCategory.startsWith('exhibition_hall') + )) + const hall = visibleMarkers.find((poi) => ( + poi.kind === 'hall' + || poi.primaryCategory.startsWith('exhibition_hall') + )) + expect(facility, 'expected a visible facility POI from the active guide data').toBeDefined() + expect(hall, 'expected a visible hall POI from the active guide data').toBeDefined() + + for (const [kind, marker] of [['facility', facility!], ['hall', hall!]] as const) { + const baseline = await page.evaluate((floorId) => { + const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) + .__GUIDE_3D_VISUAL_STABILITY__ + return api?.getFloorBaseline(floorId) || null + }, marker.floorId) + expect(baseline).not.toBeNull() + let modelRequestsAfterBaseline = 0 + page.on('request', (request) => { + if (/\.(glb|gltf)(?:$|\?)/i.test(request.url())) modelRequestsAfterBaseline += 1 + }) + + await page.mouse.click(marker.screen.x, marker.screen.y) + await expect.poll(async () => (await getReport(page)).activeFocusPoiId, { timeout: 10_000 }).toBe(marker.poiId) + await expect(page.locator('.guide-poi-card')).toBeVisible() + if (kind === 'facility') { + await page.locator('.poi-card-collapsed').click() + } + await page.locator('.poi-card-close').click() + await expect(page.locator('.guide-poi-card')).toBeHidden() + await expect.poll(async () => (await getReport(page)).activeFocusPoiId, { timeout: 10_000 }).toBe('') + await expect.poll(async () => (await getReport(page)).focus, { timeout: 10_000 }).toBeNull() + await expect.poll(async () => (await getReport(page)).hasPendingTargetFocus, { timeout: 10_000 }).toBe(false) + await expect.poll(async () => (await getReport(page)).isCameraTweening, { timeout: 10_000 }).toBe(false) + + const afterClose = await getReport(page) + expect(afterClose.activeView).toBe('floor') + expect(afterClose.floorId).toBe(marker.floorId) + expect(getMaximumCameraDelta(baseline!, afterClose.camera)).toBeLessThan(1e-6) + expect(modelRequestsAfterBaseline).toBe(0) + await page.waitForTimeout(300) + const afterIdle = await getReport(page) + expect(getMaximumCameraDelta(afterClose.camera, afterIdle.camera)).toBeLessThan(1e-6) + } + }) +}) diff --git a/tests/unit/GuideModelStateReset.spec.ts b/tests/unit/GuideModelStateReset.spec.ts new file mode 100644 index 0000000..a5bc1ec --- /dev/null +++ b/tests/unit/GuideModelStateReset.spec.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useGuideModelState } from '@/composables/useGuideModelState' + +describe('guide model floor baseline reset queue', () => { + beforeEach(() => { + const state = useGuideModelState() + state.invalidatePendingRequests() + const pending = state.consumePendingViewBaselineReset() + if (pending) state.completeViewBaselineReset(pending) + }) + + it('keeps only the latest pre-ready reset and consumes it once', () => { + const state = useGuideModelState() + const first = state.beginViewBaselineReset({ floorId: 'L1', rendererReason: 'poi-detail-close' }) + expect(state.queueViewBaselineReset(first)).toBe(true) + const second = state.beginViewBaselineReset({ floorId: 'L2', rendererReason: 'floor-reset' }) + expect(state.queueViewBaselineReset(second)).toBe(true) + + expect(state.consumePendingViewBaselineReset()).toEqual(second) + expect(state.consumePendingViewBaselineReset()).toBeNull() + }) + + it('does not let an obsolete reset queue or commit after a newer request', () => { + const state = useGuideModelState() + const obsolete = state.beginViewBaselineReset({ floorId: 'L1', rendererReason: 'poi-detail-close' }) + const latest = state.beginViewBaselineReset({ floorId: 'L2', rendererReason: 'floor-reset' }) + + expect(state.isCurrentRequest(obsolete.requestId)).toBe(false) + expect(state.queueViewBaselineReset(obsolete)).toBe(false) + expect(state.completeViewBaselineReset(obsolete)).toBe(false) + expect(state.queueViewBaselineReset(latest)).toBe(true) + expect(state.consumePendingViewBaselineReset()).toEqual(latest) + }) +}) diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index 19bd212..f5700c7 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -94,7 +94,7 @@ const GuideMapShellStub = defineComponent({ visiblePoiIds: { type: Array, default: null }, targetFocusRequest: { type: Object, default: null } }, - emits: ['floor-change', 'poi-click', 'selection-clear'], + emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change'], setup() { onMounted(() => { testState.mapMountCount += 1 @@ -363,6 +363,25 @@ describe('首页搜索与地图闭环', () => { expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) }) + it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => { + const originalFloors = [...floors] + floors.splice(0, floors.length, + { id: 'L5', label: '5F', order: 5 }, + { id: 'L2', label: '2F', order: 2 }, + { id: 'L1', label: '1F', order: 1 } + ) + + try { + const wrapper = await mountIndex() + const map = wrapper.getComponent(GuideMapShellStub) + + expect(map.props('activeFloor')).toBe('L1') + expect(wrapper.getComponent(PoiSearchPanelStub).props('currentFloorId')).toBe('L1') + } finally { + floors.splice(0, floors.length, ...originalFloors) + } + }) + it('分类结果进入详情后恢复目标楼层标准视图', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) @@ -606,6 +625,74 @@ describe('首页搜索与地图闭环', () => { await wrapper.vm.$nextTick() expect(wrapper.getComponent(PoiSearchPanelStub).exists()).toBe(true) expect(testState.searchMountCount).toBe(1) + expect(testState.resetToViewBaselineCalls).toHaveLength(0) + }) + + it('直接点选普通设施后展开并关闭卡片,恢复该楼层视觉基线且不重挂载地图', async () => { + const wrapper = await mountIndex() + const map = wrapper.getComponent(GuideMapShellStub) + + map.vm.$emit('poi-click', { + ...poi, + primaryCategory: 'restroom', + primaryCategoryZh: '卫生间' + }) + await wrapper.vm.$nextTick() + + expect(wrapper.find('.poi-card-collapsed').exists()).toBe(true) + await wrapper.find('.poi-card-collapsed').trigger('tap') + await wrapper.vm.$nextTick() + await wrapper.find('.poi-card-close').trigger('tap') + await flushPromises() + + expect(testState.resetToViewBaselineCalls).toEqual([{ + view: 'floor', + floorId: poi.floorId, + reason: 'floor-reset' + }]) + expect(wrapper.find('.guide-poi-card').exists()).toBe(false) + expect(map.props('visiblePoiIds')).toBeNull() + expect(map.props('targetFocusRequest')).toBeNull() + expect(testState.mapMountCount).toBe(1) + expect(testState.mapUnmountCount).toBe(0) + }) + + it('直接点选展厅后关闭卡片,复用楼层基线复位而非详情返回语义', async () => { + const wrapper = await mountIndex() + const map = wrapper.getComponent(GuideMapShellStub) + + map.vm.$emit('poi-click', { + ...poi, + kind: 'hall', + primaryCategory: 'exhibition_hall', + primaryCategoryZh: '展厅' + }) + await wrapper.vm.$nextTick() + + expect(wrapper.find('.poi-card-close').exists()).toBe(true) + await wrapper.find('.poi-card-close').trigger('tap') + await flushPromises() + + expect(testState.resetToViewBaselineCalls).toEqual([{ + view: 'floor', + floorId: poi.floorId, + reason: 'floor-reset' + }]) + expect(wrapper.find('.guide-poi-card').exists()).toBe(false) + expect(testState.mapMountCount).toBe(1) + expect(testState.mapUnmountCount).toBe(0) + }) + + it('renderer 清选和返回建筑外观不会触发楼层复位', async () => { + const wrapper = await mountIndex() + const map = wrapper.getComponent(GuideMapShellStub) + + map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom' }) + map.vm.$emit('selection-clear') + map.vm.$emit('indoor-view-change', 'overview') + await wrapper.vm.$nextTick() + + expect(testState.resetToViewBaselineCalls).toHaveLength(0) }) it('单结果分类写入 resultCount=1,主动取消恢复默认标记并清除目标', async () => {