mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #257 from fsolla/cursor/pool-i254-84456924-7f4c
B125 — Município mais próximo no topo das sugestões (busca geral + wizard)
This commit is contained in:
commit
cf64e7f77d
14 changed files with 333 additions and 119 deletions
65
docs/plans/municipio-geo-topo-sugestoes-busca-impl.md
Normal file
65
docs/plans/municipio-geo-topo-sugestoes-busca-impl.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Impl: B125 — Município mais próximo no topo das sugestões (busca geral + wizard)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-02
|
||||
Issue: #254
|
||||
Intenção: docs/plans/municipio-geo-topo-sugestoes-busca.md
|
||||
Appetite restante: ~1 dia — restauração B117 + alinhamento wizard
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** Na busca geral (Início + drawer) e no passo município do wizard, com query vazia, o município operacional mais próximo no escopo do ator aparece como **1ª linha normal** da lista — sem grupo geo, sem copy “Perto de você”, sem km.
|
||||
- **O que NÃO negociar:** prompt geo 1×/sessão de aba (`GEO_PROMPT_SESSION_KEY` B14); matching só no cliente; leader lockdown; Salvador multi-ZE omitir prefixo quando `zoneCity`; sem migration/Consent/POST lat/lng.
|
||||
- **O que reavaliar:** B117 (`fe2748c5`) é referência de restauração para home search, não contrato; wizard B94 deve **parar** de usar reason geo na UI; hooks finos compartilhados vs duplicar prompt.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
focus[Focus busca / idle wizard] --> hook[useNearestMunicipalitySlug]
|
||||
hook --> prompt[Prompt 1x sessão B14]
|
||||
prompt --> resolve[resolveNearbyMunicipality client]
|
||||
resolve --> merge[merge prefix dedup]
|
||||
merge --> row[HomeSearchHitRow normal]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) GeoProvider global; B) restaurar B117 + patch wizard separado; C) hook geo compartilhado + dois merges (home + wizard).
|
||||
**Recomendação:** C — um `useNearestMunicipalitySlug` com política B14/B117; `mergeHomeSearchNearestMunicipality` (restaurar) e `mergeWizardMunicipalitySuggestions` com `geoSlug` sem override de region.
|
||||
**Rejeitadas:** A (refactor grande); duplicar prompt em dois hooks (twin).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`useNearestMunicipalitySlug`** (`src/components/campaign/shared/`): resolve nearest slug; prompt `hasPromptedThisSession` / `markPromptedThisSession`; fail-soft.
|
||||
- **`useHomeSearchNearestMunicipality`**: re-export ou uso direto do shared hook em `HomeSearchMunicipalityGroup`.
|
||||
- **`homeSearchNearestMunicipalityMerge`**: restaurar prefix + dedup sem geo copy.
|
||||
- **`loadHomeSearchSuggestions`**: devolver `scopeMunicipalities` com `ibgeCode` para merge client.
|
||||
- **`wizardMunicipalitySuggestMerge`**: `geoSlug` em vez de objeto com distance; remover `formatWizardGeoSecondary` / `WIZARD_GEO_NEARBY_REASON`.
|
||||
- **`useWizardNearestMunicipality`**: substituir por shared hook ou delegar.
|
||||
- **Migration:** sem migration
|
||||
- **Access / Consent:** staff-only loaders existentes; geo client-only
|
||||
- **UI:** mesma `HomeSearchHitRow`; Impeccable B — empty/suggest only
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Proximidade só ordena; row usa `hit.region` normal — rejeitada copy geo na secondary (produto gate 2026-08-02).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Server + lib** — `scopeMunicipalities`, merges, shared hook, wizard merge sem copy
|
||||
2. **UI** — `HomeSearchMunicipalityGroup` wiring; wizard step usa slug hook
|
||||
3. **Gates** — unit merges + int suggest scope; `pnpm gate:fast`; `pnpm push`
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- GeoProvider global; CTA retry quando denied; alterar `NearestMunicipalityCard` (B14).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Prompt duplicado Quadro × busca:** mesma `GEO_PROMPT_SESSION_KEY` — já compartilhada.
|
||||
- **Salvador zoneCity:** `resolveNearbyMunicipality` retorna `zoneCity` → hook retorna null — aceite.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto
|
||||
- [x] Invariantes AGENTS/engineering-standards
|
||||
- [x] Testes unit merges + int scopeMunicipalities
|
||||
|
|
@ -107,7 +107,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/components/campaign/auth',
|
||||
`${CAMPAIGN_APP}/acoes`,
|
||||
'src/components/campaign/shared/WizardMunicipality',
|
||||
'src/components/campaign/shared/useWizardNearestMunicipality',
|
||||
'src/components/campaign/shared/useNearestMunicipalitySlug',
|
||||
'src/lib/wizardMunicipalitySuggestMerge',
|
||||
`${CAMPAIGN_APP}/apoiadores`,
|
||||
`${CAMPAIGN_APP}/assessores`,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,74 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
import { useHomeSearchNearestMunicipality } from '@/components/campaign/dashboard/useHomeSearchNearestMunicipality'
|
||||
import {
|
||||
homeSearchMunicipalityGroupHasHits,
|
||||
toHomeSearchMunicipalityHit,
|
||||
type HomeSearchScopeMunicipality,
|
||||
} from '@/lib/campaignHomeSearchHits'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { sliceHomeSearchMunicipalityGroup } from '@/lib/homeSearchHitBudget'
|
||||
import { mergeHomeSearchNearestMunicipality } from '@/lib/homeSearchNearestMunicipalityMerge'
|
||||
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
|
||||
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
|
||||
import { buildTerritoryPageHref } from '@/lib/territoryAnchor'
|
||||
|
||||
const scopeToAccessible = (
|
||||
scope: readonly HomeSearchScopeMunicipality[],
|
||||
): AccessibleMunicipality[] => scope.map(({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode }))
|
||||
|
||||
const buildHitBySlug = (scope: readonly HomeSearchScopeMunicipality[]) =>
|
||||
new Map(scope.map((doc) => [doc.slug, toHomeSearchMunicipalityHit(doc)]))
|
||||
|
||||
export const HomeSearchMunicipalityGroup = () => {
|
||||
const { query, uiFocused } = useHomeSearch()
|
||||
const { results, resultKind } = useHomeSearchResults()
|
||||
const hitLimit = useHomeSearchHitLimit('municipalities')
|
||||
|
||||
const suggestMode = uiFocused && !query.isActive
|
||||
|
||||
const scopeMunicipalities = useMemo(
|
||||
() => (results.status === 'success' ? (results.data.scopeMunicipalities ?? []) : []),
|
||||
[results],
|
||||
)
|
||||
|
||||
const accessible = useMemo(() => scopeToAccessible(scopeMunicipalities), [scopeMunicipalities])
|
||||
const hitBySlug = useMemo(() => buildHitBySlug(scopeMunicipalities), [scopeMunicipalities])
|
||||
|
||||
const nearestSlug = useHomeSearchNearestMunicipality(
|
||||
accessible,
|
||||
suggestMode && scopeMunicipalities.length > 0,
|
||||
)
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
|
||||
const { municipalities, territories } = results.data
|
||||
if (!homeSearchMunicipalityGroupHasHits(results.data)) return null
|
||||
|
||||
const mergedMunicipalities =
|
||||
resultKind === 'suggest'
|
||||
? mergeHomeSearchNearestMunicipality({
|
||||
nearestSlug,
|
||||
serverHits: municipalities,
|
||||
hitBySlug,
|
||||
})
|
||||
: municipalities
|
||||
|
||||
const includeTerritories = resultKind === 'search'
|
||||
const { municipalities: visibleMunicipalities, territories: visibleTerritories } =
|
||||
sliceHomeSearchMunicipalityGroup(municipalities, territories, hitLimit, includeTerritories)
|
||||
sliceHomeSearchMunicipalityGroup(
|
||||
mergedMunicipalities,
|
||||
territories,
|
||||
hitLimit,
|
||||
includeTerritories,
|
||||
)
|
||||
|
||||
if (visibleMunicipalities.length === 0 && visibleTerritories.length === 0) return null
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
|
||||
|
||||
import { useNearestMunicipalitySlug } from '@/components/campaign/shared/useNearestMunicipalitySlug'
|
||||
|
||||
/**
|
||||
* B117 / B125 — nearest in-scope município slug for global search suggest.
|
||||
* Reuses shared prompt + resolve hook (B14 session key).
|
||||
*/
|
||||
export const useHomeSearchNearestMunicipality = (
|
||||
accessible: readonly AccessibleMunicipality[],
|
||||
enabled: boolean,
|
||||
): string | null => useNearestMunicipalitySlug(accessible, enabled)
|
||||
|
|
@ -8,7 +8,7 @@ import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashbo
|
|||
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
|
||||
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { useWizardNearestMunicipality } from '@/components/campaign/shared/useWizardNearestMunicipality'
|
||||
import { useNearestMunicipalitySlug } from '@/components/campaign/shared/useNearestMunicipalitySlug'
|
||||
import { wizardActionHref } from '@/lib/campaignActionRoutes'
|
||||
import type {
|
||||
HomeSearchMunicipalityHit,
|
||||
|
|
@ -84,7 +84,7 @@ export const WizardMunicipalitySearchStep = ({
|
|||
const suggestMode = !query.isActive
|
||||
const searchMode = query.isActive
|
||||
const resultsBusy = isDebouncing || results.status === 'loading'
|
||||
const nearestMunicipality = useWizardNearestMunicipality(accessibleMunicipalities, suggestMode)
|
||||
const nearestSlug = useNearestMunicipalitySlug(accessibleMunicipalities, suggestMode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!suggestMode || results.status !== 'success' || results.mode !== 'suggest') {
|
||||
|
|
@ -161,11 +161,11 @@ export const WizardMunicipalitySearchStep = ({
|
|||
|
||||
return mergeWizardMunicipalitySuggestions({
|
||||
continuity: continuityReady ? continuitySlugs : [],
|
||||
geo: nearestMunicipality,
|
||||
geoSlug: nearestSlug,
|
||||
serverHits: results.suggest.municipalities,
|
||||
hitBySlug: results.suggest.hitBySlug,
|
||||
})
|
||||
}, [continuityReady, continuitySlugs, nearestMunicipality, results])
|
||||
}, [continuityReady, continuitySlugs, nearestSlug, results])
|
||||
|
||||
const showEmpty =
|
||||
query.isActive &&
|
||||
|
|
|
|||
|
|
@ -7,22 +7,23 @@ import {
|
|||
loadMunicipalityZoneGeometryModule,
|
||||
} from '@/lib/bahiaGeometries'
|
||||
import { resolveNearbyMunicipality, type AccessibleMunicipality } from '@/lib/municipalityProximity'
|
||||
import type { WizardGeoMunicipalitySuggestion } from '@/lib/wizardMunicipalitySuggestMerge'
|
||||
import {
|
||||
hasPromptedThisSession,
|
||||
markPromptedThisSession,
|
||||
readGeolocationPermissionState,
|
||||
requestCurrentPosition,
|
||||
} from '@/utilities/campaignGeolocation'
|
||||
|
||||
/**
|
||||
* B94 — resolves the nearest in-scope município for the wizard idle state.
|
||||
* Only runs when geolocation permission is already `granted` (prompt lives on the Quadro).
|
||||
* B125 — nearest in-scope município slug for suggest surfaces (home search + wizard).
|
||||
* Prompts once per tab session (B14 key) on first enable when permission is not denied.
|
||||
* Fail-soft: every other outcome returns `null` with no error UI.
|
||||
*/
|
||||
export const useWizardNearestMunicipality = (
|
||||
export const useNearestMunicipalitySlug = (
|
||||
accessible: readonly AccessibleMunicipality[],
|
||||
enabled: boolean,
|
||||
): WizardGeoMunicipalitySuggestion | null => {
|
||||
const [geo, setGeo] = useState<WizardGeoMunicipalitySuggestion | null>(null)
|
||||
): string | null => {
|
||||
const [nearestSlug, setNearestSlug] = useState<string | null>(null)
|
||||
const autoStartedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -31,10 +32,7 @@ export const useWizardNearestMunicipality = (
|
|||
|
||||
let cancelled = false
|
||||
|
||||
const resolve = async () => {
|
||||
const permission = await readGeolocationPermissionState()
|
||||
if (permission !== 'granted') return
|
||||
|
||||
const resolveNearest = async () => {
|
||||
const loaded = await Promise.all([
|
||||
requestCurrentPosition(),
|
||||
loadMunicipalityGeometryModule(),
|
||||
|
|
@ -56,19 +54,29 @@ export const useWizardNearestMunicipality = (
|
|||
|
||||
if (cancelled || resolution.kind !== 'inScope') return
|
||||
|
||||
setGeo({
|
||||
slug: resolution.municipality.slug,
|
||||
name: resolution.municipality.name,
|
||||
distanceKm: resolution.match === 'nearestZone' ? resolution.distanceKm : undefined,
|
||||
})
|
||||
setNearestSlug(resolution.municipality.slug)
|
||||
}
|
||||
|
||||
void resolve()
|
||||
const start = async () => {
|
||||
const permission = await readGeolocationPermissionState()
|
||||
|
||||
if (permission === 'granted') {
|
||||
void resolveNearest()
|
||||
return
|
||||
}
|
||||
|
||||
if (permission !== 'denied' && !hasPromptedThisSession()) {
|
||||
markPromptedThisSession()
|
||||
void resolveNearest()
|
||||
}
|
||||
}
|
||||
|
||||
void start()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accessible, enabled])
|
||||
|
||||
return geo
|
||||
return nearestSlug
|
||||
}
|
||||
|
|
@ -57,6 +57,15 @@ export type HomeSearchDemandHit = {
|
|||
|
||||
type HomeSearchResultKind = 'search' | 'suggest'
|
||||
|
||||
/** Scope rows for client geo merge (B117 / B125) — full portfolio, not only top-8 suggest. */
|
||||
export type HomeSearchScopeMunicipality = {
|
||||
slug: string
|
||||
name: string
|
||||
region: string
|
||||
priority: 'alta' | 'normal' | null
|
||||
ibgeCode: string
|
||||
}
|
||||
|
||||
export type HomeSearchSuccessResponse = {
|
||||
status: 'success'
|
||||
resultKind: HomeSearchResultKind
|
||||
|
|
@ -67,6 +76,8 @@ export type HomeSearchSuccessResponse = {
|
|||
stateDeputies: HomeSearchStateDeputyHit[]
|
||||
activities: HomeSearchActivityHit[]
|
||||
demands: HomeSearchDemandHit[]
|
||||
/** Present on `suggest` for client nearest-municipality prefix (B117 / B125). */
|
||||
scopeMunicipalities?: HomeSearchScopeMunicipality[]
|
||||
}
|
||||
|
||||
export const homeSearchMunicipalityGroupHasHits = (
|
||||
|
|
|
|||
20
src/lib/homeSearchNearestMunicipalityMerge.ts
Normal file
20
src/lib/homeSearchNearestMunicipalityMerge.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits'
|
||||
|
||||
/**
|
||||
* B117 / B125 — prefix the nearest in-scope município on suggest, deduping the server list.
|
||||
* The hit uses the normal suggest shape — no geo reason or distance metadata.
|
||||
*/
|
||||
export const mergeHomeSearchNearestMunicipality = (input: {
|
||||
nearestSlug: string | null | undefined
|
||||
serverHits: readonly HomeSearchMunicipalityHit[]
|
||||
hitBySlug: ReadonlyMap<string, HomeSearchMunicipalityHit>
|
||||
}): HomeSearchMunicipalityHit[] => {
|
||||
const slug = input.nearestSlug?.trim()
|
||||
if (!slug) return [...input.serverHits]
|
||||
|
||||
const nearestHit = input.hitBySlug.get(slug)
|
||||
if (!nearestHit) return [...input.serverHits]
|
||||
|
||||
const withoutDup = input.serverHits.filter((hit) => hit.slug !== slug)
|
||||
return [nearestHit, ...withoutDup]
|
||||
}
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits'
|
||||
import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest'
|
||||
import { formatDistanceKm } from '@/lib/municipalityProximity'
|
||||
|
||||
export const WIZARD_GEO_NEARBY_REASON = 'Perto de você' as const
|
||||
|
||||
export const WIZARD_CONTINUITY_LAST_ACTED_LABEL = 'Última ação' as const
|
||||
export const WIZARD_CONTINUITY_VISITED_LABEL = 'Visitado' as const
|
||||
|
|
@ -21,12 +18,6 @@ export type WizardContinuityVisitInput = {
|
|||
kind: 'municipality' | 'municipalityList'
|
||||
}
|
||||
|
||||
export type WizardGeoMunicipalitySuggestion = {
|
||||
slug: string
|
||||
name: string
|
||||
distanceKm?: number
|
||||
}
|
||||
|
||||
export type WizardMunicipalityMergedRow = {
|
||||
hit: HomeSearchMunicipalityHit
|
||||
continuityReason?: string
|
||||
|
|
@ -40,19 +31,6 @@ export const municipalitySlugFromRecentVisitHref = (href: string): string | null
|
|||
return slug ? slug : null
|
||||
}
|
||||
|
||||
/** Secondary line for a geo-resolved wizard hit — region first when known. */
|
||||
export const formatWizardGeoSecondary = (
|
||||
region: string | undefined,
|
||||
distanceKm?: number,
|
||||
): string => {
|
||||
const reason =
|
||||
distanceKm !== undefined
|
||||
? `${WIZARD_GEO_NEARBY_REASON} (~${formatDistanceKm(distanceKm)})`
|
||||
: WIZARD_GEO_NEARBY_REASON
|
||||
|
||||
return region ? `${region} · ${reason}` : reason
|
||||
}
|
||||
|
||||
const continuityReasonForSource = (source: WizardContinuitySource): string => {
|
||||
switch (source) {
|
||||
case 'last-acted':
|
||||
|
|
@ -103,12 +81,12 @@ export const listWizardContinuitySlugs = (input: {
|
|||
}
|
||||
|
||||
/**
|
||||
* B93 + B94 — merge geo (B94), continuity (B93) and server suggestions (B92).
|
||||
* Dedup order: geo → last-acted → visited → forgotten.
|
||||
* B93 + B125 — merge geo prefix, continuity (B93) and server suggestions (B92).
|
||||
* Dedup order: geo → last-acted → visited → forgotten. Geo row has no proximity copy.
|
||||
*/
|
||||
export const mergeWizardMunicipalitySuggestions = (input: {
|
||||
continuity: WizardContinuitySlug[]
|
||||
geo?: WizardGeoMunicipalitySuggestion | null
|
||||
geoSlug?: string | null
|
||||
serverHits: HomeSearchMunicipalityHit[]
|
||||
hitBySlug: ReadonlyMap<string, HomeSearchMunicipalityHit>
|
||||
limit?: number
|
||||
|
|
@ -117,23 +95,17 @@ export const mergeWizardMunicipalitySuggestions = (input: {
|
|||
const merged: WizardMunicipalityMergedRow[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushHit = (slug: string, continuityReason?: string, regionOverride?: string) => {
|
||||
const pushHit = (slug: string, continuityReason?: string) => {
|
||||
if (seen.has(slug)) return
|
||||
const hit = input.hitBySlug.get(slug)
|
||||
if (!hit) return
|
||||
seen.add(slug)
|
||||
merged.push({
|
||||
hit: regionOverride ? { ...hit, region: regionOverride } : hit,
|
||||
continuityReason,
|
||||
})
|
||||
merged.push({ hit, continuityReason })
|
||||
}
|
||||
|
||||
const geoSlug = input.geo?.slug.trim()
|
||||
const geoSlug = input.geoSlug?.trim()
|
||||
if (geoSlug) {
|
||||
const baseHit = input.hitBySlug.get(geoSlug)
|
||||
if (baseHit) {
|
||||
pushHit(geoSlug, undefined, formatWizardGeoSecondary(baseHit.region, input.geo?.distanceKm))
|
||||
}
|
||||
pushHit(geoSlug)
|
||||
}
|
||||
|
||||
for (const item of input.continuity) {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ export const loadHomeSearchSuggestions = async (
|
|||
status: 'success',
|
||||
resultKind: 'suggest',
|
||||
municipalities: municipalityHits,
|
||||
scopeMunicipalities: municipalities.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
name: doc.name,
|
||||
region: doc.region,
|
||||
priority: doc.priority ?? null,
|
||||
ibgeCode: doc.ibgeCode,
|
||||
})),
|
||||
territories: [],
|
||||
advisors: [],
|
||||
leaderships: [],
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ describe('loadHomeSearchSuggestions (B68)', () => {
|
|||
for (const hit of result.municipalities) {
|
||||
expect(hit.priority).toBe('alta')
|
||||
}
|
||||
expect(result.scopeMunicipalities?.length).toBeGreaterThan(0)
|
||||
expect(result.scopeMunicipalities?.every((row) => row.ibgeCode.length > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('scopes advisor suggestions to administered municipalities', async () => {
|
||||
|
|
|
|||
69
tests/unit/homeSearchNearestMunicipalityMerge.unit.spec.ts
Normal file
69
tests/unit/homeSearchNearestMunicipalityMerge.unit.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits'
|
||||
import { mergeHomeSearchNearestMunicipality } from '@/lib/homeSearchNearestMunicipalityMerge'
|
||||
|
||||
const hit = (slug: string, name: string): HomeSearchMunicipalityHit => ({
|
||||
kind: 'municipality',
|
||||
slug,
|
||||
name,
|
||||
region: 'TI',
|
||||
priority: 'normal',
|
||||
votePosition2022: null,
|
||||
})
|
||||
|
||||
describe('mergeHomeSearchNearestMunicipality (B117 / B125)', () => {
|
||||
const serverHits = [hit('b', 'Brumado'), hit('c', 'Cairu')]
|
||||
const hitBySlug = new Map([
|
||||
['a', hit('a', 'Abaíra')],
|
||||
['b', hit('b', 'Brumado')],
|
||||
['c', hit('c', 'Cairu')],
|
||||
])
|
||||
|
||||
it('prefixes nearest slug and dedupes from server list', () => {
|
||||
const merged = mergeHomeSearchNearestMunicipality({
|
||||
nearestSlug: 'a',
|
||||
serverHits,
|
||||
hitBySlug,
|
||||
})
|
||||
expect(merged.map((row) => row.slug)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('keeps server order when nearest is already first on server', () => {
|
||||
const merged = mergeHomeSearchNearestMunicipality({
|
||||
nearestSlug: 'b',
|
||||
serverHits,
|
||||
hitBySlug,
|
||||
})
|
||||
expect(merged.map((row) => row.slug)).toEqual(['b', 'c'])
|
||||
})
|
||||
|
||||
it('returns server list unchanged when nearest slug is missing', () => {
|
||||
const merged = mergeHomeSearchNearestMunicipality({
|
||||
nearestSlug: 'missing',
|
||||
serverHits,
|
||||
hitBySlug,
|
||||
})
|
||||
expect(merged).toEqual(serverHits)
|
||||
})
|
||||
|
||||
it('returns server list unchanged when nearest slug is null', () => {
|
||||
const merged = mergeHomeSearchNearestMunicipality({
|
||||
nearestSlug: null,
|
||||
serverHits,
|
||||
hitBySlug,
|
||||
})
|
||||
expect(merged).toEqual(serverHits)
|
||||
})
|
||||
|
||||
it('does not add geo reason or distance to the prefixed hit', () => {
|
||||
const merged = mergeHomeSearchNearestMunicipality({
|
||||
nearestSlug: 'a',
|
||||
serverHits,
|
||||
hitBySlug,
|
||||
})
|
||||
const nearest = merged[0]
|
||||
expect(nearest.region).toBe('TI')
|
||||
expect(JSON.stringify(nearest)).not.toMatch(/Perto de você|km/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
|
||||
import { HomeSearchResultsProvider } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
|
|
@ -10,6 +11,30 @@ import {
|
|||
HOME_SEARCH_GROUP_LIST_CLASS,
|
||||
HOME_SEARCH_HIT_ROW_WRAPPER_CLASS,
|
||||
} from '@/lib/homeSearchUi'
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const homeSearchController = (uiFocused = false) =>
|
||||
stub({
|
||||
query: { raw: '', debounced: '', isActive: false },
|
||||
setRaw: () => {},
|
||||
clear: () => {},
|
||||
isDebouncing: false,
|
||||
inputFocused: uiFocused,
|
||||
setInputFocused: () => {},
|
||||
uiFocused,
|
||||
})
|
||||
|
||||
const renderMunicipalityGroup = (
|
||||
resultsValue: ReturnType<typeof searchResultsValue>,
|
||||
uiFocused = false,
|
||||
) =>
|
||||
render(
|
||||
<HomeSearchProvider value={homeSearchController(uiFocused)}>
|
||||
<HomeSearchResultsProvider value={resultsValue}>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
</HomeSearchResultsProvider>
|
||||
</HomeSearchProvider>,
|
||||
)
|
||||
|
||||
const searchResultsValue = (
|
||||
data: Partial<HomeSearchSuccessResponse> = {},
|
||||
|
|
@ -50,42 +75,15 @@ describe('HomeSearchHitRow', () => {
|
|||
|
||||
const bleed = container.firstElementChild
|
||||
expect(bleed?.className).toBe(HOME_SEARCH_HIT_ROW_WRAPPER_CLASS)
|
||||
expect(screen.getByRole('link', { name: /Cairu/i }).className).not.toContain('rounded-md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HomeSearchMunicipalityGroup', () => {
|
||||
it('omits the visible heading in suggest mode while keeping an accessible section name', () => {
|
||||
render(
|
||||
<HomeSearchResultsProvider
|
||||
value={searchResultsValue(
|
||||
{
|
||||
municipalities: [
|
||||
{
|
||||
kind: 'municipality',
|
||||
slug: 'cairu',
|
||||
name: 'Cairu',
|
||||
region: 'Recôncavo',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
'suggest',
|
||||
)}
|
||||
>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
</HomeSearchResultsProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Sugestões' })).toBeNull()
|
||||
expect(screen.getByRole('region', { name: 'Sugestões' })).toBeTruthy()
|
||||
expect(screen.getByRole('link', { name: /Cairu/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a discreet sentence-case heading and list without bullets', () => {
|
||||
const { container } = render(
|
||||
<HomeSearchResultsProvider
|
||||
value={searchResultsValue({
|
||||
renderMunicipalityGroup(
|
||||
searchResultsValue(
|
||||
{
|
||||
municipalities: [
|
||||
{
|
||||
kind: 'municipality',
|
||||
|
|
@ -96,10 +94,31 @@ describe('HomeSearchMunicipalityGroup', () => {
|
|||
votePosition2022: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
</HomeSearchResultsProvider>,
|
||||
},
|
||||
'suggest',
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Sugestões' })).toBeNull()
|
||||
expect(screen.getByRole('region', { name: 'Sugestões' })).toBeTruthy()
|
||||
expect(screen.getByRole('link', { name: /Cairu/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a discreet sentence-case heading and list without bullets', () => {
|
||||
const { container } = renderMunicipalityGroup(
|
||||
searchResultsValue({
|
||||
municipalities: [
|
||||
{
|
||||
kind: 'municipality',
|
||||
slug: 'cairu',
|
||||
name: 'Cairu',
|
||||
region: 'Recôncavo',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const heading = screen.getByRole('heading', { name: 'Municípios' })
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@ import { describe, expect, it } from 'vitest'
|
|||
import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits'
|
||||
import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest'
|
||||
import {
|
||||
formatWizardGeoSecondary,
|
||||
listWizardContinuitySlugs,
|
||||
mergeWizardMunicipalitySuggestions,
|
||||
municipalitySlugFromRecentVisitHref,
|
||||
WIZARD_CONTINUITY_LAST_ACTED_LABEL,
|
||||
WIZARD_CONTINUITY_VISITED_LABEL,
|
||||
WIZARD_GEO_NEARBY_REASON,
|
||||
type WizardContinuityVisitInput,
|
||||
} from '@/lib/wizardMunicipalitySuggestMerge'
|
||||
|
||||
|
|
@ -31,24 +29,6 @@ const visit = (
|
|||
...overrides,
|
||||
})
|
||||
|
||||
describe('formatWizardGeoSecondary', () => {
|
||||
it('formats reason without distance', () => {
|
||||
expect(formatWizardGeoSecondary(undefined)).toBe(WIZARD_GEO_NEARBY_REASON)
|
||||
expect(formatWizardGeoSecondary('Costa do Dendê')).toBe(
|
||||
`Costa do Dendê · ${WIZARD_GEO_NEARBY_REASON}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('formats reason with distance', () => {
|
||||
expect(formatWizardGeoSecondary('Costa do Dendê', 1.2)).toBe(
|
||||
`Costa do Dendê · ${WIZARD_GEO_NEARBY_REASON} (~1,2 km)`,
|
||||
)
|
||||
expect(formatWizardGeoSecondary(undefined, 0.4)).toBe(
|
||||
`${WIZARD_GEO_NEARBY_REASON} (~menos de 1 km)`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('municipalitySlugFromRecentVisitHref', () => {
|
||||
it('extracts municipality slug from detail href', () => {
|
||||
expect(municipalitySlugFromRecentVisitHref('/campanha/municipios/itabuna')).toBe('itabuna')
|
||||
|
|
@ -117,9 +97,9 @@ describe('mergeWizardMunicipalitySuggestions', () => {
|
|||
].map((row) => [row.slug, row]),
|
||||
)
|
||||
|
||||
it('dedupes in geo > continuity > server order with reason labels', () => {
|
||||
it('dedupes in geo > continuity > server order without geo copy on the row', () => {
|
||||
const merged = mergeWizardMunicipalitySuggestions({
|
||||
geo: { slug: 'forgot-a', name: 'Forgot A', distanceKm: 2.5 },
|
||||
geoSlug: 'forgot-a',
|
||||
continuity: [
|
||||
{ source: 'last-acted', slug: 'acted' },
|
||||
{ source: 'visited', slug: 'visited' },
|
||||
|
|
@ -133,10 +113,10 @@ describe('mergeWizardMunicipalitySuggestions', () => {
|
|||
})
|
||||
|
||||
expect(merged.map((row) => row.hit.slug)).toEqual(['forgot-a', 'acted', 'visited', 'forgot-b'])
|
||||
expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toContain(
|
||||
WIZARD_GEO_NEARBY_REASON,
|
||||
expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toBe('Costa do Dendê')
|
||||
expect(JSON.stringify(merged.find((row) => row.hit.slug === 'forgot-a')?.hit)).not.toMatch(
|
||||
/Perto de você|km/i,
|
||||
)
|
||||
expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toContain('~2,5 km')
|
||||
expect(merged.find((row) => row.hit.slug === 'acted')?.continuityReason).toBe(
|
||||
WIZARD_CONTINUITY_LAST_ACTED_LABEL,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue