270 lines
14 KiB
JavaScript
270 lines
14 KiB
JavaScript
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
|
||
|
||
return spaces.find((space) => {
|
||
const spaceName = normalizedHallName(space.name)
|
||
return spaceName && (placeName.includes(spaceName) || spaceName.includes(placeName))
|
||
})
|
||
}
|
||
|
||
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))
|
||
if (ownerName) return `hall-place-${ownerName}`
|
||
return `hall-place-${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 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 } },
|
||
// Finding 3: unmatched exhibition space with center coordinates emits fallback POI
|
||
{ id: 6, name: '自然科学展厅', type: 'exhibition_hall', floorId: 'L2', center: { x: 110, y: 0, z: 120 } },
|
||
// Finding 4: space that will match when entrance has no coordinates
|
||
{ id: 7, name: '地球科学展厅', type: 'exhibition_hall', floorId: 'L3', center: { x: 130, y: 0, z: 140 } },
|
||
// Finding 1: space matched by entrance with nodeId for sourceConfidence assertion
|
||
{ id: 8, name: '古生物展厅', type: 'exhibition_hall', floorId: 'L2', center: { x: 150, y: 0, z: 160 } }
|
||
]
|
||
|
||
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' },
|
||
// Finding 3: entrance with missing coordinates falls back to matched space.center (creates POI with space-center sourceConfidence)
|
||
{ id: 104, name: '地球科学展厅 出入口', ownerName: '地球科学展厅', floorId: 'L3' },
|
||
// Finding 1: entrance-backed hall (with nodeId) must have backend-sgs-sdk-hall-entrance
|
||
{ id: 105, name: '古生物展厅 出入口', ownerName: '古生物展厅', floorId: 'L2', position: { x: 151, y: 0, z: 161 }, nodeId: 'n105' },
|
||
// Finding 2: entrance WITHOUT nodeId that matches a space should use space-center semantics (no position provided)
|
||
{ id: 107, name: '自然科学展厅 出入口', ownerName: '自然科学展厅', floorId: 'L2' },
|
||
// Finding 2: place with coordinates but no nodeId should match space center, not entrance position
|
||
{ id: 108, name: '自然科学展厅【主】出入口1', ownerName: '自然科学展厅', floorId: 'L2', position: { x: 111, y: 0, z: 121 } },
|
||
// Finding 1: later entrance with BOTH nodeId and position upgrades POI from space-center to entrance-backed
|
||
{ id: 109, name: '地球 科学 展厅 (主)出入口 1', ownerName: '地球科学展厅', floorId: 'L3', position: { x: 131, y: 0, z: 141 }, nodeId: 'n109' }
|
||
]
|
||
|
||
const result = toHallPois(spaces, places, 'B2')
|
||
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 7 unique halls (verified by ID-based map)
|
||
assert.equal(result.length, 7, 'result contains 7 POIs')
|
||
assert.equal(byId.size, 7, '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')
|
||
|
||
// Issue 4: keep unmatched exhibition space fallback coverage
|
||
assert.equal(byName.has('自然科学展厅'), true, 'unmatched space-center POI still emitted')
|
||
|
||
console.log('SGS hall POI adapter smoke check passed')
|