Files
frontend-miniapp/scripts/check-sgs-hall-poi-adapter.mjs

308 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
const exhibitionSpaceTypeWhitelist = new Set([
'exhibition_hall',
'theater',
'education_activity',
'ramp'
])
const exhibitionSpaceKeywords = [
'展厅',
'临展',
'展览',
'影院',
'球幕',
'巨幕',
'报告厅',
'活动室',
'科普',
'实验室',
'展览坡道',
'宇宙',
'地球',
'演化',
'恐龙',
'人类',
'生物',
'生态',
'家园'
]
const normalizedText = (value) => (value || '').trim()
const normalizedHallName = (value) => normalizedText(value).replace(/[\s()【】\[\]_-]/g, '')
const hasExhibitionKeyword = (value) => exhibitionSpaceKeywords.some((keyword) => normalizedText(value).includes(keyword))
const isEligiblePublicExhibitionSpace = (space) => {
const type = normalizedText(space.type).toLowerCase()
const name = normalizedText(space.name)
// Finding 2: Enforce whitelist first, reject unrelated types before keyword heuristics
if (!exhibitionSpaceTypeWhitelist.has(type)) return false
if (type === 'exhibition_hall') return true
if (type === 'theater') return hasExhibitionKeyword(name)
if (type === 'education_activity') return hasExhibitionKeyword(name)
if (type === 'ramp') return name.includes('展览坡道')
return false
}
const placeHallName = (place) => (
normalizedText(place.ownerName)
|| normalizedText(place.name)
.replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '')
.trim()
|| normalizedText(place.name)
)
const isExhibitionHallNavigablePlace = (place) => {
const searchableText = [
place.name,
place.ownerName,
place.category,
place.type,
place.typeCode,
place.typeName
]
.map(normalizedText)
.filter(Boolean)
.join(' ')
return hasExhibitionKeyword(searchableText)
}
const findMatchedHallSpace = (place, spaces) => {
const placeName = normalizedHallName(placeHallName(place))
if (!placeName) return undefined
const candidates = spaces
.map((space) => {
const spaceName = normalizedHallName(space.name)
if (!spaceName) return null
const exact = placeName === spaceName
const overlaps = exact || placeName.includes(spaceName) || spaceName.includes(placeName)
if (!overlaps) return null
return {
space,
exact,
specificity: spaceName.length
}
})
.filter(Boolean)
.sort((a, b) => {
if (a.exact !== b.exact) return a.exact ? -1 : 1
return b.specificity - a.specificity
})
return candidates[0]?.space
}
const normalizePositionSource = (source) => {
const x = Number(source?.x)
const y = Number(source?.y)
const z = Number(source?.z)
if (![x, y, z].every(Number.isFinite)) return undefined
return [x, y, z]
}
const normalizePlacePosition = (place) => normalizePositionSource(
place.position
? { x: place.position.x, y: place.position.y ?? 0, z: place.position.z }
: { x: place.x, y: place.y ?? 0, z: place.z }
)
const normalizeSpaceCenter = (space) => normalizePositionSource(space.center || {})
const createHallPoiId = (space, place, fallbackIndex) => {
if (space?.id) return `hall-${space.id}`
const ownerName = normalizedHallName(placeHallName(place))
const floorId = normalizedHallName(String(place.floorId || 'unknown-floor'))
if (ownerName) return `hall-place-${floorId}-${ownerName}`
return `hall-place-${floorId}-${place.id || fallbackIndex + 1}`
}
const toHallPois = (spaces, navigablePlaces, fallbackFloorId) => {
const hallSpaces = spaces.filter(isEligiblePublicExhibitionSpace)
const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace)
const halls = new Map()
hallPlaces.forEach((place, index) => {
const matchedSpace = findMatchedHallSpace(place, hallSpaces)
const entrancePosition = normalizePlacePosition(place)
const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined
const position = entrancePosition || fallbackPosition
const poiId = createHallPoiId(matchedSpace, place, index)
if (!position) return
const existing = halls.get(poiId)
if (existing) {
// Finding 1: Upgrade existing POI if later entrance has BOTH position and nodeId
if (entrancePosition && place.nodeId) {
existing.positionGltf = entrancePosition
existing.sourceConfidence = 'backend-sgs-sdk-hall-entrance'
existing.sourceObjectName = place.name
}
existing.entrances.push({ name: place.name, positionGltf: entrancePosition })
return
}
// backend-sgs-sdk-hall-entrance requires BOTH valid entrance position AND nodeId
const isHallEntranceSource = entrancePosition && place.nodeId
const sourceConfidence = isHallEntranceSource ? 'backend-sgs-sdk-hall-entrance' : 'backend-sgs-sdk-space-center'
halls.set(poiId, {
id: poiId,
name: matchedSpace?.name || placeHallName(place),
floorId: String(place.floorId || matchedSpace?.floorId || fallbackFloorId),
primaryCategory: 'exhibition_hall',
positionGltf: position,
sourceConfidence,
sourceObjectName: place.name,
entrances: [{ name: place.name, positionGltf: entrancePosition }]
})
})
hallSpaces.forEach((space) => {
const poiId = `hall-${space.id}`
if (halls.has(poiId)) return
const position = normalizeSpaceCenter(space)
if (!position) return
halls.set(poiId, {
id: poiId,
name: space.name,
floorId: String(space.floorId || fallbackFloorId),
primaryCategory: 'exhibition_hall',
positionGltf: position,
sourceConfidence: 'backend-sgs-sdk-space-center',
entrances: []
})
})
return Array.from(halls.values())
}
const createHallPoiDiagnostics = (spaces, navigablePlaces, hallPois) => {
const hallSpaces = spaces.filter(isEligiblePublicExhibitionSpace)
const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length
const hallPoiIds = new Set(hallPois.map((poi) => poi.id))
return {
skippedSpaceCount: hallSpaces.filter((space) => !hallPoiIds.has(`hall-${space.id}`)).length,
skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0)
}
}
const spaces = [
{ id: 1, name: '展厅1宇宙厅', type: 'exhibition_hall', floorId: 'B2', center: { x: 10, y: 0, z: 20 } },
{ id: 2, name: '巨幕影院', type: 'theater', floorId: 'L1', center: { x: 30, y: 0, z: 40 } },
{ id: 3, name: '博物馆之友活动室', type: 'education_activity', floorId: 'L4', center: { x: 50, y: 0, z: 60 } },
{ id: 4, name: '展览坡道', type: 'ramp', floorId: 'L1.5', center: { x: 70, y: 0, z: 80 } },
{ id: 5, name: '茶水间', type: 'service', floorId: 'L1', center: { x: 90, y: 0, z: 100 } },
{ id: 6, name: '自然科学展厅', type: 'exhibition_hall', floorId: 'L2', center: { x: 110, y: 0, z: 120 } },
{ id: 7, name: '地球科学展厅', type: 'exhibition_hall', floorId: 'L3', center: { x: 130, y: 0, z: 140 } },
{ id: 8, name: '古生物展厅', type: 'exhibition_hall', floorId: 'L2', center: { x: 150, y: 0, z: 160 } },
{ id: 9, name: '古生物展厅A', type: 'exhibition_hall', floorId: 'L2', center: { x: 170, y: 0, z: 180 } },
{ id: 10, name: '星空展厅', type: 'exhibition_hall', floorId: 'L5', center: { x: 190, y: 0, z: 200 } }
]
const places = [
{ id: 101, name: '展厅1宇宙厅 出入口 1', ownerName: '展厅1宇宙厅', floorId: 'B2', position: { x: 11, y: 0, z: 21 }, nodeId: 'n101' },
{ id: 102, name: '巨幕影院 出入口 1', ownerName: '巨幕影院', floorId: 'L1', position: { x: 31, y: 0, z: 41 }, nodeId: 'n102' },
{ id: 104, name: '地球科学展厅 出入口', ownerName: '地球科学展厅', floorId: 'L3' },
{ id: 105, name: '古生物展厅 出入口', ownerName: '古生物展厅', floorId: 'L2', position: { x: 151, y: 0, z: 161 }, nodeId: 'n105' },
{ id: 107, name: '自然科学展厅 出入口', ownerName: '自然科学展厅', floorId: 'L2' },
{ id: 108, name: '自然科学展厅【主】出入口1', ownerName: '自然科学展厅', floorId: 'L2', position: { x: 111, y: 0, z: 121 } },
{ id: 109, name: '地球 科学 展厅 (主)出入口 1', ownerName: '地球科学展厅', floorId: 'L3', position: { x: 131, y: 0, z: 141 }, nodeId: 'n109' },
{ id: 110, name: '古生物展厅A 出入口', ownerName: '古生物展厅A', floorId: 'L2', position: { x: 171, y: 0, z: 181 }, nodeId: 'n110' },
{ id: 111, name: '共享展厅 出入口', ownerName: '共享展厅', floorId: 'L6', position: { x: 210, y: 0, z: 220 }, nodeId: 'n111' },
{ id: 112, name: '共享展厅 出入口', ownerName: '共享展厅', floorId: 'L7', position: { x: 230, y: 0, z: 240 }, nodeId: 'n112' }
]
const result = toHallPois(spaces, places, 'B2')
const diagnostics = createHallPoiDiagnostics(spaces, places, result)
const byName = new Map(result.map((poi) => [poi.name, poi]))
const byId = new Map(result.map((poi) => [poi.id, poi]))
// All fixtures merge into existing POIs, maintaining 11 unique halls (verified by ID-based map)
assert.equal(result.length, 11, 'result contains 11 POIs')
assert.equal(byId.size, 11, 'all POI IDs are unique')
assert.deepEqual(byId.get('hall-1').positionGltf, [11, 0, 21])
assert.deepEqual(byId.get('hall-2').positionGltf, [31, 0, 41])
assert.deepEqual(byId.get('hall-3').positionGltf, [50, 0, 60])
assert.deepEqual(byId.get('hall-4').positionGltf, [70, 0, 80])
assert.equal(byId.has('hall-5'), false)
assert.equal(byId.get('hall-3').sourceConfidence, 'backend-sgs-sdk-space-center')
// Finding 2: whitelist enforcement prevents unrelated types
assert.equal(byName.has('茶水间'), false, 'service type rejected by whitelist')
// Finding 3: unmatched exhibition space with center emits fallback POI
assert.equal(byName.has('自然科学展厅'), true, 'unmatched space-center POI emitted')
// Place 107 (自然科学展厅 出入口) without nodeId matches space 6, falls back to space center [110, 0, 120]
assert.deepEqual(byName.get('自然科学展厅').positionGltf, [110, 0, 120], 'falls back to space center when entrance has no coordinates')
assert.equal(byName.get('自然科学展厅').sourceConfidence, 'backend-sgs-sdk-space-center', 'entrance without nodeId or coordinates uses space-center sourceConfidence')
// Both place 107 and 108 entrances should be recorded
assert.equal(byName.get('自然科学展厅').entrances.length, 2, 'both place 107 and 108 entrances recorded')
assert.equal(byName.get('自然科学展厅').entrances.some((e) => e.name === '自然科学展厅 出入口'), true, 'place 107 entrance without nodeId recorded')
assert.equal(byName.get('自然科学展厅').entrances.some((e) => e.name === '自然科学展厅【主】出入口1'), true, 'place 108 entrance with brackets recorded')
// Finding 4: entrance with missing coordinates falls back to matched space.center
assert.equal(byName.has('地球科学展厅'), true, 'entrance fallback to space.center')
// Finding 1: later entrance with BOTH nodeId and position upgrades POI to entrance-backed
assert.deepEqual(byName.get('地球科学展厅').positionGltf, [131, 0, 141], 'upgraded to entrance position [131, 0, 141]')
assert.equal(byName.get('地球科学展厅').sourceConfidence, 'backend-sgs-sdk-hall-entrance', 'Finding 1: upgraded to hall-entrance sourceConfidence')
// Finding 3: both entrances recorded, including the no-coordinate one
assert.equal(byName.get('地球科学展厅').entrances.length, 2, 'both place 104 (no-coord) and 109 (with nodeId) recorded')
assert.equal(byName.get('地球科学展厅').entrances[0].name, '地球科学展厅 出入口')
assert.equal(byName.get('地球科学展厅').entrances[0].positionGltf, undefined, 'first entrance has no coordinates')
assert.equal(byName.get('地球科学展厅').entrances.some((e) => e.name === '地球 科学 展厅 (主)出入口 1'), true, 'normalized entrance name recorded')
// Finding 1: entrance-derived sourceConfidence branch — entrance-backed halls must be backend-sgs-sdk-hall-entrance
assert.equal(byName.has('古生物展厅'), true, 'entrance with nodeId creates hall POI')
assert.equal(byName.get('古生物展厅').sourceConfidence, 'backend-sgs-sdk-hall-entrance', 'entrance with nodeId and position must have backend-sgs-sdk-hall-entrance')
assert.deepEqual(byName.get('古生物展厅').positionGltf, [151, 0, 161], 'entrance position is used for backend-sgs-sdk-hall-entrance')
// Finding 2: enforce entrance/reachable-node semantics — entrance WITHOUT nodeId that matches space should NOT get backend-sgs-sdk-hall-entrance
const nonNodeEntrance = places.find((p) => p.id === 107)
assert.equal(nonNodeEntrance.nodeId, undefined, 'fixture includes entrance without nodeId')
const naturalSciencePoi = byName.get('自然科学展厅')
assert.equal(naturalSciencePoi !== undefined, true, 'entrance without nodeId that matches space still creates POI')
assert.equal(naturalSciencePoi.sourceConfidence, 'backend-sgs-sdk-space-center', 'entrance without nodeId gets space-center not hall-entrance')
// Issue 2: Fixture validation — place 108 has coordinates but no nodeId, merges into space center POI
const place108 = places.find((p) => p.id === 108)
assert.equal(place108.nodeId, undefined, 'fixture 108 has no nodeId')
assert.deepEqual(place108.position, { x: 111, y: 0, z: 121 }, 'fixture 108 has coordinates')
// Issue 3: normalization/matching fixture with spacing and brackets
const place109 = places.find((p) => p.id === 109)
assert.equal(place109.nodeId, 'n109', 'fixture 109 has nodeId')
assert.equal(byName.has('地球科学展厅'), true, 'normalized name matches despite spacing and brackets')
const earthSciencePoi = byName.get('地球科学展厅')
// Place 104 (no nodeId, no position) created the POI with space-center sourceConfidence
// Place 109 (has nodeId and position) upgrades the POI to entrance-backed with new position
assert.equal(earthSciencePoi.sourceConfidence, 'backend-sgs-sdk-hall-entrance', 'sourceConfidence upgraded by place 109 with nodeId and position')
assert.deepEqual(earthSciencePoi.positionGltf, [131, 0, 141], 'position upgraded to place 109 entrance coordinates')
assert.equal(earthSciencePoi.entrances.length, 2, 'both place 104 and 109 entrances recorded')
assert.equal(earthSciencePoi.entrances.some((e) => e.name === '地球科学展厅 出入口'), true, 'place 104 no-coordinate entrance recorded')
assert.equal(earthSciencePoi.entrances.some((e) => e.name === '地球 科学 展厅 (主)出入口 1'), true, 'place 109 normalized entrance recorded')
// Finding 2 re-review: prefer exact match over shorter substring matches
assert.equal(byId.get('hall-9').name, '古生物展厅A', 'exact normalized match beats overlapping shorter hall names')
assert.deepEqual(byId.get('hall-9').positionGltf, [171, 0, 181], 'exact matched hall uses its own entrance position')
// Finding 3 re-review: place-only fallback IDs include floor identity to avoid collisions across floors
assert.equal(byId.has('hall-place-L6-共享展厅'), true, 'floor-scoped fallback ID created for L6 place-only hall')
assert.equal(byId.has('hall-place-L7-共享展厅'), true, 'floor-scoped fallback ID created for L7 place-only hall')
assert.notEqual(byId.get('hall-place-L6-共享展厅').id, byId.get('hall-place-L7-共享展厅').id, 'same-named place-only halls on different floors stay distinct')
// Finding 1 re-review: diagnostics count skipped spaces by missing hall-{space.id}, not by total hall POI count
assert.equal(diagnostics.skippedSpaceCount, 0, 'place-only halls do not inflate skipped-space diagnostics when all eligible spaces have hall-{space.id}')
assert.equal(diagnostics.skippedPlaceCount, 0, 'all hall places are represented by entrances')
console.log('SGS hall POI adapter smoke check passed')