import { afterEach, describe, expect, it, vi } from 'vitest' import { GUIDE_FLOOR_BASEMAP_PROJECTION, GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION, GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, findGuideCompositeBasemap, findGuideFloorBasemap, getGuideFloorBasemapFrame, loadGuideFloorBasemapManifest, projectPointToGuideFloorBasemap, resetGuideFloorBasemapManifestCacheForTests, isCompatibleGuideFloorBasemapManifest, isValidGuideFloorOrthographicProjection, unprojectGuideFloorBasemapPoint, type GuideCompositeBasemapEntry, type GuideFloorBasemapEntry } from '@/services/model/GuideFloorBasemap' const yaw = 54 * Math.PI / 180 const screenRight = { x: Math.cos(yaw), z: -Math.sin(yaw) } const screenDown = { x: Math.sin(yaw), z: Math.cos(yaw) } const createBasemap = (): GuideFloorBasemapEntry => ({ floorId: 'L1', floorCode: 'L1', modelVersion: 'v1', projection: { type: 'orthographic-xz', minX: -43.737275, maxX: 43.737275, minZ: -47.055327, maxZ: 47.055327, planeY: 6, center: { x: 0, z: 0 }, width: 80, height: 50, screenRight, screenDown }, initialViewport: { centerX: 0, centerZ: 0, visibleWorldSpan: 40, maxVisibleWorldSpan: 64 }, image: { path: '/static/guide-floor-basemaps/L1.v1.ortho-yaw54-v2.compact-floor-center-v1.webp', width: 1600, height: 1000, format: 'webp' } }) const createCompositeBasemap = (): GuideCompositeBasemapEntry => ({ ...createBasemap(), assetId: 'route-asset-1', role: 'SAME_GROUND_COMPOSITE', floorId: 'EXTERIOR_L1', floorCode: 'EXTERIOR_L1', modelVersion: 'composite-v1', modelUrl: '/app-api/composite.glb', coverageFloorCodes: ['EXTERIOR', 'L1'], coverageFloorIds: ['exterior-id', 'l1-id'], image: { ...createBasemap().image, path: '/static/guide-floor-basemaps/EXTERIOR_L1.composite-v1.ortho-yaw54-v2.compact-floor-center-v1.webp' } }) describe('GuideFloorBasemap projection', () => { afterEach(() => { resetGuideFloorBasemapManifestCacheForTests() vi.unstubAllGlobals() }) it('round-trips a GLB point through the orthographic WebP projection', () => { const basemap = createBasemap() const frame = getGuideFloorBasemapFrame(390, 844, 1600, 1000, 'cover') const source = { x: 18, y: 0, z: -22 } const projected = projectPointToGuideFloorBasemap(source, basemap, frame) const restored = unprojectGuideFloorBasemapPoint(projected, basemap, frame) expect(restored.x).toBeCloseTo(source.x, 6) expect(restored.z).toBeCloseTo(source.z, 6) }) it('maps projection-basis corners to the displayed image corners', () => { const basemap = createBasemap() const frame = getGuideFloorBasemapFrame(390, 844, 1600, 1000, 'contain') const projection = basemap.projection! const toWorld = (right: number, down: number) => ({ x: projection.center.x + right * projection.screenRight.x + down * projection.screenDown.x, y: 999, z: projection.center.z + right * projection.screenRight.z + down * projection.screenDown.z }) const topLeft = projectPointToGuideFloorBasemap( toWorld(-projection.width / 2, -projection.height / 2), basemap, frame ) const bottomRight = projectPointToGuideFloorBasemap( toWorld(projection.width / 2, projection.height / 2), basemap, frame ) expect(topLeft.x).toBeCloseTo(frame.offsetX, 8) expect(topLeft.y).toBeCloseTo(frame.offsetY, 8) expect(bottomRight.x).toBeCloseTo(frame.offsetX + frame.width, 8) expect(bottomRight.y).toBeCloseTo(frame.offsetY + frame.height, 8) }) it('rejects a non-orthogonal projection basis', () => { const projection = { ...createBasemap().projection!, screenDown: { ...screenRight } } expect(isValidGuideFloorOrthographicProjection(projection)).toBe(false) }) it('ignores POI height in the orthographic projection', () => { const basemap = createBasemap() const frame = getGuideFloorBasemapFrame(750, 1500, 750, 1500) expect(projectPointToGuideFloorBasemap({ x: 5, y: -100, z: 8 }, basemap, frame)) .toEqual(projectPointToGuideFloorBasemap({ x: 5, y: 100, z: 8 }, basemap, frame)) }) it('revalidates the manifest instead of force-caching an obsolete schema', async () => { const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ schemaVersion: 3, render: { projection: 'orthographic-xz', projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION }, floors: [], routeAssets: [] }) })) vi.stubGlobal('fetch', fetchMock) await loadGuideFloorBasemapManifest() expect(fetchMock).toHaveBeenCalledWith( '/static/guide-floor-basemaps/manifest.json', { cache: 'no-cache' } ) }) it('rejects an obsolete projection identity and can recover after cache reset', async () => { const basemap = createBasemap() const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, render: { projection: 'orthographic-xz', projectionVersion: 'oblique-v0' }, floors: [basemap], routeAssets: [] }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, render: { projection: 'orthographic-xz', projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION }, floors: [basemap], routeAssets: [] }) }) vi.stubGlobal('fetch', fetchMock) const obsolete = await loadGuideFloorBasemapManifest() expect(obsolete).toBeNull() expect(findGuideFloorBasemap({ schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, render: { projection: 'orthographic-xz', projectionVersion: 'oblique-v0' }, floors: [basemap], routeAssets: [] }, 'L1')).toBeNull() const recovered = await loadGuideFloorBasemapManifest() expect(findGuideFloorBasemap(recovered, 'L1')).toEqual(basemap) expect(fetchMock).toHaveBeenCalledTimes(2) }) it('rejects the previous ortho-v1 cache identity', () => { expect(isCompatibleGuideFloorBasemapManifest({ schemaVersion: 3, render: { projection: GUIDE_FLOOR_BASEMAP_PROJECTION, projectionVersion: 'ortho-v1' }, floors: [createBasemap()], routeAssets: [] })).toBe(false) }) it('rejects a schema 2 oblique manifest even when it contains a legacy camera projection', async () => { const basemap = createBasemap() const legacyBasemap: GuideFloorBasemapEntry = { ...basemap, projection: undefined, cameraProjection: { type: 'perspective', position: { x: 0, y: 100, z: 100 }, target: { x: 0, y: 0, z: 0 }, up: { x: 0, y: 1, z: 0 }, fov: 45, near: 0.1, far: 1000, aspect: 1.6 } } const legacyManifest = { schemaVersion: 2, floors: [legacyBasemap], routeAssets: [] } const fetchMock = vi.fn(async () => ({ ok: true, json: async () => legacyManifest })) vi.stubGlobal('fetch', fetchMock) expect(findGuideFloorBasemap(legacyManifest, 'L1')).toBeNull() expect(await loadGuideFloorBasemapManifest()).toBeNull() }) it('uses the sole valid floor basemap when the published GLB version is newer', () => { const basemap = createBasemap() const manifest = { schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, render: { projection: GUIDE_FLOOR_BASEMAP_PROJECTION, projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION }, floors: [basemap], routeAssets: [] } expect(findGuideFloorBasemap(manifest, 'L1', 'v2')).toEqual(basemap) }) it('does not guess a basemap when a floor has multiple nonmatching versions', () => { const older = createBasemap() const newer = { ...createBasemap(), modelVersion: 'v2' } const manifest = { schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, render: { projection: GUIDE_FLOOR_BASEMAP_PROJECTION, projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION }, floors: [older, newer], routeAssets: [] } expect(findGuideFloorBasemap(manifest, 'L1', 'v3')).toBeNull() }) it('requires an orthographic floor entry under the schema 4 render identity', () => { const basemap = createBasemap() const manifest = { schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, render: { projection: GUIDE_FLOOR_BASEMAP_PROJECTION, projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION }, floors: [{ ...basemap, projection: undefined, cameraProjection: { type: 'perspective' as const, position: { x: 0, y: 100, z: 100 }, target: { x: 0, y: 0, z: 0 }, up: { x: 0, y: 1, z: 0 }, fov: 45, near: 0.1, far: 1000, aspect: 1.6 } }], routeAssets: [] } expect(findGuideFloorBasemap(manifest, 'L1')).toBeNull() }) it('selects only the strict composite whose coverage equals the unique route floor set', () => { const composite = createCompositeBasemap() const manifest = { schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION, render: { projection: GUIDE_FLOOR_BASEMAP_PROJECTION, projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION }, floors: [createBasemap()], routeAssets: [composite] } expect(findGuideCompositeBasemap(manifest, ['l1-id', 'exterior-id', 'l1-id'])) .toEqual(composite) expect(findGuideCompositeBasemap(manifest, ['L1', 'EXTERIOR'])).toEqual(composite) expect(findGuideCompositeBasemap(manifest, ['l1-id'])).toBeNull() expect(findGuideCompositeBasemap(manifest, ['l1-id', 'exterior-id', 'L2'])).toBeNull() }) it('rejects schema 3 manifests instead of silently treating an ordinary exterior image as composite', () => { const manifest = { schemaVersion: 3, render: { projection: GUIDE_FLOOR_BASEMAP_PROJECTION, projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION }, floors: [createBasemap()], routeAssets: [createCompositeBasemap()] } expect(isCompatibleGuideFloorBasemapManifest(manifest)).toBe(false) expect(findGuideCompositeBasemap(manifest, ['L1', 'EXTERIOR'])).toBeNull() }) })