diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index c77ac9ef..ccc30def 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -85,6 +85,7 @@ alwaysApply: false - Em 2026-07-17, `Contact.city` passou a opcional porque Núcleos podem representar um Território de Identidade sem município/localidade; os fluxos públicos continuam exigindo cidade em seus próprios schemas. O cadastro de liderança mascara o celular no cliente, normaliza 11 dígitos no servidor e preserva valores/erros após falhas. - Em 2026-07-17, decisão de produto: `Município` e `Bairro` do Núcleo passam a aceitar múltiplas entradas (arrays planos `cities`/`neighborhoods`), e `region` vira `regions` derivado no servidor a partir dos municípios, permitindo cruzar Territórios de Identidade. Bairros exigem município único. Modelo A (arrays planos) escolhido em vez de array estruturado por município. Plano: [`docs/plans/territorio-multi-municipio-bairro.md`](../../../docs/plans/territorio-multi-municipio-bairro.md); item do roadmap: A1. - Em 2026-07-18, decisão de produto (revisão A2): no formulário de território, sugestões cruzadas **opt-in** via chips `{rótulo} +` (município/TI → união de ZEs faltantes; TI → municípios irmãos; ZE → municípios oficiais), em vez do auto-preenchimento forçado + Zonas TSE read-only da versão 2026-07-17 do plano. TI a partir do município continua derivado (A1). Cadastro estático município↔zona (`bahiaTseZones`) permanece. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md); item do roadmap: A2. +- Em 2026-07-18, **A2 entregue:** `src/lib/bahiaTseZones.ts` (TSE 2024 `detalhe_votacao_munzona` BA, fixture `bahia-tse-zones.official.json`), `territorySuggestions.ts`, `TerritorySuggestionChips` + `NucleusTerritoryAndZonesFields` no formulário de criar/editar núcleo; `TseZoneInput` controlado; aviso âmbar opcional para ZE fora do cadastro da geografia; sem migration. - Em 2026-07-18, **A1 entregue:** `regions`/`cities`/`neighborhoods` como `text` + `hasMany` (tabela `electoral_nucleus_texts`); migration `20260718_190559_territorio_multi_municipio_bairro` com backfill; filtros da lista permanecem single-select na URL e consultam com `{ equals }` (associação ao array — não o operador substring `contains`); UI de `/campanha` com chips multi + bairros só com 1 município; dashboard e criação de liderança atualizados (`Contact.city` só quando `cities.length === 1`). - O DTO paginado de atualizações contém somente filtro/paginação/feed; a visão geral chama um loader dedicado que busca `limit: 3` e apenas os autores necessários à prévia. - A lista paginada de lideranças seleciona e serializa somente os campos da linha; avaliação interna e dados completos de contato são carregados apenas para a liderança selecionada. O fechamento usa `onCloseAutoFocus` no Sheet e `finalFocus` no Drawer, focaliza a linha de origem (ou o título do Núcleo) antes de navegar para a URL canônica, sem RAF/timeout. diff --git a/docs/plans/zonas-por-municipio.md b/docs/plans/zonas-por-municipio.md index bbf90d9e..db6b0ce8 100644 --- a/docs/plans/zonas-por-municipio.md +++ b/docs/plans/zonas-por-municipio.md @@ -1,10 +1,13 @@ # Zonas TSE por município + sugestões cruzadas de território -Status: rascunho +Status: entregue Atualizado em: 2026-07-18 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A → A2) Responsável: — +Revisões: +- 2026-07-18 (entrega): cadastro estático `bahiaTseZones` a partir do TSE 2024 `detalhe_votacao_munzona` BA (SHA-256 no cabeçalho); motor `territorySuggestions` + chips opt-in no coordenador `NucleusTerritoryAndZonesFields`; sem migration. Corrigido drift factual: a validação server-side existente chama-se `validateTerritoryAndZones` (não `validateGeographyAndZones`). Reuso de `canonicalizeMunicipalityName` / aliases de A3 na geração do dataset. + ## Premissa atualizada (A1 entregue) A1 ([territorio-multi-municipio-bairro.md](territorio-multi-municipio-bairro.md)) renomeou o território do núcleo para arrays `regions` / `cities` / `neighborhoods` (`text` + `hasMany`). Este plano **nasce contra `cities[]`**: sugestões e validações leem o conjunto de municípios/territórios selecionados. Bairros continuam a exigir exatamente um município (regra A1). @@ -58,7 +61,7 @@ Exemplo canônico (TI oficial `Vale do Jiquiriçá`): - **Sugestão opt-in, nunca auto-gravação forçada de ZEs/municípios irmãos.** Adicionar Itiruçu **não** preenche `tseZones` sozinho; só aparece o chip até o usuário clicar (ou digitar). Municípios irmãos e municípios-por-ZE idem. (Decisão de produto 2026-07-18; revisa o auto-preenchimento forçado de 2026-07-17.) - **TI a partir do município continua automático** via A1 (`territoriesForCities` / rederivação no servidor). Isso **não** é chip de sugestão — é derivação. Remover municípios restaura edição manual de `regions`. -- **Mapeamento como dado estático versionado.** `src/lib/bahiaTseZones.ts` a partir do “Eleitorado por município e zona” (UF BA), cabeçalho com URL, versão/data e SHA-256 — estilo `bahiaTerritories.ts`. Fixture independente nunca lido a partir do módulo TypeScript. +- **Mapeamento como dado estático versionado.** `src/lib/bahiaTseZones.ts` a partir do TSE 2024 `detalhe_votacao_munzona` (UF BA — cobertura eleição-validada dos 417 municípios; preferido aos resultados 2022 por risco de remanejamento de zonas), cabeçalho com URL, data de extração e SHA-256 — estilo `bahiaTerritories.ts`. Nomes TSE reconciliados via `canonicalizeMunicipalityName`. Fixture independente nunca lido a partir do módulo TypeScript. - **Sem migration.** Campo `tseZones` já é `array<{ zoneNumber, label }>`. - **Estado compartilhado.** Hoje `NucleusTerritoryFields` e `TseZoneInput` são siblings com estado independente em `NucleusForm`. Subir estado de `regions` / `cities` / `neighborhoods` / `tseZones` para um coordenador cliente (ex. `NucleusTerritoryAndZonesFields`) que emite hidden inputs e calcula sugestões. - **União, não replace.** Clique em 「Itiruçu +」 = `tseZones ∪ tseZonesForCity('Itiruçu')`. Clique em 「Vale do Jiquiriçá +」 = união de todas as ZEs de `citiesForTerritory('Vale do Jiquiriçá')`. Clique em 「Maracás +」 = adiciona o município (respeitando `MAX_NUCLEUS_CITIES`). @@ -139,7 +142,7 @@ Componentes: - `src/components/campaign/NucleusForm.tsx`, `NucleusTerritoryFields.tsx`, `TseZoneInput.tsx` — formulário a coordenar - `src/lib/bahiaTerritories.ts`, `tests/fixtures/bahia-identity-territories.official.json`, `tests/int/bahiaTerritories.int.spec.ts` — padrão a espelhar - `src/collections/ElectoralNucleus.ts` — `validateNucleusTerritoryAndZones` -- `src/lib/schemas/nucleus.ts` — `validateGeographyAndZones` +- `src/lib/schemas/nucleus.ts` — `validateTerritoryAndZones` - `src/utilities/tseZone.ts` / `nucleusUi.ts` — parse de ZEs - `docs/design-refs/latest/Formulario-Territorio.{png,html}` — referência visual (banner + “sugerida”) - Portal de Dados Abertos do TSE — “Eleitorado por município e zona” (UF BA): https://dadosabertos.tse.jus.br/ diff --git a/docs/roadmap.md b/docs/roadmap.md index a9397d1d..1ec950dd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 revisado: sugestões cruzadas opt-in no formulário de território, em vez de auto-preenchimento forçado) +Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue: cadastro município↔ZE + chips opt-in no formulário de território) Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos. @@ -56,6 +56,10 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de - **C1 Compartilhar página** — diálogo no detalhe do núcleo com destinatários WhatsApp (coordenação geral, coordenadores, lideranças engajadas) + copiar link; envia só o link, não concede acesso. [Plano](plans/compartilhar-pagina.md). - **D1 PWA `/campanha`** — manifest + service worker escopados em `/campanha`, shell offline, toast de instalação (Android/iOS) e limpeza de cache no logout; fundação do push (D2). [Plano](plans/pwa-campanha.md). +### Ciclo 2+ — A2 entregue em 2026-07-18 + +- **A2 Zonas TSE + sugestões cruzadas** — cadastro estático `bahiaTseZones` (TSE 2024 `detalhe_votacao_munzona` BA, 417 municípios), motor puro `territorySuggestions`, coordenador `NucleusTerritoryAndZonesFields` com chips `{rótulo} +` opt-in (município/TI → ZEs; irmãos do TI e cidades da ZE → Municípios). Sem migration, sem igualdade forçada no save. [Plano](plans/zonas-por-municipio.md). + ### Referências de design (UX Pilot, 2026-07-18) Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/design-refs/latest/`](design-refs/latest/) (pares `.png` + `.html` com o mesmo nome). **A UX/estrutura é a referência a seguir; a paleta não é** — os arquivos usam a paleta antiga (vermelho escuro `#8E0E23`, navy `#1B2B4B`, dourado `#C8874B`); toda implementação usa os tokens claros do tema `data-theme='campaign'` (`src/app/(frontend)/styles.css`: fundo branco, primário `#C51414`, superfícies neutras). Cada plano detalha o uso na sua seção "Referência visual (UX Pilot)". @@ -89,7 +93,7 @@ flowchart TD subgraph TrilhaA["Trilha A — dados eleitorais e território"] A1["A1 Território multi-município/bairro
(migration) ✓"] - A2["A2 Zonas TSE + sugestões cruzadas
(chips opt-in território↔ZE)"] + A2["A2 Zonas TSE + sugestões cruzadas
(chips opt-in território↔ZE) ✓"] A3["A3 Baseline TSE 2022 — Fase 1
(import, collections) ✓"] A4["A4 Baseline no produto + Gap vs 2022
(detalhe, overview)"] A5["A5 Insights: conversão, classificação,
alavancagem, mobilização, competitiva"] @@ -138,7 +142,7 @@ flowchart TD JUR -.chave de push.-> D2 ``` -Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A2 (nasce contra `cities[]`; UX revisada 2026-07-18 para chips `{rótulo} +` opt-in), A4 (A3 + B1 prontos), C3/C4 (A1 pronto) e D2 push (D1 pronto; falta a chave jurídica). +Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto) e D2 push (D1 pronto; falta a chave jurídica). ### Sequência de execução por janela do calendário @@ -148,7 +152,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (enge | ----- | ---------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------- | | 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo | | 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 | -| 3 | A2 Zonas TSE + sugestões cruzadas (chips opt-in território↔ZE) | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1 ✓, C1 ✓, A3 ✓ | +| 3 | A2 Zonas TSE + sugestões cruzadas (chips opt-in território↔ZE) **(entregue 2026-07-18)** | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1 ✓, C1 ✓, A3 ✓ | | 4 | B1 Overview da lista de núcleos **(entregue 2026-07-18)** | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 | | 5 | C1 Compartilhar página **(entregue 2026-07-18)** | [detalhes](plans/compartilhar-pagina.md) | — | tudo | | 6 | A3 Baseline TSE 2022 — Fase 1 (import) **(entregue 2026-07-18)** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo | diff --git a/src/components/campaign/NucleusForm.tsx b/src/components/campaign/NucleusForm.tsx index 998d54a0..2455d8a2 100644 --- a/src/components/campaign/NucleusForm.tsx +++ b/src/components/campaign/NucleusForm.tsx @@ -3,8 +3,7 @@ import { useActionState } from 'react' import Link from 'next/link' -import { NucleusTerritoryFields } from '@/components/campaign/NucleusTerritoryFields' -import { TseZoneInput } from '@/components/campaign/TseZoneInput' +import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -169,7 +168,7 @@ export const NucleusFormFields = ({ - +
Localidade @@ -206,10 +205,6 @@ export const NucleusFormFields = ({ {errorFor('territoryNotes')} ) : null} - - Zonas TSE - - diff --git a/src/components/campaign/NucleusTerritoryFields.tsx b/src/components/campaign/NucleusTerritoryAndZonesFields.tsx similarity index 76% rename from src/components/campaign/NucleusTerritoryFields.tsx rename to src/components/campaign/NucleusTerritoryAndZonesFields.tsx index 8926807b..ca1295bd 100644 --- a/src/components/campaign/NucleusTerritoryFields.tsx +++ b/src/components/campaign/NucleusTerritoryAndZonesFields.tsx @@ -4,36 +4,30 @@ import { useMemo, useState } from 'react' import { XIcon } from 'lucide-react' import { StrictCombobox } from '@/components/campaign/StrictCombobox' +import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips' +import { TseZoneInput } from '@/components/campaign/TseZoneInput' import { Badge } from '@/components/ui/Badge' import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field' import { InputGroup, InputGroupInput } from '@/components/ui/input-group' import { territoriesForCities } from '@/lib/bahiaTerritories' import { + dedupeTrimmedStrings, MAX_NUCLEUS_CITIES, MAX_NUCLEUS_NEIGHBORHOODS, MAX_NUCLEUS_REGIONS, } from '@/lib/schemas/nucleus' +import { buildTerritorySuggestions } from '@/lib/territorySuggestions' import { - municipalityComboboxOptions, + allMunicipalityComboboxOptions, territoryComboboxOptions, } from '@/utilities/territoryComboboxOptions' +import { sortedUniqueZoneNumbers } from '@/utilities/tseZone' -type TerritoryValues = { +type TerritoryAndZonesValues = { regions?: string[] | null cities?: string[] | null neighborhoods?: string[] | null -} - -const uniquePreserveOrder = (values: string[]): string[] => { - const seen = new Set() - const result: string[] = [] - for (const value of values) { - const trimmed = value.trim() - if (!trimmed || seen.has(trimmed)) continue - seen.add(trimmed) - result.push(trimmed) - } - return result + tseZones?: number[] | null } const ChipList = ({ @@ -57,18 +51,19 @@ const ChipList = ({
) -export const NucleusTerritoryFields = ({ +export const NucleusTerritoryAndZonesFields = ({ values, fieldErrors = {}, }: { - values?: TerritoryValues + values?: TerritoryAndZonesValues fieldErrors?: Record }) => { - const [regions, setRegions] = useState(() => uniquePreserveOrder(values?.regions ?? [])) - const [cities, setCities] = useState(() => uniquePreserveOrder(values?.cities ?? [])) + const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? [])) + const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? [])) const [neighborhoods, setNeighborhoods] = useState(() => - uniquePreserveOrder(values?.neighborhoods ?? []), + dedupeTrimmedStrings(values?.neighborhoods ?? []), ) + const [tseZones, setTseZones] = useState(() => sortedUniqueZoneNumbers(values?.tseZones ?? [])) const [cityDraft, setCityDraft] = useState('') const [regionDraft, setRegionDraft] = useState('') const [neighborhoodDraft, setNeighborhoodDraft] = useState('') @@ -82,16 +77,28 @@ export const NucleusTerritoryFields = ({ const regionsAreDerived = cities.length > 0 const neighborhoodsEnabled = cities.length === 1 + const { zoneSuggestions, citySuggestions, outsideZones } = useMemo( + () => + buildTerritorySuggestions({ + cities, + regions: displayRegions, + tseZones, + }), + [cities, displayRegions, tseZones], + ) + const visibleRegionError = regionError ?? errorFor('regions') const visibleCityError = cityError ?? errorFor('cities') const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods') - const availableCityOptions = municipalityComboboxOptions().filter( - (option) => !cities.includes(option.value), - ) - const availableRegionOptions = territoryComboboxOptions.filter( - (option) => !regions.includes(option.value), - ) + const availableCityOptions = useMemo(() => { + const selected = new Set(cities) + return allMunicipalityComboboxOptions.filter((option) => !selected.has(option.value)) + }, [cities]) + const availableRegionOptions = useMemo(() => { + const selected = new Set(regions) + return territoryComboboxOptions.filter((option) => !selected.has(option.value)) + }, [regions]) const addCity = (nextCity: string) => { if (!nextCity) return @@ -101,7 +108,7 @@ export const NucleusTerritoryFields = ({ return } setCities((current) => { - const next = uniquePreserveOrder([...current, nextCity]) + const next = dedupeTrimmedStrings([...current, nextCity]) if (next.length !== 1) setNeighborhoods([]) return next }) @@ -126,7 +133,7 @@ export const NucleusTerritoryFields = ({ setRegionDraft('') return } - setRegions((current) => uniquePreserveOrder([...current, nextRegion])) + setRegions((current) => dedupeTrimmedStrings([...current, nextRegion])) setRegionDraft('') setRegionError(undefined) } @@ -152,7 +159,7 @@ export const NucleusTerritoryFields = ({ setNeighborhoodDraft('') return } - setNeighborhoods((current) => uniquePreserveOrder([...current, trimmed])) + setNeighborhoods((current) => dedupeTrimmedStrings([...current, trimmed])) setNeighborhoodDraft('') setNeighborhoodError(undefined) } @@ -214,6 +221,11 @@ export const NucleusTerritoryFields = ({ Municípios + addCity(suggestion.city)} + /> {cities.length ? ( {visibleNeighborhoodError} ) : null} + + + Zonas TSE + {zoneSuggestions.length > 0 ? ( + + Zonas sugeridas pelo cadastro oficial do TSE — confira antes de salvar. + + ) : null} + + setTseZones((current) => sortedUniqueZoneNumbers([...current, ...suggestion.zonesToAdd])) + } + /> + + {outsideZones.length > 0 ? ( + + {outsideZones.length === 1 + ? `Zona ${outsideZones[0]} não aparece no cadastro dos municípios/TIs selecionados.` + : `Zonas ${outsideZones.join(', ')} não aparecem no cadastro dos municípios/TIs selecionados.`} + + ) : null} + ) } diff --git a/src/components/campaign/TerritorySuggestionChips.tsx b/src/components/campaign/TerritorySuggestionChips.tsx new file mode 100644 index 00000000..efe22f55 --- /dev/null +++ b/src/components/campaign/TerritorySuggestionChips.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useState } from 'react' + +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import type { CitySuggestion, ZoneSuggestion } from '@/lib/territorySuggestions' + +const MAX_VISIBLE_SUGGESTION_CHIPS = 8 + +type TerritorySuggestionChipsProps = + | { + kind: 'zone' + suggestions: ZoneSuggestion[] + onAccept: (suggestion: ZoneSuggestion) => void + } + | { + kind: 'city' + suggestions: CitySuggestion[] + onAccept: (suggestion: CitySuggestion) => void + } + +type ChipView = { + key: string + label: string + ariaLabel: string + variant: 'tse' | 'outline' + onAccept: () => void +} + +const toChipViews = (props: TerritorySuggestionChipsProps): ChipView[] => { + if (props.kind === 'zone') { + return props.suggestions.map((suggestion) => ({ + key: `zone-${suggestion.kind}-${suggestion.label}`, + label: `${suggestion.label} +`, + ariaLabel: `Adicionar zonas TSE de ${suggestion.label}`, + variant: 'tse', + onAccept: () => props.onAccept(suggestion), + })) + } + return props.suggestions.map((suggestion) => ({ + key: `city-${suggestion.kind}-${suggestion.city}`, + label: `${suggestion.city} +`, + ariaLabel: `Adicionar município ${suggestion.city}`, + variant: 'outline', + onAccept: () => props.onAccept(suggestion), + })) +} + +export const TerritorySuggestionChips = (props: TerritorySuggestionChipsProps) => { + const [expanded, setExpanded] = useState(false) + + if (props.suggestions.length === 0) return null + + const chips = toChipViews(props) + const visible = expanded ? chips : chips.slice(0, MAX_VISIBLE_SUGGESTION_CHIPS) + const hiddenCount = chips.length - visible.length + + return ( +
+ {visible.map((chip) => ( + + + + ))} + {hiddenCount > 0 ? ( + + ) : null} +
+ ) +} diff --git a/src/components/campaign/TseZoneInput.tsx b/src/components/campaign/TseZoneInput.tsx index f39ab889..efd5fd3e 100644 --- a/src/components/campaign/TseZoneInput.tsx +++ b/src/components/campaign/TseZoneInput.tsx @@ -6,31 +6,37 @@ import { XIcon } from 'lucide-react' import { Badge } from '@/components/ui/Badge' import { FieldDescription, FieldError } from '@/components/ui/field' import { InputGroup, InputGroupInput } from '@/components/ui/input-group' -import { parseTseZoneNumbers, TseZoneParseError } from '@/utilities/tseZone' - -const sortedUnique = (values: number[]): number[] => - [...new Set(values)].sort((left, right) => left - right) +import { + parseTseZoneNumbers, + sortedUniqueZoneNumbers, + TseZoneParseError, +} from '@/utilities/tseZone' export const TseZoneInput = ({ - defaultValues = [], + value, + onChange, error, }: { - defaultValues?: number[] + value: number[] + onChange: (zones: number[]) => void error?: string }) => { - const [zones, setZones] = useState(() => sortedUnique(defaultValues)) const [draft, setDraft] = useState('') const [localError, setLocalError] = useState() const visibleError = localError ?? error - const commit = (value: string) => { - if (!value.trim()) { + const setZones = (updater: number[] | ((current: number[]) => number[])) => { + onChange(sortedUniqueZoneNumbers(typeof updater === 'function' ? updater(value) : updater)) + } + + const commit = (raw: string) => { + if (!raw.trim()) { setDraft('') return } try { - const parsed = parseTseZoneNumbers(value) - setZones((current) => sortedUnique([...current, ...parsed])) + const parsed = parseTseZoneNumbers(raw) + setZones((current) => [...current, ...parsed]) setDraft('') setLocalError(undefined) } catch (parseError) { @@ -42,9 +48,9 @@ export const TseZoneInput = ({ return ( <> - + - {zones.map((zoneNumber) => ( + {value.map((zoneNumber) => ( 0) { + } else if (event.key === 'Backspace' && !draft && value.length > 0) { setZones((current) => current.slice(0, -1)) } }} diff --git a/src/lib/bahiaTseZones.ts b/src/lib/bahiaTseZones.ts new file mode 100644 index 00000000..407c2895 --- /dev/null +++ b/src/lib/bahiaTseZones.ts @@ -0,0 +1,484 @@ +/** + * Official Bahia municipality ↔ TSE electoral zone mapping. + * + * Source (TSE open data — Results 2024, detalhe da apuração por município e zona, UF BA): + * https://dadosabertos.tse.jus.br/dataset/resultados-2024 + * https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2024.zip + * Entry: detalhe_votacao_munzona_2024_BA.csv + * Download SHA-256: 24e75fcf98a57b2362c33d46eb96b99a4b7070966cfc7f6398e12240e3f679f0 + * + * Chosen over 2022 result files because TSE rezoning can change municipality↔zone + * assignments between cycles. 2024 municipal elections are the most recent + * election-validated BA coverage of all 417 municipalities (extracted 2026-07-18). + * + * Municipality spellings follow the canonical application names in bahiaTerritories. + * TSE variants (Camacan, Dias d'Ávila, Muquém de São Francisco, Santa Teresinha) + * were reconciled via canonicalizeMunicipalityName. Coverage is independently + * checked by the official test evidence fixture. + */ + +import { + bahiaIdentityTerritories, + bahiaMunicipalities, + citiesForTerritory, + isBahiaMunicipality, + type BahiaIdentityTerritory, +} from '@/lib/bahiaTerritories' + +export const bahiaMunicipalityTseZones: Readonly> = { + 'Abaíra': [105], + 'Abaré': [158], + 'Acajutiba': [21], + 'Adustina': [52], + 'Água Fria': [74], + 'Aiquara': [147], + 'Alagoinhas': [163], + 'Alcobaça': [112], + 'Almadina': [135], + 'Amargosa': [36], + 'Amélia Rodrigues': [192], + 'América Dourada': [199], + 'Anagé': [161], + 'Andaraí': [119], + 'Andorinha': [45], + 'Angical': [126], + 'Anguera': [157], + 'Antas': [82], + 'Antônio Cardoso': [143], + 'Antônio Gonçalves': [53], + 'Aporá': [44], + 'Apuarema': [23], + 'Araças': [200], + 'Aracatu': [90], + 'Araci': [123], + 'Aramari': [163], + 'Arataca': [166], + 'Aratuípe': [30], + 'Aurelino Leal': [73], + 'Baianópolis': [126], + 'Baixa Grande': [86], + 'Banzaê': [110], + 'Barra': [77], + 'Barra da Estiva': [169], + 'Barra do Choça': [139], + 'Barra do Mendes': [176], + 'Barra do Rocha': [134], + 'Barreiras': [70, 75], + 'Barro Alto': [174], + 'Barro Preto': [136], + 'Barrocas': [150], + 'Belmonte': [34], + 'Belo Campo': [177], + 'Biritinga': [150], + 'Boa Nova': [59], + 'Boa Vista do Tupim': [42], + 'Bom Jesus da Lapa': [71], + 'Bom Jesus da Serra': [59], + 'Boninal': [105], + 'Bonito': [69], + 'Boquira': [65], + 'Botuporã': [168], + 'Brejões': [36], + 'Brejolândia': [190], + 'Brotas de Macaúbas': [94], + 'Brumado': [90], + 'Buerarema': [166], + 'Buritirama': [77], + 'Caatiba': [201], + 'Cabaceiras do Paraguaçu': [131], + 'Cachoeira': [118], + 'Caculé': [93], + 'Caém': [167], + 'Caetanos': [58], + 'Caetité': [63], + 'Cafarnaum': [55], + 'Cairu': [31], + 'Caldeirão Grande': [115], + 'Camacan': [133], + 'Camaçari': [170, 171], + 'Camamu': [78], + 'Campo Alegre de Lourdes': [67], + 'Campo Formoso': [53], + 'Canápolis': [99], + 'Canarana': [174], + 'Canavieiras': [116], + 'Candeal': [114], + 'Candeias': [127], + 'Candiba': [64], + 'Cândido Sales': [40], + 'Cansanção': [50], + 'Canudos': [83], + 'Capela do Alto Alegre': [196], + 'Capim Grosso': [191], + 'Caraíbas': [161], + 'Caravelas': [112], + 'Cardeal da Silva': [144], + 'Carinhanha': [125], + 'Casa Nova': [66], + 'Castro Alves': [43], + 'Catolândia': [100], + 'Catu': [129], + 'Caturama': [111], + 'Central': [159], + 'Chorrochó': [158], + 'Cícero Dantas': [82], + 'Cipó': [79], + 'Coaraci': [135], + 'Cocos': [61], + 'Conceição da Feira': [108], + 'Conceição do Almeida': [184], + 'Conceição do Coité': [132], + 'Conceição do Jacuípe': [192], + 'Conde': [21], + 'Condeúba': [60], + 'Contendas do Sincorá': [58], + 'Coração de Maria': [130], + 'Cordeiros': [60], + 'Coribe': [61], + 'Coronel João Sá': [52], + 'Correntina': [124], + 'Cotegipe': [98], + 'Cravolândia': [38], + 'Crisópolis': [81], + 'Cristópolis': [126], + 'Cruz das Almas': [142], + 'Curaçá': [85], + 'Dário Meira': [147], + "Dias d'Ávila": [186], + 'Dom Basílio': [101], + 'Dom Macedo Costa': [202], + 'Elísio Medrado': [107], + 'Encruzilhada': [152], + 'Entre Rios': [144], + 'Érico Cardoso': [111], + 'Esplanada': [21], + 'Euclides da Cunha': [102], + 'Eunápolis': [188, 203], + 'Fátima': [82], + 'Feira da Mata': [125], + 'Feira de Santana': [154, 155, 156, 157], + 'Filadélfia': [149], + 'Firmino Alves': [137], + 'Floresta Azul': [29], + 'Formosa do Rio Preto': [187], + 'Gandu': [151], + 'Gavião': [191], + 'Gentio do Ouro': [68], + 'Glória': [84], + 'Gongogi': [73], + 'Governador Mangabeira': [131], + 'Guajeru': [93], + 'Guanambi': [64], + 'Guaratinga': [189], + 'Heliópolis': [110], + 'Iaçu': [193], + 'Ibiassucê': [93], + 'Ibicaraí': [29], + 'Ibicoara': [169], + 'Ibicuí': [146], + 'Ibipeba': [176], + 'Ibipitanga': [65], + 'Ibiquera': [42], + 'Ibirapitanga': [134], + 'Ibirapuã': [153], + 'Ibirataia': [24], + 'Ibitiara': [88], + 'Ibititá': [104], + 'Ibotirama': [173], + 'Ichu': [132], + 'Igaporã': [168], + 'Igrapiúna': [32], + 'Iguaí': [146], + 'Ilhéus': [25, 26], + 'Inhambupe': [44], + 'Ipecaetá': [143], + 'Ipiaú': [24], + 'Ipirá': [62], + 'Ipupiara': [94], + 'Irajuba': [76], + 'Iramaia': [169], + 'Iraquara': [89], + 'Irará': [74], + 'Irecê': [95], + 'Itabela': [189], + 'Itaberaba': [42], + 'Itabuna': [27, 28], + 'Itacaré': [198], + 'Itaeté': [119], + 'Itagi': [147], + 'Itagibá': [147], + 'Itagimirim': [188], + 'Itaguaçu da Bahia': [68], + 'Itaju do Colônia': [137], + 'Itajuípe': [136], + 'Itamaraju': [172], + 'Itamari': [151], + 'Itambé': [201], + 'Itanagra': [185], + 'Itanhém': [148], + 'Itaparica': [141], + 'Itapé': [27], + 'Itapebi': [188], + 'Itapetinga': [140], + 'Itapicuru': [81], + 'Itapitanga': [135], + 'Itaquara': [76], + 'Itarantim': [138], + 'Itatim': [107], + 'Itiruçu': [37], + 'Itiúba': [149], + 'Itororó': [137], + 'Ituaçu': [58], + 'Ituberá': [32], + 'Iuiú': [175], + 'Jaborandi': [61], + 'Jacaraci': [92], + 'Jacobina': [46], + 'Jaguaquara': [76], + 'Jaguarari': [179], + 'Jaguaripe': [30], + 'Jandaíra': [49], + 'Jequié': [22, 23], + 'Jeremoabo': [51], + 'Jiquiriçá': [109], + 'Jitaúna': [24], + 'João Dourado': [199], + 'Juazeiro': [47, 48], + 'Jucuruçu': [172], + 'Jussara': [159], + 'Jussari': [28], + 'Jussiape': [101], + 'Lafaiete Coutinho': [23], + 'Lagoa Real': [63], + 'Laje': [109], + 'Lajedão': [153], + 'Lajedinho': [87], + 'Lajedo do Tabocal': [37], + 'Lamarão': [160], + 'Lapão': [104], + 'Lauro de Freitas': [180], + 'Lençóis': [89], + 'Licínio de Almeida': [117], + 'Livramento de Nossa Senhora': [101], + 'Luís Eduardo Magalhães': [205], + 'Macajuba': [87], + 'Macarani': [91], + 'Macaúbas': [65], + 'Macururé': [158], + 'Madre de Deus': [162], + 'Maetinga': [161], + 'Maiquinique': [91], + 'Mairi': [86], + 'Malhada': [125], + 'Malhada de Pedras': [90], + 'Manoel Vitorino': [22], + 'Mansidão': [97], + 'Maracás': [37], + 'Maragogipe': [118], + 'Maraú': [78], + 'Marcionílio Souza': [193], + 'Mascote': [133], + 'Mata de São João': [185], + 'Matina': [113], + 'Medeiros Neto': [153], + 'Miguel Calmon': [103], + 'Milagres': [193], + 'Mirangaba': [167], + 'Mirante': [59], + 'Monte Santo': [50], + 'Morpará': [173], + 'Morro do Chapéu': [55], + 'Mortugaba': [92], + 'Mucugê': [119], + 'Mucuri': [35], + 'Mulungu do Morro': [55], + 'Mundo Novo': [54], + 'Muniz Ferreira': [202], + 'Muquém de São Francisco': [98], + 'Muritiba': [131], + 'Mutuípe': [109], + 'Nazaré': [30], + 'Nilo Peçanha': [32], + 'Nordestina': [106], + 'Nova Canaã': [146], + 'Nova Fátima': [196], + 'Nova Ibiá': [151], + 'Nova Itarana': [36], + 'Nova Redenção': [119], + 'Nova Soure': [79], + 'Nova Viçosa': [35], + 'Novo Horizonte': [88], + 'Novo Triunfo': [82], + 'Olindina': [81], + 'Oliveira dos Brejinhos': [94], + 'Ouriçangas': [74], + 'Ourolândia': [167], + 'Palmas de Monte Alto': [175], + 'Palmeiras': [89], + 'Paramirim': [111], + 'Paratinga': [173], + 'Paripiranga': [52], + 'Pau Brasil': [133], + 'Paulo Afonso': [84, 181], + 'Pé de Serra': [114], + 'Pedrão': [130], + 'Pedro Alexandre': [51], + 'Piatã': [105], + 'Pilão Arcado': [195], + 'Pindaí': [117], + 'Pindobaçu': [115], + 'Pintadas': [62], + 'Piraí do Norte': [151], + 'Piripá': [177], + 'Piritiba': [54], + 'Planaltino': [37], + 'Planalto': [139], + 'Poções': [59], + 'Pojuca': [200], + 'Ponto Novo': [149], + 'Porto Seguro': [121, 122], + 'Potiraguá': [138], + 'Prado': [112], + 'Presidente Dutra': [95], + 'Presidente Jânio Quadros': [60], + 'Presidente Tancredo Neves': [197], + 'Queimadas': [106], + 'Quijingue': [102], + 'Quixabeira': [191], + 'Rafael Jambeiro': [43], + 'Remanso': [67], + 'Retirolândia': [196], + 'Riachão das Neves': [182], + 'Riachão do Jacuípe': [114], + 'Riacho de Santana': [113], + 'Ribeira do Amparo': [79], + 'Ribeira do Pombal': [110], + 'Ribeirão do Largo': [152], + 'Rio de Contas': [101], + 'Rio do Antônio': [93], + 'Rio do Pires': [111], + 'Rio Real': [49], + 'Rodelas': [158], + 'Ruy Barbosa': [87], + 'Salinas da Margarida': [30], + 'Salvador': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], + 'Santa Bárbara': [160], + 'Santa Brígida': [181], + 'Santa Cruz Cabrália': [121], + 'Santa Cruz da Vitória': [29], + 'Santa Inês': [38], + 'Santa Luzia': [133], + 'Santa Maria da Vitória': [72], + 'Santa Rita de Cássia': [97], + 'Santa Teresinha': [107], + 'Santaluz': [145], + 'Santana': [99], + 'Santanópolis': [74], + 'Santo Amaro': [178], + 'Santo Antônio de Jesus': [56, 202], + 'Santo Estêvão': [143], + 'São Desidério': [100], + 'São Domingos': [120], + 'São Felipe': [184], + 'São Félix': [118], + 'São Félix do Coribe': [72], + 'São Francisco do Conde': [162], + 'São Gabriel': [95], + 'São Gonçalo dos Campos': [108], + 'São José da Vitória': [166], + 'São José do Jacuípe': [191], + 'São Miguel das Matas': [36], + 'São Sebastião do Passé': [128], + 'Sapeaçu': [142], + 'Sátiro Dias': [44], + 'Saubara': [178], + 'Saúde': [115], + 'Seabra': [88], + 'Sebastião Laranjeiras': [175], + 'Senhor do Bonfim': [45], + 'Sento Sé': [96], + 'Serra do Ramalho': [71], + 'Serra Dourada': [190], + 'Serra Preta': [155], + 'Serrinha': [150], + 'Serrolândia': [46], + 'Simões Filho': [33], + 'Sítio do Mato': [71], + 'Sítio do Quinto': [51], + 'Sobradinho': [66], + 'Souto Soares': [174], + 'Tabocas do Brejo Velho': [190], + 'Tanhaçu': [58], + 'Tanque Novo': [168], + 'Tanquinho': [160], + 'Taperoá': [32], + 'Tapiramutá': [54], + 'Teixeira de Freitas': [183], + 'Teodoro Sampaio': [130], + 'Teofilândia': [123], + 'Teolândia': [197], + 'Terra Nova': [128], + 'Tremedal': [177], + 'Tucano': [80], + 'Uauá': [83], + 'Ubaíra': [38], + 'Ubaitaba': [73], + 'Ubatã': [134], + 'Uibaí': [159], + 'Umburanas': [167], + 'Una': [116], + 'Urandi': [117], + 'Uruçuca': [198], + 'Utinga': [69], + 'Valença': [31], + 'Valente': [120], + 'Várzea da Roça': [86], + 'Várzea do Poço': [103], + 'Várzea Nova': [55], + 'Varzedo': [56], + 'Vera Cruz': [141], + 'Vereda': [148], + 'Vitória da Conquista': [39, 40, 41], + 'Wagner': [69], + 'Wanderley': [98], + 'Wenceslau Guimarães': [197], + 'Xique-Xique': [68], +} as const + +const emptyZones: readonly number[] = [] + +export const tseZonesForCity = (city: string): readonly number[] => { + if (!isBahiaMunicipality(city)) return emptyZones + return bahiaMunicipalityTseZones[city] ?? emptyZones +} + +const citiesByZone = new Map() +for (const city of bahiaMunicipalities) { + for (const zone of bahiaMunicipalityTseZones[city] ?? []) { + const list = citiesByZone.get(zone) + if (list) list.push(city) + else citiesByZone.set(zone, [city]) + } +} +for (const list of citiesByZone.values()) { + list.sort((left, right) => left.localeCompare(right, 'pt-BR')) +} + +const zonesByTerritory = new Map() +for (const territory of bahiaIdentityTerritories) { + const zones = new Set() + for (const city of citiesForTerritory(territory)) { + for (const zone of tseZonesForCity(city)) zones.add(zone) + } + zonesByTerritory.set( + territory, + [...zones].sort((left, right) => left - right), + ) +} + +export const tseZonesForTerritory = ( + territory: BahiaIdentityTerritory, +): readonly number[] => zonesByTerritory.get(territory) ?? emptyZones + +export const citiesForTseZone = (zoneNumber: number): readonly string[] => + citiesByZone.get(zoneNumber) ?? [] diff --git a/src/lib/schemas/nucleus.ts b/src/lib/schemas/nucleus.ts index 33cf066a..b76cce7c 100644 --- a/src/lib/schemas/nucleus.ts +++ b/src/lib/schemas/nucleus.ts @@ -37,7 +37,7 @@ export const MAX_NUCLEUS_REGIONS = 27 export const MAX_NUCLEUS_CITIES = 27 export const MAX_NUCLEUS_NEIGHBORHOODS = 30 -const dedupeTrimmedStrings = (values: string[]): string[] => { +export const dedupeTrimmedStrings = (values: readonly string[]): string[] => { const seen = new Set() const result: string[] = [] for (const value of values) { diff --git a/src/lib/territorySuggestions.ts b/src/lib/territorySuggestions.ts new file mode 100644 index 00000000..22968cd5 --- /dev/null +++ b/src/lib/territorySuggestions.ts @@ -0,0 +1,106 @@ +import { + citiesForTerritory, + isBahiaIdentityTerritory, +} from '@/lib/bahiaTerritories' +import { + citiesForTseZone, + tseZonesForCity, + tseZonesForTerritory, +} from '@/lib/bahiaTseZones' +import { MAX_NUCLEUS_CITIES } from '@/lib/schemas/nucleus' +import { sortedUniqueZoneNumbers } from '@/utilities/tseZone' + +export type ZoneSuggestion = { + kind: 'city' | 'territory' + label: string + zonesToAdd: number[] +} + +export type CitySuggestion = { + kind: 'sibling' | 'zone' + city: string +} + +export type TerritorySuggestionInput = { + cities: readonly string[] + regions: readonly string[] + tseZones: readonly number[] +} + +export type TerritorySuggestions = { + zoneSuggestions: ZoneSuggestion[] + citySuggestions: CitySuggestion[] + /** Zones present in the form that are outside the official city/TI set. Empty when no geography. */ + outsideZones: number[] +} + +const missingZones = (official: readonly number[], current: ReadonlySet): number[] => + official.filter((zone) => !current.has(zone)) + +const comparePtBr = (left: string, right: string) => left.localeCompare(right, 'pt-BR') + +/** + * Pure suggestion engine for opt-in territory ↔ TSE zone chips. + * Returns only what is still missing; never auto-applies. + */ +export const buildTerritorySuggestions = ({ + cities, + regions, + tseZones, +}: TerritorySuggestionInput): TerritorySuggestions => { + const citySet = new Set(cities) + const zoneSet = new Set(tseZones) + const displayRegions = regions.filter(isBahiaIdentityTerritory) + const official = new Set() + + const zoneSuggestions: ZoneSuggestion[] = [] + + for (const city of cities) { + const cityZones = tseZonesForCity(city) + for (const zone of cityZones) official.add(zone) + const zonesToAdd = missingZones(cityZones, zoneSet) + if (zonesToAdd.length === 0) continue + zoneSuggestions.push({ kind: 'city', label: city, zonesToAdd }) + } + + for (const territory of displayRegions) { + const territoryZones = tseZonesForTerritory(territory) + for (const zone of territoryZones) official.add(zone) + const zonesToAdd = missingZones(territoryZones, zoneSet) + if (zonesToAdd.length === 0) continue + zoneSuggestions.push({ kind: 'territory', label: territory, zonesToAdd }) + } + + const cityByName = new Map() + + if (cities.length < MAX_NUCLEUS_CITIES) { + for (const territory of displayRegions) { + for (const city of citiesForTerritory(territory)) { + if (citySet.has(city) || cityByName.has(city)) continue + cityByName.set(city, { kind: 'sibling', city }) + } + } + + for (const zoneNumber of tseZones) { + for (const city of citiesForTseZone(zoneNumber)) { + if (citySet.has(city) || cityByName.has(city)) continue + // Prefer sibling when both TI and zone sources suggest the same city + // (siblings are inserted first above). + cityByName.set(city, { kind: 'zone', city }) + } + } + } + + return { + zoneSuggestions: [...zoneSuggestions].sort((left, right) => + comparePtBr(left.label, right.label), + ), + citySuggestions: [...cityByName.values()].sort((left, right) => + comparePtBr(left.city, right.city), + ), + outsideZones: + official.size === 0 + ? [] + : sortedUniqueZoneNumbers(tseZones.filter((zone) => !official.has(zone))), + } +} diff --git a/src/utilities/territoryComboboxOptions.ts b/src/utilities/territoryComboboxOptions.ts index dcba9cf8..a1a7d6b0 100644 --- a/src/utilities/territoryComboboxOptions.ts +++ b/src/utilities/territoryComboboxOptions.ts @@ -15,7 +15,10 @@ const toOptions = (values: readonly string[]): StrictComboboxOption[] => export const territoryComboboxOptions = toOptions(bahiaIdentityTerritories) +/** Full Bahia municipality list — stable module reference for client filters. */ +export const allMunicipalityComboboxOptions = toOptions(bahiaMunicipalities) + export const municipalityComboboxOptions = (region?: string | null): StrictComboboxOption[] => - toOptions( - region && isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : bahiaMunicipalities, - ) + region && isBahiaIdentityTerritory(region) + ? toOptions(citiesForTerritory(region)) + : allMunicipalityComboboxOptions diff --git a/src/utilities/tseZone.ts b/src/utilities/tseZone.ts index 9d8a61d4..a5234a13 100644 --- a/src/utilities/tseZone.ts +++ b/src/utilities/tseZone.ts @@ -1,24 +1,25 @@ export class TseZoneParseError extends Error {} +export const sortedUniqueZoneNumbers = (values: readonly number[]): number[] => + [...new Set(values)].sort((left, right) => left - right) + export const parseTseZoneNumbers = (value: string): number[] => { if (!/^[\d,\s]*$/.test(value)) { throw new TseZoneParseError('Use apenas números, vírgulas e espaços nas Zonas TSE.') } - return [ - ...new Set( - value - .split(/[,\s]+/) - .filter(Boolean) - .map((token) => { - const zoneNumber = Number(token) - if (!Number.isInteger(zoneNumber) || zoneNumber < 1 || zoneNumber > 999) { - throw new TseZoneParseError( - `Zona TSE inválida: "${token}". Use números de 1 a 999.`, - ) - } - return zoneNumber - }), - ), - ].sort((left, right) => left - right) + return sortedUniqueZoneNumbers( + value + .split(/[,\s]+/) + .filter(Boolean) + .map((token) => { + const zoneNumber = Number(token) + if (!Number.isInteger(zoneNumber) || zoneNumber < 1 || zoneNumber > 999) { + throw new TseZoneParseError( + `Zona TSE inválida: "${token}". Use números de 1 a 999.`, + ) + } + return zoneNumber + }), + ) } diff --git a/tests/e2e/campaign-nuclei.e2e.spec.ts b/tests/e2e/campaign-nuclei.e2e.spec.ts index 5ce3533b..d58d2d29 100644 --- a/tests/e2e/campaign-nuclei.e2e.spec.ts +++ b/tests/e2e/campaign-nuclei.e2e.spec.ts @@ -61,6 +61,82 @@ test.describe('Campaign nucleus journeys', () => { expect(created.docs[0]?.tseZones?.map(({ zoneNumber }) => zoneNumber)).toEqual([12, 58]) }) + test('accepts opt-in territory suggestion chips for cities and TSE zones', async ({ + campaign, + page, + }) => { + test.setTimeout(240_000) + const generalEmail = `${campaign.fixtures.value('suggestions')}@example.com` + const password = campaign.fixtures.value('SuggestionsPassword') + const nucleusName = campaign.fixtures.value('Núcleo sugestões') + await campaign.transaction(async (payload, req) => { + await payload.create({ + collection: 'campaignUser', + data: { + name: campaign.fixtures.value('Coordenação Geral'), + email: generalEmail, + password, + role: 'geral', + }, + depth: 0, + req, + }) + }) + + await campaign.login(page, generalEmail, password) + await page.goto(`${campaign.baseURL}/campanha/nucleos/novo`) + await page.getByLabel('Nome do núcleo *').fill(nucleusName) + + const cityInput = page.getByLabel('Municípios') + await cityInput.fill('Itiruçu') + await page.getByRole('option', { name: 'Itiruçu', exact: true }).click() + await expect( + page.getByRole('button', { name: 'Remover território Vale do Jiquiriçá' }), + ).toBeVisible() + + // Wait for both suggestion strips to settle (city siblings remount the strip). + const zoneChip = page.getByRole('button', { name: 'Adicionar zonas TSE de Itiruçu' }) + await expect(zoneChip).toBeVisible() + await expect(page.getByRole('button', { name: /\+\d+ sugestões|Adicionar município / }).first()).toBeVisible() + // Native DOM click — Playwright's pointer click can race the strip remount. + await zoneChip.evaluate((el: HTMLElement) => el.click()) + await expect(page.getByRole('button', { name: 'Remover Zona TSE 37' })).toBeVisible() + + const expandCities = page.getByRole('button', { name: /\+\d+ sugestões/ }) + if (await expandCities.count()) { + await expandCities.evaluate((el: HTMLElement) => el.click()) + } + const cityChip = page.getByRole('button', { name: 'Adicionar município Maracás' }) + await expect(cityChip).toBeVisible() + await cityChip.evaluate((el: HTMLElement) => el.click()) + await expect(page.getByRole('button', { name: 'Remover município Maracás' })).toBeVisible() + + await page.getByRole('button', { name: 'Criar núcleo' }).click() + await expect + .poll( + async () => + ( + await campaign.payload.find({ + collection: 'electoralNucleus', + where: { name: { equals: nucleusName } }, + depth: 0, + limit: 1, + }) + ).docs[0], + { timeout: 30_000 }, + ) + .toBeTruthy() + + const created = await campaign.payload.find({ + collection: 'electoralNucleus', + where: { name: { equals: nucleusName } }, + depth: 0, + limit: 1, + }) + expect(created.docs[0]?.cities).toEqual(expect.arrayContaining(['Itiruçu', 'Maracás'])) + expect(created.docs[0]?.tseZones?.map(({ zoneNumber }) => zoneNumber)).toEqual([37]) + }) + test('updates coordinator assignment from the nucleus detail', async ({ campaign, page }) => { test.slow() const email = `${campaign.fixtures.value('assignment')}@example.com` diff --git a/tests/fixtures/bahia-tse-zones.official.json b/tests/fixtures/bahia-tse-zones.official.json new file mode 100644 index 00000000..ea9a5ee5 --- /dev/null +++ b/tests/fixtures/bahia-tse-zones.official.json @@ -0,0 +1,2553 @@ +{ + "provenance": { + "description": "Test-only evidence of Bahia municipality↔TSE zone pairs derived independently from the TSE 2024 detalhe_votacao_munzona BA CSV. It was not generated from src/lib/bahiaTseZones.ts.", + "source": { + "portal": "https://dadosabertos.tse.jus.br/dataset/resultados-2024", + "resource": "Detalhe da apuração por município e zona", + "url": "https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2024.zip", + "entry": "detalhe_votacao_munzona_2024_BA.csv", + "electionYear": 2024, + "downloadSha256": "24e75fcf98a57b2362c33d46eb96b99a4b7070966cfc7f6398e12240e3f679f0" + }, + "normalization": "TSE NM_MUNICIPIO spellings were reconciled to canonical bahiaTerritories names via accent/case folding plus known aliases (Camacan, Dias d'Ávila, Muquém de São Francisco, Santa Teresinha). The evidence SHA-256 covers every canonical municipality and its sorted unique zone numbers." + }, + "municipalityCount": 417, + "assignments": [ + { + "municipality": "Abaíra", + "zones": [ + 105 + ] + }, + { + "municipality": "Abaré", + "zones": [ + 158 + ] + }, + { + "municipality": "Acajutiba", + "zones": [ + 21 + ] + }, + { + "municipality": "Adustina", + "zones": [ + 52 + ] + }, + { + "municipality": "Água Fria", + "zones": [ + 74 + ] + }, + { + "municipality": "Aiquara", + "zones": [ + 147 + ] + }, + { + "municipality": "Alagoinhas", + "zones": [ + 163 + ] + }, + { + "municipality": "Alcobaça", + "zones": [ + 112 + ] + }, + { + "municipality": "Almadina", + "zones": [ + 135 + ] + }, + { + "municipality": "Amargosa", + "zones": [ + 36 + ] + }, + { + "municipality": "Amélia Rodrigues", + "zones": [ + 192 + ] + }, + { + "municipality": "América Dourada", + "zones": [ + 199 + ] + }, + { + "municipality": "Anagé", + "zones": [ + 161 + ] + }, + { + "municipality": "Andaraí", + "zones": [ + 119 + ] + }, + { + "municipality": "Andorinha", + "zones": [ + 45 + ] + }, + { + "municipality": "Angical", + "zones": [ + 126 + ] + }, + { + "municipality": "Anguera", + "zones": [ + 157 + ] + }, + { + "municipality": "Antas", + "zones": [ + 82 + ] + }, + { + "municipality": "Antônio Cardoso", + "zones": [ + 143 + ] + }, + { + "municipality": "Antônio Gonçalves", + "zones": [ + 53 + ] + }, + { + "municipality": "Aporá", + "zones": [ + 44 + ] + }, + { + "municipality": "Apuarema", + "zones": [ + 23 + ] + }, + { + "municipality": "Araças", + "zones": [ + 200 + ] + }, + { + "municipality": "Aracatu", + "zones": [ + 90 + ] + }, + { + "municipality": "Araci", + "zones": [ + 123 + ] + }, + { + "municipality": "Aramari", + "zones": [ + 163 + ] + }, + { + "municipality": "Arataca", + "zones": [ + 166 + ] + }, + { + "municipality": "Aratuípe", + "zones": [ + 30 + ] + }, + { + "municipality": "Aurelino Leal", + "zones": [ + 73 + ] + }, + { + "municipality": "Baianópolis", + "zones": [ + 126 + ] + }, + { + "municipality": "Baixa Grande", + "zones": [ + 86 + ] + }, + { + "municipality": "Banzaê", + "zones": [ + 110 + ] + }, + { + "municipality": "Barra", + "zones": [ + 77 + ] + }, + { + "municipality": "Barra da Estiva", + "zones": [ + 169 + ] + }, + { + "municipality": "Barra do Choça", + "zones": [ + 139 + ] + }, + { + "municipality": "Barra do Mendes", + "zones": [ + 176 + ] + }, + { + "municipality": "Barra do Rocha", + "zones": [ + 134 + ] + }, + { + "municipality": "Barreiras", + "zones": [ + 70, + 75 + ] + }, + { + "municipality": "Barro Alto", + "zones": [ + 174 + ] + }, + { + "municipality": "Barro Preto", + "zones": [ + 136 + ] + }, + { + "municipality": "Barrocas", + "zones": [ + 150 + ] + }, + { + "municipality": "Belmonte", + "zones": [ + 34 + ] + }, + { + "municipality": "Belo Campo", + "zones": [ + 177 + ] + }, + { + "municipality": "Biritinga", + "zones": [ + 150 + ] + }, + { + "municipality": "Boa Nova", + "zones": [ + 59 + ] + }, + { + "municipality": "Boa Vista do Tupim", + "zones": [ + 42 + ] + }, + { + "municipality": "Bom Jesus da Lapa", + "zones": [ + 71 + ] + }, + { + "municipality": "Bom Jesus da Serra", + "zones": [ + 59 + ] + }, + { + "municipality": "Boninal", + "zones": [ + 105 + ] + }, + { + "municipality": "Bonito", + "zones": [ + 69 + ] + }, + { + "municipality": "Boquira", + "zones": [ + 65 + ] + }, + { + "municipality": "Botuporã", + "zones": [ + 168 + ] + }, + { + "municipality": "Brejões", + "zones": [ + 36 + ] + }, + { + "municipality": "Brejolândia", + "zones": [ + 190 + ] + }, + { + "municipality": "Brotas de Macaúbas", + "zones": [ + 94 + ] + }, + { + "municipality": "Brumado", + "zones": [ + 90 + ] + }, + { + "municipality": "Buerarema", + "zones": [ + 166 + ] + }, + { + "municipality": "Buritirama", + "zones": [ + 77 + ] + }, + { + "municipality": "Caatiba", + "zones": [ + 201 + ] + }, + { + "municipality": "Cabaceiras do Paraguaçu", + "zones": [ + 131 + ] + }, + { + "municipality": "Cachoeira", + "zones": [ + 118 + ] + }, + { + "municipality": "Caculé", + "zones": [ + 93 + ] + }, + { + "municipality": "Caém", + "zones": [ + 167 + ] + }, + { + "municipality": "Caetanos", + "zones": [ + 58 + ] + }, + { + "municipality": "Caetité", + "zones": [ + 63 + ] + }, + { + "municipality": "Cafarnaum", + "zones": [ + 55 + ] + }, + { + "municipality": "Cairu", + "zones": [ + 31 + ] + }, + { + "municipality": "Caldeirão Grande", + "zones": [ + 115 + ] + }, + { + "municipality": "Camacan", + "zones": [ + 133 + ] + }, + { + "municipality": "Camaçari", + "zones": [ + 170, + 171 + ] + }, + { + "municipality": "Camamu", + "zones": [ + 78 + ] + }, + { + "municipality": "Campo Alegre de Lourdes", + "zones": [ + 67 + ] + }, + { + "municipality": "Campo Formoso", + "zones": [ + 53 + ] + }, + { + "municipality": "Canápolis", + "zones": [ + 99 + ] + }, + { + "municipality": "Canarana", + "zones": [ + 174 + ] + }, + { + "municipality": "Canavieiras", + "zones": [ + 116 + ] + }, + { + "municipality": "Candeal", + "zones": [ + 114 + ] + }, + { + "municipality": "Candeias", + "zones": [ + 127 + ] + }, + { + "municipality": "Candiba", + "zones": [ + 64 + ] + }, + { + "municipality": "Cândido Sales", + "zones": [ + 40 + ] + }, + { + "municipality": "Cansanção", + "zones": [ + 50 + ] + }, + { + "municipality": "Canudos", + "zones": [ + 83 + ] + }, + { + "municipality": "Capela do Alto Alegre", + "zones": [ + 196 + ] + }, + { + "municipality": "Capim Grosso", + "zones": [ + 191 + ] + }, + { + "municipality": "Caraíbas", + "zones": [ + 161 + ] + }, + { + "municipality": "Caravelas", + "zones": [ + 112 + ] + }, + { + "municipality": "Cardeal da Silva", + "zones": [ + 144 + ] + }, + { + "municipality": "Carinhanha", + "zones": [ + 125 + ] + }, + { + "municipality": "Casa Nova", + "zones": [ + 66 + ] + }, + { + "municipality": "Castro Alves", + "zones": [ + 43 + ] + }, + { + "municipality": "Catolândia", + "zones": [ + 100 + ] + }, + { + "municipality": "Catu", + "zones": [ + 129 + ] + }, + { + "municipality": "Caturama", + "zones": [ + 111 + ] + }, + { + "municipality": "Central", + "zones": [ + 159 + ] + }, + { + "municipality": "Chorrochó", + "zones": [ + 158 + ] + }, + { + "municipality": "Cícero Dantas", + "zones": [ + 82 + ] + }, + { + "municipality": "Cipó", + "zones": [ + 79 + ] + }, + { + "municipality": "Coaraci", + "zones": [ + 135 + ] + }, + { + "municipality": "Cocos", + "zones": [ + 61 + ] + }, + { + "municipality": "Conceição da Feira", + "zones": [ + 108 + ] + }, + { + "municipality": "Conceição do Almeida", + "zones": [ + 184 + ] + }, + { + "municipality": "Conceição do Coité", + "zones": [ + 132 + ] + }, + { + "municipality": "Conceição do Jacuípe", + "zones": [ + 192 + ] + }, + { + "municipality": "Conde", + "zones": [ + 21 + ] + }, + { + "municipality": "Condeúba", + "zones": [ + 60 + ] + }, + { + "municipality": "Contendas do Sincorá", + "zones": [ + 58 + ] + }, + { + "municipality": "Coração de Maria", + "zones": [ + 130 + ] + }, + { + "municipality": "Cordeiros", + "zones": [ + 60 + ] + }, + { + "municipality": "Coribe", + "zones": [ + 61 + ] + }, + { + "municipality": "Coronel João Sá", + "zones": [ + 52 + ] + }, + { + "municipality": "Correntina", + "zones": [ + 124 + ] + }, + { + "municipality": "Cotegipe", + "zones": [ + 98 + ] + }, + { + "municipality": "Cravolândia", + "zones": [ + 38 + ] + }, + { + "municipality": "Crisópolis", + "zones": [ + 81 + ] + }, + { + "municipality": "Cristópolis", + "zones": [ + 126 + ] + }, + { + "municipality": "Cruz das Almas", + "zones": [ + 142 + ] + }, + { + "municipality": "Curaçá", + "zones": [ + 85 + ] + }, + { + "municipality": "Dário Meira", + "zones": [ + 147 + ] + }, + { + "municipality": "Dias d'Ávila", + "zones": [ + 186 + ] + }, + { + "municipality": "Dom Basílio", + "zones": [ + 101 + ] + }, + { + "municipality": "Dom Macedo Costa", + "zones": [ + 202 + ] + }, + { + "municipality": "Elísio Medrado", + "zones": [ + 107 + ] + }, + { + "municipality": "Encruzilhada", + "zones": [ + 152 + ] + }, + { + "municipality": "Entre Rios", + "zones": [ + 144 + ] + }, + { + "municipality": "Érico Cardoso", + "zones": [ + 111 + ] + }, + { + "municipality": "Esplanada", + "zones": [ + 21 + ] + }, + { + "municipality": "Euclides da Cunha", + "zones": [ + 102 + ] + }, + { + "municipality": "Eunápolis", + "zones": [ + 188, + 203 + ] + }, + { + "municipality": "Fátima", + "zones": [ + 82 + ] + }, + { + "municipality": "Feira da Mata", + "zones": [ + 125 + ] + }, + { + "municipality": "Feira de Santana", + "zones": [ + 154, + 155, + 156, + 157 + ] + }, + { + "municipality": "Filadélfia", + "zones": [ + 149 + ] + }, + { + "municipality": "Firmino Alves", + "zones": [ + 137 + ] + }, + { + "municipality": "Floresta Azul", + "zones": [ + 29 + ] + }, + { + "municipality": "Formosa do Rio Preto", + "zones": [ + 187 + ] + }, + { + "municipality": "Gandu", + "zones": [ + 151 + ] + }, + { + "municipality": "Gavião", + "zones": [ + 191 + ] + }, + { + "municipality": "Gentio do Ouro", + "zones": [ + 68 + ] + }, + { + "municipality": "Glória", + "zones": [ + 84 + ] + }, + { + "municipality": "Gongogi", + "zones": [ + 73 + ] + }, + { + "municipality": "Governador Mangabeira", + "zones": [ + 131 + ] + }, + { + "municipality": "Guajeru", + "zones": [ + 93 + ] + }, + { + "municipality": "Guanambi", + "zones": [ + 64 + ] + }, + { + "municipality": "Guaratinga", + "zones": [ + 189 + ] + }, + { + "municipality": "Heliópolis", + "zones": [ + 110 + ] + }, + { + "municipality": "Iaçu", + "zones": [ + 193 + ] + }, + { + "municipality": "Ibiassucê", + "zones": [ + 93 + ] + }, + { + "municipality": "Ibicaraí", + "zones": [ + 29 + ] + }, + { + "municipality": "Ibicoara", + "zones": [ + 169 + ] + }, + { + "municipality": "Ibicuí", + "zones": [ + 146 + ] + }, + { + "municipality": "Ibipeba", + "zones": [ + 176 + ] + }, + { + "municipality": "Ibipitanga", + "zones": [ + 65 + ] + }, + { + "municipality": "Ibiquera", + "zones": [ + 42 + ] + }, + { + "municipality": "Ibirapitanga", + "zones": [ + 134 + ] + }, + { + "municipality": "Ibirapuã", + "zones": [ + 153 + ] + }, + { + "municipality": "Ibirataia", + "zones": [ + 24 + ] + }, + { + "municipality": "Ibitiara", + "zones": [ + 88 + ] + }, + { + "municipality": "Ibititá", + "zones": [ + 104 + ] + }, + { + "municipality": "Ibotirama", + "zones": [ + 173 + ] + }, + { + "municipality": "Ichu", + "zones": [ + 132 + ] + }, + { + "municipality": "Igaporã", + "zones": [ + 168 + ] + }, + { + "municipality": "Igrapiúna", + "zones": [ + 32 + ] + }, + { + "municipality": "Iguaí", + "zones": [ + 146 + ] + }, + { + "municipality": "Ilhéus", + "zones": [ + 25, + 26 + ] + }, + { + "municipality": "Inhambupe", + "zones": [ + 44 + ] + }, + { + "municipality": "Ipecaetá", + "zones": [ + 143 + ] + }, + { + "municipality": "Ipiaú", + "zones": [ + 24 + ] + }, + { + "municipality": "Ipirá", + "zones": [ + 62 + ] + }, + { + "municipality": "Ipupiara", + "zones": [ + 94 + ] + }, + { + "municipality": "Irajuba", + "zones": [ + 76 + ] + }, + { + "municipality": "Iramaia", + "zones": [ + 169 + ] + }, + { + "municipality": "Iraquara", + "zones": [ + 89 + ] + }, + { + "municipality": "Irará", + "zones": [ + 74 + ] + }, + { + "municipality": "Irecê", + "zones": [ + 95 + ] + }, + { + "municipality": "Itabela", + "zones": [ + 189 + ] + }, + { + "municipality": "Itaberaba", + "zones": [ + 42 + ] + }, + { + "municipality": "Itabuna", + "zones": [ + 27, + 28 + ] + }, + { + "municipality": "Itacaré", + "zones": [ + 198 + ] + }, + { + "municipality": "Itaeté", + "zones": [ + 119 + ] + }, + { + "municipality": "Itagi", + "zones": [ + 147 + ] + }, + { + "municipality": "Itagibá", + "zones": [ + 147 + ] + }, + { + "municipality": "Itagimirim", + "zones": [ + 188 + ] + }, + { + "municipality": "Itaguaçu da Bahia", + "zones": [ + 68 + ] + }, + { + "municipality": "Itaju do Colônia", + "zones": [ + 137 + ] + }, + { + "municipality": "Itajuípe", + "zones": [ + 136 + ] + }, + { + "municipality": "Itamaraju", + "zones": [ + 172 + ] + }, + { + "municipality": "Itamari", + "zones": [ + 151 + ] + }, + { + "municipality": "Itambé", + "zones": [ + 201 + ] + }, + { + "municipality": "Itanagra", + "zones": [ + 185 + ] + }, + { + "municipality": "Itanhém", + "zones": [ + 148 + ] + }, + { + "municipality": "Itaparica", + "zones": [ + 141 + ] + }, + { + "municipality": "Itapé", + "zones": [ + 27 + ] + }, + { + "municipality": "Itapebi", + "zones": [ + 188 + ] + }, + { + "municipality": "Itapetinga", + "zones": [ + 140 + ] + }, + { + "municipality": "Itapicuru", + "zones": [ + 81 + ] + }, + { + "municipality": "Itapitanga", + "zones": [ + 135 + ] + }, + { + "municipality": "Itaquara", + "zones": [ + 76 + ] + }, + { + "municipality": "Itarantim", + "zones": [ + 138 + ] + }, + { + "municipality": "Itatim", + "zones": [ + 107 + ] + }, + { + "municipality": "Itiruçu", + "zones": [ + 37 + ] + }, + { + "municipality": "Itiúba", + "zones": [ + 149 + ] + }, + { + "municipality": "Itororó", + "zones": [ + 137 + ] + }, + { + "municipality": "Ituaçu", + "zones": [ + 58 + ] + }, + { + "municipality": "Ituberá", + "zones": [ + 32 + ] + }, + { + "municipality": "Iuiú", + "zones": [ + 175 + ] + }, + { + "municipality": "Jaborandi", + "zones": [ + 61 + ] + }, + { + "municipality": "Jacaraci", + "zones": [ + 92 + ] + }, + { + "municipality": "Jacobina", + "zones": [ + 46 + ] + }, + { + "municipality": "Jaguaquara", + "zones": [ + 76 + ] + }, + { + "municipality": "Jaguarari", + "zones": [ + 179 + ] + }, + { + "municipality": "Jaguaripe", + "zones": [ + 30 + ] + }, + { + "municipality": "Jandaíra", + "zones": [ + 49 + ] + }, + { + "municipality": "Jequié", + "zones": [ + 22, + 23 + ] + }, + { + "municipality": "Jeremoabo", + "zones": [ + 51 + ] + }, + { + "municipality": "Jiquiriçá", + "zones": [ + 109 + ] + }, + { + "municipality": "Jitaúna", + "zones": [ + 24 + ] + }, + { + "municipality": "João Dourado", + "zones": [ + 199 + ] + }, + { + "municipality": "Juazeiro", + "zones": [ + 47, + 48 + ] + }, + { + "municipality": "Jucuruçu", + "zones": [ + 172 + ] + }, + { + "municipality": "Jussara", + "zones": [ + 159 + ] + }, + { + "municipality": "Jussari", + "zones": [ + 28 + ] + }, + { + "municipality": "Jussiape", + "zones": [ + 101 + ] + }, + { + "municipality": "Lafaiete Coutinho", + "zones": [ + 23 + ] + }, + { + "municipality": "Lagoa Real", + "zones": [ + 63 + ] + }, + { + "municipality": "Laje", + "zones": [ + 109 + ] + }, + { + "municipality": "Lajedão", + "zones": [ + 153 + ] + }, + { + "municipality": "Lajedinho", + "zones": [ + 87 + ] + }, + { + "municipality": "Lajedo do Tabocal", + "zones": [ + 37 + ] + }, + { + "municipality": "Lamarão", + "zones": [ + 160 + ] + }, + { + "municipality": "Lapão", + "zones": [ + 104 + ] + }, + { + "municipality": "Lauro de Freitas", + "zones": [ + 180 + ] + }, + { + "municipality": "Lençóis", + "zones": [ + 89 + ] + }, + { + "municipality": "Licínio de Almeida", + "zones": [ + 117 + ] + }, + { + "municipality": "Livramento de Nossa Senhora", + "zones": [ + 101 + ] + }, + { + "municipality": "Luís Eduardo Magalhães", + "zones": [ + 205 + ] + }, + { + "municipality": "Macajuba", + "zones": [ + 87 + ] + }, + { + "municipality": "Macarani", + "zones": [ + 91 + ] + }, + { + "municipality": "Macaúbas", + "zones": [ + 65 + ] + }, + { + "municipality": "Macururé", + "zones": [ + 158 + ] + }, + { + "municipality": "Madre de Deus", + "zones": [ + 162 + ] + }, + { + "municipality": "Maetinga", + "zones": [ + 161 + ] + }, + { + "municipality": "Maiquinique", + "zones": [ + 91 + ] + }, + { + "municipality": "Mairi", + "zones": [ + 86 + ] + }, + { + "municipality": "Malhada", + "zones": [ + 125 + ] + }, + { + "municipality": "Malhada de Pedras", + "zones": [ + 90 + ] + }, + { + "municipality": "Manoel Vitorino", + "zones": [ + 22 + ] + }, + { + "municipality": "Mansidão", + "zones": [ + 97 + ] + }, + { + "municipality": "Maracás", + "zones": [ + 37 + ] + }, + { + "municipality": "Maragogipe", + "zones": [ + 118 + ] + }, + { + "municipality": "Maraú", + "zones": [ + 78 + ] + }, + { + "municipality": "Marcionílio Souza", + "zones": [ + 193 + ] + }, + { + "municipality": "Mascote", + "zones": [ + 133 + ] + }, + { + "municipality": "Mata de São João", + "zones": [ + 185 + ] + }, + { + "municipality": "Matina", + "zones": [ + 113 + ] + }, + { + "municipality": "Medeiros Neto", + "zones": [ + 153 + ] + }, + { + "municipality": "Miguel Calmon", + "zones": [ + 103 + ] + }, + { + "municipality": "Milagres", + "zones": [ + 193 + ] + }, + { + "municipality": "Mirangaba", + "zones": [ + 167 + ] + }, + { + "municipality": "Mirante", + "zones": [ + 59 + ] + }, + { + "municipality": "Monte Santo", + "zones": [ + 50 + ] + }, + { + "municipality": "Morpará", + "zones": [ + 173 + ] + }, + { + "municipality": "Morro do Chapéu", + "zones": [ + 55 + ] + }, + { + "municipality": "Mortugaba", + "zones": [ + 92 + ] + }, + { + "municipality": "Mucugê", + "zones": [ + 119 + ] + }, + { + "municipality": "Mucuri", + "zones": [ + 35 + ] + }, + { + "municipality": "Mulungu do Morro", + "zones": [ + 55 + ] + }, + { + "municipality": "Mundo Novo", + "zones": [ + 54 + ] + }, + { + "municipality": "Muniz Ferreira", + "zones": [ + 202 + ] + }, + { + "municipality": "Muquém de São Francisco", + "zones": [ + 98 + ] + }, + { + "municipality": "Muritiba", + "zones": [ + 131 + ] + }, + { + "municipality": "Mutuípe", + "zones": [ + 109 + ] + }, + { + "municipality": "Nazaré", + "zones": [ + 30 + ] + }, + { + "municipality": "Nilo Peçanha", + "zones": [ + 32 + ] + }, + { + "municipality": "Nordestina", + "zones": [ + 106 + ] + }, + { + "municipality": "Nova Canaã", + "zones": [ + 146 + ] + }, + { + "municipality": "Nova Fátima", + "zones": [ + 196 + ] + }, + { + "municipality": "Nova Ibiá", + "zones": [ + 151 + ] + }, + { + "municipality": "Nova Itarana", + "zones": [ + 36 + ] + }, + { + "municipality": "Nova Redenção", + "zones": [ + 119 + ] + }, + { + "municipality": "Nova Soure", + "zones": [ + 79 + ] + }, + { + "municipality": "Nova Viçosa", + "zones": [ + 35 + ] + }, + { + "municipality": "Novo Horizonte", + "zones": [ + 88 + ] + }, + { + "municipality": "Novo Triunfo", + "zones": [ + 82 + ] + }, + { + "municipality": "Olindina", + "zones": [ + 81 + ] + }, + { + "municipality": "Oliveira dos Brejinhos", + "zones": [ + 94 + ] + }, + { + "municipality": "Ouriçangas", + "zones": [ + 74 + ] + }, + { + "municipality": "Ourolândia", + "zones": [ + 167 + ] + }, + { + "municipality": "Palmas de Monte Alto", + "zones": [ + 175 + ] + }, + { + "municipality": "Palmeiras", + "zones": [ + 89 + ] + }, + { + "municipality": "Paramirim", + "zones": [ + 111 + ] + }, + { + "municipality": "Paratinga", + "zones": [ + 173 + ] + }, + { + "municipality": "Paripiranga", + "zones": [ + 52 + ] + }, + { + "municipality": "Pau Brasil", + "zones": [ + 133 + ] + }, + { + "municipality": "Paulo Afonso", + "zones": [ + 84, + 181 + ] + }, + { + "municipality": "Pé de Serra", + "zones": [ + 114 + ] + }, + { + "municipality": "Pedrão", + "zones": [ + 130 + ] + }, + { + "municipality": "Pedro Alexandre", + "zones": [ + 51 + ] + }, + { + "municipality": "Piatã", + "zones": [ + 105 + ] + }, + { + "municipality": "Pilão Arcado", + "zones": [ + 195 + ] + }, + { + "municipality": "Pindaí", + "zones": [ + 117 + ] + }, + { + "municipality": "Pindobaçu", + "zones": [ + 115 + ] + }, + { + "municipality": "Pintadas", + "zones": [ + 62 + ] + }, + { + "municipality": "Piraí do Norte", + "zones": [ + 151 + ] + }, + { + "municipality": "Piripá", + "zones": [ + 177 + ] + }, + { + "municipality": "Piritiba", + "zones": [ + 54 + ] + }, + { + "municipality": "Planaltino", + "zones": [ + 37 + ] + }, + { + "municipality": "Planalto", + "zones": [ + 139 + ] + }, + { + "municipality": "Poções", + "zones": [ + 59 + ] + }, + { + "municipality": "Pojuca", + "zones": [ + 200 + ] + }, + { + "municipality": "Ponto Novo", + "zones": [ + 149 + ] + }, + { + "municipality": "Porto Seguro", + "zones": [ + 121, + 122 + ] + }, + { + "municipality": "Potiraguá", + "zones": [ + 138 + ] + }, + { + "municipality": "Prado", + "zones": [ + 112 + ] + }, + { + "municipality": "Presidente Dutra", + "zones": [ + 95 + ] + }, + { + "municipality": "Presidente Jânio Quadros", + "zones": [ + 60 + ] + }, + { + "municipality": "Presidente Tancredo Neves", + "zones": [ + 197 + ] + }, + { + "municipality": "Queimadas", + "zones": [ + 106 + ] + }, + { + "municipality": "Quijingue", + "zones": [ + 102 + ] + }, + { + "municipality": "Quixabeira", + "zones": [ + 191 + ] + }, + { + "municipality": "Rafael Jambeiro", + "zones": [ + 43 + ] + }, + { + "municipality": "Remanso", + "zones": [ + 67 + ] + }, + { + "municipality": "Retirolândia", + "zones": [ + 196 + ] + }, + { + "municipality": "Riachão das Neves", + "zones": [ + 182 + ] + }, + { + "municipality": "Riachão do Jacuípe", + "zones": [ + 114 + ] + }, + { + "municipality": "Riacho de Santana", + "zones": [ + 113 + ] + }, + { + "municipality": "Ribeira do Amparo", + "zones": [ + 79 + ] + }, + { + "municipality": "Ribeira do Pombal", + "zones": [ + 110 + ] + }, + { + "municipality": "Ribeirão do Largo", + "zones": [ + 152 + ] + }, + { + "municipality": "Rio de Contas", + "zones": [ + 101 + ] + }, + { + "municipality": "Rio do Antônio", + "zones": [ + 93 + ] + }, + { + "municipality": "Rio do Pires", + "zones": [ + 111 + ] + }, + { + "municipality": "Rio Real", + "zones": [ + 49 + ] + }, + { + "municipality": "Rodelas", + "zones": [ + 158 + ] + }, + { + "municipality": "Ruy Barbosa", + "zones": [ + 87 + ] + }, + { + "municipality": "Salinas da Margarida", + "zones": [ + 30 + ] + }, + { + "municipality": "Salvador", + "zones": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ] + }, + { + "municipality": "Santa Bárbara", + "zones": [ + 160 + ] + }, + { + "municipality": "Santa Brígida", + "zones": [ + 181 + ] + }, + { + "municipality": "Santa Cruz Cabrália", + "zones": [ + 121 + ] + }, + { + "municipality": "Santa Cruz da Vitória", + "zones": [ + 29 + ] + }, + { + "municipality": "Santa Inês", + "zones": [ + 38 + ] + }, + { + "municipality": "Santa Luzia", + "zones": [ + 133 + ] + }, + { + "municipality": "Santa Maria da Vitória", + "zones": [ + 72 + ] + }, + { + "municipality": "Santa Rita de Cássia", + "zones": [ + 97 + ] + }, + { + "municipality": "Santa Teresinha", + "zones": [ + 107 + ] + }, + { + "municipality": "Santaluz", + "zones": [ + 145 + ] + }, + { + "municipality": "Santana", + "zones": [ + 99 + ] + }, + { + "municipality": "Santanópolis", + "zones": [ + 74 + ] + }, + { + "municipality": "Santo Amaro", + "zones": [ + 178 + ] + }, + { + "municipality": "Santo Antônio de Jesus", + "zones": [ + 56, + 202 + ] + }, + { + "municipality": "Santo Estêvão", + "zones": [ + 143 + ] + }, + { + "municipality": "São Desidério", + "zones": [ + 100 + ] + }, + { + "municipality": "São Domingos", + "zones": [ + 120 + ] + }, + { + "municipality": "São Felipe", + "zones": [ + 184 + ] + }, + { + "municipality": "São Félix", + "zones": [ + 118 + ] + }, + { + "municipality": "São Félix do Coribe", + "zones": [ + 72 + ] + }, + { + "municipality": "São Francisco do Conde", + "zones": [ + 162 + ] + }, + { + "municipality": "São Gabriel", + "zones": [ + 95 + ] + }, + { + "municipality": "São Gonçalo dos Campos", + "zones": [ + 108 + ] + }, + { + "municipality": "São José da Vitória", + "zones": [ + 166 + ] + }, + { + "municipality": "São José do Jacuípe", + "zones": [ + 191 + ] + }, + { + "municipality": "São Miguel das Matas", + "zones": [ + 36 + ] + }, + { + "municipality": "São Sebastião do Passé", + "zones": [ + 128 + ] + }, + { + "municipality": "Sapeaçu", + "zones": [ + 142 + ] + }, + { + "municipality": "Sátiro Dias", + "zones": [ + 44 + ] + }, + { + "municipality": "Saubara", + "zones": [ + 178 + ] + }, + { + "municipality": "Saúde", + "zones": [ + 115 + ] + }, + { + "municipality": "Seabra", + "zones": [ + 88 + ] + }, + { + "municipality": "Sebastião Laranjeiras", + "zones": [ + 175 + ] + }, + { + "municipality": "Senhor do Bonfim", + "zones": [ + 45 + ] + }, + { + "municipality": "Sento Sé", + "zones": [ + 96 + ] + }, + { + "municipality": "Serra do Ramalho", + "zones": [ + 71 + ] + }, + { + "municipality": "Serra Dourada", + "zones": [ + 190 + ] + }, + { + "municipality": "Serra Preta", + "zones": [ + 155 + ] + }, + { + "municipality": "Serrinha", + "zones": [ + 150 + ] + }, + { + "municipality": "Serrolândia", + "zones": [ + 46 + ] + }, + { + "municipality": "Simões Filho", + "zones": [ + 33 + ] + }, + { + "municipality": "Sítio do Mato", + "zones": [ + 71 + ] + }, + { + "municipality": "Sítio do Quinto", + "zones": [ + 51 + ] + }, + { + "municipality": "Sobradinho", + "zones": [ + 66 + ] + }, + { + "municipality": "Souto Soares", + "zones": [ + 174 + ] + }, + { + "municipality": "Tabocas do Brejo Velho", + "zones": [ + 190 + ] + }, + { + "municipality": "Tanhaçu", + "zones": [ + 58 + ] + }, + { + "municipality": "Tanque Novo", + "zones": [ + 168 + ] + }, + { + "municipality": "Tanquinho", + "zones": [ + 160 + ] + }, + { + "municipality": "Taperoá", + "zones": [ + 32 + ] + }, + { + "municipality": "Tapiramutá", + "zones": [ + 54 + ] + }, + { + "municipality": "Teixeira de Freitas", + "zones": [ + 183 + ] + }, + { + "municipality": "Teodoro Sampaio", + "zones": [ + 130 + ] + }, + { + "municipality": "Teofilândia", + "zones": [ + 123 + ] + }, + { + "municipality": "Teolândia", + "zones": [ + 197 + ] + }, + { + "municipality": "Terra Nova", + "zones": [ + 128 + ] + }, + { + "municipality": "Tremedal", + "zones": [ + 177 + ] + }, + { + "municipality": "Tucano", + "zones": [ + 80 + ] + }, + { + "municipality": "Uauá", + "zones": [ + 83 + ] + }, + { + "municipality": "Ubaíra", + "zones": [ + 38 + ] + }, + { + "municipality": "Ubaitaba", + "zones": [ + 73 + ] + }, + { + "municipality": "Ubatã", + "zones": [ + 134 + ] + }, + { + "municipality": "Uibaí", + "zones": [ + 159 + ] + }, + { + "municipality": "Umburanas", + "zones": [ + 167 + ] + }, + { + "municipality": "Una", + "zones": [ + 116 + ] + }, + { + "municipality": "Urandi", + "zones": [ + 117 + ] + }, + { + "municipality": "Uruçuca", + "zones": [ + 198 + ] + }, + { + "municipality": "Utinga", + "zones": [ + 69 + ] + }, + { + "municipality": "Valença", + "zones": [ + 31 + ] + }, + { + "municipality": "Valente", + "zones": [ + 120 + ] + }, + { + "municipality": "Várzea da Roça", + "zones": [ + 86 + ] + }, + { + "municipality": "Várzea do Poço", + "zones": [ + 103 + ] + }, + { + "municipality": "Várzea Nova", + "zones": [ + 55 + ] + }, + { + "municipality": "Varzedo", + "zones": [ + 56 + ] + }, + { + "municipality": "Vera Cruz", + "zones": [ + 141 + ] + }, + { + "municipality": "Vereda", + "zones": [ + 148 + ] + }, + { + "municipality": "Vitória da Conquista", + "zones": [ + 39, + 40, + 41 + ] + }, + { + "municipality": "Wagner", + "zones": [ + 69 + ] + }, + { + "municipality": "Wanderley", + "zones": [ + 98 + ] + }, + { + "municipality": "Wenceslau Guimarães", + "zones": [ + 197 + ] + }, + { + "municipality": "Xique-Xique", + "zones": [ + 68 + ] + } + ], + "evidenceSha256": "4f23bbc46c87891b66ca9aeee453bc9e513618edc3e0dea67ef0ae63ed61d624" +} diff --git a/tests/int/bahiaTseZones.int.spec.ts b/tests/int/bahiaTseZones.int.spec.ts new file mode 100644 index 00000000..2ac1a0e3 --- /dev/null +++ b/tests/int/bahiaTseZones.int.spec.ts @@ -0,0 +1,99 @@ +// @vitest-environment node + +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +import { + bahiaMunicipalities, + citiesForTerritory, + isBahiaIdentityTerritory, +} from '@/lib/bahiaTerritories' +import { + bahiaMunicipalityTseZones, + citiesForTseZone, + tseZonesForCity, + tseZonesForTerritory, +} from '@/lib/bahiaTseZones' + +type OfficialEvidence = { + municipalityCount: number + assignments: Array<{ municipality: string; zones: number[] }> + evidenceSha256: string +} + +const officialEvidence = JSON.parse( + readFileSync(new URL('../fixtures/bahia-tse-zones.official.json', import.meta.url), 'utf8'), +) as OfficialEvidence + +describe('Bahia municipality ↔ TSE zone mapping', () => { + it('covers all 417 Bahia municipalities with zones in 1–999', () => { + expect(Object.keys(bahiaMunicipalityTseZones)).toHaveLength(417) + expect(bahiaMunicipalities).toHaveLength(417) + + for (const city of bahiaMunicipalities) { + const zones = bahiaMunicipalityTseZones[city] + expect(zones, city).toBeDefined() + expect(zones.length, city).toBeGreaterThan(0) + for (const zone of zones) { + expect(zone).toBeGreaterThanOrEqual(1) + expect(zone).toBeLessThanOrEqual(999) + } + expect(zones).toEqual([...zones].sort((left, right) => left - right)) + expect(new Set(zones).size).toBe(zones.length) + } + }) + + it('matches the independently downloaded official municipality↔zone evidence', () => { + expect(officialEvidence.municipalityCount).toBe(417) + expect( + officialEvidence.assignments.map(({ municipality, zones }) => ({ municipality, zones })), + ).toEqual( + Object.entries(bahiaMunicipalityTseZones) + .sort(([left], [right]) => left.localeCompare(right, 'pt-BR')) + .map(([municipality, zones]) => ({ municipality, zones: [...zones] })), + ) + }) + + it('matches every official assignment by a fixed evidence checksum', () => { + const assignmentRows = Object.entries(bahiaMunicipalityTseZones) + .sort(([left], [right]) => left.localeCompare(right, 'pt-BR')) + .map(([municipality, zones]) => `M\t${municipality}\t${zones.join(',')}\n`) + .join('') + + expect(createHash('sha256').update(assignmentRows).digest('hex')).toBe( + officialEvidence.evidenceSha256, + ) + }) + + it('supports bidirectional lookups and territory unions', () => { + expect(tseZonesForCity('Salvador')).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + ]) + expect(tseZonesForCity('Itiruçu')).toEqual([37]) + expect(tseZonesForCity('Maracás')).toEqual([37]) + expect(tseZonesForCity('Município inexistente')).toEqual([]) + expect(tseZonesForCity('')).toEqual([]) + + expect(tseZonesForCity('Salvador').includes(1)).toBe(true) + expect(tseZonesForCity('Salvador').includes(999)).toBe(false) + expect(tseZonesForCity('Município inexistente').includes(1)).toBe(false) + + expect(citiesForTseZone(37)).toEqual( + expect.arrayContaining(['Itiruçu', 'Maracás']), + ) + expect([...citiesForTseZone(37)]).toEqual( + [...citiesForTseZone(37)].sort((left, right) => left.localeCompare(right, 'pt-BR')), + ) + expect(citiesForTseZone(9999)).toEqual([]) + + expect(isBahiaIdentityTerritory('Vale do Jiquiriçá')).toBe(true) + const valeZones = tseZonesForTerritory('Vale do Jiquiriçá') + const expectedUnion = [ + ...new Set(citiesForTerritory('Vale do Jiquiriçá').flatMap((city) => [...tseZonesForCity(city)])), + ].sort((left, right) => left - right) + expect(valeZones).toEqual(expectedUnion) + expect(valeZones.includes(37)).toBe(true) + }) +}) diff --git a/tests/unit/campaignNucleusUi.unit.spec.ts b/tests/unit/campaignNucleusUi.unit.spec.ts index 270c464c..c976baa0 100644 --- a/tests/unit/campaignNucleusUi.unit.spec.ts +++ b/tests/unit/campaignNucleusUi.unit.spec.ts @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, useState } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' @@ -8,7 +8,7 @@ import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog import { NucleusFormFields } from '@/components/campaign/NucleusForm' import { NucleusList } from '@/components/campaign/NucleusList' import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination' -import { NucleusTerritoryFields } from '@/components/campaign/NucleusTerritoryFields' +import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields' import { TseZoneInput } from '@/components/campaign/TseZoneInput' import { campaignNav, getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav' import { @@ -79,7 +79,7 @@ describe('campaign nucleus UI contracts', () => { }) it('adds a municipality as a chip via the strict combobox and derives its territory chip', async () => { - render(createElement(NucleusTerritoryFields)) + render(createElement(NucleusTerritoryAndZonesFields)) const city = screen.getByLabelText('Municípios') as HTMLInputElement fireEvent.focus(city) @@ -95,7 +95,7 @@ describe('campaign nucleus UI contracts', () => { it('uses neutral editable lookup identifiers and exposes only the canonical hidden fields', () => { const { container } = render( - createElement(NucleusTerritoryFields, { + createElement(NucleusTerritoryAndZonesFields, { fieldErrors: { regions: ['Território inválido.'], cities: ['Município inválido.'] }, }), ) @@ -131,7 +131,7 @@ describe('campaign nucleus UI contracts', () => { }) it('shows only word-start municipality matches', () => { - render(createElement(NucleusTerritoryFields)) + render(createElement(NucleusTerritoryAndZonesFields)) const city = screen.getByLabelText('Municípios') fireEvent.focus(city) @@ -144,7 +144,7 @@ describe('campaign nucleus UI contracts', () => { }) it('clears an invalid region or city draft on blur and exposes an error', () => { - render(createElement(NucleusTerritoryFields)) + render(createElement(NucleusTerritoryAndZonesFields)) const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement const city = screen.getByLabelText('Municípios') as HTMLInputElement @@ -162,7 +162,7 @@ describe('campaign nucleus UI contracts', () => { }) it('allows manually adding regions only until a city is selected, after which they become derived', async () => { - render(createElement(NucleusTerritoryFields)) + render(createElement(NucleusTerritoryAndZonesFields)) const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement const city = screen.getByLabelText('Municípios') as HTMLInputElement @@ -183,7 +183,7 @@ describe('campaign nucleus UI contracts', () => { }) it('derives regions from multiple cities across territories', () => { - render(createElement(NucleusTerritoryFields)) + render(createElement(NucleusTerritoryAndZonesFields)) const city = screen.getByLabelText('Municípios') as HTMLInputElement fireEvent.focus(city) @@ -204,7 +204,7 @@ describe('campaign nucleus UI contracts', () => { it('enables neighborhoods only when exactly one city is set, resetting them otherwise', async () => { render( - createElement(NucleusTerritoryFields, { + createElement(NucleusTerritoryAndZonesFields, { values: { cities: ['Seabra'], neighborhoods: ['Centro'] }, }), ) @@ -223,7 +223,7 @@ describe('campaign nucleus UI contracts', () => { }) it('removes a city chip and re-derives the territory from the remaining cities', async () => { - render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra', 'Salvador'] } })) + render(createElement(NucleusTerritoryAndZonesFields, { values: { cities: ['Seabra', 'Salvador'] } })) expect(screen.getByText('Chapada Diamantina')).toBeTruthy() expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy() @@ -235,7 +235,7 @@ describe('campaign nucleus UI contracts', () => { }) it('adds multiple neighborhoods via Enter and removes the last one via Backspace', () => { - render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra'] } })) + render(createElement(NucleusTerritoryAndZonesFields, { values: { cities: ['Seabra'] } })) const neighborhood = screen.getByLabelText('Bairros') as HTMLInputElement fireEvent.change(neighborhood, { target: { value: 'Centro' } }) @@ -369,6 +369,38 @@ describe('campaign nucleus UI contracts', () => { expect(parseNucleusUpdateFormData(formData).neighborhoods).toEqual([]) }) + it('offers opt-in TSE zone and sibling city suggestion chips and unions on accept', async () => { + render(createElement(NucleusTerritoryAndZonesFields)) + const city = screen.getByLabelText('Municípios') as HTMLInputElement + + fireEvent.focus(city) + fireEvent.change(city, { target: { value: 'Itiruçu' } }) + fireEvent.keyDown(city, { key: 'ArrowDown' }) + fireEvent.keyDown(city, { key: 'Enter' }) + + await waitFor(() => expect(screen.getByText('Itiruçu')).toBeTruthy()) + expect(screen.getByText('Vale do Jiquiriçá')).toBeTruthy() + + const zoneChip = await screen.findByRole('button', { + name: 'Adicionar zonas TSE de Itiruçu', + }) + expect( + screen.getByRole('button', { name: 'Adicionar zonas TSE de Vale do Jiquiriçá' }), + ).toBeTruthy() + + const expandCities = screen.queryByRole('button', { name: /\+\d+ sugestões/ }) + if (expandCities) fireEvent.click(expandCities) + const cityChip = await screen.findByRole('button', { name: 'Adicionar município Maracás' }) + + fireEvent.click(zoneChip) + await waitFor(() => expect(screen.getByLabelText('Remover Zona TSE 37')).toBeTruthy()) + expect(screen.queryByRole('button', { name: 'Adicionar zonas TSE de Itiruçu' })).toBeNull() + + fireEvent.click(cityChip) + await waitFor(() => expect(screen.getByLabelText('Remover município Maracás')).toBeTruthy()) + expect(screen.queryByRole('button', { name: 'Adicionar município Maracás' })).toBeNull() + }) + it('parses Zona TSE tags as sorted unique bounded numbers', () => { expect(parseTseZoneNumbers('58, 12 58\n5')).toEqual([5, 12, 58]) expect(() => parseTseZoneNumbers('58;59')).toThrow('apenas números') @@ -376,7 +408,11 @@ describe('campaign nucleus UI contracts', () => { }) it('renders sorted unique Zona TSE tags inside the editable input group', () => { - render(createElement(TseZoneInput, { defaultValues: [58] })) + const ControlledTseZoneInput = () => { + const [zones, setZones] = useState([58]) + return createElement(TseZoneInput, { value: zones, onChange: setZones }) + } + render(createElement(ControlledTseZoneInput)) const input = screen.getByLabelText('Adicionar Zona TSE') fireEvent.change(input, { target: { value: '12' } }) diff --git a/tests/unit/territorySuggestions.unit.spec.ts b/tests/unit/territorySuggestions.unit.spec.ts new file mode 100644 index 00000000..1f7985fb --- /dev/null +++ b/tests/unit/territorySuggestions.unit.spec.ts @@ -0,0 +1,114 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { citiesForTerritory } from '@/lib/bahiaTerritories' +import { tseZonesForCity, tseZonesForTerritory } from '@/lib/bahiaTseZones' +import { MAX_NUCLEUS_CITIES } from '@/lib/schemas/nucleus' +import { buildTerritorySuggestions } from '@/lib/territorySuggestions' + +describe('buildTerritorySuggestions', () => { + it('suggests missing city and territory zones for the Vale do Jiquiriçá example', () => { + const result = buildTerritorySuggestions({ + cities: ['Itiruçu'], + regions: ['Vale do Jiquiriçá'], + tseZones: [], + }) + + expect(result.zoneSuggestions).toEqual( + expect.arrayContaining([ + { + kind: 'city', + label: 'Itiruçu', + zonesToAdd: [...tseZonesForCity('Itiruçu')], + }, + { + kind: 'territory', + label: 'Vale do Jiquiriçá', + zonesToAdd: [...tseZonesForTerritory('Vale do Jiquiriçá')], + }, + ]), + ) + expect(result.zoneSuggestions.map((s) => s.label)).toEqual( + [...result.zoneSuggestions.map((s) => s.label)].sort((a, b) => a.localeCompare(b, 'pt-BR')), + ) + + expect(result.citySuggestions.map((s) => s.city)).toContain('Maracás') + expect(result.citySuggestions.find((s) => s.city === 'Maracás')?.kind).toBe('sibling') + expect(result.citySuggestions.map((s) => s.city)).not.toContain('Itiruçu') + }) + + it('hides city zone chip when that city set is complete but keeps territory chip', () => { + const itirucuZones = [...tseZonesForCity('Itiruçu')] + const result = buildTerritorySuggestions({ + cities: ['Itiruçu'], + regions: ['Vale do Jiquiriçá'], + tseZones: itirucuZones, + }) + + expect(result.zoneSuggestions.find((s) => s.kind === 'city' && s.label === 'Itiruçu')).toBeUndefined() + const territoryChip = result.zoneSuggestions.find( + (s) => s.kind === 'territory' && s.label === 'Vale do Jiquiriçá', + ) + expect(territoryChip).toBeDefined() + expect(territoryChip?.zonesToAdd.length).toBeGreaterThan(0) + for (const zone of itirucuZones) { + expect(territoryChip?.zonesToAdd).not.toContain(zone) + } + }) + + it('returns no zone chips when the territory union is already present', () => { + const valeZones = [...tseZonesForTerritory('Vale do Jiquiriçá')] + const valeCities = [...citiesForTerritory('Vale do Jiquiriçá')] + const result = buildTerritorySuggestions({ + cities: valeCities, + regions: ['Vale do Jiquiriçá'], + tseZones: valeZones, + }) + + expect(result.zoneSuggestions).toEqual([]) + // Shared zones may still suggest municipalities outside the TI — that is expected. + expect(result.citySuggestions.every((s) => !valeCities.includes(s.city))).toBe(true) + }) + + it('suggests municipalities from a selected shared zone and dedupes with siblings', () => { + const result = buildTerritorySuggestions({ + cities: ['Itiruçu'], + regions: ['Vale do Jiquiriçá'], + tseZones: [37], + }) + + const maracas = result.citySuggestions.filter((s) => s.city === 'Maracás') + expect(maracas).toHaveLength(1) + expect(maracas[0]?.kind).toBe('sibling') + }) + + it('suggests municipalities from zone alone when no region is selected', () => { + const result = buildTerritorySuggestions({ + cities: [], + regions: [], + tseZones: [37], + }) + + expect(result.citySuggestions.map((s) => s.city)).toEqual( + expect.arrayContaining(['Itiruçu', 'Maracás']), + ) + expect(result.citySuggestions.every((s) => s.kind === 'zone')).toBe(true) + }) + + it('suppresses city suggestions when the municipality quota is reached', () => { + const cities = [ + ...citiesForTerritory('Vale do Jiquiriçá'), + ...citiesForTerritory('Metropolitano de Salvador'), + ].slice(0, MAX_NUCLEUS_CITIES) + expect(cities).toHaveLength(MAX_NUCLEUS_CITIES) + + const result = buildTerritorySuggestions({ + cities, + regions: ['Vale do Jiquiriçá', 'Metropolitano de Salvador'], + tseZones: [1], + }) + + expect(result.citySuggestions).toEqual([]) + }) +})