import { afterEach, describe, expect, it, vi } from 'vitest' import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider' import { SgsSdkGuideRepository } from '@/repositories/GuideRepository' import { SgsSdkGuideModelRepository } from '@/repositories/GuideModelRepository' import { isExhibitionHallSpace, toMuseumPoiFromSgs, toMuseumSpacePointFromSgs } from '@/data/adapters/sgsSdkGuideAdapter' import { getPoiCategoryById, matchesPoiCategory, resolvePoiCategory } from '@/domain/poiCategories' import { toFacilityDetailViewModel } from '@/view-models/guideViewModels' const manifest: SgsSdkManifestPayload = { mapId: 'museum', mapName: '深圳自然博物馆', floors: [ { floorId: '101', floorCode: 'L1', floorName: '1F', sortOrder: 1 } ] } const floorPoi = { id: 'poi-toilet', name: '一层卫生间', type: 'toilet', typeName: '卫生间', floorId: '101', position: { x: 4, y: 12, z: 8 } } const theaterSpace = { id: 'space-theater', name: '球幕影院', type: 'theater', floorId: '101', boundaryWkt: 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))' } const createProvider = (overrides: Partial = {}) => ({ getManifest: vi.fn().mockResolvedValue(manifest), getFloorPois: vi.fn().mockResolvedValue([floorPoi]), getFloorBusinessPois: vi.fn().mockResolvedValue([]), getFloorSpaces: vi.fn().mockResolvedValue([theaterSpace]), getNavigablePlaces: vi.fn().mockResolvedValue([]), ...overrides }) as unknown as SgsSdkApiProvider afterEach(() => { vi.restoreAllMocks() }) describe('SgsSdkGuideRepository 点位搜索', () => { it('源数据仅有 floorCode 时回填 manifest 的稳定 floorId', () => { const poi = toMuseumPoiFromSgs({ ...floorPoi, id: 'poi-floor-code-only', floorId: undefined, floorCode: 'L1' }, manifest.floors) expect(poi.floorId).toBe('101') expect(poi.floorLabel).toBe('1F') }) it('SDK null 坐标不会被误转换为模型原点', () => { const poi = toMuseumPoiFromSgs({ ...floorPoi, id: 'poi-with-null-position', position: { x: null, y: null, z: null } }, manifest.floors) expect(poi.positionGltf).toBeUndefined() }) it('普通 POI 坐标与大屏端一致优先使用 meshCenter', () => { const poi = toMuseumPoiFromSgs({ ...floorPoi, meshCenter: { x: 11, y: 12, z: 13 }, position: { x: 21, y: 22, z: 23 }, labelPosition: { x: 31, y: 32, z: 33 }, x: 41, y: 42, z: 43 }, manifest.floors) expect(poi.positionGltf).toEqual([11, 12, 13]) }) it('普通 POI 坐标按 position、labelPosition、旧坐标依次回退', () => { const basePoi = { ...floorPoi, position: { x: null, y: null, z: null }, labelPosition: { x: 31, y: 32, z: 33 }, x: 41, y: 42, z: 43 } expect(toMuseumPoiFromSgs(basePoi, manifest.floors).positionGltf).toEqual([31, 32, 33]) expect(toMuseumPoiFromSgs({ ...basePoi, labelPosition: { x: null, y: null, z: null } }, manifest.floors).positionGltf).toEqual([41, 42, 43]) }) it('设备终端解析对象 extParams 的全部精确模型节点', () => { const poi = toMuseumPoiFromSgs({ ...floorPoi, id: 'guide-screen-pair', name: '一楼导览屏', type: 'device_terminal', extParams: { sourceNodeNames: ['L1_导览屏_01', 'L1_导览屏_02'] } }, manifest.floors) expect(poi.primaryCategory.iconType).toBe('device_terminal') expect(poi.sourceObjectName).toBe('L1_导览屏_01') expect(poi.mergedSourceObjectNames).toEqual(['L1_导览屏_02']) }) it('设备终端解析字符串 extParams,并保持显式节点字段优先级', () => { const poi = toMuseumPoiFromSgs({ ...floorPoi, id: 'elevator-with-node-chain', name: 'L1_H电梯', type: 'device_terminal', anchorNodeName: 'L1_H_电梯16', sourceNodeName: 'L1_H_电梯15', extParams: JSON.stringify({ sourceNodeNames: ['L1_H_电梯14', 'L1_H_电梯16'] }) }, manifest.floors) expect(poi.sourceObjectName).toBe('L1_H_电梯16') expect(poi.mergedSourceObjectNames).toEqual(['L1_H_电梯15', 'L1_H_电梯14']) }) it('设备终端解析 extParams 中的单个 sourceNodeName', () => { const poi = toMuseumPoiFromSgs({ ...floorPoi, id: 'vending-machine', name: '自助售卖机', type: 'device_terminal', extParams: { sourceNodeName: 'L1_自助售卖机' } }, manifest.floors) expect(poi.sourceObjectName).toBe('L1_自助售卖机') expect(poi.mergedSourceObjectNames).toEqual([]) }) it('缺少节点元数据的设备终端仅用精确后台名称作为模型节点', () => { const poi = toMuseumPoiFromSgs({ ...floorPoi, id: 'security-device', name: 'L1_安检机_01', type: 'device_terminal' }, manifest.floors) expect(poi.sourceObjectName).toBe('L1_安检机_01') expect(poi.mergedSourceObjectNames).toEqual([]) }) it('空间是否为展厅仅由后端分类决定', () => { expect(isExhibitionHallSpace({ id: 'hall', name: '人类厅', type: 'exhibition_hall', floorId: '101' })).toBe(true) expect(isExhibitionHallSpace({ ...theaterSpace, name: '动感多维影院' })).toBe(false) expect(isExhibitionHallSpace({ id: 'education-space', name: '青少年探索中心', type: 'education_activity', floorId: '101' })).toBe(false) }) it('空间节点名为占位符时按楼层和空间名称匹配模型', () => { const poi = toMuseumSpacePointFromSgs({ id: 'tea-room', name: '茶水间', type: 'service_space', floorId: '101', sourceNodeName: 'L1_???', position: { x: 1, y: 12, z: 2 } }, manifest.floors, '101') expect(poi?.sourceObjectName).toBe('L1_茶水间') }) it('合并普通点位与空间点位,并保留影院真实分类和楼层高度', async () => { const repository = new SgsSdkGuideRepository(createProvider()) const results = await repository.searchPois() const theaterResults = results.filter((poi) => poi.name === '球幕影院') expect(theaterResults).toHaveLength(1) expect(theaterResults[0]).toMatchObject({ id: 'space-space-theater', floorId: '101', floorLabel: '1F', primaryCategory: { id: 'space_theater' }, positionGltf: [5, 12, 5] }) expect(resolvePoiCategory(theaterResults[0])?.id).toBe('cinema') expect(matchesPoiCategory( theaterResults[0], getPoiCategoryById('cinema')! )).toBe(true) expect(matchesPoiCategory( theaterResults[0], getPoiCategoryById('exhibition-hall')! )).toBe(false) expect(toFacilityDetailViewModel(theaterResults[0]).category).toBe('影院') expect(results.some((poi) => poi.id === 'poi-toilet')).toBe(true) }) it('搜索可定位 ID 与同楼层地图 marker ID 保持一致', async () => { const provider = createProvider() const guideRepository = new SgsSdkGuideRepository(provider) const modelRepository = new SgsSdkGuideModelRepository(provider, guideRepository) const [searchResults, renderPois] = await Promise.all([ guideRepository.searchPois(), modelRepository.loadFloorPois('101') ]) const renderPoiIds = new Set(renderPois.map((poi) => poi.id)) const locatableSearchIds = searchResults .filter((poi) => poi.floorId === '101' && poi.positionGltf) .map((poi) => poi.id) expect(locatableSearchIds.length).toBeGreaterThan(0) expect(locatableSearchIds.every((poiId) => renderPoiIds.has(poiId))).toBe(true) expect(renderPois.find((poi) => poi.id === 'space-space-theater')).toMatchObject({ primaryCategory: 'space_theater', iconType: 'theater' }) }) it('地图标签只读取当前楼层 SDK 数据,不触发全馆搜索聚合', async () => { const provider = createProvider() const guideSearchRepository = { listPois: vi.fn().mockResolvedValue([]), listSpacePoints: vi.fn().mockResolvedValue([]) } const modelRepository = new SgsSdkGuideModelRepository( provider, guideSearchRepository as never ) const renderPois = await modelRepository.loadFloorPois('101') expect(renderPois).not.toHaveLength(0) expect(provider.getFloorPois).toHaveBeenCalledTimes(1) expect(provider.getFloorBusinessPois).toHaveBeenCalledTimes(1) expect(provider.getFloorSpaces).toHaveBeenCalledTimes(1) expect(provider.getNavigablePlaces).toHaveBeenCalledTimes(1) expect(guideSearchRepository.listPois).not.toHaveBeenCalled() expect(guideSearchRepository.listSpacePoints).not.toHaveBeenCalled() }) it('地图渲染链路透传设备终端的全部模型节点', async () => { const provider = createProvider({ getFloorPois: vi.fn().mockResolvedValue([{ ...floorPoi, id: 'guide-screen-pair', name: '一楼导览屏', type: 'device_terminal', extParams: JSON.stringify({ sourceNodeNames: ['L1_导览屏_01', 'L1_导览屏_02'] }) }]) }) const repository = new SgsSdkGuideModelRepository(provider) const renderPoi = (await repository.loadFloorPois('101')) .find((poi) => poi.id === 'guide-screen-pair') expect(renderPoi).toMatchObject({ iconType: 'device_terminal', sourceObjectName: 'L1_导览屏_01', mergedSourceObjectNames: ['L1_导览屏_02'] }) }) it('首屏标签优先读取楼层 Bundle,并在首帧后预取搜索补充数据', async () => { const getFloorBundle = vi.fn().mockResolvedValue({ pois: [floorPoi], spaces: [theaterSpace] }) const getFloorBusinessPois = vi.fn().mockResolvedValue([]) const getNavigablePlaces = vi.fn().mockResolvedValue([]) const provider = createProvider({ getFloorBundle, getFloorBusinessPois, getNavigablePlaces }) const repository = new SgsSdkGuideModelRepository(provider) const renderPois = await repository.loadFloorPoisFast('101') expect(renderPois).toEqual(expect.arrayContaining([ expect.objectContaining({ id: 'poi-toilet' }), expect.objectContaining({ id: 'space-space-theater' }) ])) expect(getFloorBundle).toHaveBeenCalledWith('101') expect(provider.getFloorPois).not.toHaveBeenCalled() await new Promise((resolve) => setTimeout(resolve, 0)) expect(getFloorBusinessPois).toHaveBeenCalledWith('101') expect(getNavigablePlaces).toHaveBeenCalledWith('101') }) it('外观首屏不会预取室内搜索补充数据', async () => { const getFloorBusinessPois = vi.fn().mockResolvedValue([]) const getNavigablePlaces = vi.fn().mockResolvedValue([]) const provider = createProvider({ getManifest: vi.fn().mockResolvedValue({ ...manifest, floors: [{ floorId: 'outdoor', floorCode: 'EXTERIOR', floorName: '室外' }] }), getFloorBundle: vi.fn().mockResolvedValue({ pois: [], spaces: [] }), getFloorBusinessPois, getNavigablePlaces }) const repository = new SgsSdkGuideModelRepository(provider) await expect(repository.loadFloorPoisFast('outdoor')).resolves.toEqual([]) await new Promise((resolve) => setTimeout(resolve, 0)) expect(getFloorBusinessPois).not.toHaveBeenCalled() expect(getNavigablePlaces).not.toHaveBeenCalled() }) it('核心点位来源短暂失败时仍返回可直接定位的稳定空间 ID', async () => { const getFloorPois = vi.fn() .mockResolvedValueOnce([floorPoi]) .mockRejectedValueOnce(new Error('pois offline')) .mockResolvedValue([floorPoi]) const getFloorSpaces = vi.fn() .mockResolvedValueOnce([theaterSpace]) .mockRejectedValueOnce(new Error('spaces offline')) .mockResolvedValue([theaterSpace]) const provider = createProvider({ getFloorPois, getFloorSpaces }) const guideRepository = new SgsSdkGuideRepository(provider) const modelRepository = new SgsSdkGuideModelRepository(provider, guideRepository) vi.spyOn(console, 'warn').mockImplementation(() => undefined) await guideRepository.listSpacePoints() const fallbackResults = await guideRepository.searchPois() const fallbackTheater = fallbackResults.find((poi) => poi.name === '球幕影院') expect(fallbackTheater?.id).toBe('space-space-theater') const recoveredTheater = (await guideRepository.searchPois()) .find((poi) => poi.name === '球幕影院') expect(recoveredTheater?.id).toBe(fallbackTheater?.id) await expect(guideRepository.getPoiById('space-space-theater')) .resolves.toMatchObject({ id: 'space-space-theater' }) const renderPois = await modelRepository.loadFloorPois('101') expect(renderPois.some((poi) => poi.id === fallbackTheater?.id)).toBe(true) }) it('精确空间 ID 与普通点位 ID 同号时不会定位到错误点位', async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined) const provider = createProvider({ getFloorPois: vi.fn().mockResolvedValue([{ ...floorPoi, id: '42', name: '普通卫生间' }]), getFloorSpaces: vi.fn().mockResolvedValue([{ id: '42', name: '公共空间', type: 'public_area', floorId: '101', boundaryWkt: 'POLYGON((0 0, 8 0, 8 8, 0 8, 0 0))' }]) }) const repository = new SgsSdkGuideRepository(provider) await expect(repository.getPoiById('space-42')).resolves.toMatchObject({ id: 'space-42', name: '公共空间' }) await expect(repository.getPoiById('42')).resolves.toMatchObject({ id: '42', name: '普通卫生间' }) }) it('相同模型对象名不会删除不同稳定 ID 和坐标的普通点位', async () => { const provider = createProvider({ getFloorSpaces: vi.fn().mockResolvedValue([]), getFloorPois: vi.fn().mockResolvedValue([ { ...floorPoi, id: '5625', name: '男卫生间001', sourceNodeName: 'L2_男卫生间001', position: { x: 1, y: 12, z: 1 } }, { ...floorPoi, id: '5629', name: '卫生间01', sourceNodeName: 'L2_男卫生间001', position: { x: 9, y: 12, z: 9 } } ]) }) const guideRepository = new SgsSdkGuideRepository(provider) const modelRepository = new SgsSdkGuideModelRepository(provider, guideRepository) const renderPois = await modelRepository.loadFloorPois('101') expect(renderPois.filter((poi) => poi.id === '5625' || poi.id === '5629')) .toHaveLength(2) }) it('外观地图使用室外楼层预计算的空间标签位置', async () => { const exteriorFloor = { floorId: 'outdoor', floorCode: 'EXTERIOR', floorName: '外观/室外层', sortOrder: 10 } const exteriorRoadSpace = { id: 'space-honghua-road', name: '红花路', type: 'road_area', floorId: exteriorFloor.floorId, sourceNodeName: '室外_红花路', labelPosition: { x: -1.206116, y: 2.505, z: -84.258389 }, meshCenter: { x: -1.206116, y: 0.005, z: -84.258389 } } const getFloorSpaces = vi.fn().mockImplementation(async (floorId: string) => ( floorId === exteriorFloor.floorId ? [exteriorRoadSpace] : [theaterSpace] )) const getFloorBundle = vi.fn().mockResolvedValue({ model: { modelUrl: '/models/overview.glb' } }) const provider = createProvider({ getManifest: vi.fn().mockResolvedValue({ ...manifest, floors: [exteriorFloor, ...manifest.floors] }), getFloorPois: vi.fn().mockResolvedValue([]), getFloorSpaces, getFloorBundle }) const repository = new SgsSdkGuideModelRepository(provider, new SgsSdkGuideRepository(provider)) const packageData = await repository.loadPackage() const renderPois = await repository.loadFloorPois(exteriorFloor.floorId) expect(packageData.overviewFloorId).toBe(exteriorFloor.floorId) expect(getFloorSpaces).toHaveBeenCalledWith(exteriorFloor.floorId) expect(renderPois).toContainEqual(expect.objectContaining({ id: 'space-space-honghua-road', name: '红花路', floorId: exteriorFloor.floorId, sourceObjectName: '室外_红花路', positionGltf: [-1.206116, 2.505, -84.258389] })) }) it('Manifest 提供模型地址时不再为每个楼层读取 Bundle', async () => { const exteriorFloor = { floorId: 'outdoor', floorCode: 'EXTERIOR', floorName: '外观/室外层', sortOrder: 10, modelUrl: '/models/overview-lightweight.glb', fallbackModelUrl: '/models/overview.glb' } const indoorFloor = { ...manifest.floors[0], modelUrl: '/models/l1-lightweight.glb', fallbackModelUrl: '/models/l1.glb' } const getFloorBundle = vi.fn() const provider = createProvider({ getManifest: vi.fn().mockResolvedValue({ ...manifest, floors: [exteriorFloor, indoorFloor] }), getFloorBundle }) const repository = new SgsSdkGuideModelRepository(provider, new SgsSdkGuideRepository(provider)) await expect(repository.loadPackage()).resolves.toMatchObject({ overviewModelUrl: '/models/overview-lightweight.glb', floors: [expect.objectContaining({ floorId: '101', modelUrl: '/models/l1-lightweight.glb', modelUrls: ['/models/l1-lightweight.glb', '/models/l1.glb'] })] }) expect(getFloorBundle).not.toHaveBeenCalled() }) it('部分来源失败时继续返回可用点位,并在后续搜索重试失败来源', async () => { const getFloorSpaces = vi.fn() .mockRejectedValueOnce(new Error('spaces offline')) .mockRejectedValueOnce(new Error('spaces offline')) .mockResolvedValue([theaterSpace]) const provider = createProvider({ getFloorSpaces }) const repository = new SgsSdkGuideRepository(provider) vi.spyOn(console, 'warn').mockImplementation(() => undefined) const firstResults = await repository.searchPois() expect(firstResults.map((poi) => poi.id)).toEqual(['poi-toilet']) const secondResults = await repository.searchPois() expect(secondResults.some((poi) => poi.id === 'space-space-theater')).toBe(true) expect(getFloorSpaces).toHaveBeenCalledTimes(4) }) it('补充来源失败不会阻止核心点位结果缓存', async () => { const getFloorPois = vi.fn().mockResolvedValue([floorPoi]) const getFloorSpaces = vi.fn().mockResolvedValue([theaterSpace]) const getFloorBusinessPois = vi.fn().mockRejectedValue(new Error('business offline')) const provider = createProvider({ getFloorPois, getFloorSpaces, getFloorBusinessPois }) const repository = new SgsSdkGuideRepository(provider) vi.spyOn(console, 'warn').mockImplementation(() => undefined) await repository.listPois() await repository.listPois() expect(getFloorPois).toHaveBeenCalledTimes(1) expect(getFloorSpaces).toHaveBeenCalledTimes(1) expect(getFloorBusinessPois).toHaveBeenCalledTimes(1) }) it('楼层高度来源短暂失败时不缓存缺失的空间点位', async () => { const getFloorPois = vi.fn() .mockRejectedValueOnce(new Error('pois offline')) .mockRejectedValueOnce(new Error('pois offline')) .mockResolvedValue([floorPoi]) const provider = createProvider({ getFloorPois }) const repository = new SgsSdkGuideRepository(provider) vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect(repository.searchPois()).rejects.toThrow('点位数据加载失败,请检查网络后重试') const recoveredResults = await repository.searchPois() expect(recoveredResults.find((poi) => poi.id === 'space-space-theater')?.positionGltf) .toEqual([5, 12, 5]) expect(getFloorPois).toHaveBeenCalledTimes(4) }) it('所有核心楼层与空间请求失败时返回明确的可重试错误', async () => { const provider = createProvider({ getFloorPois: vi.fn().mockRejectedValue(new Error('pois offline')), getFloorSpaces: vi.fn().mockRejectedValue(new Error('spaces offline')) }) const repository = new SgsSdkGuideRepository(provider) vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect(repository.searchPois()).rejects.toThrow('点位数据加载失败,请检查网络后重试') }) it('将同楼层编号售票机渲染为一个逻辑对象并保留全部物理锚点', 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: '101', position: { x, y: 12, z: x } })) const provider = createProvider({ getFloorPois: vi.fn().mockResolvedValue([ ...numberedTicketMachines, { id: '5591', name: '售票机', type: 'ticket_office', floorId: '101', position: { x: 50, y: 12, z: 50 } }, { id: '352502409978861752', name: '展厅售票点', type: 'ticket_office', floorId: '101', position: { x: 60, y: 12, z: 60 } } ]), getFloorSpaces: vi.fn().mockResolvedValue([]) }) const repository = new SgsSdkGuideModelRepository(provider) const renderPois = await repository.loadFloorPois('101') const numberedSeries = renderPois.find((poi) => poi.id === '5682') expect(numberedSeries).toMatchObject({ name: '售票机', positionGltf: [10, 12, 10], displayPolicy: { labelVisible: true, minLabelTier: 'tight' }, mergedDisplayPositions: [ [20, 12, 20], [30, 12, 30], [40, 12, 40] ], mergedMarkerPositions: [ [20, 12, 20], [30, 12, 30], [40, 12, 40] ] }) expect(renderPois.map((poi) => poi.id)).toEqual(expect.arrayContaining([ '5682', '5591', '352502409978861752' ])) expect(renderPois.find((poi) => poi.id === '5591')?.displayPolicy).toMatchObject({ labelVisible: true, minLabelTier: 'tight' }) expect(renderPois.find((poi) => poi.id === '352502409978861752')?.displayPolicy).toMatchObject({ labelVisible: true, minLabelTier: 'tight' }) expect(renderPois).toHaveLength(3) }) it('仅把 FLOOR_EXIT 导航点转换为楼层出入口标签', async () => { const provider = createProvider({ getFloorPois: vi.fn().mockResolvedValue([]), getFloorSpaces: vi.fn().mockResolvedValue([]), getNavigablePlaces: vi.fn().mockResolvedValue([ { id: '2077983437229297849', name: '展览入口', category: 'door', anchorType: 'FLOOR_EXIT', floorId: '101', position: { x: -28.536788, y: 1, z: -4.662431 } }, { id: '2077983437229297850', name: '观众次入口', category: 'door', anchorType: 'FLOOR_EXIT', floorId: '101', position: { x: -106.745851, y: 1, z: 76.900369 } }, { id: '2077983437229297851', name: '影剧院入口', category: 'door', anchorType: 'FLOOR_EXIT', floorId: '101', position: { x: -8.49571, y: 1, z: 11.77092 } }, { id: 'space-entrance', name: '普通空间入口', category: 'door', anchorType: 'SPACE_ENTRANCE', floorId: '101', position: { x: 1, y: 1, z: 1 } }, { id: 'poi-entrance', name: '普通点位入口', category: 'door', anchorType: 'POI_ENTRANCE', floorId: '101', position: { x: 2, y: 1, z: 2 } } ]) }) const repository = new SgsSdkGuideModelRepository(provider) const renderPois = await repository.loadFloorPois('101') const floorExits = renderPois.filter((poi) => poi.id.startsWith('floor-exit-')) expect(floorExits.map((poi) => poi.name)).toEqual([ '展览入口', '观众次入口', '影剧院入口' ]) expect(floorExits[0]).toMatchObject({ positionGltf: [-28.536788, 1, -4.662431], primaryCategory: 'navigation_anchor', iconType: 'entrance_exit', displayPolicy: { labelVisible: true, minLabelTier: 'tight' } }) expect(renderPois.some((poi) => poi.id.includes('space-entrance'))).toBe(false) expect(renderPois.some((poi) => poi.id.includes('poi-entrance'))).toBe(false) }) })