Files
frontend-miniapp/tests/unit/GuideViewport.spec.ts
lyf 9ea1bfab71
Some checks failed
CI / verify (push) Has been cancelled
同步 sgs-frontend-mobile 源码
2026-07-27 11:26:01 +08:00

202 lines
7.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
createGuideBoundaryIntentState,
createGuideViewport,
getGuideAutoSwitchVisibleSpanThreshold,
isGuideFloorAutoExitBlocked,
isGuideAutoSwitchVisibleSpanThresholdReached,
panGuideViewport,
projectGuideWorldPoint,
reduceGuideBoundaryIntent,
setGuideViewportSpanAt,
unprojectGuideViewportPoint,
zoomGuideViewport
} from '@/domain/guideViewport'
const bounds = {
minX: -150,
maxX: 150,
minZ: -300,
maxZ: 300,
screenXAxis: '+X' as const,
screenYAxis: '-Z' as const
}
const size = { width: 390, height: 780 }
const yaw = 54 * Math.PI / 180
const yawBounds = {
minX: -500,
maxX: 500,
minZ: -1000,
maxZ: 1000,
projectionCenterX: 29.0598,
projectionCenterZ: 23.9424,
projectionWidth: 600,
projectionHeight: 1200,
initialVisibleWorldSpan: 120,
maxVisibleWorldSpan: 150,
screenRight: { x: Math.cos(yaw), z: -Math.sin(yaw) },
screenDown: { x: Math.sin(yaw), z: Math.cos(yaw) }
}
describe('GuideViewport', () => {
it('round-trips X/Z world coordinates with less than 1e-6m error', () => {
const viewport = createGuideViewport({ scene: 'floor', floorId: 'L2', bounds, size })
const source = { x: 73.125, z: -124.875 }
const restored = unprojectGuideViewportPoint(
projectGuideWorldPoint(source, viewport, size, bounds),
viewport,
size,
bounds
)
expect(Math.abs(restored.x - source.x)).toBeLessThan(1e-6)
expect(Math.abs(restored.z - source.z)).toBeLessThan(1e-6)
})
it('maps orthographic world corners to image corners with screen up equal to -Z', () => {
const viewport = createGuideViewport({ scene: 'overview', bounds, size })
expect(projectGuideWorldPoint({ x: bounds.minX, z: bounds.minZ }, viewport, size, bounds))
.toEqual({ x: 0, y: 0 })
expect(projectGuideWorldPoint({ x: bounds.maxX, z: bounds.maxZ }, viewport, size, bounds))
.toEqual({ x: size.width, y: size.height })
})
it('round-trips points through the yaw54 orthographic basis', () => {
const viewport = createGuideViewport({ scene: 'overview', bounds: yawBounds, size })
const source = { x: -72.4, z: 184.25 }
const screen = projectGuideWorldPoint(source, viewport, size, yawBounds)
const restored = unprojectGuideViewportPoint(screen, viewport, size, yawBounds)
expect(restored.x).toBeCloseTo(source.x, 8)
expect(restored.z).toBeCloseTo(source.z, 8)
})
it('pans opposite to the drag in the yaw54 screen basis', () => {
const viewport = createGuideViewport({ scene: 'overview', bounds: yawBounds, size })
const dragged = panGuideViewport(viewport, { x: 39, y: 78 }, yawBounds, size)
const expectedRightShift = -39 / size.width * viewport.visibleWorldSpan
const expectedDownShift = -78 / size.height
* viewport.visibleWorldSpan * size.height / size.width
expect(dragged.centerX - viewport.centerX).toBeCloseTo(
expectedRightShift * yawBounds.screenRight.x
+ expectedDownShift * yawBounds.screenDown.x,
8
)
expect(dragged.centerZ - viewport.centerZ).toBeCloseTo(
expectedRightShift * yawBounds.screenRight.z
+ expectedDownShift * yawBounds.screenDown.z,
8
)
})
it('keeps an off-center world anchor stable while zooming', () => {
const viewport = createGuideViewport({ scene: 'floor', floorId: 'L1', bounds: yawBounds, size })
const anchor = { x: 96, y: 233 }
const worldBefore = unprojectGuideViewportPoint(anchor, viewport, size, yawBounds)
const zoomed = setGuideViewportSpanAt(
viewport,
viewport.visibleWorldSpan / 1.25,
anchor,
size,
yawBounds
)
const worldAfter = unprojectGuideViewportPoint(anchor, zoomed, size, yawBounds)
expect(worldAfter.x).toBeCloseTo(worldBefore.x, 8)
expect(worldAfter.z).toBeCloseTo(worldBefore.z, 8)
})
it('starts at the GLB baseline span and stops at the configured maximum span', () => {
const viewport = createGuideViewport({ scene: 'overview', bounds: yawBounds, size })
const first = zoomGuideViewport(viewport, 'out', yawBounds, size)
const boundary = zoomGuideViewport(first.viewport, 'out', yawBounds, size)
expect(viewport.visibleWorldSpan).toBe(120)
expect(first.viewport.visibleWorldSpan).toBe(150)
expect(boundary.viewport.visibleWorldSpan).toBe(150)
expect(boundary).toMatchObject({ changed: false, boundaryAttempt: true })
})
it('zooms continuously without changing scene or requesting a scene transition', () => {
const viewport = createGuideViewport({ scene: 'overview', bounds, size })
const first = zoomGuideViewport(viewport, 'in', bounds, size)
const second = zoomGuideViewport(first.viewport, 'in', bounds, size)
expect(first.changed).toBe(true)
expect(first.boundaryAttempt).toBe(false)
expect(second.viewport.scene).toBe('overview')
expect(second.viewport.visibleWorldSpan).toBeLessThan(first.viewport.visibleWorldSpan)
})
it('uses the GLB-equivalent 0.85 overview entry threshold', () => {
expect(getGuideAutoSwitchVisibleSpanThreshold(100, 'in')).toBe(85)
expect(isGuideAutoSwitchVisibleSpanThresholdReached({
scene: 'overview',
direction: 'in',
currentVisibleWorldSpan: 85,
referenceVisibleWorldSpan: 100
})).toBe(true)
expect(isGuideAutoSwitchVisibleSpanThresholdReached({
scene: 'overview',
direction: 'in',
currentVisibleWorldSpan: 85.0001,
referenceVisibleWorldSpan: 100
})).toBe(false)
})
it('uses the GLB-equivalent 1.15 floor exit threshold', () => {
expect(getGuideAutoSwitchVisibleSpanThreshold(100, 'out')).toBeCloseTo(115, 10)
expect(isGuideAutoSwitchVisibleSpanThresholdReached({
scene: 'floor',
direction: 'out',
currentVisibleWorldSpan: 115,
referenceVisibleWorldSpan: 100
})).toBe(true)
expect(isGuideAutoSwitchVisibleSpanThresholdReached({
scene: 'floor',
direction: 'out',
currentVisibleWorldSpan: 114.9999,
referenceVisibleWorldSpan: 100
})).toBe(false)
})
it('requires a second boundary intent and enforces cross-scene cooldown', () => {
const initial = createGuideBoundaryIntentState()
const armed = reduceGuideBoundaryIntent(initial, {
scene: 'overview',
direction: 'in',
boundaryAttempt: true,
now: 1000
})
const confirmed = reduceGuideBoundaryIntent(armed.state, {
scene: 'overview',
direction: 'in',
boundaryAttempt: true,
now: 1400
})
const bounce = reduceGuideBoundaryIntent(confirmed.state, {
scene: 'floor',
direction: 'out',
boundaryAttempt: true,
now: 1500
})
expect(armed).toMatchObject({ allowed: false, reason: 'armed' })
expect(confirmed).toMatchObject({ allowed: true, reason: 'confirmed' })
expect(bounce).toMatchObject({ allowed: false, reason: 'cooldown' })
})
it.each([
['query', { hasQuery: true }],
['detail', { hasSelection: true }],
['route', { hasRoute: true }],
['navigation', { isNavigating: true }],
['explicit lock', { disableAutoExit: true }]
])('blocks floor auto-exit while %s state is active', (_label, state) => {
expect(isGuideFloorAutoExitBlocked(state)).toBe(true)
})
})