diff --git a/docs/plans/sollinha-cidades-mais-votado-impl.md b/docs/plans/sollinha-cidades-mais-votado-impl.md new file mode 100644 index 00000000..e9825fab --- /dev/null +++ b/docs/plans/sollinha-cidades-mais-votado-impl.md @@ -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
instantâneo, imutável] + D -- outro número/nome --> R[resolve em electionCandidate
/electionCandidateVote] + R -- 1 match --> S[SQL window RANK()
election_candidate_vote] + R -- >1 match --> O[opções p/ desambiguar] + A --> M[monta linhas city/rank/votes/candidates
+ 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 diff --git a/docs/plans/sollinha-tools-eleitorais-leader-lockdown.md b/docs/plans/sollinha-tools-eleitorais-leader-lockdown.md new file mode 100644 index 00000000..98b80f3e --- /dev/null +++ b/docs/plans/sollinha-tools-eleitorais-leader-lockdown.md @@ -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). diff --git a/src/lib/leadingMunicipalities.ts b/src/lib/leadingMunicipalities.ts new file mode 100644 index 00000000..65d65c21 --- /dev/null +++ b/src/lib/leadingMunicipalities.ts @@ -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), + } +} diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index 5a58c252..3a81de08 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -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. diff --git a/src/utilities/ai/tools/getLeadingMunicipalities.ts b/src/utilities/ai/tools/getLeadingMunicipalities.ts new file mode 100644 index 00000000..906bce73 --- /dev/null +++ b/src/utilities/ai/tools/getLeadingMunicipalities.ts @@ -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) => Promise +} + +/** + * 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 => { + // 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 => { + const byNumber = new Map() + 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> => { + 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 => { + 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 => { + 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.' } + } +} diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts index 35688a46..314cf3e5 100644 --- a/src/utilities/ai/tools/index.ts +++ b/src/utilities/ai/tools/index.ts @@ -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), diff --git a/tests/fixtures/tse/detalhe_votacao_munzona_fixture.csv b/tests/fixtures/tse/detalhe_votacao_munzona_fixture.csv index 55ac6e70..38612e6f 100644 --- a/tests/fixtures/tse/detalhe_votacao_munzona_fixture.csv +++ b/tests/fixtures/tse/detalhe_votacao_munzona_fixture.csv @@ -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 diff --git a/tests/fixtures/tse/votacao_candidato_munzona_fixture.csv b/tests/fixtures/tse/votacao_candidato_munzona_fixture.csv index efb58929..175aea85 100644 --- a/tests/fixtures/tse/votacao_candidato_munzona_fixture.csv +++ b/tests/fixtures/tse/votacao_candidato_munzona_fixture.csv @@ -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 diff --git a/tests/int/leadingMunicipalities.int.spec.ts b/tests/int/leadingMunicipalities.int.spec.ts new file mode 100644 index 00000000..9af89eff --- /dev/null +++ b/tests/int/leadingMunicipalities.int.spec.ts @@ -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({ 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 => + 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 => { + const byCity = new Map>() + 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() + 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() + 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) + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 1a246f54..1558a0c3 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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!)) diff --git a/tests/unit/leadingMunicipalities.unit.spec.ts b/tests/unit/leadingMunicipalities.unit.spec.ts new file mode 100644 index 00000000..c7e5e2b4 --- /dev/null +++ b/tests/unit/leadingMunicipalities.unit.spec.ts @@ -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({ collection: 'campaignUser', role: 'leader' }) + const coordinator = stub({ collection: 'campaignUser', role: 'coordinator' }) + expect(() => assertCanReadElectionData(leader)).toThrow('Leitura de dados eleitorais negada') + expect(() => assertCanReadElectionData(coordinator)).not.toThrow() + }) +})