Merge pull request #708 from fsolla/C131-pessoas-salvador-como-opcao-no-dropdown-de-municipios-a

C131: 'Salvador' como opção agregada no dropdown de municípios
This commit is contained in:
Francisco Solla 2026-08-12 14:10:27 -03:00 committed by GitHub
commit d74d6e1253
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 279 additions and 4 deletions

View file

@ -5,6 +5,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-12):** **Remoção do `migration-lock`** (CI) — o check de `ci-pr.yml` que falhava quando >1 PR aberto tocava `src/migrations/`/`payload-types.ts` foi **removido** (deadlock de CI: com 2 PRs de schema abertos, AMBOS falhavam o check e nenhum mergeava sem bypass manual — 9 de 13 runs de CI falhos desde 11/08 foram por isso). A ameaça que ele serializava (cadeia de migrations com drift de snapshot) já é coberta pelo CI em todo PR e na main: `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation.int.spec.ts`, checksum do schema migrado vs config); o conflito real (index.ts/payload-types.ts) resolve no rebase. Removido: job `migration-lock` do `ci-pr.yml` + `needs` do rollup `checks` + contexto required da branch protection (`pnpm configure:branch-protection` agora aplica só `checks`); pool mantém a serialização de spawn de issues `needs:migration` (`countOpenSchemaPrs` em `agent-pool-github.mjs` — independe do CI). Docs/skills atualizados (AGENT-OPS contrato de PR, GUARDRAILS, engineering-standards, agent-pool, work-issue, agent-pr-workflow). Sem migration/access/Consent/UI/schema.
**Recently resolved (2026-08-11):** **C131** (Issue #698) — "Salvador" como opção agregada no dropdown de municípios das células de capacidade (pessoas ×3, assessores, lideranças, dobradinhas): o hit "Salvador — Todas as zonas" entra **primeiro** na busca (`searchMunicipalityPortfolio` ganha `kind: 'city'` derivado do catálogo — constante `NORMALIZED_ZONE_CITIES` com a contagem de zonas por cidade-zona; um toque adiciona as 19 ZEs num lote via máquina existente do `RelationChipCell` — delta otimista, cap/floor, undo, colapso "Salvador (19)" B178, e o exact-match C116 faz "salvador"+Enter eleger o agregado). **Gate de escopo por contagem:** o agregado só aparece quando a cidade inteira está no escopo `addableIds` do ator (assessor com carteira parcial segue com as opções individuais — a label decidida no gate "Todas as zonas" nunca mente); o matcher de word-start é query-prefixo-de-label, então um query "Salvador — ZE N" só surfacia o hit exato da ZE (o agregado nunca rouba o keyboard contract do teste B34 — verificado, sem guard); sem `count` no hit (descrição fixa); célula ganhou só um branch em `hitDescription`. Sem migration/access/Consent/UI shape; superfícies fora da célula não usam essa busca (wizard tem busca própria). Testes: unit busca pura (19 ids, soma com selecionado, supressão por escopo parcial, supressão tudo-atribuído, primeiro da lista), cell render (opção "Salvador"/"Todas as zonas"), e2e novo em `campaignPeople` (Lidera → agregado → chip "Remover Salvador — 19 municípios" → 19 zonas no DB, assert condicional se o claim caiu em zona). Gates: unit+int 2084/748 verdes, e2e pessoas+lideranças 11/11 (modo prod local `E2E_PROD=1` — prewarm dev estourou o orçamento de 180s sob carga, flake de contenção conhecida; C117 flakeou 1× em paralelo e passou isolado), build OK; knip segue na falha conhecida P3 de ambiente (payload.config.ts). Intenção [`pessoas-salvador-completa-dropdown.md`](plans/pessoas-salvador-completa-dropdown.md); impl [`pessoas-salvador-completa-dropdown-impl.md`](plans/pessoas-salvador-completa-dropdown-impl.md).
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).

View file

@ -0,0 +1,82 @@
# Impl: Pessoas: "Salvador" como opção no dropdown de municípios (agregado das 19 zonas)
Status: aprovado
Atualizado em: 2026-08-11
Issue: #698
Intenção: docs/plans/pessoas-salvador-completa-dropdown.md
Appetite restante: ~0,25–0,5 dia eng (herdado; sem corte)
## Leitura da intenção
- **Outcome:** o dropdown de adição de municípios (células de capacidade de `/campanha/pessoas` e demais superfícies do mesmo dropdown) oferece "Salvador" com descrição "Todas as zonas" como hit buscável, que adiciona as 19 zonas de uma vez; soma com o selecionado; respeita o escopo do assessor; a exibição colapsa no chip "Salvador (19)" que já existe.
- **O que NÃO negociar:** não é cidade virtual (nada de catálogo/collection/linha de lista nova); é atalho de seleção; escopo de acesso (nunca adiciona zona fora da carteira do assessor); label "Salvador" + "Todas as zonas" (decidido no gate, não reabrir).
- **O que reavaliar:** a hipótese de "Direção no codebase" aponta `searchMunicipalityPortfolio` + `MunicipalityPortfolioCell` — confirmada pelo grep: a busca é consumida **apenas** por `MunicipalityPortfolioCell` (assessores/lideranças/dobradinhas/pessoas herdam via a mesma célula). Nenhuma superfície fora da célula precisa de trabalho extra; o wizard de atividade usa busca própria (fora de escopo).
## Abordagem recomendada
```mermaid
flowchart LR
A[searchMunicipalityPortfolio] --> B[hit kind city<br/>Salvador · 19 ids]
B --> C[MunicipalityPortfolioCell<br/>hitDescription 'Todas as zonas']
C --> D[RelationChipCell<br/>commit 19 ids — máquina existente]
D --> E[buildMunicipalityPortfolioChips<br/>colapso 'Salvador 19' — B178 existente]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — estender `searchMunicipalityPortfolio` com um hit agregado `kind: 'city'` (primeiro da lista), mapeado pela célula com a descrição decidida. A máquina inteira do lado do consumo (delta otimista multi-id, cap/floor, undo toast, colapso de chip "Salvador (19)", remoção das 19, exact-match C116 que faz "Salvador" + Enter eleger o agregado) já existe e é reusada sem tocar em `RelationChipCell`.
**Rejeitadas:** B — criar entidade "Salvador" agregada no catálogo/collection: viola o anti-goal explícito da intenção (não entra no catálogo, não vira linha de lista) e exigiria migration + access + filtros; C — pós-filtrar/adicionar o agregado no client fora da busca pura: duplicaria a derivação de ids por cidade e o gate de escopo fora do módulo que já memoiza a derivação por index (`portfolioIndexDerivations`), além de não resolver a ordem sob o `limit`.
### Decisões de engenharia
**1. Gate de escopo — o agregado só aparece quando a cidade inteira está no escopo addable.**
Opções: A | B | C
Recomendação: A — o hit só é oferecido quando `idsByCity.get(city)` (derivado do index **escopado** por `addableIds`, então já é "o que o ator pode adicionar") tem exatamente o número de zonas que o catálogo tem para a cidade (19). Para assessor com carteira parcial, o agregado não aparece — as opções individuais "Salvador — ZE N" continuam servindo; para staff e assessor com a cidade inteira, aparece com todas as zonas.
Alternativas rejeitadas: B — mostrar com subset (agregado com só as zonas da carteira): a label decidida no gate ("Todas as zonas") mentiria e a contagem seria ambígua; C — mostrar sempre que houver ≥1 zona não atribuída: mesma mentira, pior.
**2. Posição do hit — primeiro, antes do loop de municípios.**
Recomendação: antes do loop de municípios — com `limit` 12 e 19 hits individuais "Salvador — ZE N", o agregado depois dos municípios nunca seria alcançado (o loop retorna ao bater o limite). Adicionalmente o exact-match C116 ("a linha cujo label normalizado == query") faz "Salvador" + Enter eleger o agregado — comportamento desejado do fluxo ("digito salvador → seleciono"). Sem regressão: o território "Metropolitano de Salvador" já era inalcançável para "salvador" antes (o limite cortava no loop de municípios).
**3. Sem `count` no hit `city`.**
Recomendação: a descrição do hit é fixa ("Todas as zonas" — decidida no gate), diferente de território/ZE que carregam a contagem na descrição; um campo não consumido é YAGNI. `MunicipalityPortfolioSearchHit` ganha `{ kind: 'city'; key: string; label: string; city: string; municipalityIds: number[] }`; a key segue a convenção do chip (`city:Salvador`). TS força o branch novo em `hitDescription` (o `return` final usaria `hit.count`).
**4. Nada muda em `RelationChipCell` / chips / escrita.**
O commit de um hit com `ids[]` de 19 já é o caminho de território/ZE (um toque = um lote na mesma transação); o colapso "Salvador (19)" e a remoção das 19 são B178. `toRelationHit` já cobre `city` no braço genérico (`hit.municipalityIds`).
### Componentes / mudanças
- **`searchMunicipalityPortfolio`** (`src/lib/municipalityPortfolio.ts`): novo bloco antes do loop de municípios, iterando `ZONE_MUNICIPALITY_CITIES` com constante estática (como `NORMALIZED_ZONE_LABELS`): nome normalizado + contagem de zonas no catálogo (`municipalityCatalogEntriesForCity(city).filter(e => e.kind === 'zona').length`). Gate de escopo por contagem, filtro de `alreadyAssignedIds`, push do hit `city`, respeito ao `limit`. Extensão do tipo `MunicipalityPortfolioSearchHit`.
- **`hitDescription`** (`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`): branch `hit.kind === 'city'` → `'Todas as zonas'`.
- **Migration:** nenhuma — lógica pura sobre catálogo estático; nada de schema/DB.
- **Access / Consent:** nenhum — read paths e write paths intactos; o escopo do assessor já é aplicado pelo `scopedPortfolioIndex` que o bloco consome.
- **UI:** Impeccable B — encaixe no dropdown existente; sem shape novo (mesma linha label + descrição dos demais hits). Copy: "Todas as zonas" (decidida no gate).
## Fases verificáveis
1. **Busca pura + tipos** — `src/lib/municipalityPortfolio.ts` + unit tests em `tests/unit/municipalityPortfolio.unit.spec.ts` (hit com 19 ids; soma com selecionado; supressão por escopo parcial; supressão com tudo atribuído; primeiro da lista).
2. **Célula** — `hitDescription` + unit test de render em `tests/unit/municipalityPortfolioCell.unit.spec.ts` (index com as 19 zonas; digita "salvador"; espera opção "Salvador" + descrição "Todas as zonas"). Se o Popover Radix flakear em jsdom, cair para cobertura pura (decisão registrada no impl).
3. **E2E** — 1 teste em `tests/e2e/campaignPeople.e2e.spec.ts` no padrão do `campaignLeaderships` (fill "salvador" na célula Lidera → opção "Salvador"/"Todas as zonas" → persist() → chip "Remover Salvador — 19 municípios" → estado no DB: 19 zonas + o município já vinculado, com assert condicional para o caso de o `claimMunicipality` cair em zona de Salvador).
4. **Gates** — `pnpm gate:fast` (tsc, lint, format, knip, cycles, test); e2e `campaignPeople` local; entrada curta em `docs/CHANGELOG-AGENTS.md`; push via `pnpm push` (PR Ready + merge auto).
Self-score decision-quality: 5/5 (rejeitadas documentadas; appetite cabe; rabbit holes nomeados; reusa busca/chips existentes — depth check sem módulo novo; aceite de produto intacto).
## Rabbit holes / Não escopo (engenharia)
- Nova entidade/collection/migration — anti-goal da intenção.
- Alterar `RelationChipCell`, copy, chips, ações de escrita — máquina existente cobre.
- Wizard de atividade / outras buscas que não passam por `searchMunicipalityPortfolio`.
- "Salvador" como facet/filtro/ordenação — fora de escopo da intenção.
- Agregados de outras cidades — o mecanismo é derivado de `ZONE_MUNICIPALITY_CITIES` (catálogo), cobre sozinho se um dia existir outra.
## Riscos e mitigação
- **`limit` 12 engole o agregado** → posicionado antes do loop de municípios (decisão 2).
- **Label "Todas as zonas" mentindo para assessor parcial** → gate de escopo por contagem (decisão 1); testado em unit.
- **Radix Popover em jsdom (cell test)** → tentativa com fallback explícito para cobertura pura; a regressão visível (descrição errada) também ficaria presa pelo e2e real.
- **e2e: `claimMunicipality` pode cair em zona de Salvador** → assert condicional pelo slug (`salvador-ze-*`): 19 ou 20 municípios no DB, sempre superset das 19 zonas.
- **Mudança de comportamento herdada pelas outras superfícies (assessores/lideranças/dobradinhas)** → é o aceite ("herdam o atalho sem trabalho extra"); readOnly não renderiza busca; `addableIds` de assessor editando assessor aplica o mesmo gate.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (opção buscável "Salvador" / "Todas as zonas"; soma com o selecionado; escopo; chip colapsado)
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access change, sem escrita multi-collection nova, identificadores EN / copy pt-BR)
- [x] Testes de domínio previstos (unit busca pura + cell render + e2e pessoas)

View file

@ -22,6 +22,7 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
const hitDescription = (hit: MunicipalityPortfolioSearchHit): string => {
if (hit.kind === 'municipality') return 'Município'
if (hit.kind === 'city') return 'Todas as zonas'
if (hit.kind === 'territory') return `Território · ${hit.count} municípios`
return `Zona eleitoral · ${hit.count} municípios`
}

View file

@ -75,6 +75,13 @@ export type MunicipalityPortfolioSearchHit =
municipalityIds: number[]
count: number
}
| {
/** A zone-city (Salvador) offered as one aggregate — "Salvador" adds every unassigned zone. */
kind: 'city'
key: string
label: string
municipalityIds: number[]
}
| {
kind: 'zone'
key: string
@ -320,6 +327,23 @@ const NORMALIZED_TERRITORIES: readonly { territory: BahiaIdentityTerritory; norm
normalized: normalizeSearchPhrase(territory),
}))
/**
* The aggregate "Salvador" search option, derived from the catalog: one entry per
* zone-city, with the number of zone entries the catalog defines for it. A zone
* count is what the aggregate's scope gate compares against, so a future
* zone-city is covered by this table without new code.
*/
const NORMALIZED_ZONE_CITIES: readonly {
city: string
normalized: string
zoneCount: number
}[] = ZONE_MUNICIPALITY_CITIES.map((city) => ({
city,
normalized: normalizeSearchPhrase(city),
zoneCount: municipalityCatalogEntriesForCity(city).filter((entry) => entry.kind === 'zona')
.length,
}))
const queryLooksLikeZone = (normalizedQuery: string): number | null => {
const match = normalizedQuery.match(/^(?:ze|zona)?\s*(\d{1,3})$/)
if (!match) return null
@ -339,10 +363,33 @@ export const searchMunicipalityPortfolio = (
// Normalize the query ONCE: it used to be re-normalized inside the matcher for
// each of ~660 candidates (435 names + 27 TI + up to 199 ZE labels).
const normalizedQuery = normalizeSearchPhrase(trimmed)
const { byId, idBySlug, idsByTerritory, normalizedNames } = portfolioIndexDerivations(index)
const { byId, idBySlug, idsByTerritory, idsByCity, normalizedNames } =
portfolioIndexDerivations(index)
const hits: MunicipalityPortfolioSearchHit[] = []
const zoneFromQuery = queryLooksLikeZone(normalizedQuery)
// The aggregate zone-city hit comes FIRST: its label ("Salvador") is the exact
// match for the query that reaches it, and the 19 individual zone hits would
// crowd it out of the `limit` if the municipality loop ran first.
for (const { city, normalized, zoneCount } of NORMALIZED_ZONE_CITIES) {
if (!matchesNormalizedAtWordStart(normalized, normalizedQuery)) continue
const cityIds = idsByCity.get(city)
// Scope gate: the aggregate's description says "Todas as zonas", so it is
// only offered when the actor may add every zone of the city — staff, or an
// advisor whose carteira covers the whole city. A partial scope keeps the
// individual zone hits, which never lie.
if (!cityIds || cityIds.length !== zoneCount) continue
const unassigned = cityIds.filter((id) => !alreadyAssignedIds.has(id))
if (unassigned.length === 0) continue
hits.push({
kind: 'city',
key: `city:${city}`,
label: city,
municipalityIds: unassigned,
})
if (hits.length >= limit) return hits
}
// `byId` is built in index order, so the suggestions stay alphabetical for the
// same reason the index is — and an entry the catalog cannot name is not here.
for (const entry of byId.values()) {

View file

@ -68,6 +68,9 @@ test.describe('campaign leaderships list', () => {
const linked = await fixtures.claimMunicipality()
const added = await fixtures.claimMunicipality()
// C131 — even a Salvador-zone query ("Salvador — ZE N") only surfaces the
// exact zone hit: the aggregate matches word-start PREFIX queries of
// "salvador", never a query longer than the label. No guard needed.
const keyboardQuery = await fixtures.claimMunicipality()
const { contactName, leadershipId } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Municípios',

View file

@ -1,4 +1,9 @@
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
import {
campaignPageChrome,
expect,
expectPostResponse,
test,
} from './fixtures/campaignE2EFixtures.js'
/**
* C100 — the unified people list: staff lands on `/campanha/pessoas`, sees the
@ -344,4 +349,69 @@ test.describe('Pessoas — lista unificada', () => {
expect(oneCityIndex).toBeGreaterThanOrEqual(0)
expect(twoCitiesIndex).toBeLessThan(oneCityIndex)
})
test('the Lidera cell offers Salvador as one aggregate and saves all 19 zones (C131)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const linked = await fixtures.claimMunicipality()
const { contactName, leadershipId } = await fixtures.createStaffLeadership({
namePrefix: 'Salvador Agregado',
municipalities: [linked],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/pessoas?q=${encodeURIComponent(contactName)}`)
await expect(campaignPageChrome(page, 'Pessoas')).toBeVisible()
// A leadership-only row has a single editable capacity cell, so the Lidera
// search input is the row's only combobox.
const row = page.getByRole('row', { name: new RegExp(contactName) })
const search = row.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
})
await search.fill('salvador')
// The aggregate is its own option — label "Salvador", description
// "Todas as zonas" — ahead of the 19 individual zone hits.
const aggregate = page
.getByRole('option')
.filter({ has: page.getByText('Salvador', { exact: true }) })
await expect(aggregate).toBeVisible()
await expect(aggregate.getByText('Todas as zonas')).toBeVisible()
const persisted = () => expectPostResponse(page, '/campanha/pessoas')
await Promise.all([persisted(), aggregate.click()])
// All 19 zones collapse into the existing "Salvador (19)" chip, removable
// in one tap — one POST carried the whole batch.
await expect(
page.getByRole('button', { name: 'Remover Salvador — 19 municípios' }),
).toBeVisible()
const zoneSlugs = Array.from({ length: 19 }, (_, index) => `salvador-ze-${index + 1}`)
const zoneRows = await campaign.payload.find({
collection: 'municipality',
where: { slug: { in: zoneSlugs } },
depth: 0,
limit: 0,
pagination: false,
})
expect(zoneRows.docs).toHaveLength(19)
const zoneIds = zoneRows.docs.map((zone) => zone.id)
const stored = await campaign.payload.findByID({
collection: 'leadership',
id: leadershipId,
depth: 0,
})
// `claimMunicipality` may have handed out one of Salvador's own zones — the
// aggregate still completes the city, so the set is the zones (19 or 20).
const linkedIsZone = linked.slug.startsWith('salvador-ze-')
expect(stored.municipalities).toHaveLength(linkedIsZone ? 19 : 20)
for (const zoneId of zoneIds) expect(stored.municipalities).toContain(zoneId)
expect(stored.municipalities).toContain(linked.id)
})
})

View file

@ -139,4 +139,47 @@ describe('municipalityPortfolio', () => {
const zoneHits = searchMunicipalityPortfolio('ZE 3', index, new Set())
expect(zoneHits.some((hit) => hit.kind === 'zone' && hit.zoneNumber === 3)).toBe(true)
})
it('offers Salvador as one aggregate hit with all 19 zones, first in the list', () => {
const index = buildIndex()
const hits = searchMunicipalityPortfolio('salvador', index, new Set())
expect(hits[0]).toMatchObject({ kind: 'city', key: 'city:Salvador', label: 'Salvador' })
expect(hits[0]?.kind === 'city' ? hits[0].municipalityIds : []).toHaveLength(19)
})
it('the Salvador aggregate omits already-assigned zones', () => {
const index = buildIndex()
const salvadorSlugs = slugsForCities(['Salvador'])
const salvadorIds = index
.filter((entry) => salvadorSlugs.has(entry.slug))
.map((entry) => entry.id)
const assigned = new Set(salvadorIds.slice(0, 5))
const hits = searchMunicipalityPortfolio('salvador', index, assigned)
expect(hits[0]?.kind).toBe('city')
expect(hits[0]?.kind === 'city' ? hits[0].municipalityIds : []).toHaveLength(14)
})
it('suppresses the aggregate when the whole city is not in the addable scope', () => {
const index = buildIndex()
const salvadorSlugs = slugsForCities(['Salvador'])
const threeZones = index.filter((entry) => salvadorSlugs.has(entry.slug)).slice(0, 3)
expect(threeZones).toHaveLength(3)
const hits = searchMunicipalityPortfolio('salvador', threeZones, new Set())
expect(hits.some((hit) => hit.kind === 'city')).toBe(false)
expect(hits.filter((hit) => hit.kind === 'municipality')).toHaveLength(3)
})
it('offers no aggregate when every zone is already assigned (the chip takes over)', () => {
const index = buildIndex()
const salvadorSlugs = slugsForCities(['Salvador'])
const salvadorIds = index
.filter((entry) => salvadorSlugs.has(entry.slug))
.map((entry) => entry.id)
const hits = searchMunicipalityPortfolio('salvador', index, new Set(salvadorIds))
expect(hits.some((hit) => hit.kind === 'city')).toBe(false)
})
})

View file

@ -41,12 +41,12 @@ const INDEX: MunicipalityPortfolioIndexEntry[] = [
const TERRITORY_IDS = INDEX.slice(0, TERRITORY_SLUGS.length).map((entry) => entry.id)
const ILHEUS_ID = 90
const cell = (municipalityIds: number[]) =>
const cell = (municipalityIds: number[], index: MunicipalityPortfolioIndexEntry[] = INDEX) =>
createElement(MunicipalityPortfolioCell, {
ownerId: 7,
ownerName: 'Maria Souza',
municipalityIds,
municipalityIndex: INDEX,
municipalityIndex: index,
commitAction: async () => ({ status: 'success' as const }),
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',
@ -107,3 +107,31 @@ describe('município portfolio cell — optimistic baseline', () => {
expect(screen.getByText('Ilhéus')).toBeTruthy()
})
})
describe('município portfolio cell — Salvador aggregate suggestion (C131)', () => {
// The default INDEX has no Salvador zones, so this test builds its own: the 19
// zone entries (the aggregate's search target) plus Ilhéus, so a non-matching
// sibling proves the suggestion is scoped to the query.
const SALVADOR_INDEX: MunicipalityPortfolioIndexEntry[] = [
...municipalityCatalog
.filter((entry) => entry.city === 'Salvador')
.map((entry, position) => ({ id: position + 1, slug: entry.slug })),
{ id: 90, slug: 'ilheus' },
]
it('offers "Salvador — Todas as zonas" as the first suggestion for "salvador"', async () => {
render(cell([], SALVADOR_INDEX))
fireEvent.change(
screen.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
}),
{ target: { value: 'salvador' } },
)
const options = await screen.findAllByRole('option')
const first = options[0]
expect(first?.textContent).toContain('Salvador')
expect(first?.textContent).toContain('Todas as zonas')
})
})