mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C131): Salvador como opção agregada no dropdown de municípios das células de capacidade
O dropdown de adição de municípios (pessoas ×3, assessores, lideranças, dobradinhas) oferece 'Salvador — Todas as zonas' como primeiro hit da busca: um toque adiciona as 19 ZEs num lote, via máquina existente do RelationChipCell (delta otimista, cap/floor, undo, colapso 'Salvador (19)' B178, exact-match C116). - searchMunicipalityPortfolio ganha kind 'city' (NORMALIZED_ZONE_CITIES, derivado do catálogo) com gate de escopo por contagem: o agregado só aparece quando a cidade inteira está no escopo addableIds do ator — a label 'Todas as zonas' nunca mente para assessor com carteira parcial. - hitDescription ganha o branch 'Todas as zonas'; sem migration/access/schema. - Testes: unit busca pura (19 ids, soma, escopo parcial, tudo atribuído, posição), cell render, e2e em campaignPeople (agregado → chip → 19 zonas no DB).
This commit is contained in:
parent
a6d5a2695c
commit
7e05dc925d
8 changed files with 279 additions and 4 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
82
docs/plans/pessoas-salvador-completa-dropdown-impl.md
Normal file
82
docs/plans/pessoas-salvador-completa-dropdown-impl.md
Normal 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)
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue