202 lines
7.1 KiB
TypeScript
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)
|
|
})
|
|
})
|