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 { canonicalizeVisitorSearchPois, 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 } }, { id: 'space-private-tea', name: '茶水间', type: 'service_space', floorId: 'L1', center: { x: 5, y: 12, z: 5 } }, { id: 'space-private-vip', name: '贵宾接待区', type: 'service_space', floorId: 'L1', center: { x: 6, y: 12, z: 6 } }, { id: 'space-public-rest', name: '游客休息区', type: 'service_space', floorId: 'L1', center: { x: 7, y: 12, z: 7 }, visitorVisible: true } ] 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 = {}) => ({ 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, privateKeyword, publicKeyword] = await Promise.all([ repository.listDestinationPois('L1'), repository.getQuickFindCategoryAvailability('L1'), repository.searchPois('茶水间', 'L1'), repository.searchPois('游客休息区', 'L1') ]) expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([ 'hall-space-hall', 'hall-space-cinema', 'space-space-dining', 'space-space-shopping', 'space-space-public-rest' ])) for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) { expect(defaultResults.map((poi) => poi.id)).not.toContain(privatePoiId) } expect(privateKeyword).toEqual([]) expect(publicKeyword.map((poi) => poi.id)).toEqual(['space-space-public-rest']) 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) expect(renderPoiIds.has('space-space-public-rest')).toBe(true) for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) { expect(renderPoiIds.has(hiddenPoiId)).toBe(false) } for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) { expect(renderPoiIds.has(privatePoiId)).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('does not resolve private direct IDs outside the visitor search pool', async () => { const repository = new SgsSdkGuideRepository(createSgsProvider({ queryPois: vi.fn().mockResolvedValue([{ id: 'private-direct-space', name: '茶水间', type: 'service_space', floorId: 'L1', visitorVisible: false, position: { x: 30, y: 12, z: 30 } }]) })) await expect(repository.getPoiById('private-direct-space')).resolves.toBeNull() }) it('keeps private and structural space duplicates out of search lists and map markers', async () => { const provider = createSgsProvider({ getFloorPois: vi.fn().mockResolvedValue([ { id: 'poi-private-dining', name: '西侧餐饮区', type: 'restaurant', floorId: 'L1', spatialAreaId: 'space-private-dining', position: { x: 30, y: 12, z: 30 } }, { id: 'poi-service-space', name: '服务空间', type: 'service_space', floorId: 'L1', position: { x: 31, y: 12, z: 31 } } ]), getFloorBusinessPois: vi.fn().mockResolvedValue([]), getFloorSpaces: vi.fn().mockResolvedValue([ { id: 'space-private-dining', name: '西侧餐饮区', type: 'restaurant', floorId: 'L1', visitorVisible: false, center: { x: 30, y: 12, z: 30 } }, { id: 'space-private-cinema', name: '私享影院', type: 'theater', floorId: 'L1', visitorVisible: false, center: { x: 32, y: 12, z: 32 } }, { id: 'space-route-node', name: '导航区域', type: 'navigable_place', floorId: 'L1', center: { x: 33, y: 12, z: 33 } } ]), getNavigablePlaces: vi.fn().mockResolvedValue([]) }) const repository = new SgsSdkGuideRepository(provider) const modelRepository = new SgsSdkGuideModelRepository(provider, repository) const [defaultResults, dining, cinema, privateKeyword, serviceKeyword, structuralKeyword, renderPois] = await Promise.all([ repository.listDestinationPois('L1'), repository.listQuickFindPois('dining', 'L1'), repository.listQuickFindPois('cinema', 'L1'), repository.searchPois('西侧餐饮区', 'L1'), repository.searchPois('服务空间', 'L1'), repository.searchPois('导航区域', 'L1'), modelRepository.loadFloorPois('L1') ]) expect(defaultResults).toEqual([]) expect(dining).toEqual([]) expect(cinema).toEqual([]) expect(privateKeyword).toEqual([]) expect(serviceKeyword).toEqual([]) expect(structuralKeyword).toEqual([]) expect(renderPois).toEqual([]) }) it('merges source-place duplicates before returning canonical visitor IDs', () => { const basePoi = { floorId: 'L1', floorLabel: '1F', primaryCategory: { id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' }, categories: [{ id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' }], positionGltf: [40, 12, 40] as [number, number, number], accessible: false, kind: 'facility' as const, sourcePlaceId: 'place-cafe' } const results = canonicalizeVisitorSearchPois([ { ...basePoi, id: 'poi-cafe-a', name: '中庭咖啡' }, { ...basePoi, id: 'poi-cafe-b', name: '中庭咖啡取餐台', positionGltf: [41, 12, 41] } ]) expect(results.map((poi) => poi.id)).toEqual(['poi-cafe-a']) }) 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) }, { id: 'static-vip-reception', name: '贵宾接待区', floorId: 'L1', primaryCategory: 'basic_service_facility', primaryCategoryZh: '基础服务设施', iconType: 'restroom', positionGltf: position(5) }, { id: 'static-vip-restroom', name: '贵宾卫生间', floorId: 'L1', primaryCategory: 'basic_service_facility', primaryCategoryZh: '基础服务设施', iconType: 'restroom', positionGltf: position(6) } ]) } 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([]) await expect(repository.searchPois('贵宾', 'L1')).resolves.toEqual([]) await expect(repository.getPoiById('static-tea-room')).resolves.toBeNull() await expect(repository.getPoiById('static-vip-restroom')).resolves.toBeNull() 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) }, { id: 'static-private-tea', name: '茶水间', floorId: 'L1', primaryCategory: 'basic_service_facility', primaryCategoryZh: '基础服务设施', iconType: 'restroom', positionGltf: position(3) }, { id: 'static-vip-restroom', name: '贵宾卫生间', floorId: 'L1', primaryCategory: 'basic_service_facility', primaryCategoryZh: '基础服务设施', iconType: 'restroom', positionGltf: position(4) } ]) } as unknown as StaticNavAssetsProvider const renderPois = await new StaticGuideModelRepository(provider).loadFloorPois('L1') expect(renderPois.map((poi) => poi.id)).toEqual(['static-hall']) }) })