This commit is contained in:
@@ -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<void>
|
||||
showOverview: () => Promise<void>
|
||||
@@ -64,10 +76,11 @@ interface VisualStabilityApi {
|
||||
view: 'overview' | 'floor'
|
||||
floorId?: string
|
||||
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||||
}) => Promise<boolean>
|
||||
resetToInitialState: () => Promise<boolean>
|
||||
}) => 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<PoiFocusCandidate[]>
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
34
tests/unit/GuideModelStateReset.spec.ts
Normal file
34
tests/unit/GuideModelStateReset.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user