diff --git a/src/components/search/PoiSearchPanel.vue b/src/components/search/PoiSearchPanel.vue index 549027e..a65fd8f 100644 --- a/src/components/search/PoiSearchPanel.vue +++ b/src/components/search/PoiSearchPanel.vue @@ -44,13 +44,18 @@ - + @@ -129,14 +134,15 @@ - + @@ -220,8 +226,7 @@ import type { } from '@/domain/museum' import { compareFloorsTopToBottom, - isIndoorNavigableFloor, - isPoiOnIndoorNavigableFloor + isIndoorNavigableFloor } from '@/domain/guideFloor' import { guideUseCase @@ -232,18 +237,14 @@ import { import { HOME_POI_CATEGORIES, POI_CATEGORIES, - findPoiCategoryByKeyword, getPoiCategoryIconHref, getPoiCategoryById, - getPoiDataIssues, - inspectPoiCollection, - isPoiSearchCategorySupported, - matchesPoiCategory, resolvePoiCategory, type PoiCategoryDefinition, type PoiCategoryId } from '@/domain/poiCategories' import type { + GuidePoiSearchViewState, PoiCategoryResultState, PoiSearchContext } from '@/domain/poiSearch' @@ -273,12 +274,6 @@ const emit = defineEmits<{ cancel: [] }>() -type PreparedPoiResults = { - visiblePois: MuseumPoi[] - duplicateCount: number - excludedCount: number -} - const homeCategories = HOME_POI_CATEGORIES const searchCategories = POI_CATEGORIES const searchCategoryColumns = computed(() => ( @@ -299,6 +294,7 @@ const homeContentTapBlockedUntil = ref(0) const floors = ref([]) const activeFloor = ref('') const pois = ref([]) +const searchViewState = ref(null) const isLoading = ref(false) const searchError = ref('') const floorLoadError = ref('') @@ -306,7 +302,6 @@ const excludedPoiCount = ref(0) const duplicatePoiCount = ref(0) const resultListScrollTop = ref(0) const resultListRef = ref(null) -let cachedPoiPool: PreparedPoiResults | null = null let searchRequestSeq = 0 const collapseDragThreshold = 48 @@ -324,6 +319,9 @@ const isHomeCategoryMode = computed(() => ( const isHomeCollapsed = computed(() => props.variant === 'home' && !showSearchContent.value) const activeCategory = computed(() => getPoiCategoryById(activeCategoryId.value)) const loadError = computed(() => searchError.value || floorLoadError.value) +const categoryStatesById = computed(() => new Map( + (searchViewState.value?.categories || []).map((item) => [item.definition.id, item] as const) +)) const displayFloors = computed(() => [...floors.value] .filter(isIndoorNavigableFloor) @@ -349,24 +347,13 @@ const emptyDesc = computed(() => { : '可切换楼层或更换关键词' }) -const isVisiblePoi = (poi: MuseumPoi) => isPoiSearchCategorySupported(poi) - && isPoiOnIndoorNavigableFloor(poi) - -const isPoiLocatable = (poi: MuseumPoi) => !getPoiDataIssues(poi).some((issue) => ( - issue.code === 'missing-id' - || issue.code === 'missing-floor' - || issue.code === 'missing-position' - || issue.code === 'invalid-position' -)) - -const unavailablePoiCount = computed(() => pois.value.filter((poi) => !isPoiLocatable(poi)).length) +const isPoiLocatable = (poi: MuseumPoi) => Boolean(poi.positionGltf) // 数据质量统计保留给诊断日志,普通游客只看到可恢复的加载状态。 const dataWarning = computed(() => { const diagnostic = { excluded: excludedPoiCount.value, duplicates: duplicatePoiCount.value, - unavailable: unavailablePoiCount.value, floorLoadError: floorLoadError.value } if (import.meta.env.DEV && Object.values(diagnostic).some(Boolean)) { @@ -375,26 +362,11 @@ const dataWarning = computed(() => { return '' }) -const dedupePois = (items: MuseumPoi[]) => { - const seenIds = new Set() - return items.filter((poi) => { - if (!poi.id?.trim() || seenIds.has(poi.id)) return false - seenIds.add(poi.id) - return true - }) -} - -const preparePoiResults = (items: MuseumPoi[]): PreparedPoiResults => { - const inspection = inspectPoiCollection(items) - const missingIdCount = items.filter((poi) => !poi.id?.trim()).length - const uniquePois = dedupePois(items) - const visiblePois = uniquePois.filter(isVisiblePoi) - - return { - visiblePois, - duplicateCount: inspection.duplicateIds.length, - excludedCount: missingIdCount + uniquePois.length - visiblePois.length - } +const isCategoryDisabled = (category: PoiCategoryDefinition) => { + const state = categoryStatesById.value.get(category.id) + return isLoading.value + || !state + || state.disabled } const activeFloorOption = computed(() => ( @@ -402,19 +374,15 @@ const activeFloorOption = computed(() => ( )) const createSearchContext = (): PoiSearchContext => { - const visiblePoiIds = floorResults.value - .filter(isPoiLocatable) - .map((poi) => poi.id) - const resultCount = isHomeCategoryMode.value - ? floorResults.value.length - : pois.value.length + const visiblePoiIds = searchViewState.value?.visiblePoiIds || [] + const resultCount = pois.value.length return { origin: props.variant, keyword: searchKeyword.value, categoryId: activeCategoryId.value, - floorId: activeFloorOption.value?.id || props.currentFloorId || '', - floorLabel: activeFloor.value || props.currentFloorLabel || '', + floorId: searchViewState.value?.floorId || activeFloorOption.value?.id || props.currentFloorId || '', + floorLabel: searchViewState.value?.floorLabel || activeFloor.value || props.currentFloorLabel || '', resultCount, floorResultCount: floorResults.value.length, visiblePoiIds, @@ -424,7 +392,8 @@ const createSearchContext = (): PoiSearchContext => { const emitResultsState = () => { const context = createSearchContext() - const active = isHomeCategoryMode.value + const active = props.variant === 'home' + && (searchViewState.value?.mode === 'category' || searchViewState.value?.mode === 'keyword') emit('results-change', { ...context, visiblePoiIds: active ? context.visiblePoiIds : [], @@ -432,6 +401,20 @@ const emitResultsState = () => { }) } +const emitPendingHomeSearchResults = (floor?: Pick) => { + if (props.variant !== 'home') return + + const context = createSearchContext() + emit('results-change', { + ...context, + floorId: floor?.id || context.floorId, + floorLabel: floor?.label || context.floorLabel, + visiblePoiIds: [], + active: true, + pending: true + }) +} + const setHomeCategoryMode = (active: boolean) => { const nextValue = props.variant === 'home' && active if (homeCategoryMode.value === nextValue) return @@ -459,29 +442,6 @@ const syncActiveFloor = () => { || displayFloors.value[0].label } -const syncActiveFloorToResults = (items: MuseumPoi[]) => { - if (!displayFloors.value.length) { - activeFloor.value = '' - return - } - - if (props.variant === 'home' && (props.currentFloorId || props.currentFloorLabel)) { - syncActiveFloor() - return - } - - const resultFloorLabels = new Set(items.map((poi) => poi.floorLabel).filter(Boolean)) - if (activeFloor.value && resultFloorLabels.has(activeFloor.value)) return - - const firstFloorWithResult = displayFloors.value.find((floor) => resultFloorLabels.has(floor.label)) - if (firstFloorWithResult) { - activeFloor.value = firstFloorWithResult.label - return - } - - syncActiveFloor() -} - const loadFloors = async () => { floorLoadError.value = '' try { @@ -494,26 +454,66 @@ const loadFloors = async () => { } } -const loadPoiPool = async () => { - const result = await guideUseCase.getInitialSearchSpacePoints() - return preparePoiResults(result) +const activeFloorId = () => ( + activeFloorOption.value?.id + || searchViewState.value?.floorId + || props.currentFloorId + || '' +) + +const commitPoiSearchViewState = async ( + state: GuidePoiSearchViewState, + requestSeq: number +) => { + if (requestSeq !== searchRequestSeq) return + + searchViewState.value = state + pois.value = state.results + activeFloor.value = state.floorLabel + duplicatePoiCount.value = 0 + excludedPoiCount.value = 0 + await nextTick() + if (requestSeq !== searchRequestSeq) return + emitResultsState() } -const setPreparedResults = async (prepared: PreparedPoiResults) => { - pois.value = prepared.visiblePois - duplicatePoiCount.value = prepared.duplicateCount - excludedPoiCount.value = prepared.excludedCount - syncActiveFloorToResults(prepared.visiblePois) - await nextTick() - emitResultsState() +const runPoiSearchRequest = async ( + loader: () => Promise +) => { + const requestSeq = ++searchRequestSeq + isLoading.value = true + searchError.value = '' + + try { + const state = await loader() + await commitPoiSearchViewState(state, requestSeq) + return state + } catch (error) { + await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:') + return null + } finally { + if (requestSeq === searchRequestSeq) isLoading.value = false + } } const handleLoadFailure = async (requestSeq: number, error: unknown, message: string) => { if (requestSeq !== searchRequestSeq) return console.error(message, error) pois.value = [] + if (searchViewState.value) { + searchViewState.value = { + ...searchViewState.value, + results: [], + visiblePoiIds: [] + } + } searchError.value = '点位加载失败' await nextTick() + if (requestSeq !== searchRequestSeq) return + if (props.variant === 'home' && (homeCategoryMode.value || Boolean(searchKeyword.value))) { + emitPendingHomeSearchResults() + return + } emitResultsState() } @@ -523,10 +523,9 @@ const loadInitialSpacePoints = async () => { searchError.value = '' try { - const prepared = await loadPoiPool() + const state = await guideUseCase.createInitialPoiSearchState(activeFloorId()) if (requestSeq !== searchRequestSeq) return - cachedPoiPool = prepared - await setPreparedResults(prepared) + await commitPoiSearchViewState(state, requestSeq) } catch (error) { await handleLoadFailure(requestSeq, error, '加载点位基础数据失败:') } finally { @@ -543,14 +542,19 @@ const loadPois = async (keyword = '') => { const requestSeq = ++searchRequestSeq isLoading.value = true searchError.value = '' + if (props.variant === 'home') { + // Keep a pending query from briefly showing stale list or marker IDs. + pois.value = [] + emitPendingHomeSearchResults() + } try { - const [result, pool] = await Promise.all([ - guideUseCase.searchPois(keyword), - loadPoiPool() - ]) + const state = await guideUseCase.searchPoiKeyword( + searchViewState.value, + keyword, + activeFloorId() + ) if (requestSeq !== searchRequestSeq) return - cachedPoiPool = pool - await setPreparedResults(preparePoiResults(result)) + await commitPoiSearchViewState(state, requestSeq) } catch (error) { await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:') } finally { @@ -558,6 +562,24 @@ const loadPois = async (keyword = '') => { } } +const refreshCurrentPoiSearchFloor = async (floorLabel: string) => { + const floor = displayFloors.value.find((item) => item.label === floorLabel) + if (!floor) return + + activeFloor.value = floor.label + resultListScrollTop.value = 0 + const hasExplicitQuery = searchViewState.value?.mode === 'category' + || searchViewState.value?.mode === 'keyword' + if (props.variant === 'home' && hasExplicitQuery) { + emitPendingHomeSearchResults(floor) + } + + await runPoiSearchRequest(() => guideUseCase.changePoiSearchFloor( + searchViewState.value, + floor.id + )) +} + const focusSearchInput = async () => { searchInputFocused.value = false await nextTick() @@ -571,7 +593,6 @@ const expandHomePanel = () => { } setHomeCategoryMode(false) homeExpanded.value = true - emitResultsState() } const shouldBlockHomeContentTap = () => ( @@ -619,7 +640,6 @@ const collapseHomePanel = () => { } const resetSearchState = async () => { - searchRequestSeq += 1 searchKeyword.value = '' searchDraftKeyword.value = '' activeCategoryId.value = '' @@ -628,18 +648,14 @@ const resetSearchState = async () => { setHomeCategoryMode(false) homeExpanded.value = false searchInputFocused.value = false - if (cachedPoiPool) { - pois.value = cachedPoiPool.visiblePois - duplicatePoiCount.value = cachedPoiPool.duplicateCount - excludedPoiCount.value = cachedPoiPool.excludedCount - } else { + if (searchViewState.value?.mode !== 'default') { pois.value = [] - duplicatePoiCount.value = 0 - excludedPoiCount.value = 0 + emitPendingHomeSearchResults() } - syncActiveFloor() - await nextTick() - emitResultsState() + await runPoiSearchRequest(() => guideUseCase.clearPoiSearch( + searchViewState.value, + activeFloorId() + )) } // 从详情页返回时只清理搜索 UI 与分类 marker,不参与地图目标焦点的复位。 @@ -739,7 +755,9 @@ const enterFullSearch = () => { activeCategoryId.value = '' searchKeyword.value = '' searchDraftKeyword.value = '' - if (cachedPoiPool) void setPreparedResults(cachedPoiPool) + pois.value = [] + emitPendingHomeSearchResults() + void loadInitialSpacePoints() } } @@ -761,7 +779,14 @@ const handleSearchClear = async () => { searchKeyword.value = '' activeCategoryId.value = '' setHomeCategoryMode(false) - await loadInitialSpacePoints() + if (searchViewState.value?.mode !== 'default') { + pois.value = [] + emitPendingHomeSearchResults() + } + await runPoiSearchRequest(() => guideUseCase.clearPoiSearch( + searchViewState.value, + activeFloorId() + )) await focusSearchInput() } @@ -776,7 +801,7 @@ const searchShortcut = async ( const category = typeof categoryInput === 'string' ? getPoiCategoryById(categoryInput) : categoryInput - if (!category) return + if (!category || isCategoryDisabled(category)) return const requestSeq = ++searchRequestSeq const useHomeResultList = props.variant === 'home' @@ -793,17 +818,21 @@ const searchShortcut = async ( searchError.value = '' setHomeCategoryMode(useHomeResultList) isLoading.value = true + if (useHomeResultList) { + // The category result replaces the current home list, so clear both + // surfaces until its canonical IDs have resolved. + pois.value = [] + emitPendingHomeSearchResults() + } try { - const pool = await loadPoiPool() + const state = await guideUseCase.selectPoiSearchCategory( + searchViewState.value, + category.id, + activeFloorId() + ) if (requestSeq !== searchRequestSeq) return - cachedPoiPool = pool - const prepared: PreparedPoiResults = { - visiblePois: pool.visiblePois.filter((poi) => matchesPoiCategory(poi, category)), - duplicateCount: pool.duplicateCount, - excludedCount: pool.excludedCount - } - await setPreparedResults(prepared) + await commitPoiSearchViewState(state, requestSeq) } catch (error) { await handleLoadFailure(requestSeq, error, '加载点位分类搜索结果失败:') } finally { @@ -827,12 +856,6 @@ const handleSearchConfirm = async (event?: any) => { setHomeCategoryMode(false) if (props.variant === 'home' && !homeExpanded.value) expandHomePanel() - const matchedCategory = findPoiCategoryByKeyword(searchKeyword.value) - if (matchedCategory) { - await searchShortcut(matchedCategory) - return - } - activeCategoryId.value = '' resultListScrollTop.value = 0 await loadPois(searchKeyword.value) @@ -840,8 +863,7 @@ const handleSearchConfirm = async (event?: any) => { const handleFloorChange = (floor: string) => { if (activeFloor.value === floor) return - activeFloor.value = floor - resultListScrollTop.value = 0 + void refreshCurrentPoiSearchFloor(floor) } const handleResultListScroll = (event: any) => { @@ -946,11 +968,6 @@ const applyInitialKeyword = async (keyword: string) => { if (props.variant === 'home' && normalizedKeyword) expandHomePanel() if (props.variant === 'home' && !normalizedKeyword) homeExpanded.value = false - const matchedCategory = findPoiCategoryByKeyword(normalizedKeyword) - if (matchedCategory) { - await searchShortcut(matchedCategory) - return - } activeCategoryId.value = '' if (normalizedKeyword) { await loadPois(normalizedKeyword) @@ -963,16 +980,14 @@ watch(() => props.initialKeyword, (keyword) => { void applyInitialKeyword(keyword || '') }) -watch([() => props.currentFloorId, () => props.currentFloorLabel], async () => { +watch([() => props.currentFloorId, () => props.currentFloorLabel], () => { if (props.variant !== 'home') return - syncActiveFloor() - await nextTick() - emitResultsState() -}) - -watch(activeFloor, async () => { - await nextTick() - emitResultsState() + const requestedFloor = displayFloors.value.find((floor) => ( + floor.id === props.currentFloorId + || floor.label === props.currentFloorLabel + )) + if (!requestedFloor || requestedFloor.id === searchViewState.value?.floorId) return + void refreshCurrentPoiSearchFloor(requestedFloor.label) }) watch(showSearchContent, (expanded) => { diff --git a/src/data/adapters/navAssetsAdapter.ts b/src/data/adapters/navAssetsAdapter.ts index a47b616..9003221 100644 --- a/src/data/adapters/navAssetsAdapter.ts +++ b/src/data/adapters/navAssetsAdapter.ts @@ -9,6 +9,9 @@ import type { import { isIndoorNavigableFloor } from '@/domain/guideFloor' +import { + normalizePoiSemanticValue +} from '@/domain/poiCategories' const floorIdPattern = /^L(-?\d+(?:\.\d+)?)$/ @@ -43,33 +46,176 @@ export const isStaticIndoorNavigableFloorId = (floorId: string) => ( }) ) -const toCategory = (category: StaticNavPoiCategoryPayload): MuseumCategory => ({ - id: category.topCategory, - label: category.topCategoryZh, - iconType: category.iconType -}) +const normalizedText = (value?: string | null) => (value || '').trim().toLowerCase() + +const staticDestinationCategoryBySemanticType: Readonly> = { + exhibition_hall: 'exhibition_hall', + theater: 'space_theater', + restaurant: 'space_restaurant', + cafe: 'space_restaurant', + shop: 'space_shop', + cultural_shop: 'space_shop', + bookstore: 'space_shop', + vending: 'space_shop' +} + +const staticFallbackDestinationTypes = new Set( + Object.keys(staticDestinationCategoryBySemanticType) +) + +const staticFacilityCategoryBySemanticType: Readonly> = { + ticket_office: 'basic_service_facility', + service_desk: 'basic_service_facility', + locker: 'basic_service_facility', + rental_service: 'basic_service_facility', + toilet: 'basic_service_facility', + accessible_toilet: 'accessibility_special_service', + mother_baby_room: 'basic_service_facility', + elevator: 'transport_circulation', + escalator: 'transport_circulation', + stairs: 'transport_circulation' +} + +const staticCategoryLabelBySemanticType: Readonly> = { + exhibition_hall: '展厅', + theater: '影院', + restaurant: '餐饮', + cafe: '餐饮', + shop: '购物', + cultural_shop: '购物', + bookstore: '购物', + vending: '购物', + ticket_office: '售票处', + service_desk: '服务', + locker: '服务', + rental_service: '服务', + toilet: '洗手间', + accessible_toilet: '洗手间', + mother_baby_room: '母婴', + elevator: '电梯', + escalator: '扶梯', + stairs: '楼梯' +} + +const staticNameSemanticType = (name: string) => { + if (/(?:售票|票务|ticket)/.test(name)) return 'ticket_office' + if (/(?:服务台|咨询台|接待|服务中心|service[_ ]?(?:desk|center)|information)/.test(name)) return 'service_desk' + if (/(?:存包|寄存|储物|locker)/.test(name)) return 'locker' + if (/(?:租赁|租借|rental)/.test(name)) return 'rental_service' + if (/(?:茶水间|饮水间|water)/.test(name)) return 'service_space' + if (/(?:影院|影厅|剧场|cinema|theater)/.test(name)) return 'theater' + if (/(?:餐饮|餐厅|餐馆|咖啡|restaurant|dining|food|cafe)/.test(name)) return 'restaurant' + if (/(?:购物|商店|商铺|文创|零售|shop|store|retail)/.test(name)) return 'shop' + return '' +} + +/** + * The static navigation package has legacy facility labels where stairways, + * escalators, and nursing rooms inherit a generic elevator/restroom icon. + * Keep the provider source-faithful and repair that visitor-facing semantic at + * the adapter boundary. + */ +export const normalizeStaticPoiIconType = ( + poi: Pick, + rawIconType?: string | null +) => { + const name = normalizedText(poi.name) + const iconType = normalizedText(rawIconType) + + if (name.includes('扶梯')) return 'escalator' + if (name.includes('楼梯')) return 'stairs' + if (name.includes('母婴')) return 'mother_baby_room' + if (/(?:无障碍|残疾人|残障).*(?:卫生间|洗手间|厕所)/.test(name)) { + return 'accessible_toilet' + } + if (/(?:卫生间|洗手间|厕所)/.test(name)) return 'toilet' + + return iconType +} + +const getStaticPoiSemanticType = ( + poi: StaticNavPoiPayload, + rawIconType?: string | null +) => ( + staticNameSemanticType(normalizedText(poi.name)) + || normalizePoiSemanticValue(normalizeStaticPoiIconType(poi, rawIconType)) +) + +const getStaticPoiCategoryId = ( + rawCategoryId: string, + semanticType: string +) => { + const normalizedCategoryId = normalizePoiSemanticValue(rawCategoryId) + const isTouringFallback = normalizedCategoryId === 'touring_poi' + || staticFallbackDestinationTypes.has(normalizedCategoryId) + + if (isTouringFallback) { + return staticDestinationCategoryBySemanticType[semanticType] + || staticFacilityCategoryBySemanticType[semanticType] + || rawCategoryId + } + + return rawCategoryId +} + +const getStaticPoiCategoryLabel = ( + rawCategoryId: string, + rawCategoryLabel: string, + semanticType: string +) => { + const normalizedCategoryId = normalizePoiSemanticValue(rawCategoryId) + const isGenericTouringCategory = normalizedCategoryId === 'touring_poi' + return isGenericTouringCategory + ? staticCategoryLabelBySemanticType[semanticType] || rawCategoryLabel + : rawCategoryLabel +} + +const getStaticPoiKind = (categoryId: string): MuseumPoi['kind'] => { + if (categoryId === 'exhibition_hall') return 'hall' + if (categoryId.startsWith('space_')) return 'space' + return 'facility' +} + +const toCategory = ( + poi: StaticNavPoiPayload, + category: StaticNavPoiCategoryPayload +): MuseumCategory => { + const semanticType = getStaticPoiSemanticType(poi, category.iconType) + return { + id: getStaticPoiCategoryId(category.topCategory, semanticType), + label: getStaticPoiCategoryLabel(category.topCategory, category.topCategoryZh, semanticType), + iconType: semanticType + } +} const isPoiAccessible = (poi: StaticNavPoiPayload) => ( poi.primaryCategory === 'accessibility_special_service' || poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true ) -export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => ({ - id: poi.id, - name: poi.name, - floorId: poi.floorId, - floorLabel: formatNavFloorLabel(poi.floorId), - primaryCategory: { - id: poi.primaryCategory, - label: poi.primaryCategoryZh, - iconType: poi.iconType - }, - categories: (poi.categories || []).map(toCategory), - positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName, - sourceConfidence: poi.sourceConfidence, - navigationReadiness: poi.navigationReadiness, - accessible: isPoiAccessible(poi), - kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility', - hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined -}) +export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => { + const categoryFallbackIconType = poi.categories?.[0]?.iconType + const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType) + const primaryCategoryId = getStaticPoiCategoryId(poi.primaryCategory, iconType) + const kind = getStaticPoiKind(primaryCategoryId) + + return { + id: poi.id, + name: poi.name, + floorId: poi.floorId, + floorLabel: formatNavFloorLabel(poi.floorId), + primaryCategory: { + id: primaryCategoryId, + label: getStaticPoiCategoryLabel(poi.primaryCategory, poi.primaryCategoryZh, iconType), + iconType + }, + categories: (poi.categories || []).map((category) => toCategory(poi, category)), + positionGltf: poi.positionGltf, + sourceObjectName: poi.sourceObjectName, + sourceConfidence: poi.sourceConfidence, + navigationReadiness: poi.navigationReadiness, + accessible: isPoiAccessible(poi), + kind, + hallName: kind === 'hall' ? poi.name : undefined + } +} diff --git a/src/data/adapters/sgsSdkGuideAdapter.ts b/src/data/adapters/sgsSdkGuideAdapter.ts index b3d6a7f..277ef35 100644 --- a/src/data/adapters/sgsSdkGuideAdapter.ts +++ b/src/data/adapters/sgsSdkGuideAdapter.ts @@ -19,7 +19,8 @@ import { isIndoorNavigableFloor } from '@/domain/guideFloor' import { - isPoiSearchCategorySupported + isPoiSearchCategorySupported, + normalizePoiSemanticValue } from '@/domain/poiCategories' import type { SgsFloorDiagnosticsPayload, @@ -91,9 +92,39 @@ const spaceCategoryBySgsType: Record = { iconType: 'service_space' }, commercial: { - id: 'space_commercial', - label: '商业空间', - iconType: 'commercial' + id: 'space_shop', + label: '购物空间', + iconType: 'shop' + }, + restaurant: { + id: 'space_restaurant', + label: '餐饮空间', + iconType: 'restaurant' + }, + cafe: { + id: 'space_restaurant', + label: '餐饮空间', + iconType: 'cafe' + }, + shop: { + id: 'space_shop', + label: '购物空间', + iconType: 'shop' + }, + cultural_shop: { + id: 'space_shop', + label: '购物空间', + iconType: 'cultural_shop' + }, + bookstore: { + id: 'space_shop', + label: '购物空间', + iconType: 'bookstore' + }, + vending: { + id: 'space_shop', + label: '购物空间', + iconType: 'vending' }, parking: { id: 'space_parking', @@ -113,6 +144,15 @@ const spaceCategoryBySgsType: Record = { } const categoryBySgsType: Record = { + exhibition_hall: hallCategory, + theater: spaceCategoryBySgsType.theater, + commercial: spaceCategoryBySgsType.commercial, + restaurant: spaceCategoryBySgsType.restaurant, + cafe: spaceCategoryBySgsType.cafe, + shop: spaceCategoryBySgsType.shop, + cultural_shop: spaceCategoryBySgsType.cultural_shop, + bookstore: spaceCategoryBySgsType.bookstore, + vending: spaceCategoryBySgsType.vending, poi: { id: 'poi', label: '点位', @@ -126,7 +166,7 @@ const categoryBySgsType: Record ( : undefined ) -const normalizedText = (value?: string | null) => (value || '').trim() +const normalizedText = (value?: string | null) => (value || '').trim() + +export const normalizeSgsPoiSemanticType = (value?: string | null) => normalizePoiSemanticValue(value) const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => { const x = optionalNumber(source.x) @@ -302,6 +360,8 @@ const nonHallNavigablePlaceTypes = new Set([ 'toilet', 'accessible_toilet', 'restroom', + 'route_node', + 'navigable_place', '卫生间', '洗手间', '无障碍卫生间', @@ -322,15 +382,38 @@ export interface SgsHallPoiDiagnostics { } const businessTypeLabels: Record = { - shop: '商店', + shop: '购物', + commercial: '购物', restaurant: '餐饮', - cafe: '咖啡', - vending: '售卖机', - bookstore: '书店', - cultural_shop: '文创商店', + cafe: '餐饮', + vending: '购物', + bookstore: '购物', + cultural_shop: '购物', photo_spot: '打卡点' } +const businessTypeIconTypes: Record = { + shop: 'shop', + commercial: 'shop', + restaurant: 'restaurant', + cafe: 'cafe', + vending: 'vending', + bookstore: 'bookstore', + cultural_shop: 'cultural_shop', + photo_spot: 'photo_spot' +} + +const categoryForBusinessType = (businessType?: string | null): MuseumCategory => { + const normalizedBusinessType = normalizeSgsPoiSemanticType(businessType) + if (!normalizedBusinessType) return businessCategory + + return { + ...businessCategory, + label: businessTypeLabels[normalizedBusinessType] || businessCategory.label, + iconType: businessTypeIconTypes[normalizedBusinessType] || normalizedBusinessType + } +} + interface SgsHallPoiBuildOptions { fallbackY?: number } @@ -339,10 +422,28 @@ const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center' const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center' const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance' -const normalizeTypeId = (value?: string | null) => normalizedText(value) - .toLowerCase() +const normalizeTypeId = (value?: string | null) => normalizeSgsPoiSemanticType(value) .replace(/[^a-z0-9_-]+/g, '_') .replace(/^_+|_+$/g, '') + +const hiddenSgsPoiTypes = new Set([ + 'entrance_exit', + 'hall_entrance', + 'entrance_anchor', + 'route_node', + 'navigable_place', + 'operation_experience' +]) + +/** Raw navigable-place records may enrich a hall, but are never visitor results themselves. */ +export const isSgsNavigablePlaceVisitorResult = (_place: SgsNavigablePlacePayload) => false + +export const isSgsPoiHiddenFromVisitorSearch = (poi: Pick< + SgsPoiPayload, + 'type' | 'typeName' +>) => [poi.type, poi.typeName] + .map(normalizeSgsPoiSemanticType) + .some((type) => hiddenSgsPoiTypes.has(type)) const hasExhibitionKeyword = (value?: string | null) => ( exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword)) @@ -412,7 +513,7 @@ const normalizeSpacePosition = ( } export const isExhibitionHallSpace = (space: SgsSpacePayload) => { - const type = normalizedText(space.type).toLowerCase() + const type = normalizeSgsPoiSemanticType(space.type) const name = normalizedText(space.name) const searchableText = searchableSpaceText(space) @@ -430,7 +531,7 @@ export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) place.typeCode, place.typeName ] - .map((value) => normalizedText(value).toLowerCase()) + .map((value) => normalizeSgsPoiSemanticType(value)) .filter(Boolean) if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false @@ -480,23 +581,26 @@ const normalizePlacePosition = ( ) const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => { - const normalizedType = poi.type?.trim().toLowerCase() - const normalizedBusinessType = poi.businessType?.trim().toLowerCase() + const normalizedType = normalizeSgsPoiSemanticType(poi.type) + const normalizedTypeName = normalizeSgsPoiSemanticType(poi.typeName) + const resolvedType = categoryBySgsType[normalizedType] + ? normalizedType + : normalizedTypeName || normalizedType + const normalizedBusinessType = normalizeSgsPoiSemanticType(poi.businessType) if (String(poi.poiGroup || '').toUpperCase() === 'BUSINESS') { - return { - ...businessCategory, - label: normalizedBusinessType ? businessTypeLabels[normalizedBusinessType] || poi.typeName || businessCategory.label : poi.typeName || businessCategory.label, - iconType: normalizedBusinessType || businessCategory.iconType - } + const category = categoryForBusinessType(normalizedBusinessType || resolvedType) + return category.iconType === businessCategory.iconType && poi.typeName + ? { ...category, label: poi.typeName } + : category } if (String(poi.poiGroup || '').toUpperCase() === 'OTHER') { - return categoryBySgsType[normalizedType || ''] || defaultCategory + return categoryBySgsType[resolvedType] || defaultCategory } - if (normalizedType && categoryBySgsType[normalizedType]) { - return categoryBySgsType[normalizedType] + if (resolvedType && categoryBySgsType[resolvedType]) { + return categoryBySgsType[resolvedType] } if (poi.typeName) { @@ -511,9 +615,13 @@ const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolea } const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => { - if (category.id === 'business_poi') return 'facility' - if (category.id === 'operation_experience') return 'guide' + if (isSgsPoiHiddenFromVisitorSearch(poi)) return 'guide' + if (category.id === 'operation_experience' || category.id === 'navigation_anchor') return 'guide' + if (category.id === hallCategory.id) return 'hall' + // Raw POI endpoints can use a space-like semantic type (restaurant, shop, + // theater) without being a source space. Space records are adapted through + // toMuseumSpacePointFromSgs; raw records remain facility POIs. return 'facility' } @@ -527,10 +635,14 @@ export const toMuseumPoiFromSgs = ( || sourceFloorId || stringifyId(poi.floorCode) const category = categoryFor(poi) - - return { - id: stringifyId(poi.id), - name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`, + const kind = kindForSgsPoi(poi, category) + const spatialAreaId = stringifyId(poi.spatialAreaId) + const spatialAreaName = normalizedText(poi.spatialAreaName) + const name = poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}` + + return { + id: stringifyId(poi.id), + name, floorId, floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName), primaryCategory: { @@ -550,7 +662,11 @@ export const toMuseumPoiFromSgs = ( sourceConfidence: 'backend-sgs-sdk', navigationReadiness: '位置预览', accessible: category.accessible === true, - kind: kindForSgsPoi(poi, category) + kind, + hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined, + hallName: spatialAreaName || (kind === 'hall' ? name : undefined), + spaceId: spatialAreaId || undefined, + sourceSpaceId: spatialAreaId || undefined } } diff --git a/src/domain/poiCategories.ts b/src/domain/poiCategories.ts index 4455a1b..ae3a927 100644 --- a/src/domain/poiCategories.ts +++ b/src/domain/poiCategories.ts @@ -4,17 +4,15 @@ import type { } from '@/domain/museum' export type PoiCategoryId = - | 'itinerary' | 'exhibition-hall' | 'cinema' | 'ticket-office' - | 'service-center' | 'dining' - | 'souvenir' - | 'nursing-room' + | 'shopping' + | 'service-center' | 'restroom' + | 'nursing-room' | 'elevator' - | 'stairs' | 'escalator' export interface PoiCategoryDefinition { @@ -31,33 +29,22 @@ export interface PoiCategoryDefinition { const definitions: PoiCategoryDefinition[] = [ { - id: 'itinerary', - label: '行程', - icon: 'itinerary', + id: 'exhibition-hall', + label: '展厅', + icon: 'exhibition', order: 1, homeVisible: true, - keywords: ['行程', '导览', '参观', 'touring_poi'], - categoryIds: ['touring_poi'], - iconTypes: ['touring', 'guide'] - }, - { - id: 'exhibition-hall', - label: '展览', - icon: 'exhibition', - order: 2, - homeVisible: true, keywords: ['展厅', '展览', '展馆', 'exhibition_hall', 'hall'], - categoryIds: ['exhibition_hall', 'exhibition_hall_entrance'], - iconTypes: ['exhibition_hall', 'hall_entrance', 'exhibition'], - excludedCategoryIds: ['touring_poi'] + categoryIds: ['exhibition_hall'], + iconTypes: ['exhibition_hall', 'exhibition'] }, { id: 'cinema', label: '影院', icon: 'cinema', - order: 3, + order: 2, homeVisible: true, - keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater'], + keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater', '影院空间'], categoryIds: ['space_theater'], iconTypes: ['cinema', 'theater'] }, @@ -65,47 +52,47 @@ const definitions: PoiCategoryDefinition[] = [ id: 'ticket-office', label: '售票处', icon: 'ticket', - order: 6, + order: 3, homeVisible: true, keywords: ['售票处', '售票', '票务', 'ticket_office'], categoryIds: [], iconTypes: ['ticket_office'] }, - { - id: 'service-center', - label: '服务中心', - icon: 'service', - order: 8, - homeVisible: true, - keywords: ['服务中心', '服务台', '咨询台', 'service_desk'], - categoryIds: [], - iconTypes: ['service_desk'] - }, { id: 'dining', - label: '餐厅', + label: '餐饮', icon: 'restaurant', - order: 5, + order: 4, homeVisible: true, keywords: ['餐饮', '餐厅', '快餐', '咖啡', 'restaurant', 'dining', 'food', 'cafe'], categoryIds: [], iconTypes: ['restaurant', 'dining', 'food', 'cafe'] }, { - id: 'souvenir', - label: '文创', + id: 'shopping', + label: '购物', icon: 'bag', - order: 11, - homeVisible: false, - keywords: ['文创', '纪念品', '商店', 'souvenir', 'gift', 'shop'], + order: 5, + homeVisible: true, + keywords: ['购物', '商店', '文创', '纪念品', '零售', 'souvenir', 'gift', 'shop', 'shopping', 'store'], categoryIds: [], - iconTypes: ['souvenir', 'gift', 'shop', 'cultural_creative'] + iconTypes: ['souvenir', 'gift', 'shop', 'store', 'retail', 'cultural_shop', 'bookstore', 'vending'] + }, + { + id: 'service-center', + label: '服务', + icon: 'service', + order: 6, + homeVisible: true, + keywords: ['服务', '服务中心', '服务台', '咨询台', '租赁', '寄存', 'service_desk', 'rental_service', 'locker'], + categoryIds: [], + iconTypes: ['service_desk', 'service_center', 'rental_service', 'locker'] }, { id: 'nursing-room', - label: '母婴室', + label: '母婴', icon: 'nursing', - order: 7, + order: 8, homeVisible: true, keywords: ['母婴室', '母婴间', 'mother_baby_room', 'nursing_room'], categoryIds: [], @@ -115,7 +102,7 @@ const definitions: PoiCategoryDefinition[] = [ id: 'restroom', label: '洗手间', icon: 'restroom', - order: 4, + order: 7, homeVisible: true, keywords: ['卫生间', '洗手间', '厕所', 'toilet', 'accessible_toilet', 'restroom'], categoryIds: [], @@ -131,16 +118,6 @@ const definitions: PoiCategoryDefinition[] = [ categoryIds: [], iconTypes: ['elevator'] }, - { - id: 'stairs', - label: '楼梯', - icon: 'stairs', - order: 12, - homeVisible: false, - keywords: ['楼梯', 'stairs', 'stair'], - categoryIds: [], - iconTypes: ['stairs', 'stair'] - }, { id: 'escalator', label: '扶梯', @@ -160,11 +137,12 @@ export const POI_CATEGORIES = Object.freeze( const homeCategoryOrder: readonly PoiCategoryId[] = [ 'exhibition-hall', 'cinema', - 'restroom', - 'dining', 'ticket-office', - 'nursing-room', + 'dining', + 'shopping', 'service-center', + 'restroom', + 'nursing-room', 'elevator', 'escalator' ] @@ -176,17 +154,15 @@ export const HOME_POI_CATEGORIES = Object.freeze( ) const POI_CATEGORY_ICON_SYMBOLS: Readonly> = { - itinerary: 'poi-itinerary', 'exhibition-hall': 'poi-exhibition-hall', cinema: 'poi-cinema', 'ticket-office': 'poi-ticket-office', - 'service-center': 'poi-service-center', dining: 'poi-dining', - souvenir: 'poi-souvenir', - 'nursing-room': 'poi-nursing-room', + shopping: 'poi-souvenir', + 'service-center': 'poi-service-center', restroom: 'poi-restroom', + 'nursing-room': 'poi-nursing-room', elevator: 'poi-elevator', - stairs: 'poi-stairs', escalator: 'poi-escalator' } @@ -194,52 +170,218 @@ export const getPoiCategoryIconHref = (categoryId: PoiCategoryId) => ( `/static/icons/poi/shortcut-icons.svg#${POI_CATEGORY_ICON_SYMBOLS[categoryId]}` ) -type PoiCategorySource = Pick +export type PoiCategorySource = Pick< + MuseumPoi, + | 'id' + | 'name' + | 'floorId' + | 'primaryCategory' + | 'categories' + | 'kind' + | 'hallId' + | 'spaceId' + | 'sourcePlaceId' + | 'sourceSpaceId' + | 'sourceObjectName' +> -const normalizeValue = (value?: string | null) => (value || '').trim().toLowerCase() +const normalizeValue = (value?: string | null) => (value || '') + .trim() + .normalize('NFKC') + .toLowerCase() + .replace(/[\s-]+/g, '_') + .replace(/^_+|_+$/g, '') -const categoryValues = (categories: MuseumCategory[]) => categories.flatMap((category) => [ - category.id, - category.label, - category.iconType || '' -]) +const poiSemanticAliases: Readonly> = { + exhibition: 'exhibition_hall', + exhibition_hall: 'exhibition_hall', + hall: 'exhibition_hall', + 展厅: 'exhibition_hall', + 展览厅: 'exhibition_hall', + 展馆: 'exhibition_hall', + cinema: 'theater', + theater: 'theater', + 影院: 'theater', + 影厅: 'theater', + 剧场: 'theater', + ticket_office: 'ticket_office', + ticketing: 'ticket_office', + 售票处: 'ticket_office', + 售票: 'ticket_office', + 票务: 'ticket_office', + service_desk: 'service_desk', + service_center: 'service_desk', + information_desk: 'service_desk', + 服务台: 'service_desk', + 服务中心: 'service_desk', + 咨询台: 'service_desk', + restaurant: 'restaurant', + dining: 'restaurant', + food: 'restaurant', + 餐饮: 'restaurant', + 餐厅: 'restaurant', + 餐饮服务: 'restaurant', + 快餐: 'restaurant', + cafe: 'cafe', + coffee: 'cafe', + 咖啡: 'cafe', + commercial: 'shop', + shop: 'shop', + store: 'shop', + retail: 'shop', + 购物: 'shop', + 商店: 'shop', + 店铺: 'shop', + 零售: 'shop', + cultural_shop: 'cultural_shop', + cultural_creative: 'cultural_shop', + 文创: 'cultural_shop', + 文创店: 'cultural_shop', + bookstore: 'bookstore', + 书店: 'bookstore', + vending: 'vending', + 自动售卖机: 'vending', + toilet: 'toilet', + restroom: 'toilet', + 卫生间: 'toilet', + 洗手间: 'toilet', + 厕所: 'toilet', + accessible_toilet: 'accessible_toilet', + 无障碍卫生间: 'accessible_toilet', + elevator: 'elevator', + lift: 'elevator', + 电梯: 'elevator', + escalator: 'escalator', + 扶梯: 'escalator', + 自动扶梯: 'escalator', + stairs: 'stairs', + stair: 'stairs', + 楼梯: 'stairs', + mother_baby_room: 'mother_baby_room', + nursing_room: 'mother_baby_room', + 母婴: 'mother_baby_room', + 母婴室: 'mother_baby_room', + 母婴间: 'mother_baby_room', + entrance_exit: 'entrance_exit', + entrance: 'entrance_exit', + exit: 'entrance_exit', + door: 'entrance_exit', + 入口: 'entrance_exit', + 出入口: 'entrance_exit', + 门点: 'entrance_exit', + hall_entrance: 'hall_entrance', + entrance_anchor: 'entrance_anchor', + 入口锚点: 'entrance_anchor', + route_node: 'route_node', + navigation_node: 'route_node', + 路线节点: 'route_node', + navigable_place: 'navigable_place', + operation_experience: 'operation_experience', + guide_point: 'operation_experience', + 讲解点: 'operation_experience' +} + +/** Normalize source-specific English/Chinese type labels before domain matching. */ +export const normalizePoiSemanticValue = (value?: string | null) => { + const normalized = normalizeValue(value) + return poiSemanticAliases[normalized] || normalized +} + +const genericSourceCategoryValues = new Set([ + 'poi', + 'basic_service_facility', + 'transport_circulation', + 'accessibility_special_service', + 'business_poi', + 'operation_experience', + 'navigation_anchor', + '服务设施', + '交通流线', + '无障碍服务', + '运营服务', + '导览点位' +].map(normalizePoiSemanticValue)) + +/** Source grouping is not a visitor-facing search term. */ +export const isGenericPoiSourceCategory = (category: Pick) => ( + genericSourceCategoryValues.has(normalizePoiSemanticValue(category.id)) +) + +const categorySearchValues = (categories: MuseumCategory[]) => categories.flatMap((category) => { + // A generic source group such as "基础服务设施" describes the data source, + // not a visitor-facing service destination. Keep all of its labels out of + // free-text category matching so a restroom cannot become a service result. + if (isGenericPoiSourceCategory(category)) return [] + + return [category.id, category.label, category.iconType || ''] + .map(normalizePoiSemanticValue) + .filter(Boolean) +}) export const matchesPoiCategory = ( poi: PoiCategorySource, definition: PoiCategoryDefinition ) => { const categories = [poi.primaryCategory, ...(poi.categories || [])] - const normalizedCategoryIds = new Set(categories.map((category) => normalizeValue(category.id))) - const normalizedIconTypes = new Set(categories.map((category) => normalizeValue(category.iconType))) + const normalizedCategoryIds = new Set(categories.map((category) => normalizePoiSemanticValue(category.id))) + const normalizedIconTypes = new Set(categories.map((category) => normalizePoiSemanticValue(category.iconType))) if (definition.excludedCategoryIds?.some((categoryId) => ( - normalizedCategoryIds.has(normalizeValue(categoryId)) + normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId)) ))) { return false } - if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizeValue(categoryId)))) { + if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId)))) { return true } - if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizeValue(iconType)))) { + if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizePoiSemanticValue(iconType)))) { return true } + // A recognized icon/category is a stronger signal than a word embedded in + // the place name. For example, "展厅售票点" is a ticket office, not an + // exhibition hall merely because its name contains "展厅". + const belongsToAnotherExplicitCategory = POI_CATEGORIES.some((candidate) => { + if (candidate.id === definition.id) return false + return candidate.categoryIds.some((categoryId) => ( + normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId)) + )) || candidate.iconTypes.some((iconType) => ( + normalizedIconTypes.has(normalizePoiSemanticValue(iconType)) + )) + }) + if (belongsToAnotherExplicitCategory) return false + const searchableText = [ poi.name, - ...categoryValues(categories) + ...categorySearchValues(categories) ] .map(normalizeValue) .filter(Boolean) .join(' ') - return definition.keywords.some((keyword) => searchableText.includes(normalizeValue(keyword))) + return definition.keywords.some((keyword) => { + const normalizedKeyword = normalizeValue(keyword) + const semanticKeyword = normalizePoiSemanticValue(keyword) + return searchableText.includes(normalizedKeyword) + || searchableText.includes(semanticKeyword) + }) } -export const getPoiCategoryById = (categoryId?: string | null) => ( - POI_CATEGORIES.find((category) => category.id === categoryId) || null -) +const legacyCategoryAliases: Readonly> = { + souvenir: 'shopping', + exhibition_hall: 'exhibition-hall', + ticket_office: 'ticket-office', + service_center: 'service-center', + nursing_room: 'nursing-room' +} + +export const getPoiCategoryById = (categoryId?: string | null) => { + const categoryIdValue = (categoryId || '').trim().toLowerCase() + const resolvedCategoryId = legacyCategoryAliases[normalizeValue(categoryIdValue)] || categoryIdValue + return POI_CATEGORIES.find((category) => category.id === resolvedCategoryId) || null +} export const findPoiCategoryByKeyword = (keyword: string) => { const normalizedKeyword = normalizeValue(keyword) @@ -255,7 +397,89 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => ( POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null ) -export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => Boolean(resolvePoiCategory(poi)) +const hiddenVisitorPrimaryTypes = new Set([ + 'entrance_exit', + 'hall_entrance', + 'entrance_anchor', + 'route_node', + 'navigable_place', + 'operation_experience' +]) + +const primaryTypeValues = (poi: PoiCategorySource) => [ + poi.primaryCategory.id, + poi.primaryCategory.label, + poi.primaryCategory.iconType || '' +] + .map(normalizePoiSemanticValue) + .filter(Boolean) + +/** A visitor result must never be a guide point, door, entrance anchor, or route node. */ +export const isVisitorSearchPoi = (poi: PoiCategorySource) => { + if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false + return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value)) +} + +/** Default floor browse only contains canonical halls and valid destination spaces. */ +export const isDefaultSearchDestinationPoi = (poi: PoiCategorySource) => { + if (!isVisitorSearchPoi(poi)) return false + if (poi.kind) return poi.kind === 'hall' || poi.kind === 'space' + + const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id) + return primaryCategoryId === 'exhibition_hall' + || primaryCategoryId === 'touring_poi' + || primaryCategoryId.startsWith('space_') +} + +export const isQuickFindPoi = ( + poi: PoiCategorySource, + definition: PoiCategoryDefinition +) => isVisitorSearchPoi(poi) && matchesPoiCategory(poi, definition) + +export const isFacilitySearchPoi = (poi: PoiCategorySource) => ( + isVisitorSearchPoi(poi) && !isDefaultSearchDestinationPoi(poi) +) + +/** + * A strong, source-stable identity for source-level deduplication. Linked space + * IDs are deliberately exposed separately because a facility inside a hall is + * not automatically a duplicate of that hall. + */ +export const getPoiStablePlaceIdentity = (poi: PoiCategorySource) => { + const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id) + const canUseLinkedSpace = poi.kind === 'hall' + || poi.kind === 'space' + || primaryCategoryId === 'business_poi' + || primaryCategoryId.startsWith('space_') + const sourceSpaceId = canUseLinkedSpace + ? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim() + : '' + if (sourceSpaceId) return `space:${sourceSpaceId}` + + const sourcePlaceId = poi.sourcePlaceId?.trim() + if (sourcePlaceId) return `place:${sourcePlaceId}` + + const poiId = poi.id?.trim() + if (poiId) return `poi:${poiId}` + + const normalizedName = normalizeValue(poi.name) + return normalizedName ? `name:${poi.floorId || ''}:${normalizedName}` : '' +} + +export const getPoiLinkedSpaceIdentity = (poi: PoiCategorySource) => { + const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id) + const canUseLinkedSpace = poi.kind === 'hall' + || poi.kind === 'space' + || primaryCategoryId === 'business_poi' + || primaryCategoryId.startsWith('space_') + const spaceId = canUseLinkedSpace + ? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim() + : '' + return spaceId ? `space:${spaceId}` : '' +} + +/** Existing consumers use this for generic search eligibility, not quick-find eligibility. */ +export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => isVisitorSearchPoi(poi) export type PoiDataIssueCode = | 'missing-id' diff --git a/src/domain/poiSearch.ts b/src/domain/poiSearch.ts index 7f0aaf5..d346ef2 100644 --- a/src/domain/poiSearch.ts +++ b/src/domain/poiSearch.ts @@ -2,6 +2,7 @@ import type { MuseumPoi } from '@/domain/museum' import type { + PoiCategoryDefinition, PoiCategoryId } from '@/domain/poiCategories' @@ -26,4 +27,29 @@ export interface PoiSearchSelection { export interface PoiCategoryResultState extends PoiSearchContext { active: boolean + pending?: boolean +} + +export type GuidePoiSearchMode = 'default' | 'category' | 'keyword' + +export interface GuidePoiSearchCategoryState { + definition: PoiCategoryDefinition + count: number + disabled: boolean +} + +/** + * Ready-to-render search state for exactly one active floor. The results and + * visible IDs deliberately share the same canonical source so map/list parity + * cannot be reimplemented by a component. + */ +export interface GuidePoiSearchViewState { + mode: GuidePoiSearchMode + floorId: string + floorLabel: string + keyword: string + categoryId: PoiCategoryId | '' + results: MuseumPoi[] + visiblePoiIds: string[] + categories: GuidePoiSearchCategoryState[] } diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 4edd229..9a61b14 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -1640,7 +1640,7 @@ const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => { } searchVisiblePoiIds.value = [...state.visiblePoiIds] - if (state.floorId) { + if (state.floorId && !state.pending) { activeGuideFloor.value = state.floorId } } diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index ea53d8b..be58a5f 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -7,6 +7,9 @@ import type { import type { MuseumPoi } from '@/domain/museum' +import { + isVisitorSearchPoi +} from '@/domain/poiCategories' import { getPoiDisplayPolicy } from '@/domain/poiDisplay' @@ -31,12 +34,12 @@ import { } from '@/data/providers/sgsSdkApiProvider' import { formatNavFloorLabel, - isStaticIndoorNavigableFloorId + isStaticIndoorNavigableFloorId, + toMuseumPoi } from '@/data/adapters/navAssetsAdapter' import { defaultStaticNavAssetsProvider, - type StaticNavAssetsProvider, - type StaticNavPoiPayload + type StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider' import { mapWithConcurrency @@ -59,26 +62,6 @@ const getNow = () => ( : Date.now() ) -const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => { - const kind = poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility' - return { - id: poi.id, - name: poi.name, - floorId: poi.floorId, - primaryCategory: poi.primaryCategory, - primaryCategoryZh: poi.primaryCategoryZh, - iconType: poi.iconType || poi.primaryCategory, - positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName, - kind, - hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined, - displayPolicy: getPoiDisplayPolicy({ - primaryCategory: poi.primaryCategory, - kind - }) - } -} - const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({ id: poi.id, name: poi.name, @@ -104,10 +87,17 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({ const toSgsRenderPoi = (poi: ReturnType) => toGuideRenderPoi(poi) const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => { + // A facility can be located inside the same spatial area as other facilities. + // That relationship must not collapse distinct visitor markers (for example, + // a restroom and an elevator in one service zone). Space identity is only a + // canonical-place key for halls, spaces, and business-place representations. + const canDedupeBySpace = poi.kind === 'hall' + || poi.kind === 'space' + || poi.primaryCategory === 'business_poi' const stableKeys = [ poi.id ? `id:${poi.id}` : '', - poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '', - poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '', + canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '', + canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '', poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : '' ].filter(Boolean) @@ -303,7 +293,9 @@ export class StaticGuideModelRepository implements GuideModelRepository { const pois = await this.provider.loadFloorPois(floor.poiDataAsset) return pois - .map(toRenderPoi) + .map(toMuseumPoi) + .filter(isVisitorSearchPoi) + .map(toGuideRenderPoi) .filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3) } } @@ -472,12 +464,19 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { loadFloorData('guidePois', () => this.guide.listPois()), loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints()) ]) - const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors))) + const ordinaryPois = pois + .map((poi) => toMuseumPoiFromSgs(poi, manifest.floors)) + .filter(isVisitorSearchPoi) + .map(toSgsRenderPoi) const repositoryBusinessPois = guidePois - .filter((poi) => poi.floorId === resolvedFloorId && poi.primaryCategory.id === 'business_poi') + .filter((poi) => ( + poi.floorId === resolvedFloorId + && poi.primaryCategory.id === 'business_poi' + && isVisitorSearchPoi(poi) + )) .map(toGuideRenderPoi) const repositorySpacePois = guideSpacePoints - .filter((poi) => poi.floorId === resolvedFloorId) + .filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi)) .map(toGuideRenderPoi) const floorPoiMedianY = getMedianPoiY(ordinaryPois) const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, { @@ -492,7 +491,9 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { }) } - const hallPois = museumHallPois.map(toSgsRenderPoi) + const hallPois = museumHallPois + .filter(isVisitorSearchPoi) + .map(toSgsRenderPoi) const adaptedPois = dedupeRenderPoisById([ ...hallPois, ...ordinaryPois, diff --git a/src/repositories/GuideRepository.ts b/src/repositories/GuideRepository.ts index f154446..9061b28 100644 --- a/src/repositories/GuideRepository.ts +++ b/src/repositories/GuideRepository.ts @@ -17,6 +17,17 @@ import { isPoiOnIndoorNavigableFloor } from '@/domain/guideFloor' import { + POI_CATEGORIES, + getPoiCategoryById, + getPoiLinkedSpaceIdentity, + getPoiStablePlaceIdentity, + isDefaultSearchDestinationPoi, + isFacilitySearchPoi, + isGenericPoiSourceCategory, + isQuickFindPoi, + isVisitorSearchPoi, + resolvePoiCategory, + type PoiCategoryId, warnPoiCollectionIssues } from '@/domain/poiCategories' import { @@ -46,33 +57,29 @@ import { toRouteReadinessFromSgsDiagnostics } from '@/data/adapters/sgsSdkGuideAdapter' -const searchableCategories = new Set([ - 'touring_poi', - 'exhibition_hall', - 'exhibition_hall_entrance', - 'basic_service_facility', - 'transport_circulation', - 'accessibility_special_service', - 'business_poi', - 'operation_experience' -]) - -const toSearchText = (poi: MuseumPoi) => [ - poi.name, - poi.hallName, - poi.floorId, - poi.floorLabel, - poi.primaryCategory.label, - poi.primaryCategory.iconType, - ...poi.categories.flatMap((category) => [ - category.label, - category.id, +const toSearchText = (poi: MuseumPoi) => { + const resolvedCategory = resolvePoiCategory(poi) + const rawCategoryTerms = [poi.primaryCategory, ...poi.categories].flatMap((category) => [ + ...(isGenericPoiSourceCategory(category) ? [] : [category.label, category.id]), category.iconType ]) -] - .filter(Boolean) - .join(' ') - .toLowerCase() + + return [ + poi.name, + poi.hallName, + poi.floorId, + poi.floorLabel, + ...rawCategoryTerms, + // Search terms must use the same normalized visitor category as quick find. + // SDK labels such as "卫生间" otherwise miss the visitor-facing "洗手间" alias. + resolvedCategory?.id, + resolvedCategory?.label, + ...(resolvedCategory?.keywords || []) + ] + .filter(Boolean) + .join(' ') + .toLowerCase() +} const normalizePoiLookupText = (value: string) => value .trim() @@ -117,11 +124,6 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({ entrances: poi.entrances }) -const isSearchableIndoorPoi = (poi: MuseumPoi) => ( - searchableCategories.has(poi.primaryCategory.id) - && isPoiOnIndoorNavigableFloor(poi) -) - const dedupePoisById = (pois: MuseumPoi[]) => { const seen = new Set() return pois.filter((poi) => { @@ -131,8 +133,6 @@ const dedupePoisById = (pois: MuseumPoi[]) => { }) } -const getPoiSpaceIdentity = (poi: MuseumPoi) => poi.sourceSpaceId || poi.spaceId || '' - const getPoiSourceLookupIdentity = (id: string) => ( id.trim().replace(/^(?:hall|space)-/, '') ) @@ -147,44 +147,159 @@ const mergePoiCategories = (...categoryGroups: MuseumCategory[][]) => { }) } -const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => { - const hallPoisBySpaceId = new Map( - pois - .filter((poi) => poi.kind === 'hall' || Boolean(poi.hallId)) - .map((poi) => [getPoiSpaceIdentity(poi), poi] as const) - .filter(([spaceId]) => Boolean(spaceId)) - ) - const spacePointsBySpaceId = new Map( - spacePoints - .map((poi) => [getPoiSpaceIdentity(poi), poi] as const) - .filter(([spaceId]) => Boolean(spaceId)) - ) +const hasRenderablePosition = (poi: MuseumPoi) => ( + Array.isArray(poi.positionGltf) + && poi.positionGltf.length === 3 + && poi.positionGltf.every((value) => Number.isFinite(value)) +) - const mergedPois = pois.map((poi) => { - const spaceId = getPoiSpaceIdentity(poi) - const matchingSpacePoint = spaceId ? spacePointsBySpaceId.get(spaceId) : null - const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null - if (!matchingSpacePoint || matchingHallPoi?.id !== poi.id) return poi +const isVisitorSearchResult = (poi: MuseumPoi) => ( + isVisitorSearchPoi(poi) + && isPoiOnIndoorNavigableFloor(poi) + && hasRenderablePosition(poi) +) - return { - ...poi, - primaryCategory: matchingSpacePoint.primaryCategory, - categories: mergePoiCategories( - [matchingSpacePoint.primaryCategory], - matchingSpacePoint.categories - ) +const normalizePlaceName = (value: string) => value + .trim() + .normalize('NFKC') + .toLowerCase() + .replace(/[\s\-_/()[\]{}]/g, '') + +const positionKey = (poi: MuseumPoi) => { + if (!hasRenderablePosition(poi)) return '' + const [x, y, z] = poi.positionGltf! + return [x, y, z].map((value) => Math.round(value * 2) / 2).join(',') +} + +const destinationQuickCategoryIds = new Set([ + 'exhibition-hall', + 'cinema', + 'dining', + 'shopping' +]) + +const canonicalCategoryId = (poi: MuseumPoi) => resolvePoiCategory(poi)?.id || '' + +const canonicalMergeKeys = (poi: MuseumPoi) => { + const categoryId = canonicalCategoryId(poi) + const keys: string[] = [] + const linkedSpace = getPoiLinkedSpaceIdentity(poi) + if (linkedSpace) { + keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`) + } + + const normalizedName = normalizePlaceName(poi.name) + const location = positionKey(poi) + // A space, regular POI, and business POI can describe the same visitor + // place without sharing a spatial-area ID. Exact normalized name and + // render position are a stronger cross-source identity than source IDs. + if (normalizedName && location) { + keys.push(`fallback:${poi.floorId}:${categoryId || 'other'}:${normalizedName}:${location}`) + } + if (keys.length) return keys + + const stableIdentity = getPoiStablePlaceIdentity(poi) + return stableIdentity ? [stableIdentity] : [] +} + +const canonicalPoiPriority = (poi: MuseumPoi) => { + const categoryId = canonicalCategoryId(poi) + const isDestinationCategory = categoryId + ? destinationQuickCategoryIds.has(categoryId) + : isDefaultSearchDestinationPoi(poi) + + let priority = hasRenderablePosition(poi) ? 8 : 0 + if (isDestinationCategory) { + if (categoryId === 'dining' || categoryId === 'shopping') { + // Keep a renderable spatial representation ahead of a business record; + // source ordering then matches the renderer's canonical marker ID. + if (poi.kind === 'hall' || poi.kind === 'space') priority += 56 + if (poi.kind === 'facility') priority += 40 + } else if (poi.kind === 'hall' || poi.kind === 'space') { + priority += 56 + } else { + priority += 16 } - }) - const unmatchedSpacePoints = spacePoints.filter((poi) => { - const spaceId = getPoiSpaceIdentity(poi) - const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null - return !matchingHallPoi - }) + } else if (poi.kind === 'facility') { + priority += 52 + } else if (poi.kind === 'hall' || poi.kind === 'space') { + priority += 30 + } - return dedupePoisById([ - ...mergedPois, - ...unmatchedSpacePoints + return priority +} + +const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => { + const primary = canonicalPoiPriority(right) > canonicalPoiPriority(left) ? right : left + const secondary = primary === left ? right : left + + return { + ...primary, + categories: mergePoiCategories( + [primary.primaryCategory], + primary.categories, + [secondary.primaryCategory], + secondary.categories + ), + hallId: primary.hallId || secondary.hallId, + hallName: primary.hallName || secondary.hallName, + spaceId: primary.spaceId || secondary.spaceId, + sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId, + sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId, + entrances: primary.entrances || secondary.entrances + } +} + +/** + * Search queries return a single map-renderable representation of each real + * visitor place. Source collections remain intact for the renderer itself. + */ +const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => { + const canonicalByKey = new Map() + + pois + .filter(isVisitorSearchResult) + .forEach((poi) => { + const keys = canonicalMergeKeys(poi) + if (!keys.length) keys.push(`poi:${poi.id}`) + + const existingPois = Array.from(new Set( + keys + .map((key) => canonicalByKey.get(key)) + .filter((candidate): candidate is MuseumPoi => Boolean(candidate)) + )) + const mergedPoi = existingPois.reduce( + (merged, existing) => mergeCanonicalPoi(existing, merged), + poi + ) + + if (existingPois.length) { + for (const [key, candidate] of canonicalByKey) { + if (existingPois.includes(candidate)) canonicalByKey.set(key, mergedPoi) + } + } + keys.forEach((key) => canonicalByKey.set(key, mergedPoi)) + }) + + return dedupePoisById(Array.from(canonicalByKey.values())) +} + +const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => ( + mergeCanonicalSearchPois([ + ...pois, + ...spacePoints ]) +) + +const restrictToFloor = (pois: MuseumPoi[], floorId?: string) => ( + floorId ? pois.filter((poi) => poi.floorId === floorId) : pois +) + +const filterByKeyword = (pois: MuseumPoi[], keyword = '') => { + const normalizedKeyword = keyword.trim().toLowerCase() + return normalizedKeyword + ? pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword)) + : pois } const getMedianPoiY = (pois: MuseumPoi[]) => { @@ -243,8 +358,11 @@ export interface GuideRepository { normalizeFloorId(labelOrId: string): string listPois(): Promise listSpacePoints(): Promise + listDestinationPois(floorId?: string): Promise + listQuickFindPois(categoryId: PoiCategoryId, floorId?: string): Promise + getQuickFindCategoryAvailability(floorId?: string): Promise getPoiById(id: string): Promise - searchPois(keyword?: string): Promise + searchPois(keyword?: string, floorId?: string): Promise getLocationPreview(poiId: string): Promise getRouteReadiness(): Promise getFloorDetail?(floorId: string): Promise @@ -252,8 +370,15 @@ export interface GuideRepository { checkDataIntegrity?(): Promise } +export interface GuideQuickFindCategoryAvailability { + id: PoiCategoryId + count: number + disabled: boolean +} + export class StaticGuideRepository implements GuideRepository { private poiCache: MuseumPoi[] | null = null + private visitorSearchPoolCache: MuseumPoi[] | null = null constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {} @@ -284,7 +409,7 @@ export class StaticGuideRepository implements GuideRepository { const adaptedPois = pois.map(toMuseumPoi) warnPoiCollectionIssues('static-nav-assets', adaptedPois) this.poiCache = dedupePoisById(adaptedPois - .filter(isSearchableIndoorPoi) + .filter(isPoiOnIndoorNavigableFloor) ) return this.poiCache @@ -294,18 +419,64 @@ export class StaticGuideRepository implements GuideRepository { return [] } + private async getVisitorSearchPool() { + if (this.visitorSearchPoolCache) return this.visitorSearchPoolCache + + const [pois, spacePoints] = await Promise.all([ + this.listPois(), + this.listSpacePoints() + ]) + this.visitorSearchPoolCache = mergeCanonicalSearchPois([ + ...pois, + ...spacePoints + ]) + return this.visitorSearchPoolCache + } + + async listDestinationPois(floorId?: string) { + const searchPool = await this.getVisitorSearchPool() + // Static nav assets do not expose a standalone space collection. Their + // hall/destination POIs are the source-faithful fallback for default browse. + return restrictToFloor(searchPool.filter(isDefaultSearchDestinationPoi), floorId) + } + + async listQuickFindPois(categoryId: PoiCategoryId, floorId?: string) { + const category = getPoiCategoryById(categoryId) + if (!category) return [] + + const searchPool = await this.getVisitorSearchPool() + return restrictToFloor(searchPool.filter((poi) => { + if (!isQuickFindPoi(poi, category)) return false + return destinationQuickCategoryIds.has(category.id) + || isFacilitySearchPoi(poi) + }), floorId) + } + + async getQuickFindCategoryAvailability(floorId?: string) { + const searchPool = await this.getVisitorSearchPool() + const floorPois = restrictToFloor(searchPool, floorId) + + return POI_CATEGORIES.map((category) => { + const count = floorPois.filter((poi) => ( + isQuickFindPoi(poi, category) + && (destinationQuickCategoryIds.has(category.id) || isFacilitySearchPoi(poi)) + )).length + return { + id: category.id, + count, + disabled: count === 0 + } + }) + } + async getPoiById(id: string) { const pois = await this.listPois() return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id) } - async searchPois(keyword = '') { - const pois = await this.listPois() - const normalizedKeyword = keyword.trim().toLowerCase() - - if (!normalizedKeyword) return pois - - return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword)) + async searchPois(keyword = '', floorId?: string) { + const searchPool = await this.getVisitorSearchPool() + return filterByKeyword(restrictToFloor(searchPool, floorId), keyword) } async getLocationPreview(poiId: string) { @@ -547,6 +718,71 @@ export class SgsSdkGuideRepository implements GuideRepository { return result } + private async getVisitorSearchPool() { + const sourceFailures: unknown[] = [] + let pois: MuseumPoi[] = [] + let spacePoints: MuseumPoi[] = [] + + try { + pois = await this.listPois() + } catch (error) { + sourceFailures.push(error) + } + + try { + spacePoints = await this.listSpacePoints() + } catch (error) { + sourceFailures.push(error) + } + + const searchPool = mergeCanonicalSearchPois([ + ...pois, + ...spacePoints + ]) + if (sourceFailures.length && !searchPool.length) { + throw new Error(poiDataLoadErrorMessage) + } + if (import.meta.env.DEV && sourceFailures.length) { + console.warn('[SGS 点位数据] 搜索使用部分可用数据', sourceFailures.map(toErrorMessage)) + } + + return searchPool + } + + async listDestinationPois(floorId?: string) { + const searchPool = await this.getVisitorSearchPool() + return restrictToFloor(searchPool.filter(isDefaultSearchDestinationPoi), floorId) + } + + async listQuickFindPois(categoryId: PoiCategoryId, floorId?: string) { + const category = getPoiCategoryById(categoryId) + if (!category) return [] + + const searchPool = await this.getVisitorSearchPool() + return restrictToFloor(searchPool.filter((poi) => { + if (!isQuickFindPoi(poi, category)) return false + return destinationQuickCategoryIds.has(category.id) + || isFacilitySearchPoi(poi) + }), floorId) + } + + async getQuickFindCategoryAvailability(floorId?: string) { + const searchPool = await this.getVisitorSearchPool() + const floorPois = restrictToFloor(searchPool, floorId) + + return POI_CATEGORIES.map((category) => { + const count = floorPois.filter((poi) => ( + isQuickFindPoi(poi, category) + && (destinationQuickCategoryIds.has(category.id) || isFacilitySearchPoi(poi)) + )).length + return { + id: category.id, + count, + disabled: count === 0 + } + }) + } + async getPoiById(id: string) { const normalizedId = id.trim() if (!normalizedId) return null @@ -581,7 +817,7 @@ export class SgsSdkGuideRepository implements GuideRepository { || findPoiByNavIdNameFallback(pois, id) } - async searchPois(keyword = '') { + async searchPois(keyword = '', floorId?: string) { const sourceFailures: unknown[] = [] let pois: MuseumPoi[] = [] let spacePoints: MuseumPoi[] = [] @@ -607,9 +843,7 @@ export class SgsSdkGuideRepository implements GuideRepository { } const normalizedKeyword = keyword.trim().toLowerCase() - if (!normalizedKeyword) return searchablePois - - return searchablePois.filter((poi) => toSearchText(poi).includes(normalizedKeyword)) + return filterByKeyword(restrictToFloor(searchablePois, floorId), normalizedKeyword) } async getLocationPreview(poiId: string) { diff --git a/src/usecases/guideUseCase.ts b/src/usecases/guideUseCase.ts index cb230d5..c8abb83 100644 --- a/src/usecases/guideUseCase.ts +++ b/src/usecases/guideUseCase.ts @@ -23,6 +23,13 @@ import { applyRouteReadinessGate, NAV_ROUTE_READINESS } from '@/domain/guideReadiness' +import { + getPoiCategoryById +} from '@/domain/poiCategories' +import type { + GuidePoiSearchMode, + GuidePoiSearchViewState +} from '@/domain/poiSearch' const startSourceLabels: Record = { 'facility-detail': '设施详情选择', @@ -98,6 +105,12 @@ export interface GuideContentLocationRequest { targetName?: string } +type PoiSearchModeInput = { + mode: GuidePoiSearchMode + keyword?: string + categoryId?: GuidePoiSearchViewState['categoryId'] +} + export class GuideUseCase { private routeReadinessCache: GuideRouteReadiness | null = null @@ -139,8 +152,115 @@ export class GuideUseCase { return this.guide.searchPois(keyword) } - getInitialSearchSpacePoints() { - return this.guide.searchPois() + getInitialSearchSpacePoints(floorId?: string) { + return this.guide.listDestinationPois(floorId) + } + + private async resolvePoiSearchFloor(floorId?: string) { + const floors = await this.guide.getFloors() + const requestedFloorId = floorId?.trim() ? this.guide.normalizeFloorId(floorId) : '' + const floor = floors.find((item) => item.id === requestedFloorId) + || floors.find((item) => item.label === floorId) + || floors.find((item) => item.label === '1F') + || floors[0] + + return floor || { + id: requestedFloorId || floorId?.trim() || '', + label: floorId?.trim() || '' + } + } + + private async loadPoiSearchView( + floorId: string | undefined, + input: PoiSearchModeInput + ): Promise { + const floor = await this.resolvePoiSearchFloor(floorId) + const keyword = input.keyword?.trim() || '' + const categoryId = input.categoryId || '' + const category = categoryId ? getPoiCategoryById(categoryId) : null + const mode = input.mode === 'category' && !category + ? 'default' + : input.mode + + const [availability, results] = await Promise.all([ + this.guide.getQuickFindCategoryAvailability(floor.id), + mode === 'category' && category + ? this.guide.listQuickFindPois(category.id, floor.id) + : mode === 'keyword' + ? this.guide.searchPois(keyword, floor.id) + : this.guide.listDestinationPois(floor.id) + ]) + + return { + mode, + floorId: floor.id, + floorLabel: floor.label, + keyword: mode === 'keyword' ? keyword : category?.label || '', + categoryId: mode === 'category' && category ? category.id : '', + results, + visiblePoiIds: results.map((poi) => poi.id), + categories: availability.map((item) => { + const definition = getPoiCategoryById(item.id) + if (!definition) { + throw new Error(`Unknown quick-find category: ${item.id}`) + } + return { + definition, + count: item.count, + disabled: item.disabled + } + }) + } + } + + createInitialPoiSearchState(floorId?: string) { + return this.loadPoiSearchView(floorId, { mode: 'default' }) + } + + selectPoiSearchCategory( + state: GuidePoiSearchViewState | null, + categoryId: GuidePoiSearchViewState['categoryId'], + floorId = state?.floorId + ) { + return this.loadPoiSearchView(floorId, { + mode: 'category', + categoryId + }) + } + + searchPoiKeyword( + state: GuidePoiSearchViewState | null, + keyword: string, + floorId = state?.floorId + ) { + const normalizedKeyword = keyword.trim() + return this.loadPoiSearchView(floorId, normalizedKeyword + ? { + mode: 'keyword', + keyword: normalizedKeyword + } + : { + mode: 'default' + }) + } + + clearPoiSearch( + state: GuidePoiSearchViewState | null, + floorId = state?.floorId + ) { + return this.loadPoiSearchView(floorId, { mode: 'default' }) + } + + changePoiSearchFloor( + state: GuidePoiSearchViewState | null, + floorId: string + ) { + const mode = state?.mode || 'default' + return this.loadPoiSearchView(floorId, { + mode, + keyword: state?.keyword, + categoryId: state?.categoryId + }) } getPoiById(id: string) { diff --git a/tests/unit/GuideRepositorySearch.spec.ts b/tests/unit/GuideRepositorySearch.spec.ts new file mode 100644 index 0000000..85851c6 --- /dev/null +++ b/tests/unit/GuideRepositorySearch.spec.ts @@ -0,0 +1,408 @@ +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 = {}) => ({ + 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']) + }) +}) diff --git a/tests/unit/GuideUseCase.spec.ts b/tests/unit/GuideUseCase.spec.ts new file mode 100644 index 0000000..cdcfed2 --- /dev/null +++ b/tests/unit/GuideUseCase.spec.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MuseumFloor, MuseumPoi } from '@/domain/museum' +import type { PoiCategoryId } from '@/domain/poiCategories' +import type { GuideRepository } from '@/repositories/GuideRepository' +import type { GuideModelRepository } from '@/repositories/GuideModelRepository' +import { GuideUseCase } from '@/usecases/guideUseCase' + +const floors: MuseumFloor[] = [ + { id: 'L1', label: '1F', order: 1 }, + { id: 'L2', label: '2F', order: 2 } +] + +const quickCategoryIds: readonly PoiCategoryId[] = [ + 'exhibition-hall', + 'cinema', + 'ticket-office', + 'dining', + 'shopping', + 'service-center', + 'restroom', + 'nursing-room', + 'elevator', + 'escalator' +] + +const createPoi = (id: string, floorId: string, categoryId: string): MuseumPoi => ({ + id, + name: id, + floorId, + floorLabel: floorId === 'L2' ? '2F' : '1F', + primaryCategory: { + id: categoryId, + label: categoryId, + iconType: categoryId + }, + categories: [], + accessible: true +}) + +const destinationsByFloor: Record = { + L1: [createPoi('space-l1-hall', 'L1', 'exhibition_hall')], + L2: [createPoi('space-l2-hall', 'L2', 'exhibition_hall')] +} + +const quickResultsByFloor: Record>> = { + L1: { + restroom: [createPoi('facility-l1-restroom', 'L1', 'toilet')] + }, + L2: { + restroom: [createPoi('facility-l2-restroom', 'L2', 'toilet')] + } +} + +const keywordResultsByFloor: Record = { + L1: [createPoi('facility-l1-shop', 'L1', 'shop')], + L2: [createPoi('facility-l2-shop', 'L2', 'shop')] +} + +const availability = quickCategoryIds.map((id, index) => ({ + id, + count: index === quickCategoryIds.indexOf('restroom') ? 1 : 0, + disabled: id !== 'restroom' +})) + +const createRepository = () => ({ + getAssetBaseUrl: vi.fn(() => ''), + getFloors: vi.fn().mockResolvedValue(floors), + normalizeFloorId: vi.fn((floorId: string) => ({ + '1F': 'L1', + '2F': 'L2' + }[floorId] || floorId)), + listPois: vi.fn().mockResolvedValue([]), + listSpacePoints: vi.fn().mockResolvedValue([]), + listDestinationPois: vi.fn((floorId?: string) => ( + Promise.resolve(destinationsByFloor[floorId || 'L1'] || []) + )), + listQuickFindPois: vi.fn((categoryId: PoiCategoryId, floorId?: string) => ( + Promise.resolve(quickResultsByFloor[floorId || 'L1']?.[categoryId] || []) + )), + getQuickFindCategoryAvailability: vi.fn().mockResolvedValue(availability), + getPoiById: vi.fn().mockResolvedValue(null), + searchPois: vi.fn((keyword?: string, floorId?: string) => ( + Promise.resolve(keyword ? keywordResultsByFloor[floorId || 'L1'] || [] : []) + )), + getLocationPreview: vi.fn().mockResolvedValue(null), + getRouteReadiness: vi.fn().mockResolvedValue({}) +}) + +const expectMapListParity = (state: { results: MuseumPoi[], visiblePoiIds: string[] }) => { + expect(state.visiblePoiIds).toEqual(state.results.map((poi) => poi.id)) +} + +describe('GuideUseCase point search state', () => { + it('loads initial destinations through listDestinationPois and never searchPois', async () => { + const repository = createRepository() + const useCase = new GuideUseCase( + repository as unknown as GuideRepository, + {} as GuideModelRepository + ) + + await expect(useCase.getInitialSearchSpacePoints('L2')) + .resolves.toEqual(destinationsByFloor.L2) + + expect(repository.listDestinationPois).toHaveBeenCalledWith('L2') + expect(repository.searchPois).not.toHaveBeenCalled() + }) + + it('creates the default state from current-floor destinations and relays all ten availability entries', async () => { + const repository = createRepository() + const useCase = new GuideUseCase( + repository as unknown as GuideRepository, + {} as GuideModelRepository + ) + + const state = await useCase.createInitialPoiSearchState('L1') + + expect(state).toMatchObject({ + mode: 'default', + floorId: 'L1', + floorLabel: '1F', + keyword: '', + categoryId: '' + }) + expect(state.results).toEqual(destinationsByFloor.L1) + expectMapListParity(state) + expect(state.categories.map((item) => ({ + id: item.definition.id, + count: item.count, + disabled: item.disabled + }))).toEqual(availability) + expect(repository.getQuickFindCategoryAvailability).toHaveBeenCalledWith('L1') + expect(repository.listDestinationPois).toHaveBeenCalledWith('L1') + expect(repository.searchPois).not.toHaveBeenCalled() + }) + + it('keeps category, keyword, clear, and floor-change states on canonical result IDs', async () => { + const repository = createRepository() + const useCase = new GuideUseCase( + repository as unknown as GuideRepository, + {} as GuideModelRepository + ) + + const initial = await useCase.createInitialPoiSearchState('L1') + const category = await useCase.selectPoiSearchCategory(initial, 'restroom') + + expect(category).toMatchObject({ + mode: 'category', + floorId: 'L1', + categoryId: 'restroom' + }) + expect(category.results).toEqual(quickResultsByFloor.L1.restroom) + expectMapListParity(category) + expect(repository.listQuickFindPois).toHaveBeenCalledWith('restroom', 'L1') + expect(repository.searchPois).not.toHaveBeenCalled() + + const keyword = await useCase.searchPoiKeyword(category, 'shop') + + expect(keyword).toMatchObject({ + mode: 'keyword', + floorId: 'L1', + keyword: 'shop', + categoryId: '' + }) + expect(keyword.results).toEqual(keywordResultsByFloor.L1) + expectMapListParity(keyword) + expect(repository.searchPois).toHaveBeenLastCalledWith('shop', 'L1') + + const switchedFloor = await useCase.changePoiSearchFloor(keyword, 'L2') + + expect(switchedFloor).toMatchObject({ + mode: 'keyword', + floorId: 'L2', + floorLabel: '2F', + keyword: 'shop', + categoryId: '' + }) + expect(switchedFloor.results).toEqual(keywordResultsByFloor.L2) + expectMapListParity(switchedFloor) + expect(repository.searchPois).toHaveBeenLastCalledWith('shop', 'L2') + + const cleared = await useCase.clearPoiSearch(switchedFloor) + + expect(cleared).toMatchObject({ + mode: 'default', + floorId: 'L2', + floorLabel: '2F', + keyword: '', + categoryId: '' + }) + expect(cleared.results).toEqual(destinationsByFloor.L2) + expectMapListParity(cleared) + expect(repository.listDestinationPois).toHaveBeenLastCalledWith('L2') + }) +}) diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index f7b505e..b84982d 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -373,6 +373,19 @@ describe('首页搜索与地图闭环', () => { expect(map.props('activeFloor')).toBe('L2') expect(map.props('indoorView')).toBe('floor') expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) + + search.vm.$emit('results-change', { + ...categoryContext, + floorId: 'L1', + floorLabel: '1F', + visiblePoiIds: [], + active: true, + pending: true + }) + await wrapper.vm.$nextTick() + + expect(map.props('activeFloor')).toBe('L2') + expect(map.props('visiblePoiIds')).toEqual([]) }) it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => { diff --git a/tests/unit/PoiSearchPanel.spec.ts b/tests/unit/PoiSearchPanel.spec.ts index 974185c..621ba7d 100644 --- a/tests/unit/PoiSearchPanel.spec.ts +++ b/tests/unit/PoiSearchPanel.spec.ts @@ -4,11 +4,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue' import type { MuseumFloor, MuseumPoi } from '@/domain/museum' +import type { GuidePoiSearchViewState } from '@/domain/poiSearch' +import { + HOME_POI_CATEGORIES, + POI_CATEGORIES, + type PoiCategoryId +} from '@/domain/poiCategories' const guideMocks = vi.hoisted(() => ({ getFloors: vi.fn(), - getInitialSearchSpacePoints: vi.fn(), - searchPois: vi.fn() + createInitialPoiSearchState: vi.fn(), + selectPoiSearchCategory: vi.fn(), + searchPoiKeyword: vi.fn(), + clearPoiSearch: vi.fn(), + changePoiSearchFloor: vi.fn() })) const hostEnvironmentMocks = vi.hoisted(() => ({ @@ -33,56 +42,184 @@ const createPoi = ( name: string, floorId: string, floorLabel: string, - iconType: string, - positionGltf?: [number, number, number] + categoryId: string, + positionGltf: [number, number, number] ): MuseumPoi => ({ id, name, floorId, floorLabel, primaryCategory: { - id: iconType, - label: name.includes('卫生间') ? '卫生间' : name.includes('母婴') ? '母婴室' : '展厅', - iconType + id: categoryId, + label: categoryId, + iconType: categoryId }, categories: [], positionGltf, accessible: true }) -const poiPool: MuseumPoi[] = [ +const destinationFloor1 = [ createPoi('hall-1', '地球厅', 'floor-1', '1F', 'exhibition_hall', [1, 0, 1]), - createPoi('restroom-1', '卫生间 A', 'floor-1', '1F', 'toilet', [2, 0, 2]), - createPoi('restroom-missing-position', '卫生间 B', 'floor-1', '1F', 'toilet'), - createPoi('restroom-2', '卫生间 C', 'floor-2', '2F', 'toilet', [3, 0, 3]), - createPoi('nursing-2', '母婴室', 'floor-2', '2F', 'mother_baby_room', [4, 0, 4]) + createPoi('space-1', '生态展区', 'floor-1', '1F', 'space_destination', [2, 0, 2]) ] +const destinationFloor2 = [ + createPoi('hall-2', '恐龙厅', 'floor-2', '2F', 'exhibition_hall', [3, 0, 3]) +] +const restroomFloor1 = [ + createPoi('restroom-1', '洗手间 A', 'floor-1', '1F', 'toilet', [4, 0, 4]) +] +const restroomFloor2 = [ + createPoi('restroom-2', '洗手间 B', 'floor-2', '2F', 'toilet', [5, 0, 5]) +] +const keywordFloor1 = [ + createPoi('dinosaur-1', '恐龙化石', 'floor-1', '1F', 'exhibition_hall', [6, 0, 6]), + createPoi('dinosaur-shop-1', '恐龙文创店', 'floor-1', '1F', 'shop', [7, 0, 7]) +] + +type SearchStateOptions = { + mode?: GuidePoiSearchViewState['mode'] + floorId?: string + keyword?: string + categoryId?: PoiCategoryId | '' + results?: MuseumPoi[] + disabledCategoryIds?: PoiCategoryId[] +} + +const floorFor = (floorId = 'floor-1') => ( + floors.find((floor) => floor.id === floorId) || floors[0] +) + +const createSearchState = ({ + mode = 'default', + floorId = 'floor-1', + keyword = '', + categoryId = '', + results, + disabledCategoryIds = [] +}: SearchStateOptions = {}): GuidePoiSearchViewState => { + const floor = floorFor(floorId) + const resolvedResults = results || (floor.id === 'floor-2' ? destinationFloor2 : destinationFloor1) + const disabled = new Set(disabledCategoryIds) + + return { + mode, + floorId: floor.id, + floorLabel: floor.label, + keyword, + categoryId, + results: resolvedResults, + visiblePoiIds: resolvedResults.map((poi) => poi.id), + categories: POI_CATEGORIES.map((definition) => ({ + definition, + count: disabled.has(definition.id) + ? 0 + : definition.id === categoryId + ? resolvedResults.length + : 1, + disabled: disabled.has(definition.id) + })) + } +} + +const createDeferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} const flushMountedSearch = async () => { await flushPromises() await flushPromises() } -const createDeferred = () => { - let resolve!: (value: T | PromiseLike) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) +const renderedResultIds = (wrapper: ReturnType) => ( + wrapper.findAll('.result-row').map((row) => ( + row.attributes('data-testid').replace('poi-result-', '') + )) +) - return { promise, resolve, reject } -} +const lastResultState = (wrapper: ReturnType) => ( + (wrapper.emitted('results-change') || []).at(-1)?.[0] as { + visiblePoiIds: string[] + active: boolean + floorId: string + categoryId: string + keyword: string + pending?: boolean + } +) beforeEach(() => { hostEnvironmentMocks.embeddedInWechatMiniProgram = false + guideMocks.getFloors.mockReset() + guideMocks.createInitialPoiSearchState.mockReset() + guideMocks.selectPoiSearchCategory.mockReset() + guideMocks.searchPoiKeyword.mockReset() + guideMocks.clearPoiSearch.mockReset() + guideMocks.changePoiSearchFloor.mockReset() + guideMocks.getFloors.mockResolvedValue(floors) - guideMocks.getInitialSearchSpacePoints.mockResolvedValue(poiPool) - guideMocks.searchPois.mockImplementation(async (keyword = '') => ( - keyword - ? poiPool.filter((poi) => poi.name.includes(keyword)) - : poiPool + guideMocks.createInitialPoiSearchState.mockImplementation(async (floorId?: string) => ( + createSearchState({ floorId }) )) + guideMocks.selectPoiSearchCategory.mockImplementation(async ( + _state: GuidePoiSearchViewState | null, + categoryId: PoiCategoryId, + floorId?: string + ) => { + const isFloor2 = floorFor(floorId).id === 'floor-2' + const results = categoryId === 'restroom' + ? isFloor2 ? restroomFloor2 : restroomFloor1 + : [] + return createSearchState({ + mode: 'category', + floorId, + categoryId, + keyword: POI_CATEGORIES.find((category) => category.id === categoryId)?.label || '', + results + }) + }) + guideMocks.searchPoiKeyword.mockImplementation(async ( + _state: GuidePoiSearchViewState | null, + keyword: string, + floorId?: string + ) => createSearchState({ + mode: 'keyword', + floorId, + keyword, + results: floorFor(floorId).id === 'floor-2' ? destinationFloor2 : keywordFloor1 + })) + guideMocks.clearPoiSearch.mockImplementation(async ( + _state: GuidePoiSearchViewState | null, + floorId?: string + ) => createSearchState({ floorId })) + guideMocks.changePoiSearchFloor.mockImplementation(async ( + state: GuidePoiSearchViewState | null, + floorId: string + ) => { + if (state?.mode === 'category') { + return createSearchState({ + mode: 'category', + floorId, + categoryId: state.categoryId, + keyword: state.keyword, + results: state.categoryId === 'restroom' ? restroomFloor2 : [] + }) + } + if (state?.mode === 'keyword') { + return createSearchState({ + mode: 'keyword', + floorId, + keyword: state.keyword, + results: destinationFloor2 + }) + } + return createSearchState({ floorId }) + }) + vi.stubGlobal('uni', { hideKeyboard: vi.fn(), navigateTo: vi.fn(), @@ -96,101 +233,72 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('POI 搜索面板实际渲染与状态闭环', () => { - it('不渲染尚未支持的语音搜索入口', () => { +describe('PoiSearchPanel 搜索状态契约', () => { + it('固定展示 10 类快捷查找,并为收起和展开区域提供 aria-label', async () => { + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + + const expectedLabels = ['展厅', '影院', '售票处', '餐饮', '购物', '服务', '洗手间', '母婴', '电梯', '扶梯'] + const collapsedCategories = wrapper.findAll('.home-category-chip') + expect(collapsedCategories).toHaveLength(10) + expect(collapsedCategories.map((item) => item.text()).sort()).toEqual([...expectedLabels].sort()) + expect(wrapper.get('.home-category-strip').attributes('aria-label')).toBe('快捷查找') + + await wrapper.get('.search-box').trigger('tap') + await flushMountedSearch() + expect(wrapper.findAll('.home-shortcut-grid .category-item')).toHaveLength(10) + expect(wrapper.get('.category-scroll').attributes('aria-label')).toBe('快捷查找') + expect(HOME_POI_CATEGORIES).toHaveLength(10) + wrapper.unmount() + }) + + it('无结果分类具有禁用态和 aria-disabled,点击不会发起分类查询', async () => { + guideMocks.createInitialPoiSearchState.mockResolvedValue(createSearchState({ + disabledCategoryIds: ['cinema'] + })) + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + + const cinema = wrapper.get('[data-testid="poi-category-cinema"]') + expect(cinema.classes()).toContain('disabled') + expect(cinema.attributes('aria-disabled')).toBe('true') + await cinema.trigger('tap') + await flushMountedSearch() + + expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled() + expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('默认列表只消费 createInitialPoiSearchState 返回的当前楼层空间目的地', async () => { const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } }) - - expect(wrapper.find('.voice-icon').exists()).toBe(false) - }) - - it('首页收缩和展开复用同一份九项快捷入口,搜索页保留完整分类', async () => { - const escalatorPoi = createPoi('escalator-1', '自动扶梯', 'floor-1', '1F', 'escalator', [5, 0, 5]) - guideMocks.getInitialSearchSpacePoints.mockResolvedValue([...poiPool, escalatorPoi]) - const homeWrapper = mount(PoiSearchPanel, { - props: { - variant: 'home', - currentFloorId: 'floor-1', - currentFloorLabel: '1F' - } - }) await flushMountedSearch() - const homeCategories = homeWrapper.findAll('.home-category-chip') - expect(homeCategories).toHaveLength(9) - expect(homeCategories.map((item) => item.text())).toEqual([ - '展览', '影院', '洗手间', '餐厅', '售票处', - '母婴室', '服务中心', '电梯', '扶梯' - ]) - expect(homeCategories.slice(0, 5).map((item) => item.text())).toEqual([ - '展览', '影院', '洗手间', '餐厅', '售票处' - ]) - homeCategories.forEach((item) => { - expect(item.find('.poi-category-icon').exists()).toBe(true) - expect(item.find('.home-category-label').exists()).toBe(true) - }) - - await homeWrapper.get('[data-testid="poi-category-escalator"]').trigger('tap') - await flushMountedSearch() - expect(homeWrapper.find('[data-testid="poi-result-escalator-1"]').exists()).toBe(true) - - await homeWrapper.get('[data-testid="poi-category-cancel"]').trigger('tap') - await flushMountedSearch() - - await homeWrapper.get('.search-box').trigger('tap') - await flushMountedSearch() - const expandedCategories = homeWrapper.findAll('.home-shortcut-grid .category-item') - expect(expandedCategories).toHaveLength(9) - expect(expandedCategories.map((item) => item.text())).toEqual([ - '展览', '影院', '洗手间', '餐厅', '售票处', - '母婴室', '服务中心', '电梯', '扶梯' - ]) - expect(expandedCategories.slice(0, 5).map((item) => item.text())) - .toEqual(homeCategories.slice(0, 5).map((item) => item.text())) - homeWrapper.unmount() - - const pageWrapper = mount(PoiSearchPanel, { props: { variant: 'page' } }) - await flushMountedSearch() - expect(pageWrapper.findAll('.category-item')).toHaveLength(12) - expect(pageWrapper.text()).toContain('影院') - expect(pageWrapper.text()).toContain('服务中心') - expect(pageWrapper.text()).toContain('文创') - expect(pageWrapper.text()).not.toContain('服务设施') - pageWrapper.unmount() - }) - - it('普通 H5 展开时保留内部标题与返回,并可分类和收起', async () => { - const wrapper = mount(PoiSearchPanel, { - props: { - variant: 'home', - currentFloorId: 'floor-1', - currentFloorLabel: '1F' - } - }) - await flushMountedSearch() - - await wrapper.get('.search-box').trigger('tap') - await flushMountedSearch() - expect(wrapper.get('.home-fullscreen-nav').text()).toContain('点位搜索') - expect(wrapper.find('[data-testid="poi-search-cancel"]').exists()).toBe(true) - - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true) - - await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('.home-category-strip').exists()).toBe(true) - - await wrapper.get('.search-box').trigger('tap') - await flushMountedSearch() - await wrapper.get('[data-testid="poi-search-cancel"]').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('.home-category-strip').exists()).toBe(true) + expect(guideMocks.createInitialPoiSearchState).toHaveBeenCalledWith('floor-1') + expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled() + expect(guideMocks.searchPoiKeyword).not.toHaveBeenCalled() + expect(guideMocks.clearPoiSearch).not.toHaveBeenCalled() + expect(guideMocks.changePoiSearchFloor).not.toHaveBeenCalled() + expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1']) + expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(false) wrapper.unmount() }) - it('内嵌微信小程序展开时不渲染内部标题与返回,并可分类和收起', async () => { - hostEnvironmentMocks.embeddedInWechatMiniProgram = true + it('快捷分类调用 selectPoiSearchCategory,列表 ID 与 results-change visiblePoiIds 完全一致', async () => { + const initialState = createSearchState() + guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState) const wrapper = mount(PoiSearchPanel, { props: { variant: 'home', @@ -200,61 +308,90 @@ describe('POI 搜索面板实际渲染与状态闭环', () => { }) await flushMountedSearch() - await wrapper.get('.search-box').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('.home-fullscreen-nav').exists()).toBe(false) - expect(wrapper.find('[data-testid="poi-search-cancel"]').exists()).toBe(false) - expect(wrapper.get('.poi-search-panel').element.firstElementChild?.classList.contains('search-box')) - .toBe(true) - - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true) - - await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('.home-category-strip').exists()).toBe(true) - - await wrapper.get('.search-box').trigger('tap') - await flushMountedSearch() - await wrapper.get('.home-collapse-handle').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('.home-category-strip').exists()).toBe(true) - wrapper.unmount() - }) - - it('展开态快捷入口退出全屏并进入当前楼层的分类结果', async () => { - const wrapper = mount(PoiSearchPanel, { - props: { - variant: 'home', - currentFloorId: 'floor-1', - currentFloorLabel: '1F' - } - }) - await flushMountedSearch() - - await wrapper.get('.search-box').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('.poi-search-content').exists()).toBe(true) - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') await flushMountedSearch() - expect(wrapper.find('.poi-search-content').exists()).toBe(false) - expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true) - expect(wrapper.get('[data-testid="poi-home-category-results"]').text()) - .toContain('当前楼层 1F · 2 个点位') - expect(uni.hideKeyboard).toHaveBeenCalled() - expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({ + expect(guideMocks.selectPoiSearchCategory).toHaveBeenCalledWith(initialState, 'restroom', 'floor-1') + expect(renderedResultIds(wrapper)).toEqual(['restroom-1']) + expect(lastResultState(wrapper)).toMatchObject({ active: true, - categoryId: 'restroom', floorId: 'floor-1', + categoryId: 'restroom', visiblePoiIds: ['restroom-1'] }) + expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper)) wrapper.unmount() }) - it('关键词确认命中分类时仍保留全屏搜索结果', async () => { + it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => { + const initialState = createSearchState() + const deferredCategory = createDeferred() + guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState) + guideMocks.selectPoiSearchCategory.mockReturnValueOnce(deferredCategory.promise) + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + + await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') + await flushMountedSearch() + + expect(renderedResultIds(wrapper)).toEqual([]) + expect(lastResultState(wrapper)).toMatchObject({ + active: true, + pending: true, + visiblePoiIds: [] + }) + + deferredCategory.resolve(createSearchState({ + mode: 'category', + categoryId: 'restroom', + keyword: '洗手间', + results: restroomFloor1 + })) + await flushMountedSearch() + + expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper)) + wrapper.unmount() + }) + + it('关键词确认调用 searchPoiKeyword,并把关键词结果作为地图可见 ID', async () => { + const initialState = createSearchState() + guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState) + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + + await wrapper.get('.search-box').trigger('tap') + await wrapper.get('.search-input').trigger('confirm', { + detail: { value: '恐龙' } + }) + await flushMountedSearch() + + expect(guideMocks.searchPoiKeyword).toHaveBeenCalledWith(initialState, '恐龙', 'floor-1') + expect(renderedResultIds(wrapper)).toEqual(['dinosaur-1', 'dinosaur-shop-1']) + expect(lastResultState(wrapper)).toMatchObject({ + active: true, + keyword: '恐龙', + categoryId: '', + visiblePoiIds: ['dinosaur-1', 'dinosaur-shop-1'] + }) + expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper)) + wrapper.unmount() + }) + + it('输入恰好为快捷分类名称时仍走统一关键词查询', async () => { + const initialState = createSearchState() + guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState) const wrapper = mount(PoiSearchPanel, { props: { variant: 'home', @@ -270,90 +407,21 @@ describe('POI 搜索面板实际渲染与状态闭环', () => { }) await flushMountedSearch() - expect(wrapper.find('.poi-search-content').exists()).toBe(true) - expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false) - wrapper.unmount() - }) - - it('详情返回专用复位恢复默认快捷入口并清空搜索状态', async () => { - const wrapper = mount(PoiSearchPanel, { - props: { - variant: 'home', - currentFloorId: 'floor-1', - currentFloorLabel: '1F' - } - }) - await flushMountedSearch() - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - - wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 126 - await (wrapper.vm as unknown as { - returnToCollapsedAfterDetail: () => Promise - }).returnToCollapsedAfterDetail() - await flushMountedSearch() - - expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false) - expect(wrapper.find('.poi-search-content').exists()).toBe(false) - expect(wrapper.findAll('.home-category-chip')).toHaveLength(9) - expect(wrapper.findAll('.home-category-chip.active')).toHaveLength(0) - expect((wrapper.get('.search-input').element as HTMLInputElement).value).toBe('') - expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({ - active: false, - keyword: '', - categoryId: '', - visiblePoiIds: [], - listScrollTop: 0 - }) - wrapper.unmount() - }) - - it('首页分类只展示当前楼层结果,并上报可定位点位的精确 marker ID', async () => { - const wrapper = mount(PoiSearchPanel, { - props: { - variant: 'home', - currentFloorId: 'floor-1', - currentFloorLabel: '1F' - } - }) - await flushMountedSearch() - - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - - expect(wrapper.get('[data-testid="poi-home-category-results"]').text()).toContain('当前楼层 1F · 2 个点位') - expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true) - const missingPositionRow = wrapper.get('[data-testid="poi-result-restroom-missing-position"]') - expect(missingPositionRow.classes()).toContain('disabled') - expect(missingPositionRow.text()).toContain('暂无地图坐标') - - const resultStates = wrapper.emitted('results-change') || [] - expect(resultStates.at(-1)?.[0]).toMatchObject({ + expect(guideMocks.searchPoiKeyword).toHaveBeenCalledWith(initialState, '洗手间', 'floor-1') + expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled() + expect(lastResultState(wrapper)).toMatchObject({ active: true, - categoryId: 'restroom', - floorId: 'floor-1', - floorLabel: '1F', - resultCount: 2, - floorResultCount: 2, - visiblePoiIds: ['restroom-1'] - }) - - await missingPositionRow.trigger('tap') - expect(wrapper.emitted('resultTap')).toBeUndefined() - expect(uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ - title: '该点位缺少地图坐标,暂无法定位' - })) - - await wrapper.get('[data-testid="poi-result-restroom-1"]').trigger('tap') - expect(wrapper.emitted('resultTap')?.at(-1)?.[1]).toMatchObject({ - categoryId: 'restroom', - resultCount: 2, - visiblePoiIds: ['restroom-1'] + keyword: '洗手间', + categoryId: '' }) wrapper.unmount() }) - it('首页分类跟随当前地图楼层更新结果与 marker ID,并对无结果给出反馈', async () => { + it('首页关键词请求期间清空列表并下发 pending 空地图 ID', async () => { + const initialState = createSearchState() + const deferredKeyword = createDeferred() + guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState) + guideMocks.searchPoiKeyword.mockReturnValueOnce(deferredKeyword.promise) const wrapper = mount(PoiSearchPanel, { props: { variant: 'home', @@ -363,186 +431,85 @@ describe('POI 搜索面板实际渲染与状态闭环', () => { }) await flushMountedSearch() - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - await wrapper.setProps({ currentFloorId: 'floor-2', currentFloorLabel: '2F' }) - await flushMountedSearch() - - expect(wrapper.get('[data-testid="poi-home-category-results"]').text()).toContain('当前楼层 2F · 1 个点位') - expect(wrapper.find('[data-testid="poi-result-restroom-2"]').exists()).toBe(true) - expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({ - active: true, - floorId: 'floor-2', - visiblePoiIds: ['restroom-2'] - }) - - await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap') - await flushMountedSearch() await wrapper.get('.search-box').trigger('tap') + await wrapper.get('.search-input').trigger('confirm', { + detail: { value: '恐龙' } + }) await flushMountedSearch() - await wrapper.get('[data-testid="poi-category-nursing-room"]').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('[data-testid="poi-result-nursing-2"]').exists()).toBe(true) - await wrapper.setProps({ currentFloorId: 'floor-1', currentFloorLabel: '1F' }) - await flushMountedSearch() - expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('当前楼层暂无匹配点位') - expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({ + expect(renderedResultIds(wrapper)).toEqual([]) + expect(lastResultState(wrapper)).toMatchObject({ active: true, + pending: true, + visiblePoiIds: [] + }) + + deferredKeyword.resolve(createSearchState({ + mode: 'keyword', + keyword: '恐龙', + results: keywordFloor1 + })) + await flushMountedSearch() + + expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper)) + wrapper.unmount() + }) + + it('独立搜索页切换楼层调用 changePoiSearchFloor 并渲染新楼层当前模式结果', async () => { + const initialState = createSearchState() + guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState) + const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } }) + await flushMountedSearch() + + await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') + await flushMountedSearch() + const categoryState = guideMocks.selectPoiSearchCategory.mock.results[0]?.value + const floor2 = wrapper.findAll('.floor-tab').find((item) => item.text() === '2F') + expect(floor2).toBeDefined() + await floor2!.trigger('tap') + await flushMountedSearch() + + expect(guideMocks.changePoiSearchFloor).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'category', categoryId: 'restroom', floorId: 'floor-1' }), + 'floor-2' + ) + expect(renderedResultIds(wrapper)).toEqual(['restroom-2']) + expect(categoryState).toBeDefined() + wrapper.unmount() + }) + + it('清除关键词调用 clearPoiSearch 并恢复默认空间目的地列表', async () => { + const initialState = createSearchState() + guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState) + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + + await wrapper.get('.search-box').trigger('tap') + await wrapper.get('.search-input').trigger('confirm', { + detail: { value: '恐龙' } + }) + await flushMountedSearch() + const clearButton = wrapper.get('.clear-button') + await clearButton.trigger('tap') + await flushMountedSearch() + + expect(guideMocks.clearPoiSearch).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'keyword', keyword: '恐龙' }), + 'floor-1' + ) + expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1']) + expect(lastResultState(wrapper)).toMatchObject({ + active: false, + categoryId: '', + keyword: '', visiblePoiIds: [] }) wrapper.unmount() }) - - it('独立搜索页进入详情时传递关键词、分类、楼层、结果数和列表位置', async () => { - const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } }) - await flushMountedSearch() - - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - const floor2Tab = wrapper.findAll('.floor-tab').find((item) => item.text() === '2F') - expect(floor2Tab).toBeDefined() - await floor2Tab!.trigger('tap') - await flushMountedSearch() - wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 128 - await wrapper.get('[data-testid="poi-result-restroom-2"]').trigger('tap') - - expect(uni.navigateTo).toHaveBeenCalledTimes(1) - const url = vi.mocked(uni.navigateTo).mock.calls[0][0].url - const query = new URLSearchParams(url.split('?')[1]) - expect(query.get('source')).toBe('search') - expect(query.get('searchOrigin')).toBe('page') - expect(query.get('searchKeyword')).toBe('洗手间') - expect(query.get('searchCategoryId')).toBe('restroom') - expect(query.get('searchFloorId')).toBe('floor-2') - expect(query.get('resultCount')).toBe('3') - expect(query.get('floorResultCount')).toBe('1') - expect(query.get('visiblePoiIds')).toBe('restroom-2') - expect(query.get('listScrollTop')).toBe('128') - wrapper.unmount() - }) - - it('页面重新显示时恢复原结果列表位置', async () => { - const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } }) - await flushMountedSearch() - - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - const resultList = wrapper.get('[data-testid="poi-result-list"]') - resultList.element.scrollTop = 128 - await wrapper.get('[data-testid="poi-result-restroom-1"]').trigger('tap') - resultList.element.scrollTop = 0 - - await (wrapper.vm as unknown as { - restoreResultListScroll: () => Promise - }).restoreResultListScroll() - - expect(resultList.element.scrollTop).toBe(128) - wrapper.unmount() - }) - - it('部分数据不会固化,分类点击会重新读取并恢复结果', async () => { - guideMocks.getInitialSearchSpacePoints - .mockResolvedValueOnce([poiPool[0]]) - .mockResolvedValueOnce(poiPool) - - const wrapper = mount(PoiSearchPanel, { - props: { - variant: 'home', - currentFloorId: 'floor-1', - currentFloorLabel: '1F' - } - }) - await flushMountedSearch() - - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushMountedSearch() - - expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2) - expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true) - wrapper.unmount() - }) - - it('旧的部分结果晚到时不会覆盖最新完整缓存', async () => { - const oldRequest = createDeferred() - const latestRequest = createDeferred() - guideMocks.getInitialSearchSpacePoints - .mockImplementationOnce(() => oldRequest.promise) - .mockImplementationOnce(() => latestRequest.promise) - - const wrapper = mount(PoiSearchPanel, { - props: { - variant: 'home', - currentFloorId: 'floor-1', - currentFloorLabel: '1F' - } - }) - await flushMountedSearch() - expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(1) - - await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') - await flushPromises() - expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2) - - latestRequest.resolve(poiPool) - await flushMountedSearch() - expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true) - - oldRequest.resolve([poiPool[0]]) - await flushMountedSearch() - await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap') - await flushMountedSearch() - await wrapper.get('.search-box').trigger('tap') - await flushMountedSearch() - - expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true) - wrapper.unmount() - }) - - it('服务中心只展示真实服务台,不把普通服务空间误归类', async () => { - const serviceSpace = createPoi( - 'space-service-lounge', - '贵宾休息室', - 'floor-1', - '1F', - 'service_space', - [5, 0, 5] - ) - const serviceDesk = createPoi( - 'poi-service-desk', - '咨询服务台', - 'floor-1', - '1F', - 'service_desk', - [6, 0, 6] - ) - guideMocks.getInitialSearchSpacePoints.mockResolvedValue([serviceSpace, serviceDesk]) - - const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } }) - await flushMountedSearch() - await wrapper.get('[data-testid="poi-category-service-center"]').trigger('tap') - await flushMountedSearch() - - expect(wrapper.find('[data-testid="poi-result-poi-service-desk"]').exists()).toBe(true) - expect(wrapper.find('[data-testid="poi-result-space-service-lounge"]').exists()).toBe(false) - wrapper.unmount() - }) - - it('弱网失败显示可重试状态,恢复后重新渲染结果', async () => { - guideMocks.getInitialSearchSpacePoints - .mockRejectedValueOnce(new Error('network down')) - .mockResolvedValueOnce(poiPool) - - const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } }) - await flushMountedSearch() - - expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('点位加载失败') - expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(true) - - await wrapper.get('[data-testid="poi-retry"]').trigger('tap') - await flushMountedSearch() - expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="poi-result-hall-1"]').exists()).toBe(true) - wrapper.unmount() - }) }) diff --git a/tests/unit/navAssetsAdapter.spec.ts b/tests/unit/navAssetsAdapter.spec.ts new file mode 100644 index 0000000..f40237c --- /dev/null +++ b/tests/unit/navAssetsAdapter.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter' +import { resolvePoiCategory } from '@/domain/poiCategories' +import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider' + +const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({ + id: `poi-${name}`, + name, + floorId: 'L1', + primaryCategory: 'basic_service_facility', + primaryCategoryZh: '基础服务设施', + iconType, + categories: [{ + topCategory: 'basic_service_facility', + topCategoryZh: '基础服务设施', + subcategory: iconType, + iconType + }], + positionGltf: [1, 0, 1] +}) + +const createTouringPoi = (name: string, iconType = 'exhibition'): StaticNavPoiPayload => ({ + ...createStaticPoi(name, iconType), + primaryCategory: 'touring_poi', + primaryCategoryZh: '展厅与参观点', + categories: [{ + topCategory: 'touring_poi', + topCategoryZh: '展厅与参观点', + subcategory: iconType, + iconType + }] +}) + +describe('static nav POI adapter', () => { + it('repairs legacy static facility icons from visitor-facing names', () => { + const stairs = toMuseumPoi(createStaticPoi('西侧楼梯')) + const escalator = toMuseumPoi(createStaticPoi('中央扶梯')) + const nursing = toMuseumPoi(createStaticPoi('母婴室', 'toilet')) + const restroom = toMuseumPoi(createStaticPoi('无障碍洗手间', 'elevator')) + + expect(stairs.primaryCategory.iconType).toBe('stairs') + expect(resolvePoiCategory(stairs)).toBeNull() + expect(escalator.primaryCategory.iconType).toBe('escalator') + expect(resolvePoiCategory(escalator)?.id).toBe('escalator') + expect(nursing.primaryCategory.iconType).toBe('mother_baby_room') + expect(resolvePoiCategory(nursing)?.id).toBe('nursing-room') + expect(restroom.primaryCategory.iconType).toBe('accessible_toilet') + expect(resolvePoiCategory(restroom)?.id).toBe('restroom') + }) + + it('repairs static generic restroom icons for service facilities without misclassifying tea rooms', () => { + const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom')) + const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom')) + const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom')) + + expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center') + expect(resolvePoiCategory(locker)?.id).toBe('service-center') + expect(resolvePoiCategory(teaRoom)).toBeNull() + }) + + it('splits generic touring fallback records into their visitor-facing semantic kinds', () => { + const cinema = toMuseumPoi(createTouringPoi('巨幕影院')) + const ticketOffice = toMuseumPoi(createTouringPoi('展厅售票点')) + + expect(cinema).toMatchObject({ + kind: 'space', + primaryCategory: { id: 'space_theater', iconType: 'theater' } + }) + expect(resolvePoiCategory(cinema)?.id).toBe('cinema') + expect(ticketOffice).toMatchObject({ + kind: 'facility', + primaryCategory: { id: 'basic_service_facility', iconType: 'ticket_office' } + }) + expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office') + }) +})