Merge pull request #475 from fsolla/B177-sollinha-responder-em-quais-cidades-x-foi-o-deputado-ma

feat(B177): Sollinha responde em quais cidades X foi o deputado mais votado (rank competitivo reverso, flexível)
This commit is contained in:
Francisco Solla 2026-08-09 02:46:17 -03:00 committed by GitHub
commit 5a7f3bbb8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1019 additions and 4 deletions

View file

@ -0,0 +1,94 @@
# Impl: Sollinha — responder “em quais cidades X foi o deputado mais votado” (rank competitivo reverso, flexível)
Status: aprovado
Atualizado em: 2026-08-09
Issue: #460
Intenção: docs/plans/sollinha-cidades-mais-votado.md
Appetite restante: ~1 dia eng (herdado); sem migration / collection / Consent / UI
## Registro de execução (2026-08-09)
- **Entrega:** implementado e validado — `src/lib/leadingMunicipalities.ts` (path do artefato, puro) + `src/utilities/ai/tools/getLeadingMunicipalities.ts` (tool; SQL window-rank para terceiros) + registro em `tools/index.ts` + seção pt-BR no `systemPrompt.ts`; unit (8) e int (7) specs próprios.
- **Divergência da hipótese da intenção:** nenhuma nos aceites; a decisão nova de engenharia (SQL `RANK()` window via drizzle no request path para terceiros, mantendo o artefato só para o candidato) foi deliberada e validada por int test contra a fórmula do builder do artefato.
- **Cobertura por tests:** SQL path com valores exatos da fixture (Salvador dobra zonas numa linha; 2222 top-N), equivalência SQL ≡ fórmula do artefato, resolução por número/nome + fallback de votos + not-found, lockdown de leader (unit).
- **Correções de fixture:** TSE code de Feira de Santana `35150 → 35157` nos CSVs de fixture (o `35150` não era canônico; `bahiaTseCityCodes`/snapshot oficial pina `35157`). Sem impacto em `electionResultsImport` (totais íntegros) nem `municipalityTicketPartnerData`.
- **Simplify:** fixes aplicados — `server-only` no tool + guard de convenção alargado para `@payloadcms/db-postgres`; `cityPageSlug` único (lib reusado por tool/teste) matando twin de `entries.length===1`; iterar `municipalityCatalogEntriesForCity` em vez de rebuildar mapa; `HISTORICAL_SERIES_YEARS` reusado (sem tuple local); resolução de candidato busca todas as linhas (`limit: 0`) para a ambigüidade nunca omitir o candidato certo; loader sem throw no `execute` (erro vira `{ error }` no chat); `assertCanReadElectionData` dentro do loader (defesa em profundidade, espelha `municipalityElectoralBaseline`); int test de fallback do registro.
- **Deferidos (registrados aqui, sem Issue):** (1) fixture de empate no limite do top-N — gatilho: mudar `RANK()`/DENSE_RANK ou o CTE; (2) nota de escopo do terceiro candidato (zona leaky inclusa no escopo cru da cidade) documentada no comentário do loader.
## Leitura da intenção
- **Outcome:** uma tool de leitura no chat Sollinha que responde, para um deputado federal (número/nome; default = candidato da campanha): “em quais municípios ficou em 1º (ou até a posição X)” num ano da série (2014/2018/2022; 2022 default) — com contagem, uma linha por cidade (417, Salvador como UMA cidade, nunca 19), semântica de empate = mesma do mapa, fail-safe “sem dados” quando não houver.
- **O que NÃO negociar:** leader lockdown (não conversa de municípios); não estender para outros cargos; uma linha por cidade; sem segunda fonte paralela desalinhada para o candidato da campanha (artefato imutável é a fonte da verdade); nunca inventar número; sem migration/collection/Consent.
- **O que reavaliar:** como derivar o rank de **terceiros candidatos** sem colocar o scan de ~100k linhas no path de request (B13 documenta `loadFederalVotesByCityZoneAndCandidate` como CLI/build-only). Hipótese da intenção é “derivar das coleções de votos”; a forma concreta (SQL window vs. scan no Node) é decisão desta engenharia.
## Abordagem recomendada
```mermaid
flowchart LR
U[chat /campanha] --> T[getLeadingMunicipalities]
T --> G{assertCanReadElectionData}
G -- leader/deny --> E[error pt-BR]
G -- staff --> D{candidate dado?}
D -- não/1313 --> A[artefato federalRankByIbgeCode<br/>instantâneo, imutável]
D -- outro número/nome --> R[resolve em electionCandidate<br/>/electionCandidateVote]
R -- 1 match --> S[SQL window RANK()<br/>election_candidate_vote]
R -- >1 match --> O[opções p/ desambiguar]
A --> M[monta linhas city/rank/votes/candidates<br/>+ slug p/ link]
S --> M2[city_code→city canônico + slug]
M --> Out[resposta: contagem + ranking]
M2 --> Out
```
**Opções consideradas:**
- **A — Híbrido fonte dupla sancionada:** candidato da campanha lê o artefato commitado (`federalRankByIbgeCode` + `municipalities[slug].votesByYear`); terceiros derivam ao vivo por uma query SQL window-rank única.
- **B — SQL window-rank para todos (inclusive Solla):** um único path; re-deriva a cada chamada o que o artefato já precomputa; põe o scan de ~100k linhas no chat (contra a restrição documentada por B13).
- **C — Scan `loadFederalVotesByCityZoneAndCandidate` no path do chat:** rejeitado por proibição explícita (CLI/build-only).
- **D — Resolver ranking por cidade via N queries Payload (uma por cidade):** ~417 round-trips por chamada; inviável.
**Recomendação: A.** Porque a intenção sanciona explicitamente o par artefato (candidato) + derivação (terceiros) e o artefato É a fonte do mapa — usar SQL para o candidato criaria a “segunda fonte paralela” que a intenção proíbe, a um custo de ~100k linhas por mensagem. **Rejeitadas:** B/C (scan no request + duplicação do artefato), D (latência).
**Semântica de rank = exatamente a do mapa:** o builder do artefato (`scripts/build-election-aggregates.mjs`) faz `rank = ahead + 1` (ahead = candidatos com votos > próprios), `candidates` = candidatos com votos > 0, e pula cidade onde o próprio candidato tem 0 votos. Isso é idêntico a `RANK() OVER (PARTITION BY city_code ORDER BY votes DESC)` após filtrar `votes > 0` — empate divide colocação e o denominador é honesto. O int test abaixo trava essa equivalência.
### Componentes / mudanças
- **`src/lib/leadingMunicipalities.ts`** (novo, puro — sem import de servidor; testável unit): tipos (`LeadingMunicipalityRow { city, slug, rank, votedCandidates, votes }`, `LeadingMunicipalitiesResult`), `campaignCandidateLeadingMunicipalities(year, topN)` (path do artefato: agrupa `municipalityCatalog` por cidade → `getFederalCompetitiveRank(ibgeCode, year)` filtra `rank ≤ topN` → soma `votesByYear` sobre os slugs da cidade → slug de página `null` para cidade-zona (Salvador) / slug do município caso contrário), `sortLeadingMunicipalityRows` (rank asc, votos desc). Sem duplicar o ranker JS (o SQL é a única implementação; o int test valida a fórmula ad hoc).
- **`src/utilities/ai/tools/getLeadingMunicipalities.ts`** (novo):
- `export const loadLeadingMunicipalitiesForCandidate(payload, { candidateNumber, year, topN })` — executa o SQL window-rank via `payload.db.drizzle` + `sql` de `@payloadcms/db-postgres` (precedente exato `supporterListOverviewAggregate.ts`), mapeia `city_code` → cidade canônica (`municipalityForTseCityCode`) → slug (`municipalityCatalogEntriesForCity`). Exportado para o int test importar o path real.
- tool factory `getLeadingMunicipalities(ctx)`: schema `{ candidate?: string, year?=2022, topN?=1 (1..20) }`; `execute` → `assertCanReadElectionData(ctx.user)` fail-closed (leader → `{ error: 'Leitura de dados eleitorais negada.' }`), valida ano em 2014/2018/2022, default/branch do candidato da campanha (inclusive quando o usuário resolve p/ o número 1313) → artefato; senão resolve candidato (`electionCandidate` por número exato ou nome ILIKE urnaName/completeName; fallback `electionCandidateVote` distinct quando o registro não achar; >1 → devolve opções pro modelo desambiguar) e usa o SQL.
- **Migration:** nenhuma.
- **Access / Consent:** `assertCanReadElectionData` (única redação da regra); nenhuma chave de Consent.
- **UI:** Impeccable A — resposta em markdown no chat existente; nenhuma superfície nova.
- **Registro:** `src/utilities/ai/tools/index.ts` + orientação em `src/utilities/ai/systemPrompt.ts` (pt-BR): quando usar, default Jorge Solla, “uma linha por cidade”, oferecer link via `buildCampaignLinks` quando útil, “sem dados” explícito.
### Dados → forma
- Forma: lista textual no chat — contagem total + linhas ordenadas (colocação, cidade, votos, “de N candidatos votados”). Rejeitada: tabela/badge/UI nova (fora do appetite; rabbit hole) e série/chart (anti-goal “leaderboard de vitórias”). Cada cidade tem um lugar; Salvador linha única (sem link de página única até B178 → `slug: null`).
## Fases verificáveis
1. **Tracer / server** — lib pura + tool + registro + system prompt; unit tests do path do artefato (Solla 2022: `total ≥ 1`, uma linha por cidade, Salvador única, `rank ≤ topN`, ordenação) e do lockdown (leader nega).
2. **Int test** (`tests/int/leadingMunicipalities.int.spec.ts`, sob `ELECTION_COLLECTIONS_LEASE_KEY` + fixtures TSE como `electionResultsImport.int.spec.ts`): importa fixtures, valida SQL path com outputs exatos (Salvador 1313 = rank 1, 2100 votos, 2 candidatos — zonas dobradas numa cidade; Feira idem; 2222 topN=1 vazio / topN=2 rank 2) e compara SQL × fórmula do builder (consistência de semântica).
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não criar “referência JS duplicada” do ranker (o SQL é a única implementação; teste fixa a semântica).
- Não estender a `bahiaElectionAggregates.ts` com accessors novos só para isto (itero o catálogo p/ chegar no rank por IBGE).
- Não travar/cachear o SQL (dados de eleição são imutáveis; cada chamada é boundary e o query é único e indexado).
- Não tocar nos tools existentes (`getTopDeputies`/`getMunicipalityVotes`) — correção do possível leak de leader neles é item separado (registrar como débito).
- Não mexer no artefato nem no builder do mapa.
## Riscos e mitigação
- **SQL com casas de enum:** `office`/`turn`/`vote_type` são enums → literais inline vindos de constantes do código (sem input do usuário), números por params — mesmo padrão dos aggregates C6.
- **Drift de schema da tabela:** nomes de colunas vêm da migration congelada; guarda `payload.db.name === 'postgres'` + `drizzle.execute` presente (precedente do overview de apoiadores).
- **Lista grande (≥ centenas de cidades) em topN alto:** resposta honesta; o modelo resume; cap `topN ≤ 20`.
- **Lockdown:** `assertCanReadElectionData` no topo do `execute` (fail-closed); testado.
- **SQL path vs artefato divergirem pro candidato da campanha:** mesma tabela TSE; int test trava a fórmula; sem mecanismo de drift em prod (artefato pré-computado).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (contagem, top-N, empate, Salvador 1 linha, default Solla, lockdown, sem inventar número)
- [ ] Invariantes AGENTS/engineering-standards (sem server-only no lib; bypass justificado; pt-BR no prompt; identificadores em inglês)
- [ ] Testes previstos: unit (artefato + lockdown) e int (SQL path + equivalência de semântica) — onde o path de acesso/dados muda

View file

@ -0,0 +1,39 @@
# B180 — Sollinha: travar tools eleitorais legadas contra leader (lockdown)
Status: plano
Atualizado em: 2026-08-09
Issue: (registrar com depends: [B177])
Priority: P2
Model: composer-2.5
Model-local: deepseek-v4-flash-high
Impeccable: A — N/A (sem UI; gate no execute das tools)
Canvas UI: N/A
Appetite: ~0,5 dia eng; editar duas tools + orientar prompt; sem migration / Consent / collection
## Intenção
A B177 introduziu a tool `getLeadingMunicipalities` com o gate fail-closed `assertCanReadElectionData(ctx.user)` no topo do `execute` (leader lockdown: "liderança não tem essa conversa sobre municípios"). As duas tools eleitorais legadas do chat — `getTopDeputies` (quem foi o mais votado num município) e `getMunicipalityVotes` (votação de Solla num município) — usam `overrideAccess: true` nas queries **sem** gate de papel, então um usuário com role `leader` consegue invocá-las no chat e ler dados eleitorais (o route `api/ai-chat` expõe todas as tools a todos os papéis; o prompt só desencoraja).
Não é uma regressão recente nem vazamento de dado sensível (dados TSE públicos), mas contraria o lockdown de produto: liderança não recebe conversa de municípios/dados eleitorais no Sollinha.
## Objetivo e aceite
- `getTopDeputies` e `getMunicipalityVotes` fazem `assertCanReadElectionData(ctx.user)` no topo do `execute` (try/catch → `{ error: 'Leitura de dados eleitorais negada.' }`, fail-closed, mesmo padrão da B177).
- Nenhum outro comportamento muda (staff continua idêntico).
- Sem migration / Consent / collection / UI.
## Direção no codebase
- `src/utilities/ai/tools/getTopDeputies.ts` e `getMunicipalityVotes.ts`: adicionar o gate no `execute`.
- Registrar gate compartilhado? B177 inlinou try/catch no execute; se o padrão replicar em 3 tools, um helper `withElectionDataGate` pode valer — avaliar no momento (regra: 2 call sites = inline; 3+ = extrair; aqui já são 3 com a nova).
- Sem mudança no system prompt (já orienta leader a não receber links; o gate agora é enforcement).
## Já resolvido no simplify da B177 (não reabrir)
- Tool nova `getLeadingMunicipalities` já nasce com o gate (e `assertCanReadElectionData` também dentro do loader, defesa em profundidade).
- Guard de convenção `codebaseConventions` alargado para exigir `server-only` em quem importa `@payloadcms/db-postgres`.
## Explicitamente fora
- Flake de CI `testDatabaseLease.int.spec.ts` (consent restore sob int paralelo) — descartado no triage da B177 (S2); reavaliar se recorrer.
- Consertar `searchEntities`/outras tools não-eleitorais para leader (fora do escopo: só as eleitorais).

View file

@ -0,0 +1,120 @@
/**
* Reversed competitive rank for the Sollinha chat: given a federal deputy
* (default: the campaign candidate), answer "in which municipalities was he
* the most voted (or up to top-N)" for a year of the TSE series.
*
* Pure module — no server/database imports. Two producers share one output
* shape and the SAME rank semantics the map paints:
* - the campaign candidate reads the committed immutable artifact
* (`getFederalCompetitiveRank` + `getMunicipalityFederalBaseline`), which
* is exactly the map's source;
* - any other federal deputy derives live from the TSE collections via a
* single SQL window query (see the ai tool in
* `src/utilities/ai/tools/getLeadingMunicipalities.ts`).
*
* Rank semantics (pinned to the artifact builder in
* `scripts/build-election-aggregates.mjs`): rank = candidates strictly ahead
* + 1 (ties share a placement), the denominator is the candidates with at
* least one vote in the city, a city where the candidate took no votes yields
* no row, and every city appears exactly once — Salvador's 19 zone
* municipalities fold into a single city row.
*/
import {
getFederalCompetitiveRank,
getMunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import {
municipalityCatalog,
municipalityCatalogEntriesForCity,
type MunicipalityCatalogEntry,
} from '@/lib/municipalityCatalog'
export type LeadingMunicipalityRow = {
/** Canonical city name where the candidate placed at or above top-N. */
city: string
/**
* Canonical municipality slug for a city-level page link, or `null` when the
* city has no single-municipality page (Salvador is 19 zone units — a city
* ranking must not point at a zone page).
*/
slug: string | null
/** Placement among the federal deputies voted in the city (1 = most voted). */
rank: number
/** Federal deputies with at least one vote in the city — the "de N candidatos" denominator. */
votedCandidates: number
/** The candidate's nominal votes inside the city geography (zones folded). */
votes: number
}
export type LeadingMunicipalitiesResult = {
candidate: { candidateNumber: number; name: string; party: string | null }
year: number
topN: number
/** Number of cities where the candidate placed at or above top-N. */
total: number
municipalities: LeadingMunicipalityRow[]
}
/** Sorts by placement asc, then votes desc — both producers present the same order. */
export const sortLeadingMunicipalityRows = (
rows: readonly LeadingMunicipalityRow[],
): LeadingMunicipalityRow[] =>
[...rows].sort((left, right) => left.rank - right.rank || right.votes - left.votes)
/**
* City-level page slug for a ranking row: a city with exactly one catalog unit
* links its municipality page; a city split into zone units (Salvador, 19)
* has no single page and stays `null`. The single spelling of the row rule.
*/
export const cityPageSlug = (entries: readonly MunicipalityCatalogEntry[]): string | null =>
entries.length === 1 ? entries[0]!.slug : null
const cityNames = () => new Set(municipalityCatalog.map((entry) => entry.city))
/**
* The campaign candidate's reversed ranking straight from the committed
* artifact — instant, immutable, exactly the map's competitive placement.
* `topN` is not clamped here: the tool clamps before calling.
*/
export const campaignCandidateLeadingMunicipalities = (
year: number,
topN: number,
): LeadingMunicipalitiesResult => {
const rows: LeadingMunicipalityRow[] = []
for (const city of cityNames()) {
const entries = municipalityCatalogEntriesForCity(city)
const first = entries[0]!
const competitive = getFederalCompetitiveRank(first.ibgeCode, year)
if (!competitive || competitive.rank > topN) continue
let votes = 0
for (const entry of entries) {
votes += getMunicipalityFederalBaseline(entry.slug).votesByYear[String(year)] ?? 0
}
rows.push({
city,
// A city with more than one catalog unit (Salvador, 19 zones) shares one
// placement and has no single page to link — the zone slugs stay hidden.
slug: cityPageSlug(entries),
rank: competitive.rank,
votedCandidates: competitive.candidates,
votes,
})
}
return {
candidate: {
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
name: BASELINE_TICKET_2022.candidate.name,
party: BASELINE_TICKET_2022.candidate.party,
},
year,
topN,
total: rows.length,
municipalities: sortLeadingMunicipalityRows(rows),
}
}

View file

@ -29,6 +29,14 @@ export const AI_SYSTEM_PROMPT = `Você é a Sollinha, assistente virtual da camp
- Apresente números grandes com separador de milhar (ex: 1.234.567).
- Percentuais sempre com 1 casa decimal (ex: 24,7%).
## Reversão de ranking (em quais cidades um deputado foi o mais votado)
- Quando o usuário perguntar "em quais cidades {eu / o candidato / o deputado X} foi o deputado mais votado", "onde X ficou em primeiro", "onde X se destacou" ou "top N em quais cidades", use a ferramenta "getLeadingMunicipalities".
- Candidato default = Jorge Solla (número 1313). Se o usuário citar outro deputado federal (número ou nome), passe esse valor no campo "candidate"; se a ferramenta devolver opções ambíguas, pergunte pelo número da urna.
- A resposta deve listar as cidades com a colocação relativa (ex.: "Salvador — 1º lugar, de 2 candidatos votados"), citar a contagem total ("em N cidades") e manter contexto relativo — nunca vire uma lista de "medalhas".
- Salvador aparece como UMA cidade (um único lugar), nunca como as 19 zonas; cada cidade aparece uma única vez.
- Quando a ferramenta devolver zero cidades / "sem dados", diga claramente que não há dados para aquele ano/candidato — nunca invente número.
- Ofereça link de navegação (buildCampaignLinks) para os municípios citados quando útil (entidade singular/concreta).
## Contexto eleitoral
- A eleição para deputado federal usa o sistema proporcional de lista aberta.
- O quociente eleitoral na Bahia em 2022 foi aproximadamente 210.000 votos.

View file

@ -0,0 +1,398 @@
import 'server-only'
import { sql } from '@payloadcms/db-postgres'
import { tool } from 'ai'
import type { Where } from 'payload'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { municipalityForTseCityCode } from '@/lib/bahiaTseCityCodes'
import {
BASELINE_TICKET_2022,
ELECTION_YEAR_2022,
FEDERAL_DEPUTY_OFFICE,
HISTORICAL_SERIES_YEARS,
} from '@/lib/electionResults'
import {
campaignCandidateLeadingMunicipalities,
cityPageSlug,
sortLeadingMunicipalityRows,
type LeadingMunicipalityRow,
} from '@/lib/leadingMunicipalities'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { drizzleResultRows } from '@/utilities/drizzleBulk'
/** Valid election years for the chat — the same TSE series the baseline reads. */
const VOTE_YEARS: readonly number[] = HISTORICAL_SERIES_YEARS
const MAX_TOP_N = 20
type CandidateIdentity = {
candidateNumber: number
name: string
party: string | null
}
type DeputyResolution = CandidateIdentity | { error: string; options?: CandidateIdentity[] }
type LeadingMunicipalitiesRow = {
city_code: string
candidate_number: number
candidate_name: string
party: string | null
votes: number
rank: number
voted_candidates: number
}
/**
* Per-city window ranking of every federal deputy for one year, filtered to
* the target candidate's placements up to `topN`. The `RANK()` over
* `votes DESC` after keeping only candidates with votes reproduces the
* artifact builder's semantics exactly: rank = candidates strictly ahead + 1
* (ties share a placement), the denominator is the voted candidates, a
* candidate with no votes in a city yields no row. Zone rows are folded per
* city by the CTE, so Salvador's 19 zones rank as one city.
*
* `office`/`turn`/`vote_type`/`state` are the collection's enum/text columns
* and MUST stay inline literals — a param-bound string against an enum column
* fails Postgres typing. They come from closed TSE constants, never user
* input; only year/candidateNumber/topN are bound.
*/
const buildLeadingMunicipalitiesSql = ({
year,
candidateNumber,
topN,
}: {
year: number
candidateNumber: number
topN: number
}) => sql`
WITH per_city_candidate AS (
SELECT
"city_code",
"candidate_number",
MAX("candidate_name") AS "candidate_name",
MAX("party") AS "party",
SUM("votes") AS "votes"
FROM "election_candidate_vote"
WHERE "year" = ${year}
AND "office" = 'deputado_federal'
AND "turn" = '1'
AND "vote_type" = 'nominal'
AND "state" = 'BA'
GROUP BY "city_code", "candidate_number"
),
ranked AS (
SELECT
"city_code",
"candidate_number",
"candidate_name",
"party",
"votes",
RANK() OVER (PARTITION BY "city_code" ORDER BY "votes" DESC) AS "rank",
COUNT(*) OVER (PARTITION BY "city_code") AS "voted_candidates"
FROM per_city_candidate
WHERE "votes" > 0
)
SELECT
"city_code",
"candidate_number",
"candidate_name",
"party",
"votes",
"rank",
"voted_candidates"
FROM ranked
WHERE "candidate_number" = ${candidateNumber}
AND "rank" <= ${topN}
ORDER BY "rank" ASC, "votes" DESC
`
type PostgresDb = {
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
}
/**
* Live reversed ranking for an arbitrary federal deputy, straight from the
* TSE vote collections — the only producer for non-campaign candidates. The
* heavy window aggregation runs inside Postgres; only the target candidate's
* rows travel to Node.
*
* Third-party ranks read the raw TSE city scope (every row under the city
* code, leaky zones included); the campaign candidate always reads the
* catalog-cut artifact, so the map's reading is never affected by the cut.
*/
export const loadLeadingMunicipalitiesForCandidate = async (
payload: AIToolContext['payload'],
user: AIToolContext['user'],
{ candidateNumber, year, topN }: { candidateNumber: number; year: number; topN: number },
): Promise<LeadingMunicipalityRow[]> => {
// Fail-closed at the loader too, mirroring the municipality election loaders:
// a future caller that forgets the tool gate cannot serve a leader anyway.
assertCanReadElectionData(user)
if (payload.db.name !== 'postgres') {
throw new Error('A leitura de cidades líderes exige o adaptador PostgreSQL.')
}
const database = payload.db as unknown as { drizzle?: PostgresDb }
const drizzle = database.drizzle
if (!drizzle || typeof drizzle.execute !== 'function') {
throw new Error('A sessão PostgreSQL da leitura de cidades líderes não está disponível.')
}
const result = await drizzle.execute(
buildLeadingMunicipalitiesSql({ year, candidateNumber, topN }),
)
const rows = (drizzleResultRows(result) as unknown as LeadingMunicipalitiesRow[]) ?? []
const leading: LeadingMunicipalityRow[] = []
for (const row of rows) {
// city_code is the catalog's TSE code — every seeded row resolves; an
// unknown code is skipped defensively instead of leaking a raw TSE name.
const city = municipalityForTseCityCode(row.city_code)
if (!city) continue
leading.push({
city,
slug: cityPageSlug(municipalityCatalogEntriesForCity(city)),
rank: Number(row.rank),
votedCandidates: Number(row.voted_candidates),
votes: Number(row.votes),
})
}
return sortLeadingMunicipalityRows(leading)
}
/** The four scope conditions shared by the candidate registry and vote rows. */
const candidateScopeWhere = (year: number): Where => ({
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ state: { equals: 'BA' } },
],
})
/**
* Ballot-number or name fragments for one collection. A number matches the
* unique per-scope candidateNumber; a name is folded with `like` (Payload's
* operator is case-insensitive and adds wildcards).
*/
const nameOrNumberFragments = (input: string, nameField: 'urnaName' | 'candidateName'): Where => {
const numeric = /^\d+$/.test(input) ? Number(input) : null
if (numeric !== null) return { candidateNumber: { equals: numeric } }
if (nameField === 'candidateName') return { candidateName: { like: input } }
return { or: [{ urnaName: { like: input } }, { completeName: { like: input } }] }
}
const collectDistinctCandidates = (
docs: ReadonlyArray<{ candidateNumber: number; name: string; party: string | null }>,
): Map<number, { name: string; party: string | null }> => {
const byNumber = new Map<number, { name: string; party: string | null }>()
for (const doc of docs) {
const number = Number(doc.candidateNumber)
if (!byNumber.has(number)) {
byNumber.set(number, { name: doc.name, party: doc.party })
}
}
return byNumber
}
/** Fallback: distinct federal-deputy rows of the year when the registry misses. */
const resolveFromVoteRows = async (
payload: AIToolContext['payload'],
year: number,
input: string,
): Promise<Map<number, { name: string; party: string | null }>> => {
const scope = candidateScopeWhere(year)
const votes = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
...scope.and!,
{ voteType: { equals: 'nominal' } },
nameOrNumberFragments(input, 'candidateName'),
],
},
depth: 0,
limit: 0,
pagination: false,
select: { candidateNumber: true, candidateName: true, party: true },
// Bypass: public TSE vote rows; access gated by assertCanReadElectionData at the tool level.
overrideAccess: true,
})
return collectDistinctCandidates(
(
votes.docs as unknown as Array<{
candidateNumber: number
candidateName: string
party: string | null
}>
).map((doc) => ({
candidateNumber: doc.candidateNumber,
name: doc.candidateName,
party: doc.party ?? null,
})),
)
}
/**
* Resolve a user-spoken federal deputy (ballot number or name) inside one
* year. Preference order: number in the candidate registry, name in the
* registry, then distinct rows in the vote collection. More than one distinct
* candidate → options so the model can disambiguate; none → error. Exported
* for the int spec to pin the "number or name" acceptance.
*/
export const resolveFederalDeputy = async (
payload: AIToolContext['payload'],
year: number,
input: string,
): Promise<DeputyResolution> => {
const scope = candidateScopeWhere(year)
const registry = await payload.find({
collection: 'electionCandidate',
where: {
and: [...scope.and!, nameOrNumberFragments(input, 'urnaName')],
},
depth: 0,
limit: 0,
pagination: false,
select: { candidateNumber: true, urnaName: true, party: true },
// Bypass: public TSE candidate registry; access gated by assertCanReadElectionData at the tool level.
overrideAccess: true,
})
let byNumber = collectDistinctCandidates(
(
registry.docs as unknown as Array<{
candidateNumber: number
urnaName: string
party: string | null
}>
).map((doc) => ({
candidateNumber: doc.candidateNumber,
name: doc.urnaName,
party: doc.party ?? null,
})),
)
if (byNumber.size === 0) {
byNumber = await resolveFromVoteRows(payload, year, input)
}
if (byNumber.size > 1) {
return {
error: `Encontrei mais de um deputado federal em ${year} para "${input}". Peça o número da urna para escolher:`,
options: [...byNumber.entries()].slice(0, 10).map(([candidateNumber, identity]) => ({
candidateNumber,
name: identity.name,
party: identity.party,
})),
}
}
const match = [...byNumber.entries()][0]
if (!match) {
return {
error: `Não encontrei deputado federal em ${year} com número/nome "${input}". Verifique e tente de novo.`,
}
}
const [candidateNumber, identity] = match
return { candidateNumber, name: identity.name, party: identity.party }
}
export const getLeadingMunicipalities = (ctx: AIToolContext) =>
tool({
description:
'Returns the municipalities where a federal deputy was the most voted (rank 1) or placed up to rank N, for a year. ' +
'The reversed reading of getTopDeputies: candidate → cities, not city → candidates. ' +
'Default candidate is the campaign candidate (Jorge Solla); any federal deputy can be queried by ballot number or name. ' +
'Ties share a placement; each city appears once (Salvador as a single city, never its 19 zones). ' +
'Use when the user asks "em quais cidades X foi o deputado mais votado?" / "onde X ficou em primeiro?" / "top N em quais cidades?". ' +
'Years: 2014, 2018 or 2022 (2022 default).',
inputSchema: z.object({
candidate: z
.string()
.optional()
.describe(
'Federal deputy by ballot number (e.g. "1313") or name (e.g. "Jorge Solla"). Omit for the campaign candidate (Jorge Solla).',
),
year: z
.number()
.int()
.optional()
.default(ELECTION_YEAR_2022)
.describe('Election year: 2014, 2018 or 2022.'),
topN: z
.number()
.int()
.optional()
.default(1)
.describe(
'Maximum placement to include: 1 = most voted, 3 = among the three most voted, etc. (max 20).',
),
}),
execute: async ({ candidate, year, topN }) => {
// Leader lockdown: municipal ranking conversations are staff-only.
try {
assertCanReadElectionData(ctx.user)
} catch {
return { error: 'Leitura de dados eleitorais negada.' }
}
if (!VOTE_YEARS.includes(year)) {
return {
error: 'Apenas os anos 2014, 2018 e 2022 têm dados eleitorais. Escolha um deles.',
}
}
const safeTopN = Math.min(Math.max(Math.trunc(topN), 1), MAX_TOP_N)
const normalized = candidate?.trim()
if (!normalized) {
return campaignCandidateLeadingMunicipalities(year, safeTopN)
}
const resolution = await resolveFederalDeputy(ctx.payload, year, normalized)
if ('error' in resolution) return resolution
if (resolution.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber) {
// The campaign candidate reads the committed artifact, even when the
// user spells him by number/name — one source, never two.
return campaignCandidateLeadingMunicipalities(year, safeTopN)
}
const municipalities = await runForCandidate(ctx, resolution, year, safeTopN)
if ('error' in municipalities) return municipalities
return {
candidate: resolution,
year,
topN: safeTopN,
total: municipalities.length,
municipalities,
}
},
})
/** Executes the SQL producer, converting loader guards into chat feedback. */
const runForCandidate = async (
ctx: AIToolContext,
resolution: CandidateIdentity,
year: number,
topN: number,
): Promise<LeadingMunicipalityRow[] | { error: string }> => {
try {
return await loadLeadingMunicipalitiesForCandidate(ctx.payload, ctx.user, {
candidateNumber: resolution.candidateNumber,
year,
topN,
})
} catch {
return { error: 'Não foi possível consultar as cidades líderes agora. Tente de novo.' }
}
}

View file

@ -11,6 +11,7 @@ import { buildCampaignLinks } from '@/utilities/ai/tools/buildCampaignLinks'
import { calculate } from '@/utilities/ai/tools/calculate'
import { getDobradinhas } from '@/utilities/ai/tools/getDobradinhas'
import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
@ -22,6 +23,7 @@ export const buildAITools = (ctx: AIToolContext) => ({
buildCampaignLinks: buildCampaignLinks(ctx),
getMunicipalityVotes: getMunicipalityVotes(ctx),
getTopDeputies: getTopDeputies(ctx),
getLeadingMunicipalities: getLeadingMunicipalities(ctx),
getDobradinhas: getDobradinhas(ctx),
getMunicipalityOverview: getMunicipalityOverview(ctx),
getLeaderships: getLeaderships(ctx),

View file

@ -1,7 +1,7 @@
ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;QT_APTOS;QT_COMPARECIMENTO;QT_ABSTENCOES;QT_VOTOS_VALIDOS;QT_VOTOS_NOMINAIS_VALIDOS;QT_VOTOS_LEGENDA;QT_VOTOS_BRANCOS;QT_VOTOS_NULOS;QT_VOTOS_ANULADOS
2022;1;BA;38490;SALVADOR;1;6;10000;8000;2000;7500;7300;200;300;200;0
2022;1;BA;38490;SALVADOR;2;6;9000;7200;1800;6800;6600;200;250;150;0
2022;1;BA;35150;FEIRA DE SANTANA;10;6;5000;4000;1000;3800;3700;100;120;80;0
2022;1;BA;35157;FEIRA DE SANTANA;10;6;5000;4000;1000;3800;3700;100;120;80;0
2022;1;BA;38490;SALVADOR;1;7;10000;8000;2000;7400;7200;200;300;300;0
2022;1;BA;38490;SALVADOR;1;3;10000;8000;2000;7600;7400;200;200;200;0
2022;2;BA;38490;SALVADOR;1;3;10000;8200;1800;7800;7600;200;200;200;0

1 ANO_ELEICAO NR_TURNO SG_UF CD_MUNICIPIO NM_MUNICIPIO NR_ZONA CD_CARGO QT_APTOS QT_COMPARECIMENTO QT_ABSTENCOES QT_VOTOS_VALIDOS QT_VOTOS_NOMINAIS_VALIDOS QT_VOTOS_LEGENDA QT_VOTOS_BRANCOS QT_VOTOS_NULOS QT_VOTOS_ANULADOS
2 2022 1 BA 38490 SALVADOR 1 6 10000 8000 2000 7500 7300 200 300 200 0
3 2022 1 BA 38490 SALVADOR 2 6 9000 7200 1800 6800 6600 200 250 150 0
4 2022 1 BA 35150 35157 FEIRA DE SANTANA 10 6 5000 4000 1000 3800 3700 100 120 80 0
5 2022 1 BA 38490 SALVADOR 1 7 10000 8000 2000 7400 7200 200 300 300 0
6 2022 1 BA 38490 SALVADOR 1 3 10000 8000 2000 7600 7400 200 200 200 0
7 2022 2 BA 38490 SALVADOR 1 3 10000 8200 1800 7800 7600 200 200 200 0

View file

@ -3,8 +3,8 @@ ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;NR_CANDIDA
2022;1;BA;38490;SALVADOR;1;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;800;#NULO#
2022;1;BA;38490;SALVADOR;2;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;900;ELEITO
2022;1;BA;38490;SALVADOR;2;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;1100;#NULO#
2022;1;BA;35150;FEIRA DE SANTANA;10;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;500;ELEITO
2022;1;BA;35150;FEIRA DE SANTANA;10;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;300;#NULO#
2022;1;BA;35157;FEIRA DE SANTANA;10;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;500;ELEITO
2022;1;BA;35157;FEIRA DE SANTANA;10;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;300;#NULO#
2022;1;BA;38490;SALVADOR;1;7;13131;ESTADUAL PT;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;400;#NULO#
2022;1;BA;38490;SALVADOR;1;3;13;JERÔNIMO;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;5000;2º TURNO
2022;1;BA;38490;SALVADOR;1;3;45;RIVAL GOV;PSDB;COLIGAÇÃO OPOSIÇÃO;4200;2º TURNO

1 ANO_ELEICAO NR_TURNO SG_UF CD_MUNICIPIO NM_MUNICIPIO NR_ZONA CD_CARGO NR_CANDIDATO NM_URNA_CANDIDATO SG_PARTIDO NM_COLIGACAO QT_VOTOS_NOMINAIS DS_SIT_TOT_TURNO
3 2022 1 BA 38490 SALVADOR 1 6 2222 RIVAL FEDERAL PL PARTIDO LIBERAL 800 #NULO#
4 2022 1 BA 38490 SALVADOR 2 6 1313 JORGE SOLLA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 900 ELEITO
5 2022 1 BA 38490 SALVADOR 2 6 2222 RIVAL FEDERAL PL PARTIDO LIBERAL 1100 #NULO#
6 2022 1 BA 35150 35157 FEIRA DE SANTANA 10 6 1313 JORGE SOLLA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 500 ELEITO
7 2022 1 BA 35150 35157 FEIRA DE SANTANA 10 6 2222 RIVAL FEDERAL PL PARTIDO LIBERAL 300 #NULO#
8 2022 1 BA 38490 SALVADOR 1 7 13131 ESTADUAL PT PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 400 #NULO#
9 2022 1 BA 38490 SALVADOR 1 3 13 JERÔNIMO PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 5000 2º TURNO
10 2022 1 BA 38490 SALVADOR 1 3 45 RIVAL GOV PSDB COLIGAÇÃO OPOSIÇÃO 4200 2º TURNO

View file

@ -0,0 +1,235 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { municipalityForTseCityCode } from '@/lib/bahiaTseCityCodes'
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
import type { BuiltElectionResults } from '@/lib/electionResultsBuild'
import { cityPageSlug, type LeadingMunicipalityRow } from '@/lib/leadingMunicipalities'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import {
loadLeadingMunicipalitiesForCandidate,
resolveFederalDeputy,
} from '@/utilities/ai/tools/getLeadingMunicipalities'
import { buildImportBundles, importElectionBundles } from '@/utilities/electionResultsImport'
import { stub } from '../helpers/stub'
import {
acquireTestDatabaseLease,
ELECTION_COLLECTIONS_LEASE_KEY,
type TestDatabaseLease,
} from '../helpers/testDatabaseLease'
import { loadTseFixtureResults } from '../helpers/tseFixtures'
let payload: Payload
let electionCollectionsLease: TestDatabaseLease
const staffUser = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
const deleteAllElectionData = async () => {
for (const collection of [
'electionCandidateVote',
'electionTally',
'electionCandidate',
] as const) {
const existing = await payload.find({
collection,
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
for (const doc of existing.docs) {
await payload.delete({ collection, id: doc.id, overrideAccess: true })
}
}
}
const importFixture2022 = async () =>
importElectionBundles(payload, buildImportBundles(loadTseFixtureResults()))
const loadRows = (candidateNumber: number, topN: number): Promise<LeadingMunicipalityRow[]> =>
loadLeadingMunicipalitiesForCandidate(payload, staffUser, {
candidateNumber,
year: ELECTION_YEAR_2022,
topN,
})
/**
* In-memory re-statement of the artifact builder's rank formula
* (`scripts/build-election-aggregates.mjs`): rank = candidates strictly ahead
* + 1, denominator = candidates with votes > 0, no row when the target has no
* votes in the city. The int test asserts the SQL window query produces
* exactly this — pinning "same semantics as the map" without shipping a
* second ranker.
*/
const rankFromBuiltVotes = (
built: BuiltElectionResults,
candidateNumber: number,
topN: number,
): Map<string, { rank: number; votedCandidates: number; votes: number }> => {
const byCity = new Map<string, Map<number, number>>()
for (const row of built.votes) {
if (
row.office !== 'deputado_federal' ||
row.turn !== '1' ||
row.voteType !== 'nominal' ||
row.votes <= 0
) {
continue
}
let byCandidate = byCity.get(row.cityCode)
if (!byCandidate) {
byCandidate = new Map()
byCity.set(row.cityCode, byCandidate)
}
byCandidate.set(row.candidateNumber, (byCandidate.get(row.candidateNumber) ?? 0) + row.votes)
}
const expected = new Map<string, { rank: number; votedCandidates: number; votes: number }>()
for (const [cityCode, byCandidate] of byCity) {
const ownVotes = byCandidate.get(candidateNumber)
if (!ownVotes || ownVotes <= 0) continue
let ahead = 0
let votedCandidates = 0
for (const votes of byCandidate.values()) {
if (votes <= 0) continue
votedCandidates += 1
if (votes > ownVotes) ahead += 1
}
const rank = ahead + 1
if (rank <= topN) {
expected.set(cityCode, { rank, votedCandidates, votes: ownVotes })
}
}
return expected
}
describe('getLeadingMunicipalities — live SQL path and candidate resolution (B177)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
// The import spec wipes every election row; this lease serializes the
// fixture window against the other election-collection specs.
electionCollectionsLease = await acquireTestDatabaseLease(
payload,
ELECTION_COLLECTIONS_LEASE_KEY,
)
await deleteAllElectionData()
await importFixture2022()
}, 120_000)
afterAll(async () => {
await deleteAllElectionData()
await electionCollectionsLease.release()
}, 120_000)
it('folds Salvador zones into one city row with exact ranks and votes', async () => {
const rows = await loadRows(1313, 1)
expect(rows).toEqual([
{
city: 'Salvador',
slug: null,
rank: 1,
votedCandidates: 2,
votes: 2100,
},
{
city: 'Feira de Santana',
slug: cityPageSlug(municipalityCatalogEntriesForCity('Feira de Santana')),
rank: 1,
votedCandidates: 2,
votes: 500,
},
])
})
it('top-N widens a candidate who never reached rank 1', async () => {
expect(await loadRows(2222, 1)).toEqual([])
const top2 = await loadRows(2222, 2)
expect(top2.map((row) => [row.city, row.rank, row.votes])).toEqual([
['Salvador', 2, 1900],
['Feira de Santana', 2, 300],
])
})
it('matches the in-memory artifact-builder formula on the same fixture rows', async () => {
const expected = rankFromBuiltVotes(loadTseFixtureResults(), 1313, 20)
const cityCodeByCity = new Map<string, string>()
for (const cityCode of expected.keys()) {
const city = municipalityForTseCityCode(cityCode)
if (city) cityCodeByCity.set(city, cityCode)
}
// Non-vacuous: every expected city must resolve to a canonical name, so a
// wrong fixture code can no longer hide the city from both sides.
expect(cityCodeByCity.size).toBe(expected.size)
const rows = await loadRows(1313, 20)
expect(new Set(rows.map((row) => row.city))).toEqual(new Set(cityCodeByCity.keys()))
for (const row of rows) {
const cityCode = cityCodeByCity.get(row.city)
expect(cityCode, row.city).toBeDefined()
const expectedRow = expected.get(cityCode!)!
expect(row.rank).toBe(expectedRow.rank)
expect(row.votedCandidates).toBe(expectedRow.votedCandidates)
expect(row.votes).toBe(expectedRow.votes)
}
})
it('resolves the campaign candidate by ballot number and by name', async () => {
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, '1313')).resolves.toEqual({
candidateNumber: 1313,
name: 'JORGE SOLLA',
party: 'PT',
})
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, 'Jorge Solla')).resolves.toEqual(
{ candidateNumber: 1313, name: 'JORGE SOLLA', party: 'PT' },
)
})
it('resolves a rival by ballot number and by urn name', async () => {
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, '2222')).resolves.toEqual({
candidateNumber: 2222,
name: 'RIVAL FEDERAL',
party: 'PL',
})
await expect(
resolveFederalDeputy(payload, ELECTION_YEAR_2022, 'RIVAL FEDERAL'),
).resolves.toEqual({ candidateNumber: 2222, name: 'RIVAL FEDERAL', party: 'PL' })
})
it('falls back to the vote rows when the candidate registry misses', async () => {
// Drop 2222 from the registry only — the vote rows stay — so resolution
// takes the fallback path instead of the registry.
await payload.delete({
collection: 'electionCandidate',
where: {
and: [{ year: { equals: ELECTION_YEAR_2022 } }, { candidateNumber: { equals: 2222 } }],
},
overrideAccess: true,
})
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, '2222')).resolves.toEqual({
candidateNumber: 2222,
name: 'RIVAL FEDERAL',
party: 'PL',
})
// Restore the fixture so later file-scoped reads see a consistent registry.
await importFixture2022()
})
it('reports a not-found candidate without inventing a number', async () => {
const resolution = await resolveFederalDeputy(payload, ELECTION_YEAR_2022, 'NINGUEM')
expect('error' in resolution && !('options' in resolution)).toBe(true)
})
})

View file

@ -49,9 +49,12 @@ describe('server-only boundary in src/utilities', () => {
// P3-K widened the module set: value-imports of next/cache, next/server
// and next/headers are equally server-bound (municipalityRevalidation and
// campaignJsonMutationRoute were invisible to the Payload-only sweep).
// B177 widened it again: @payloadcms/db-postgres is the raw-SQL adapter
// used by request-path aggregates (getLeadingMunicipalities) and must be
// server-bound too.
const importsServerValues = [
...source.matchAll(
/^import\s+([^'"]+?)\s+from\s+['"](payload|@payload-config|next\/cache|next\/server|next\/headers)['"]/gm,
/^import\s+([^'"]+?)\s+from\s+['"](payload|@payload-config|next\/cache|next\/server|next\/headers|@payloadcms\/db-postgres(?:\/drizzle)?)['"]/gm,
),
].some(([, clause]) => !isTypeOnlyClause(clause!))

View file

@ -0,0 +1,116 @@
import type { CampaignUser } from '@/payload-types'
import { describe, expect, it } from 'vitest'
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults'
import {
campaignCandidateLeadingMunicipalities,
sortLeadingMunicipalityRows,
type LeadingMunicipalityRow,
} from '@/lib/leadingMunicipalities'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { stub } from '../helpers/stub'
describe('campaignCandidateLeadingMunicipalities (B177 artifact path)', () => {
it('renders the Solla 2022 rank-1 set with one line per city', () => {
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 1)
expect(result.candidate.candidateNumber).toBe(BASELINE_TICKET_2022.candidate.candidateNumber)
expect(result.year).toBe(ELECTION_YEAR_2022)
expect(result.topN).toBe(1)
expect(result.total).toBe(result.municipalities.length)
expect(result.total).toBeGreaterThan(0)
const cities = result.municipalities.map((row) => row.city)
expect(new Set(cities).size).toBe(cities.length)
for (const row of result.municipalities) {
expect(row.rank).toBe(1)
expect(row.votes).toBeGreaterThan(0)
expect(row.votedCandidates).toBeGreaterThanOrEqual(1)
if (row.city === 'Salvador') {
expect(row.slug).toBeNull()
} else {
expect(row.slug).toBeTruthy()
}
}
})
it('orders placements asc, then votes desc', () => {
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 3)
const ranks = result.municipalities.map((row) => row.rank)
expect([...ranks].sort((a, b) => a - b)).toEqual(ranks)
for (let index = 1; index < ranks.length; index += 1) {
if (ranks[index - 1] === ranks[index]) {
expect(result.municipalities[index - 1]!.votes).toBeGreaterThanOrEqual(
result.municipalities[index]!.votes,
)
}
}
})
it('a deeper top-N is a superset of a shallower one (same source)', () => {
const top1 = new Set(
campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 1).municipalities.map(
(row) => row.city,
),
)
const top3Cities = new Set(
campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 3).municipalities.map(
(row) => row.city,
),
)
for (const city of top1) expect(top3Cities.has(city)).toBe(true)
})
it('Salvador folds into one row and never links a zone page', () => {
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 20)
const salvador = result.municipalities.filter((row) => row.city === 'Salvador')
expect(salvador.length).toBeLessThanOrEqual(1)
if (salvador[0]) {
expect(salvador[0]?.slug).toBeNull()
}
const pageSlugs = result.municipalities
.filter((row) => row.slug !== null)
.map((row) => row.slug)
expect(new Set(pageSlugs).size).toBe(pageSlugs.length)
})
it('links the catalog slug that resolves back to the same canonical city', () => {
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 1)
const linked = result.municipalities.find((row) => row.slug !== null)
expect(linked).toBeDefined()
expect(getMunicipalityCatalogEntry(linked!.slug!)?.city).toBe(linked?.city)
})
})
describe('sortLeadingMunicipalityRows', () => {
it('orders by rank asc and votes desc within a tie', () => {
const rows: LeadingMunicipalityRow[] = [
{ city: 'A', slug: 'a', rank: 1, votedCandidates: 2, votes: 100 },
{ city: 'B', slug: 'b', rank: 2, votedCandidates: 2, votes: 900 },
{ city: 'C', slug: 'c', rank: 1, votedCandidates: 2, votes: 300 },
{ city: 'D', slug: 'd', rank: 2, votedCandidates: 2, votes: 500 },
]
expect(sortLeadingMunicipalityRows(rows).map((row) => row.city)).toEqual(['C', 'A', 'B', 'D'])
})
it('does not mutate the input', () => {
const rows: LeadingMunicipalityRow[] = [
{ city: 'X', slug: null, rank: 2, votedCandidates: 2, votes: 10 },
{ city: 'Y', slug: 'y', rank: 1, votedCandidates: 2, votes: 10 },
]
sortLeadingMunicipalityRows(rows)
expect(rows[0]?.city).toBe('X')
})
})
describe('leader lockdown (B177)', () => {
it('assertCanReadElectionData rejects a leader and accepts staff', () => {
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
expect(() => assertCanReadElectionData(leader)).toThrow('Leitura de dados eleitorais negada')
expect(() => assertCanReadElectionData(coordinator)).not.toThrow()
})
})