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