This commit is contained in:
304
tests/unit/GuideFloorBasemap.spec.ts
Normal file
304
tests/unit/GuideFloorBasemap.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
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('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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user