diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index a3b291bc..b0d16a74 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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). diff --git a/docs/plans/pessoas-salvador-completa-dropdown-impl.md b/docs/plans/pessoas-salvador-completa-dropdown-impl.md new file mode 100644 index 00000000..ee83a651 --- /dev/null +++ b/docs/plans/pessoas-salvador-completa-dropdown-impl.md @@ -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
Salvador · 19 ids] + B --> C[MunicipalityPortfolioCell
hitDescription 'Todas as zonas'] + C --> D[RelationChipCell
commit 19 ids — máquina existente] + D --> E[buildMunicipalityPortfolioChips
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) diff --git a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx index 654c1f36..40948d64 100644 --- a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx +++ b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx @@ -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` } diff --git a/src/lib/municipalityPortfolio.ts b/src/lib/municipalityPortfolio.ts index 9b636205..c9e47ee0 100644 --- a/src/lib/municipalityPortfolio.ts +++ b/src/lib/municipalityPortfolio.ts @@ -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()) { diff --git a/tests/e2e/campaignLeaderships.e2e.spec.ts b/tests/e2e/campaignLeaderships.e2e.spec.ts index 1a5e0871..8047a3d7 100644 --- a/tests/e2e/campaignLeaderships.e2e.spec.ts +++ b/tests/e2e/campaignLeaderships.e2e.spec.ts @@ -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', diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index 753311b7..c9f2c969 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -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) + }) }) diff --git a/tests/unit/municipalityPortfolio.unit.spec.ts b/tests/unit/municipalityPortfolio.unit.spec.ts index 94265f61..b71c0c55 100644 --- a/tests/unit/municipalityPortfolio.unit.spec.ts +++ b/tests/unit/municipalityPortfolio.unit.spec.ts @@ -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) + }) }) diff --git a/tests/unit/municipalityPortfolioCell.unit.spec.ts b/tests/unit/municipalityPortfolioCell.unit.spec.ts index 307e7354..943a8fce 100644 --- a/tests/unit/municipalityPortfolioCell.unit.spec.ts +++ b/tests/unit/municipalityPortfolioCell.unit.spec.ts @@ -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') + }) +})