Files
frontend-miniapp/tests/unit/GuideRepositorySearch.spec.ts

409 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
resolvePoiCategory,
type PoiCategoryId
} from '@/domain/poiCategories'
import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider'
import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider'
import { SgsSdkGuideRepository, StaticGuideRepository } from '@/repositories/GuideRepository'
import {
SgsSdkGuideModelRepository,
StaticGuideModelRepository
} from '@/repositories/GuideModelRepository'
const manifest: SgsSdkManifestPayload = {
mapId: 'museum',
floors: [{
floorId: 'L1',
floorCode: 'L1',
floorName: '1F',
sortOrder: 1
}]
}
const position = (x: number): [number, number, number] => [x, 12, x]
const allQuickCategoryIds: readonly PoiCategoryId[] = [
'exhibition-hall',
'cinema',
'ticket-office',
'dining',
'shopping',
'service-center',
'restroom',
'nursing-room',
'elevator',
'escalator'
]
const floorSpaces = [
{ id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } },
{ id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } },
{ id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } },
{ id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } }
]
const floorPois = [
{ id: 'ticket', name: '售票处', type: 'ticket_office', floorId: 'L1', position: { x: 10, y: 12, z: 10 } },
{ id: 'service', name: '咨询服务台', type: 'service_desk', floorId: 'L1', position: { x: 11, y: 12, z: 11 } },
{
id: 'restroom',
name: '女卫 01',
type: 'toilet',
floorId: 'L1',
spatialAreaId: 'shared-service-zone',
position: { x: 12, y: 12, z: 12 }
},
{ id: 'nursing', name: '母婴室', type: 'mother_baby_room', floorId: 'L1', position: { x: 13, y: 12, z: 13 } },
{
id: 'elevator',
name: '无障碍电梯',
type: 'elevator',
floorId: 'L1',
spatialAreaId: 'shared-service-zone',
position: { x: 14, y: 12, z: 14 }
},
{ id: 'escalator', name: '自动扶梯', type: 'escalator', floorId: 'L1', position: { x: 15, y: 12, z: 15 } },
{
id: 'poi-dining',
name: '餐饮区',
type: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-dining',
spatialAreaName: '餐饮区',
position: { x: 3, y: 12, z: 3 }
},
{ id: 'door', name: '北入口', type: 'entrance_exit', floorId: 'L1', position: { x: 16, y: 12, z: 16 } },
{ id: 'anchor', name: '入口锚点', type: 'entrance_anchor', floorId: 'L1', position: { x: 17, y: 12, z: 17 } },
{ id: 'route-node', name: '路线节点', type: 'route_node', floorId: 'L1', position: { x: 18, y: 12, z: 18 } },
{ id: 'guide-stop', name: '讲解点', type: 'operation_experience', floorId: 'L1', position: { x: 19, y: 12, z: 19 } }
]
const businessPois = [
{
id: 'business-dining',
name: '餐饮区',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
spatialAreaId: 'space-dining',
spatialAreaName: '餐饮区',
floorId: 'L1',
position: { x: 3, y: 12, z: 3 }
},
{
id: 'business-shopping',
name: '文创商店',
type: 'shop',
poiGroup: 'BUSINESS',
businessType: 'shop',
spatialAreaId: 'space-shopping',
spatialAreaName: '文创商店',
floorId: 'L1',
position: { x: 4, y: 12, z: 4 }
}
]
const createSgsProvider = (overrides: Partial<SgsSdkApiProvider> = {}) => ({
getManifest: vi.fn().mockResolvedValue(manifest),
getFloorPois: vi.fn().mockResolvedValue(floorPois),
getFloorBusinessPois: vi.fn().mockResolvedValue(businessPois),
getFloorSpaces: vi.fn().mockResolvedValue(floorSpaces),
getNavigablePlaces: vi.fn().mockResolvedValue([
{ id: 'nav-door', name: '入口', type: 'navigable_place', floorId: 'L1', position: { x: 20, y: 12, z: 20 } }
]),
...overrides
}) as unknown as SgsSdkApiProvider
describe('GuideRepository visitor search contracts', () => {
it('uses spaces for default browse, exposes all ten quick categories, and never returns anchors', async () => {
const provider = createSgsProvider()
const repository = new SgsSdkGuideRepository(provider)
const [defaultResults, availability] = await Promise.all([
repository.listDestinationPois('L1'),
repository.getQuickFindCategoryAvailability('L1')
])
expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([
'hall-space-hall',
'hall-space-cinema',
'space-space-dining',
'space-space-shopping'
]))
expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([
'restroom',
'elevator',
'ticket',
'door',
'anchor',
'route-node',
'guide-stop'
]))
expect(availability).toEqual(allQuickCategoryIds.map((id) => ({
id,
count: 1,
disabled: false
})))
await Promise.all(allQuickCategoryIds.map(async (categoryId) => {
const results = await repository.listQuickFindPois(categoryId, 'L1')
expect(results).toHaveLength(1)
expect(results[0].floorId).toBe('L1')
expect(results[0].positionGltf).toBeDefined()
expect(resolvePoiCategory(results[0])?.id).toBe(categoryId)
}))
const renderPois = await new SgsSdkGuideModelRepository(provider, repository).loadFloorPois('L1')
const renderPoiIds = new Set(renderPois.map((poi) => poi.id))
// Facilities in one spatial area are distinct visitor places and must both
// survive model marker deduplication.
expect(renderPoiIds.has('restroom')).toBe(true)
expect(renderPoiIds.has('elevator')).toBe(true)
for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) {
expect(renderPoiIds.has(hiddenPoiId)).toBe(false)
}
const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => (
repository.listQuickFindPois(categoryId, 'L1')
)))).flat().map((poi) => poi.id)
expect([...defaultResults, ...quickResultIds.map((id) => ({ id }))]
.every((poi) => renderPoiIds.has(poi.id))).toBe(true)
})
it('normalizes restaurant/shop sources, deduplicates a real place, and excludes non-visitor search records', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider())
const [dining, shopping, keywordDining, keywordRestroom, hiddenKeyword] = await Promise.all([
repository.listQuickFindPois('dining', 'L1'),
repository.listQuickFindPois('shopping', 'L1'),
repository.searchPois('餐饮', 'L1'),
repository.searchPois('洗手间', 'L1'),
repository.searchPois('入口', 'L1')
])
expect(dining.map((poi) => poi.id)).toEqual(['space-space-dining'])
expect(shopping.map((poi) => poi.id)).toEqual(['space-space-shopping'])
expect(keywordDining.map((poi) => poi.id)).toEqual(['space-space-dining'])
expect(keywordRestroom.map((poi) => poi.id)).toEqual(['restroom'])
expect(hiddenKeyword).toEqual([])
})
it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{
id: 'poi-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
floorId: 'L1',
position: { x: 40, y: 12, z: 40 }
}]),
getFloorBusinessPois: vi.fn().mockResolvedValue([{
id: 'business-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
position: { x: 40, y: 12, z: 40 }
}]),
getFloorSpaces: vi.fn().mockResolvedValue([{
id: 'space-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
floorId: 'L1',
center: { x: 40, y: 12, z: 40 }
}])
}))
const [quickFind, keyword] = await Promise.all([
repository.listQuickFindPois('dining', 'L1'),
repository.searchPois('咖啡', 'L1')
])
expect(quickFind).toHaveLength(1)
expect(keyword).toHaveLength(1)
expect(quickFind[0]).toMatchObject({ kind: 'space', name: '中庭咖啡厅' })
expect(keyword.map((poi) => poi.id)).toEqual(quickFind.map((poi) => poi.id))
})
it('deduplicates source representations linked to one space before positional fallback', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{
id: 'poi-cafe-counter',
name: '咖啡取餐台',
type: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-cafe',
position: { x: 45, y: 12, z: 45 }
}]),
getFloorBusinessPois: vi.fn().mockResolvedValue([{
id: 'business-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-cafe',
position: { x: 46, y: 12, z: 46 }
}]),
getFloorSpaces: vi.fn().mockResolvedValue([{
id: 'space-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
floorId: 'L1',
center: { x: 47, y: 12, z: 47 }
}])
}))
const [quickFind, keyword] = await Promise.all([
repository.listQuickFindPois('dining', 'L1'),
repository.searchPois('咖啡', 'L1')
])
expect(quickFind).toEqual([
expect.objectContaining({ id: 'space-space-cafe', kind: 'space' })
])
expect(keyword.map((poi) => poi.id)).toEqual(quickFind.map((poi) => poi.id))
})
it('keeps an unlinked commercial POI out of default destinations but exposes it through shopping', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([]),
getFloorBusinessPois: vi.fn().mockResolvedValue([{
id: 'business-commercial',
name: '纪念品商店',
type: 'commercial',
poiGroup: 'BUSINESS',
businessType: 'commercial',
floorId: 'L1',
position: { x: 30, y: 12, z: 30 }
}]),
getFloorSpaces: vi.fn().mockResolvedValue([])
}))
await expect(repository.listDestinationPois('L1')).resolves.toEqual([])
await expect(repository.listQuickFindPois('shopping', 'L1')).resolves.toMatchObject([
{ id: 'business-commercial', kind: 'facility' }
])
})
it('keeps an unlinked restaurant POI out of default destinations but exposes it through dining and keyword search', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{
id: 'poi-restaurant',
name: '咖啡餐饮点',
type: 'restaurant',
floorId: 'L1',
position: { x: 31, y: 12, z: 31 }
}]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([])
}))
await expect(repository.listDestinationPois('L1')).resolves.toEqual([])
await expect(repository.listQuickFindPois('dining', 'L1')).resolves.toMatchObject([
{ id: 'poi-restaurant', kind: 'facility' }
])
await expect(repository.searchPois('咖啡', 'L1')).resolves.toMatchObject([
{ id: 'poi-restaurant' }
])
})
it('falls back to static hall destinations when no independent space source exists', async () => {
const provider = {
baseUrl: '',
loadPoiIndex: vi.fn().mockResolvedValue([
{
id: 'static-hall',
name: '地球展厅',
floorId: 'L1',
primaryCategory: 'touring_poi',
primaryCategoryZh: '展厅',
iconType: 'exhibition_hall',
positionGltf: position(1)
},
{
id: 'static-restroom',
name: '男卫生间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '服务设施',
iconType: 'toilet',
positionGltf: position(2)
},
{
id: 'static-service-desk',
name: '服务台',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(3)
},
{
id: 'static-tea-room',
name: '茶水间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(4)
}
])
} as unknown as StaticNavAssetsProvider
const repository = new StaticGuideRepository(provider)
await expect(repository.listDestinationPois('L1')).resolves.toMatchObject([
{ id: 'static-hall', kind: 'hall' }
])
await expect(repository.listQuickFindPois('restroom', 'L1')).resolves.toMatchObject([
{ id: 'static-restroom' }
])
await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([
{ id: 'static-restroom' }
])
await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([
expect.objectContaining({ id: 'static-service-desk' })
])
await expect(repository.listQuickFindPois('service-center', 'L1')).resolves.toMatchObject([
{ id: 'static-service-desk' }
])
await expect(repository.getQuickFindCategoryAvailability('L1')).resolves.toEqual(expect.arrayContaining([
{ id: 'service-center', count: 1, disabled: false },
{ id: 'restroom', count: 1, disabled: false }
]))
})
it('keeps static guide records out of the model render pool', async () => {
const provider = {
loadFloorIndex: vi.fn().mockResolvedValue({
floors: [{ floorId: 'L1', poiDataAsset: 'poi_by_floor/L1.json' }]
}),
loadFloorPois: vi.fn().mockResolvedValue([
{
id: 'static-hall',
name: '地球展厅',
floorId: 'L1',
primaryCategory: 'touring_poi',
primaryCategoryZh: '展厅',
iconType: 'exhibition_hall',
positionGltf: position(1)
},
{
id: 'static-guide-point',
name: '讲解点',
floorId: 'L1',
primaryCategory: 'operation_experience',
primaryCategoryZh: '讲解点',
iconType: 'guide',
positionGltf: position(2)
}
])
} as unknown as StaticNavAssetsProvider
const renderPois = await new StaticGuideModelRepository(provider).loadFloorPois('L1')
expect(renderPois.map((poi) => poi.id)).toEqual(['static-hall'])
})
})