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) }) })