Files
frontend-miniapp/tests/unit/GuideRepositorySearch.spec.ts
lyf 9ea1bfab71
Some checks failed
CI / verify (push) Has been cancelled
同步 sgs-frontend-mobile 源码
2026-07-27 11:26:01 +08:00

647 lines
22 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('upgrades an empty current-floor pool when fresh spaces become available', async () => {
const provider = createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([floorSpaces[0]]),
getNavigablePlaces: vi.fn().mockResolvedValue([])
})
const repository = new SgsSdkGuideRepository(provider)
await expect(repository.listDestinationPois('L1')).resolves.toEqual([])
await expect(repository.listDestinationPois('L1')).resolves.toEqual([
expect.objectContaining({ id: 'hall-space-hall', floorId: 'L1' })
])
expect(provider.getFloorSpaces).toHaveBeenCalledTimes(2)
})
it('includes a storage space in 3F browse and aligns its label to the floor POI height', async () => {
const thirdFloorManifest: SgsSdkManifestPayload = {
mapId: 'museum',
floors: [{
floorId: 'floor-3',
floorCode: 'L3',
floorName: '3.0层',
sortOrder: 30
}]
}
const storageSpace = {
id: 'storage-protection',
name: '藏品技术保护区',
type: 'storage',
floorId: 'floor-3',
labelPosition: { x: -120.876557, y: 21.989281, z: 39.142601 },
meshCenter: { x: -89.91729, y: 19.489281, z: 45.423592 }
}
const provider = createSgsProvider({
getManifest: vi.fn().mockResolvedValue(thirdFloorManifest),
getFloorPois: vi.fn().mockResolvedValue([{
id: 'third-floor-restroom',
name: '男卫生间',
type: 'toilet',
floorId: 'floor-3',
position: { x: -83.324162, y: 16.540436, z: 43.138033 }
}]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([storageSpace]),
getNavigablePlaces: vi.fn().mockResolvedValue([])
})
const repository = new SgsSdkGuideRepository(provider)
const browseResults = await repository.listDestinationPois('floor-3')
const renderResults = await new SgsSdkGuideModelRepository(provider, repository)
.loadFloorPois('floor-3')
expect(browseResults).toContainEqual(expect.objectContaining({
id: 'space-storage-protection',
name: '藏品技术保护区',
floorId: 'floor-3',
floorLabel: '3F',
kind: 'space',
primaryCategory: expect.objectContaining({ id: 'space_storage' }),
positionGltf: [-120.876557, 16.540436, 39.142601]
}))
expect(renderResults).toContainEqual(expect.objectContaining({
id: 'space-storage-protection',
positionGltf: [-120.876557, 16.540436, 39.142601]
}))
})
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',
'space-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)
}))
expect(provider.getFloorPois).toHaveBeenCalledTimes(1)
expect(provider.getFloorBusinessPois).toHaveBeenCalledTimes(1)
expect(provider.getFloorSpaces).toHaveBeenCalledTimes(1)
expect(provider.getNavigablePlaces).toHaveBeenCalledTimes(1)
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('reuses the floor Bundle for search aggregation before falling back to split endpoints', async () => {
const getFloorBundle = vi.fn().mockResolvedValue({
pois: floorPois,
businessPois,
spaces: floorSpaces,
navigablePlaces: [{
id: 'nav-door',
name: '入口',
type: 'navigable_place',
floorId: 'L1',
position: { x: 20, y: 12, z: 20 }
}]
})
const provider = createSgsProvider({
getFloorBundle,
getFloorPois: vi.fn(),
getFloorBusinessPois: vi.fn(),
getFloorSpaces: vi.fn(),
getNavigablePlaces: vi.fn()
})
const repository = new SgsSdkGuideRepository(provider)
await repository.searchPois('餐饮', 'L1')
expect(getFloorBundle).toHaveBeenCalledWith('L1')
expect(provider.getFloorPois).not.toHaveBeenCalled()
expect(provider.getFloorBusinessPois).not.toHaveBeenCalled()
expect(provider.getFloorSpaces).not.toHaveBeenCalled()
expect(provider.getNavigablePlaces).not.toHaveBeenCalled()
})
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('does not broaden a specific keyword synonym into every place in its category', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([]),
getFloorBusinessPois: vi.fn().mockResolvedValue([
{
id: 'business-dining-generic',
name: '秦匠',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
position: { x: 30, y: 12, z: 30 }
},
{
id: 'business-coffee',
name: '比星咖啡',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
position: { x: 36, y: 12, z: 36 }
}
]),
getFloorSpaces: vi.fn().mockResolvedValue([])
}))
await expect(repository.searchPois('咖啡', 'L1')).resolves.toMatchObject([
{ id: 'business-coffee', name: '比星咖啡' }
])
})
it('keeps every merged business anchor for selected-state highlighting', async () => {
const provider = createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([]),
getFloorBusinessPois: vi.fn().mockResolvedValue([
{
id: 'artisan-west',
name: '秦匠',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
position: { x: 30, y: 12, z: 30 }
},
{
id: 'artisan-east',
name: '秦匠',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
position: { x: 36, y: 12, z: 34 }
}
]),
getFloorSpaces: vi.fn().mockResolvedValue([])
})
const guideRepository = new SgsSdkGuideRepository(provider)
const modelRepository = new SgsSdkGuideModelRepository(provider, guideRepository)
const [dining, keyword] = await Promise.all([
guideRepository.listQuickFindPois('dining', 'L1'),
guideRepository.searchPois('秦匠', 'L1')
])
expect(dining).toHaveLength(1)
expect(keyword).toHaveLength(1)
expect(dining[0]).toMatchObject({
id: 'artisan-west',
name: '秦匠'
})
const renderPois = await modelRepository.loadFloorPois('L1')
const artisan = renderPois.filter((poi) => poi.name === '秦匠')
expect(artisan).toHaveLength(1)
expect(artisan[0]).toMatchObject({
id: 'artisan-west',
positionGltf: [30, 12, 30],
mergedDisplayPositions: [[36, 12, 34]],
mergedMarkerPositions: undefined
})
})
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'])
})
it('returns one numbered ticket-machine series without merging independent ticket locations', async () => {
const numberedTicketMachines = [
{ id: '5682', name: 'L1_售票机_01', x: 10 },
{ id: '5683', name: 'L1_售票机_02', x: 20 },
{ id: '5684', name: 'L1_售票机_03', x: 30 },
{ id: '5685', name: 'L1_售票机_04', x: 40 }
].map(({ id, name, x }) => ({
id,
name,
type: 'ticket_office',
floorId: 'L1',
position: { x, y: 12, z: x }
}))
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([
...numberedTicketMachines,
{
id: '5591',
name: '售票机',
type: 'ticket_office',
floorId: 'L1',
position: { x: 50, y: 12, z: 50 }
},
{
id: '352502409978861752',
name: '展厅售票点',
type: 'ticket_office',
floorId: 'L1',
position: { x: 60, y: 12, z: 60 }
}
]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([])
}))
const [quickFind, keyword] = await Promise.all([
repository.listQuickFindPois('ticket-office', 'L1'),
repository.searchPois('售票', 'L1')
])
expect(quickFind.map((poi) => poi.id)).toEqual([
'5682',
'5591',
'352502409978861752'
])
expect(keyword.map((poi) => poi.id)).toEqual(quickFind.map((poi) => poi.id))
expect(quickFind[0]).toMatchObject({ name: '售票机' })
})
})