Merge branch 'cursor/f9f44897'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 03:41:44 -03:00
commit 378b19b20b
17 changed files with 262 additions and 398 deletions

View file

@ -7,9 +7,9 @@ alwaysApply: false
## Status
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B78 sobre chrome único.
- **B79–B90 registrados (2026-07-30):** pós-**B73** (remover bottom nav), chrome mobile fora do Início = bottom drawer **não-modal** (ações contextuais + busca global) — chassis **B79** + catálogos por vertical **B80–B90** (Municípios, Territórios, Lideranças, Dobradinhas, Atividades, Demandas, Apoiadores, Assessores, Organizações, Contatos leader, Conceitos+Perfil). **Quadro fora** do inventário. Granularidade = uma vertical (lista+detalhe). Lote inteiro **não cortável**. Planos em `docs/plans/chassis-bottom-drawer-acoes-rapidas.md` e `acoes-rapidas-*.md`.
- **B77 registrado (2026-07-30):** simplificar wizard ajuste de votos — três cenários numa tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm com highlight; substitui ritual `?cenario=` de B61/B76. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md).
- **B77 entregue em código (2026-07-30):** wizard ajuste de votos numa tela — `WizardExpectedVotesStep` substitui o ritual `?cenario=` de B61/B76 por editor único (strip + 3 inputs compact + atalhos no cenário focado); coerência `pessimistic ≤ central ≤ optimistic` só no confirm (`getWizardVoteViolation` + `getWizardVoteViolationHighlights`); `?cenario=` legado → redirect canônico; órfãos `wizardVoteStepTitle`/`mergeWizardVoteEstimate`/etc. removidos. Unit `wizardExpectedVotesStep`/`wizardVoteEstimate`; e2e B61 atualizado. Sem migration. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md). Desbloqueia wiring A1 com menos atrito antes de **B63**/**B64**.
- **B71 entregue em código (2026-07-30):** polimento visual dos resultados de busca no Início (UX-1) — `src/lib/homeSearchUi.ts` (`HOME_SEARCH_HIT_ROW_WRAPPER_CLASS` = bleed + hover + `focus-within`; heading/list); `HomeSearchHitRow` faixa full-bleed `-mx-4 px-4 md:-mx-6 md:px-6` (mantém `onMouseDown` preventDefault de B66); todos os `HomeSearch*Group` com `ul list-none` e título `text-xs` sentence-case (sem `uppercase`/`tracking-wide`). Unit `homeSearchUi.unit.spec.tsx`. Sem migration/action/JSON. Plano: [`polimento-resultados-busca-inicio.md`](../../../docs/plans/polimento-resultados-busca-inicio.md).
- **B66 entregue em código (2026-07-30):** modo focado da busca no **focus** (emenda B47) — `homeSearchUiFocused` (`inputFocused || query.isActive`) já no contrato/hook; overlap B68 (sugestões no focus vazio). **Nesta entrega:** `CampaignHomeLayout` retrai summary+spacer (`data-slot="home-chrome"`) e actions com grid `0fr/1fr` + opacity 220ms (`motion-reduce:transition-none`) em vez de `hidden`; `md:flex-none` no chrome; `HomeSearchHitRow` `onMouseDown` preventDefault (blur→click); e2e focus sem digitar + regressão com digitação. Sem migration. Plano: [`modo-focado-busca-no-focus.md`](../../../docs/plans/modo-focado-busca-no-focus.md).
- **B61 entregue em código (2026-07-29):** etapa de ajuste de votos no wizard A1 — `WizardExpectedVotesStep` substitui o stub B60 em `atualizar-votos` com `?municipio=`; três passos explícitos média→pessimista→otimista via `?cenario=`; atalhos `2×`/±50/±100; validação client com jump+warning (`getWizardVoteViolation`); **um POST batch** ao concluir otimista (`POST /campanha/municipios/expected-votes`). Helpers em `wizardVoteEstimate.ts` + `wizardActionHref` estendido. Sem migration/Consent. Unit `wizardVoteEstimate`; e2e avanço de cenário + violação. Plano: [`ajuste-votos-wizard.md`](../../../docs/plans/ajuste-votos-wizard.md). Desbloqueia wiring A1 / **B64** (prefill votos).

View file

@ -70,6 +70,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-30):** **B77** Simplificar wizard ajuste de votos — `WizardExpectedVotesStep` reescrito: três cenários numa tela (`VoteEstimateScenarioInputs` compact + strip + atalhos no foco); validação `pessimistic ≤ central ≤ optimistic` só no confirm com `Alert` + highlight do par conflitante (`getWizardVoteViolationHighlights`); POST batch inalterado (`MUNICIPALITY_EXPECTED_VOTES_ENDPOINT`); `?cenario=` legado redireciona para URL canônica sem param (`hasWizardScenarioParam` + `wizardActionHref` sem 3º arg); órfãos do ritual B61 removidos de `wizardVoteEstimate`/`campaignWizardCopy`. `VoteEstimateScenarioInputs` compact ganhou `activeScenario`/`onFocusScenario`/`errorScenarios`/`disabled` (lista inalterada). Sem migration. Plano: [`docs/plans/simplificar-ajuste-votos-wizard.md`](docs/plans/simplificar-ajuste-votos-wizard.md).
**Recently resolved (2026-07-30):** **B71** Polimento visual dos resultados de busca no Início — `src/lib/homeSearchUi.ts` centraliza bleed (`-mx-4 px-4 md:-mx-6 md:px-6`), heading discreto (`text-xs` sentence-case) e `ul list-none`; `HomeSearchHitRow` pinta hover/focus na faixa full-bleed; todos os `HomeSearch*Group` alinhados. Sem migration/action/contrato JSON. Plano: [`docs/plans/polimento-resultados-busca-inicio.md`](docs/plans/polimento-resultados-busca-inicio.md).
**Recently resolved (2026-07-30):** **B69** Simplificar modelo de liderança — `leadership.exclusive` boolean NOT NULL DEFAULT true ("Apoio exclusivo") via migration `20260730_043306_simplify_leadership_fields` + DROP `sector`/`sectorNotes`/`consentNote` (collection, zod, forms, filtros/sort B29 da lista, invite autofill/redemption, dossiê; `?sector=` legado canonicaliza fora). **Decisão de produto 2026-07-30 reverteu o rascunho: `organization` FICA** — mapeia ramificações políticas (SindMed com várias lideranças); vertical `/campanha/organizacoes` e todos os vínculos intactos. Lista ganha coluna "Apoio exclusivo" (badge outline "Não exclusivo" só quando false); forms com checkbox default-on + hidden "false" na criação (último valor vence; ausência cai no default do zod). Nota operacional: o prompt interativo de rename do `migrate:create` trava sem TTY — gerar drops e adds em passadas separadas (`CI=1`) e fundir à mão foi o caminho; o `NOT NULL` foi emendado sobre o SQL gerado. Gate: tsc/lint/format/cycles/build; int 518/518; unit com as mesmas 16 falhas pré-existentes do clean tree (drift B66/B71 na base); knip idêntico à base (P3 + drift); Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-lideranca.md`](docs/plans/simplificar-modelo-lideranca.md). Desbloqueia **B70**.

View file

@ -40,9 +40,9 @@ Responsável: —
~~Jump+warning ao confirmar pessimista `> média` quebrava: `useEffect` limpava `violationEditedScenario` na troca de `?cenario=`.~~ **Corrigido em B76** — preservar estado no jump ao cenário violado; limpar ao editar draft/atalho.
### Emenda UX (2026-07-30 → **B77**)
### Emenda UX (2026-07-30 → ~~B77~~ ✓)
Ritual de três passos `?cenario=` será **substituído** por uma tela com os três inputs + strip (como popover da lista); validação inline no confirm. Ver [simplificar-ajuste-votos-wizard.md](simplificar-ajuste-votos-wizard.md).
Ritual de três passos `?cenario=` **substituído** por uma tela com os três inputs + strip (como popover da lista); validação inline no confirm. Ver [simplificar-ajuste-votos-wizard.md](simplificar-ajuste-votos-wizard.md).
## Design (Impeccable)
@ -144,19 +144,22 @@ Componentes:
- Ramo morto em `buildWizardVoteViolationMessage` (cenário `central` com `optimistic < central` — `getVoteEstimateOrderViolation` já devolve `'optimistic'`).
- Estado duplicado de mensagem de violação (`violationReturn` → só `violationEditedScenario` + `activeViolation.message`).
- Banner de violação obsoleto ao voltar de cenário (limpa `violationEditedScenario` na troca).
- `parseWizardVoteDraft` duplicado em `handleConfirm`.
- `parseWizardVoteDraft` removido (B77+); parsing fica só em `VoteEstimateScenarioInputs.parseScenarioInput`.
- Alias `WizardVoteEditScenario` = `VoteEstimateScenario`; `getWizardVoteViolation` lazy.
- Constante `MUNICIPALITY_EXPECTED_VOTES_ENDPOINT` compartilhada com `MunicipalityListExpectedVotesControl`.
- **B77+:** `getWizardVoteViolation` devolve `highlightScenarios` num único pass; `MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE` compartilhada wizard+lista; prop `municipalitySlug` morta no passo; `hasWizardScenarioParam` delega a `parseWizardMunicipioParam`; CTA `WIZARD_VOTES_FINAL_CTA_LABEL` em `campaignWizardCopy.ts`.
## Explicitamente fora (pós-/simplify, defer)
| Achado | Gatilho para revisitar |
| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Unificar `parseWizardVoteDraft` com `VoteEstimateScenarioInputs.parseScenarioInput` (pt-BR `"1.200"` difere) | 3ª superfície de input de votos **ou** passe repo-wide de parsing numérico pt-BR |
| Extrair `SAVE_ERROR_MESSAGE` / copy de pending do wizard e da lista | 3º consumidor de POST JSON de célula **ou** helper `readCampaignJsonMutationResult` (precedente B32+) |
| Validar body do wizard com `voteEstimateScenarioFieldsSchema` no client | Gravação por cenário (write parcial) em vez do batch único |
| Helper genérico `readCampaignJsonMutationResult` | 3ª rota/painel wizard com o mesmo shape de resposta JSON |
| Generalizar `parseWizardMunicipioParam` / `resolveWizardScenarioParam` num parser de query do wizard | B63/B64 reutilizarem `?cenario=` ou outro param de passo na mesma rota |
| Achado | Gatilho para revisitar |
| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Unificar `VoteEstimateScenarioInputs.parseScenarioInput` com clamp `MAX_VOTE_COUNT` no wizard | 3ª superfície de input de votos **ou** passe repo-wide de parsing numérico pt-BR |
| Extrair shell `ExpectedVotesEditor` (wizard + lista) | `/simplify` medir duplicação &gt;~60 linhas **e** políticas de save alinhadas (hoje wizard = confirm, lista = autosave) |
| Validar ordem pessimista/média/otimista no client na lista (popover) | Produto pedir feedback inline antes do autosave **ou** 3º editor inline de votos estimados |
| `useCampaignCellAutosave` no wizard | **Não** — submit explícito é decisão de produto (`campanha-edit-where-you-see`); ver Não escopo B77 |
| Validar body do wizard com `voteEstimateScenarioFieldsSchema` no client | Gravação por cenário (write parcial) em vez do batch único |
| Helper genérico `readCampaignJsonMutationResult` | 3ª rota/painel wizard com o mesmo shape de resposta JSON |
| Generalizar `parseWizardMunicipioParam` / `resolveWizardScenarioParam` num parser de query do wizard | B63/B64 reutilizarem `?cenario=` ou outro param de passo na mesma rota |
## Referências

View file

@ -1,12 +1,20 @@
# Simplificar ajuste de votos no wizard (três cenários numa tela)
Status: rascunho
Status: **entregue** (2026-07-30)
Atualizado em: 2026-07-30
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B77 — UX-1 wizards)
Impeccable: B — encaixe em `WizardExpectedVotesStep` existente; espelha o popover da lista de municípios
Appetite: ~0,75–1 dia eng; substituir ritual `?cenario=` por editor único + confirm; reuso de strip/inputs/atalhos; sem migration
Responsável: —
## As-built (entrega)
- `WizardExpectedVotesStep`: uma tela com `VoteEstimateScenarioInputs` (`variant="compact"`) + atalhos no cenário focado + CTA único `Salvar estimativas →`; violação inline no confirm (`Alert` + `aria-invalid` no par conflitante via `getWizardVoteViolationHighlights`).
- `VoteEstimateScenarioInputs` compact ganhou `activeScenario`, `onFocusScenario`, `errorScenarios`, `disabled` (lista inalterada — props opcionais).
- `wizardVoteEstimate.ts`: novo `getWizardVoteViolationHighlights`; removidos helpers do ritual `?cenario=` (`getNextWizardVoteScenario`, `wizardVoteStepTitle`, …).
- `campaignActionRoutes.ts`: `wizardActionHref` sem 3º arg `cenario`; `hasWizardScenarioParam` + redirect canônico em `acoes/[slug]/page.tsx`.
- Testes: `wizardExpectedVotesStep.unit.spec.tsx`, `wizardVoteEstimate.unit.spec.ts`, `campaignActionRoutes.unit.spec.ts`, e2e `campaignHomeActions` (B61/B77).
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Clarity under pressure; Feel the action) / `DESIGN.md` · referência visual = `MunicipalityListExpectedVotesControl` + `VoteEstimateScenarioInputs` (`variant="compact"`) · tema `campaign`.

File diff suppressed because one or more lines are too long

View file

@ -5,9 +5,9 @@ import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/Wizar
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
hasWizardScenarioParam,
isCampaignWizardActionSlug,
parseWizardMunicipioParam,
resolveWizardScenarioParam,
WIZARD_MUNICIPIO_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
wizardActionHref,
@ -58,11 +58,8 @@ export default async function CampaignActionWizardPage({
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
const { scenario, invalid } = resolveWizardScenarioParam(
resolvedSearchParams[WIZARD_SCENARIO_QUERY_KEY],
)
if (invalid) {
redirect(wizardActionHref(slug, municipalitySlug, 'central'))
if (hasWizardScenarioParam(resolvedSearchParams[WIZARD_SCENARIO_QUERY_KEY])) {
redirect(wizardActionHref(slug, municipalitySlug))
}
return (
@ -70,9 +67,7 @@ export default async function CampaignActionWizardPage({
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
currentScenario={scenario}
/>
)
}

View file

@ -2,6 +2,9 @@ import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
export const MUNICIPALITY_EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' as const
export const MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE =
'Não foi possível salvar os votos estimados. Tente novamente.' as const
export type MunicipalityListExpectedVotesResponse =
| {
status: 'success'

View file

@ -2,6 +2,7 @@
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
@ -21,7 +22,6 @@ import {
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
const AUTOSAVE_MS = 600
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.'
/**
* `municipalityName` is the Drawer's subject line and part of the trigger's
@ -57,7 +57,7 @@ export const MunicipalityListExpectedVotesControl = ({
endpoint: MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
buildBody: (values) => ({ municipalityId: municipalityID, expectedVotes: values }),
readSaved: (payload) => payload.savedExpectedVotes,
errorMessage: SAVE_ERROR_MESSAGE,
errorMessage: MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
pendingMessage: 'Salvando votos estimados.',
})

View file

@ -1,184 +1,108 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { useState, useTransition } from 'react'
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
WIZARD_VOTES_INVALID_DRAFT_MESSAGE,
WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER,
WIZARD_VOTES_SAVE_ERROR_MESSAGE,
WIZARD_VOTES_FINAL_CTA_LABEL,
WIZARD_VOTES_SAVED_MESSAGE,
wizardFlowTitleForSlug,
wizardVoteReturnToScenarioLabel,
wizardNextStepPlaceholder,
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { formatElectionNumber } from '@/lib/electionFormat'
import { cn } from '@/lib/utils'
import { voteEstimateScenarioLabels, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
applyVoteShortcut,
draftTextForVoteValue,
getNextWizardVoteScenario,
getPreviousWizardVoteScenario,
getWizardVoteViolation,
mergeWizardVoteEstimate,
parseWizardVoteDraft,
VOTE_SHORTCUTS,
voteShortcutLabels,
wizardVoteFinalCtaLabel,
wizardVoteStepCtaLabel,
wizardVoteStepTitle,
type VoteShortcut,
type WizardVoteEditScenario,
type WizardVoteViolation,
} from '@/lib/wizardVoteEstimate'
type WizardExpectedVotesStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
initialExpectedVotes: VoteEstimateScenarioViewModel
currentScenario: WizardVoteEditScenario
}
export const WizardExpectedVotesStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug,
initialExpectedVotes,
currentScenario,
}: WizardExpectedVotesStepProps) => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [confirmed, setConfirmed] = useState<VoteEstimateScenarioViewModel>(initialExpectedVotes)
const [draft, setDraft] = useState(() =>
draftTextForVoteValue(initialExpectedVotes[currentScenario]),
)
const [draftError, setDraftError] = useState<string | null>(null)
const [estimates, setEstimates] = useState(initialExpectedVotes)
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
const [violation, setViolation] = useState<WizardVoteViolation | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [phase, setPhase] = useState<'editing' | 'saved'>('editing')
const [violationEditedScenario, setViolationEditedScenario] =
useState<WizardVoteEditScenario | null>(null)
const previousScenarioRef = useRef(currentScenario)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (previousScenarioRef.current === currentScenario) return
const clearViolationState = () => {
if (violation) setViolation(null)
}
setDraft(draftTextForVoteValue(confirmed[currentScenario]))
setDraftError(null)
const violation = violationEditedScenario != null ? getWizardVoteViolation(confirmed) : null
const keepViolationState =
violationEditedScenario != null &&
violation != null &&
violation.violatingScenario === currentScenario
if (!keepViolationState) {
setViolationEditedScenario(null)
}
previousScenarioRef.current = currentScenario
}, [confirmed, currentScenario, violationEditedScenario])
const activeViolation = violationEditedScenario != null ? getWizardVoteViolation(confirmed) : null
const showViolationBanner =
violationEditedScenario != null &&
activeViolation != null &&
activeViolation.violatingScenario === currentScenario
const previousScenario = getPreviousWizardVoteScenario(currentScenario)
const previousHref = previousScenario
? wizardActionHref(actionSlug, municipalitySlug, previousScenario)
: wizardActionHref(actionSlug)
const handleValuesChange = (next: VoteEstimateScenarioViewModel) => {
setEstimates(next)
clearViolationState()
setSaveError(null)
}
const handleShortcut = (shortcut: VoteShortcut) => {
const currentValue = parseWizardVoteDraft(draft)
const nextValue = applyVoteShortcut(currentValue, shortcut)
setDraft(draftTextForVoteValue(nextValue))
setDraftError(null)
setSaveError(null)
setViolationEditedScenario(null)
inputRef.current?.focus()
const nextValue = applyVoteShortcut(estimates[focusedScenario], shortcut)
handleValuesChange({ ...estimates, [focusedScenario]: nextValue })
}
const handleConfirm = () => {
if (isPending) return
const trimmed = draft.trim()
const parsed = trimmed ? parseWizardVoteDraft(draft) : null
if (trimmed && parsed === null) {
setDraftError(WIZARD_VOTES_INVALID_DRAFT_MESSAGE)
return
}
const nextConfirmed = mergeWizardVoteEstimate(confirmed, currentScenario, parsed)
const violation = getWizardVoteViolation(nextConfirmed)
if (violation) {
setConfirmed(nextConfirmed)
setViolationEditedScenario(currentScenario)
const nextViolation = getWizardVoteViolation(estimates)
if (nextViolation) {
setViolation(nextViolation)
setSaveError(null)
if (violation.violatingScenario !== currentScenario) {
router.replace(wizardActionHref(actionSlug, municipalitySlug, violation.violatingScenario))
}
return
}
setConfirmed(nextConfirmed)
setViolationEditedScenario(null)
setSaveError(null)
setDraftError(null)
const nextScenario = getNextWizardVoteScenario(currentScenario)
if (nextScenario) {
router.replace(wizardActionHref(actionSlug, municipalitySlug, nextScenario))
return
}
clearViolationState()
startTransition(async () => {
const { ok, payload } = await postCampaignJson<MunicipalityListExpectedVotesResponse>(
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
{
municipalityId,
expectedVotes: nextConfirmed,
expectedVotes: estimates,
},
)
if (!ok || payload.status !== 'success') {
setSaveError(payload.status === 'error' ? payload.message : WIZARD_VOTES_SAVE_ERROR_MESSAGE)
setSaveError(
payload.status === 'error'
? payload.message
: MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
)
return
}
setConfirmed(payload.savedExpectedVotes)
setEstimates(payload.savedExpectedVotes)
setPhase('saved')
})
}
const handleReturnToEdited = () => {
if (!violationEditedScenario) return
router.replace(wizardActionHref(actionSlug, municipalitySlug, violationEditedScenario))
setViolationEditedScenario(null)
}
const currentValue = confirmed[currentScenario]
const nextScenario = getNextWizardVoteScenario(currentScenario)
const ctaLabel =
nextScenario == null ? wizardVoteFinalCtaLabel : wizardVoteStepCtaLabel(currentScenario)
if (phase === 'saved') {
return (
<CampaignWizardShell
@ -191,7 +115,7 @@ export const WizardExpectedVotesStep = ({
>
<div className="flex flex-col gap-4" role="status" aria-live="polite">
<p className="text-sm text-muted-foreground">{WIZARD_VOTES_SAVED_MESSAGE}</p>
<p className="text-sm text-muted-foreground">{WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER}</p>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>
<Button asChild className="min-h-11 w-full sm:w-auto">
<Link href={CAMPAIGN_HOME}>Voltar ao Início</Link>
</Button>
@ -203,9 +127,9 @@ export const WizardExpectedVotesStep = ({
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardVoteStepTitle(currentScenario)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={previousHref}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
municipalityLabel={municipalityName}
>
@ -214,35 +138,18 @@ export const WizardExpectedVotesStep = ({
aria-busy={isPending}
data-pending={isPending ? '' : undefined}
>
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Valor atual:{' '}
{currentValue == null ? 'não informado' : formatElectionNumber(currentValue)}
</p>
<label htmlFor="wizard-expected-votes-input" className="sr-only">
{voteEstimateScenarioLabels[currentScenario]} em {municipalityName}
</label>
<Input
ref={inputRef}
id="wizard-expected-votes-input"
type="text"
inputMode="numeric"
autoComplete="off"
value={draft}
onChange={(event) => {
setDraft(event.currentTarget.value.replace(/\D/g, ''))
setDraftError(null)
setSaveError(null)
setViolationEditedScenario(null)
}}
onFocus={(event) => event.currentTarget.select()}
disabled={isPending}
className={cn(
'h-auto min-h-14 border-input px-3 py-3 text-center text-2xl font-medium tabular-nums',
'md:min-h-16 md:text-3xl',
)}
/>
</div>
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={estimates}
idPrefix={`wizard-expected-votes-${municipalityId}`}
variant="compact"
autoFocusScenario="central"
activeScenario={focusedScenario}
onFocusScenario={setFocusedScenario}
errorScenarios={violation ? new Set(violation.highlightScenarios) : undefined}
disabled={isPending}
onValuesChange={handleValuesChange}
/>
<div className="flex flex-wrap gap-2">
{VOTE_SHORTCUTS.map((shortcut) => (
@ -259,27 +166,9 @@ export const WizardExpectedVotesStep = ({
))}
</div>
{showViolationBanner && activeViolation ? (
{violation ? (
<Alert variant="pending" className="border-estimate-pending">
<AlertDescription className="flex flex-col gap-2 text-sm">
<span>{activeViolation.message}</span>
<Button
type="button"
variant="link"
className="h-auto justify-start p-0 text-sm"
onClick={handleReturnToEdited}
>
{wizardVoteReturnToScenarioLabel(
voteEstimateScenarioLabels[violationEditedScenario],
)}
</Button>
</AlertDescription>
</Alert>
) : null}
{draftError ? (
<Alert variant="destructive">
<AlertDescription className="text-sm">{draftError}</AlertDescription>
<AlertDescription className="text-sm">{violation.message}</AlertDescription>
</Alert>
) : null}
@ -305,7 +194,7 @@ export const WizardExpectedVotesStep = ({
Salvando…
</>
) : (
ctaLabel
WIZARD_VOTES_FINAL_CTA_LABEL
)}
</Button>
</div>

View file

@ -58,6 +58,10 @@ type VoteEstimateScenarioInputsProps = {
idPrefix: string
variant?: 'labeled' | 'compact'
autoFocusScenario?: VoteEstimateScenario
activeScenario?: VoteEstimateScenario
onFocusScenario?: (scenario: VoteEstimateScenario) => void
errorScenarios?: ReadonlySet<VoteEstimateScenario>
disabled?: boolean
onValuesChange?: (values: VoteEstimateScenarioViewModel) => void
}
@ -66,6 +70,10 @@ const CompactVoteEstimateScenarioInputs = ({
values,
idPrefix,
autoFocusScenario,
activeScenario = 'central',
onFocusScenario,
errorScenarios,
disabled = false,
onValuesChange,
}: Omit<VoteEstimateScenarioInputsProps, 'variant'> & {
onValuesChange: (values: VoteEstimateScenarioViewModel) => void
@ -92,6 +100,7 @@ const CompactVoteEstimateScenarioInputs = ({
<div className="flex flex-col gap-2.5">
<VoteEstimateScenarioStrip
values={values}
activeScenario={activeScenario}
labelMode="none"
markerMode="active-only"
stretch
@ -106,15 +115,22 @@ const CompactVoteEstimateScenarioInputs = ({
inputMode="numeric"
autoComplete="off"
autoFocus={autoFocusScenario === scenario}
disabled={disabled}
value={drafts[scenario]}
onChange={(event) => handleDraftChange(scenario, event.currentTarget.value)}
onFocus={(event) => event.currentTarget.select()}
onFocus={(event) => {
onFocusScenario?.(scenario)
event.currentTarget.select()
}}
aria-label={voteEstimateScenarioLabels[scenario]}
aria-invalid={errorScenarios?.has(scenario) ? true : undefined}
className={cn(
compactInputClassName,
scenario === 'central'
? 'border-primary/50 bg-primary/5 font-medium ring-1 ring-primary/15'
: 'bg-muted/30',
errorScenarios?.has(scenario)
? 'border-destructive ring-1 ring-destructive/30'
: scenario === 'central'
? 'border-primary/50 bg-primary/5 font-medium ring-1 ring-primary/15'
: 'bg-muted/30',
)}
/>
))}
@ -129,6 +145,10 @@ export const VoteEstimateScenarioInputs = ({
idPrefix,
variant = 'labeled',
autoFocusScenario,
activeScenario,
onFocusScenario,
errorScenarios,
disabled,
onValuesChange,
}: VoteEstimateScenarioInputsProps) => {
if (variant === 'compact') {
@ -141,6 +161,10 @@ export const VoteEstimateScenarioInputs = ({
values={values}
idPrefix={idPrefix}
autoFocusScenario={autoFocusScenario}
activeScenario={activeScenario}
onFocusScenario={onFocusScenario}
errorScenarios={errorScenarios}
disabled={disabled}
onValuesChange={onValuesChange}
/>
)

View file

@ -1,8 +1,5 @@
/** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */
import type { WizardVoteEditScenario } from '@/lib/wizardVoteEstimate'
import { isWizardVoteEditScenario } from '@/lib/wizardVoteEstimate'
export const CAMPAIGN_ACTIONS_HOME = '/campanha/acoes' as const
export type CampaignWizardActionId =
@ -42,21 +39,15 @@ export const campaignActionEntryHref = (id: CampaignWizardActionId): string =>
export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
/** Legacy B61 ritual param — canonical URLs omit it (B77). */
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
export const wizardActionHref = (
actionSlug: string,
municipalitySlug?: string,
scenario?: WizardVoteEditScenario,
): string => {
export const wizardActionHref = (actionSlug: string, municipalitySlug?: string): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
if (scenario) {
params.set(WIZARD_SCENARIO_QUERY_KEY, scenario)
}
return `${base}?${params.toString()}`
}
@ -68,16 +59,5 @@ export const parseWizardMunicipioParam = (
return trimmed ? trimmed : undefined
}
export const resolveWizardScenarioParam = (
value: string | string[] | undefined,
): { scenario: WizardVoteEditScenario; invalid: boolean } => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) {
return { scenario: 'central', invalid: false }
}
if (isWizardVoteEditScenario(trimmed)) {
return { scenario: trimmed, invalid: false }
}
return { scenario: 'central', invalid: true }
}
export const hasWizardScenarioParam = (value: string | string[] | undefined): boolean =>
parseWizardMunicipioParam(value) !== undefined

View file

@ -31,14 +31,7 @@ export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as cons
export const WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER =
'Próximo passo deste fluxo (sinal, tendência ou resumo) em breve.' as const
export const WIZARD_VOTES_SAVE_ERROR_MESSAGE =
'Não foi possível salvar os votos estimados. Tente novamente.' as const
export const WIZARD_VOTES_INVALID_DRAFT_MESSAGE =
'Informe um número válido ou deixe em branco.' as const
export const wizardVoteReturnToScenarioLabel = (scenarioLabel: string): string =>
`Voltar para ${scenarioLabel.toLowerCase()}`
export const WIZARD_VOTES_FINAL_CTA_LABEL = 'Salvar estimativas →' as const
const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const

View file

@ -8,15 +8,6 @@ import {
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
/** Mesa ritual order: média → pessimista → otimista (≠ storage order pessimistic ≤ central ≤ optimistic). */
export const WIZARD_VOTE_SCENARIO_EDIT_ORDER = [
'central',
'pessimistic',
'optimistic',
] as const satisfies readonly VoteEstimateScenario[]
export type WizardVoteEditScenario = VoteEstimateScenario
export type VoteShortcut = 'double' | '+50' | '-50' | '+100' | '-100'
export const VOTE_SHORTCUTS: readonly VoteShortcut[] = [
@ -35,9 +26,6 @@ export const voteShortcutLabels: Record<VoteShortcut, string> = {
'-100': '−100',
}
export const isWizardVoteEditScenario = (value: string): value is WizardVoteEditScenario =>
(WIZARD_VOTE_SCENARIO_EDIT_ORDER as readonly string[]).includes(value)
export const applyVoteShortcut = (value: number | null, shortcut: VoteShortcut): number => {
const base = value ?? 0
if (shortcut === 'double') {
@ -50,50 +38,10 @@ export const applyVoteShortcut = (value: number | null, shortcut: VoteShortcut):
return Math.max(0, Math.min(base + delta, MAX_VOTE_COUNT))
}
export const parseWizardVoteDraft = (raw: string): number | null => {
const trimmed = raw.trim()
if (!trimmed) return null
const digitsOnly = trimmed.replace(/\D/g, '')
if (!digitsOnly) return null
const parsed = Number(digitsOnly)
if (!Number.isFinite(parsed)) return null
return Math.min(Math.trunc(parsed), MAX_VOTE_COUNT)
}
export const draftTextForVoteValue = (value: number | null | undefined): string =>
value == null ? '' : String(value)
export const getNextWizardVoteScenario = (
current: WizardVoteEditScenario,
): WizardVoteEditScenario | null => {
const index = WIZARD_VOTE_SCENARIO_EDIT_ORDER.indexOf(current)
if (index < 0 || index >= WIZARD_VOTE_SCENARIO_EDIT_ORDER.length - 1) return null
return WIZARD_VOTE_SCENARIO_EDIT_ORDER[index + 1]!
}
export const getPreviousWizardVoteScenario = (
current: WizardVoteEditScenario,
): WizardVoteEditScenario | null => {
const index = WIZARD_VOTE_SCENARIO_EDIT_ORDER.indexOf(current)
if (index <= 0) return null
return WIZARD_VOTE_SCENARIO_EDIT_ORDER[index - 1]!
}
export const wizardVoteStepTitle = (scenario: WizardVoteEditScenario): string => {
const label = voteEstimateScenarioLabels[scenario]
return `Qual a nova estimativa ${label.toLowerCase()}?`
}
export const wizardVoteStepCtaLabel = (scenario: WizardVoteEditScenario): string => {
const label = voteEstimateScenarioLabels[scenario]
return `Ajustar estimativa ${label.toLowerCase()} →`
}
export const wizardVoteFinalCtaLabel = 'Salvar estimativas →' as const
export type WizardVoteViolation = {
violatingScenario: VoteEstimateScenario
message: string
highlightScenarios: VoteEstimateScenario[]
}
export const getWizardVoteViolation = (
@ -105,9 +53,37 @@ export const getWizardVoteViolation = (
return {
violatingScenario,
message: buildWizardVoteViolationMessage(estimates, violatingScenario),
highlightScenarios: resolveWizardVoteHighlights(estimates, violatingScenario),
}
}
const resolveWizardVoteHighlights = (
estimates: VoteEstimateScenarioViewModel,
violatingScenario: VoteEstimateScenario,
): VoteEstimateScenario[] => {
const { pessimistic, central, optimistic } = estimates
if (
violatingScenario === 'central' &&
pessimistic != null &&
central != null &&
pessimistic > central
) {
return ['pessimistic', 'central']
}
if (
violatingScenario === 'optimistic' &&
central != null &&
optimistic != null &&
optimistic < central
) {
return ['optimistic', 'central']
}
return [violatingScenario]
}
const buildWizardVoteViolationMessage = (
estimates: VoteEstimateScenarioViewModel,
violatingScenario: VoteEstimateScenario,
@ -134,12 +110,3 @@ const buildWizardVoteViolationMessage = (
return VOTE_ESTIMATE_ORDER_ERROR_MESSAGE
}
export const mergeWizardVoteEstimate = (
base: VoteEstimateScenarioViewModel,
scenario: WizardVoteEditScenario,
value: number | null,
): VoteEstimateScenarioViewModel => ({
...base,
[scenario]: value,
})

View file

@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
@ -168,12 +170,18 @@ test.describe('Wizard — busca município (B60)', () => {
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/)
await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible()
})
})
test.describe('Wizard — ajuste de votos (B61)', () => {
test('confirming média advances to pessimista step', async ({ campaign, page }) => {
test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
const wizardVoteStep = (page: Page) =>
page.getByRole('main', { name: /Ajustar votos estimados/i })
test('shows three scenario inputs and saves without cenario param', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -182,21 +190,27 @@ test.describe('Wizard — ajuste de votos (B61)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
const step = wizardVoteStep(page)
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
timeout: 15000,
})
await expect(step.getByRole('textbox', { name: 'Pessimista' })).toBeVisible()
await expect(step.getByRole('textbox', { name: 'Média' })).toBeVisible()
await expect(step.getByRole('textbox', { name: 'Otimista' })).toBeVisible()
const input = page.getByLabel(/Média em Cairu/i)
await input.fill('500')
await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click()
await step.getByRole('textbox', { name: 'Pessimista' }).fill('150')
await step.getByRole('textbox', { name: 'Média' }).fill('250')
await step.getByRole('textbox', { name: 'Otimista' }).fill('350')
await page.getByRole('button', { name: 'Salvar estimativas →' }).click()
await page.waitForURL(/cenario=pessimistic/)
await expect(
page.getByRole('heading', { name: /Qual a nova estimativa pessimista/i }),
).toBeVisible()
await expect(page.getByText('Votos estimados atualizados.')).toBeVisible({ timeout: 15000 })
expect(page.url()).not.toContain('cenario=')
})
test('incoherent pessimista shows warning and return link', async ({ campaign, page }) => {
test('incoherent estimates show inline warning without changing URL', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -205,20 +219,31 @@ test.describe('Wizard — ajuste de votos (B61)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
timeout: 15000,
})
await page.getByLabel(/Média em Cairu/i).fill('200')
await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click()
await page.waitForURL(/cenario=pessimistic/)
const step = wizardVoteStep(page)
await step.getByRole('textbox', { name: 'Pessimista' }).fill('900')
await step.getByRole('textbox', { name: 'Média' }).fill('100')
await page.getByRole('button', { name: 'Salvar estimativas →' }).click()
await page.getByLabel(/Pessimista em Cairu/i).fill('900')
await page.getByRole('button', { name: 'Ajustar estimativa pessimista →' }).click()
await page.waitForURL(/cenario=central/)
const step = page.getByRole('main', { name: /Qual a nova estimativa média/i })
await expect(step.getByRole('alert')).toContainText(/Pessimista/i)
await expect(step.getByRole('button', { name: /Voltar para pessimista/i })).toBeVisible()
expect(page.url()).not.toContain('cenario=')
})
test('legacy cenario param redirects to canonical URL', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu&cenario=pessimistic')
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu$/)
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
timeout: 15000,
})
})
})

View file

@ -5,10 +5,10 @@ import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignActionEntryHref,
campaignWizardActionIdForSlug,
hasWizardScenarioParam,
isCampaignWizardActionId,
isCampaignWizardActionSlug,
parseWizardMunicipioParam,
resolveWizardScenarioParam,
wizardActionHref,
} from '@/lib/campaignActionRoutes'
@ -36,18 +36,12 @@ describe('campaignActionRoutes', () => {
expect(wizardActionHref('atualizar-votos', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
)
expect(wizardActionHref('atualizar-votos', 'cairu', 'pessimistic')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&cenario=pessimistic`,
)
})
it('resolves scenario search param with default and invalid flag', () => {
expect(resolveWizardScenarioParam(undefined)).toEqual({ scenario: 'central', invalid: false })
expect(resolveWizardScenarioParam('optimistic')).toEqual({
scenario: 'optimistic',
invalid: false,
})
expect(resolveWizardScenarioParam('invalid')).toEqual({ scenario: 'central', invalid: true })
it('detects legacy scenario search param', () => {
expect(hasWizardScenarioParam(undefined)).toBe(false)
expect(hasWizardScenarioParam('central')).toBe(true)
expect(hasWizardScenarioParam(' ')).toBe(false)
})
it('parses municipio search param', () => {

View file

@ -1,14 +1,9 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
const replace = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace }),
}))
import { postCampaignJson } from '@/lib/campaignJsonRequest'
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
@ -26,7 +21,6 @@ const defaultProps = {
actionSlug: 'atualizar-votos',
municipalityId: 1,
municipalityName: 'Cairu',
municipalitySlug: 'cairu',
initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 },
}
@ -40,43 +34,70 @@ const renderVotesStep = (props: React.ComponentProps<typeof WizardExpectedVotesS
describe('WizardExpectedVotesStep', () => {
afterEach(() => {
cleanup()
replace.mockClear()
vi.mocked(postCampaignJson).mockReset()
})
it('shows violation banner on média immediately after pessimista breaks order', () => {
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
it('applies shortcuts to the focused scenario', () => {
renderVotesStep({ ...defaultProps })
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
fireEvent.focus(screen.getByLabelText('Pessimista'))
fireEvent.click(screen.getByRole('button', { name: '2×' }))
expect(replace).toHaveBeenCalledWith(expect.stringContaining('cenario=central'))
expect((screen.getByLabelText('Pessimista') as HTMLInputElement).value).toBe('200')
expect((screen.getByLabelText('Média') as HTMLInputElement).value).toBe('200')
})
rerender(
<CampaignWizardChromeProvider>
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
</CampaignWizardChromeProvider>,
)
it('shows violation alert and highlights inputs without navigation', () => {
renderVotesStep({ ...defaultProps })
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
fireEvent.change(screen.getByLabelText('Pessimista'), { target: { value: '900' } })
fireEvent.click(screen.getByRole('button', { name: 'Salvar estimativas →' }))
const step = screen.getByRole('main', { name: /Ajustar votos estimados/i })
expect(within(step).getByRole('alert').textContent).toMatch(/Pessimista/i)
expect(within(step).getByRole('button', { name: /Voltar para pessimista/i })).toBeTruthy()
expect(screen.getByLabelText('Pessimista').getAttribute('aria-invalid')).toBe('true')
expect(screen.getByLabelText('Média').getAttribute('aria-invalid')).toBe('true')
})
it('clears violation banner when média draft is edited', () => {
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
it('clears violation when a draft is edited', () => {
renderVotesStep({ ...defaultProps })
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
rerender(
<CampaignWizardChromeProvider>
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
</CampaignWizardChromeProvider>,
)
fireEvent.change(screen.getByLabelText('Pessimista'), { target: { value: '900' } })
fireEvent.click(screen.getByRole('button', { name: 'Salvar estimativas →' }))
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
const step = screen.getByRole('main', { name: /Ajustar votos estimados/i })
expect(within(step).getByRole('alert')).toBeTruthy()
fireEvent.change(screen.getByLabelText(/Média em Cairu/i), { target: { value: '250' } })
fireEvent.change(screen.getByLabelText('Média'), { target: { value: '950' } })
expect(within(step).queryByRole('alert')).toBeNull()
expect(screen.getByLabelText('Pessimista').getAttribute('aria-invalid')).toBeNull()
})
it('posts batch expected votes when estimates are coherent', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: {
status: 'success',
savedExpectedVotes: { pessimistic: 150, central: 250, optimistic: 350 },
},
})
renderVotesStep({ ...defaultProps })
fireEvent.change(screen.getByLabelText('Pessimista'), { target: { value: '150' } })
fireEvent.change(screen.getByLabelText('Média'), { target: { value: '250' } })
fireEvent.change(screen.getByLabelText('Otimista'), { target: { value: '350' } })
fireEvent.click(screen.getByRole('button', { name: 'Salvar estimativas →' }))
await waitFor(() => {
expect(postCampaignJson).toHaveBeenCalledWith('/campanha/municipios/expected-votes', {
municipalityId: 1,
expectedVotes: { pessimistic: 150, central: 250, optimistic: 350 },
})
})
await waitFor(() => {
expect(screen.getByRole('status').textContent).toMatch(/Votos estimados atualizados/i)
})
})
})

View file

@ -1,16 +1,7 @@
import { describe, expect, it } from 'vitest'
import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
import {
applyVoteShortcut,
getNextWizardVoteScenario,
getPreviousWizardVoteScenario,
getWizardVoteViolation,
mergeWizardVoteEstimate,
parseWizardVoteDraft,
WIZARD_VOTE_SCENARIO_EDIT_ORDER,
wizardVoteStepCtaLabel,
} from '@/lib/wizardVoteEstimate'
import { applyVoteShortcut, getWizardVoteViolation } from '@/lib/wizardVoteEstimate'
describe('applyVoteShortcut', () => {
it('doubles the current value', () => {
@ -35,54 +26,23 @@ describe('applyVoteShortcut', () => {
})
})
describe('wizard scenario navigation', () => {
it('walks média → pessimista → otimista', () => {
expect(WIZARD_VOTE_SCENARIO_EDIT_ORDER).toEqual(['central', 'pessimistic', 'optimistic'])
expect(getNextWizardVoteScenario('central')).toBe('pessimistic')
expect(getNextWizardVoteScenario('pessimistic')).toBe('optimistic')
expect(getNextWizardVoteScenario('optimistic')).toBeNull()
expect(getPreviousWizardVoteScenario('pessimistic')).toBe('central')
expect(getPreviousWizardVoteScenario('central')).toBeNull()
})
it('labels CTA per scenario', () => {
expect(wizardVoteStepCtaLabel('central')).toBe('Ajustar estimativa média →')
expect(wizardVoteStepCtaLabel('pessimistic')).toBe('Ajustar estimativa pessimista →')
expect(wizardVoteStepCtaLabel('optimistic')).toBe('Ajustar estimativa otimista →')
})
})
describe('parseWizardVoteDraft', () => {
it('parses digits and empty as null', () => {
expect(parseWizardVoteDraft('')).toBeNull()
expect(parseWizardVoteDraft(' 1.200 ')).toBe(1200)
expect(parseWizardVoteDraft('abc')).toBeNull()
})
})
describe('getWizardVoteViolation', () => {
it('returns null for ordered triples', () => {
expect(getWizardVoteViolation({ pessimistic: 100, central: 200, optimistic: 300 })).toBeNull()
})
it('flags pessimista above média with readable message', () => {
it('flags pessimista above média with readable message and highlights', () => {
const violation = getWizardVoteViolation({ pessimistic: 300, central: 200, optimistic: 400 })
expect(violation?.violatingScenario).toBe('central')
expect(violation?.message).toContain('Pessimista')
expect(violation?.message).toContain('média')
expect(violation?.highlightScenarios).toEqual(['pessimistic', 'central'])
})
it('flags otimista below média', () => {
it('flags otimista below média with highlights', () => {
const violation = getWizardVoteViolation({ pessimistic: 100, central: 500, optimistic: 400 })
expect(violation?.violatingScenario).toBe('optimistic')
expect(violation?.message).toContain('Otimista')
})
})
describe('mergeWizardVoteEstimate', () => {
it('updates one scenario while keeping the rest', () => {
expect(
mergeWizardVoteEstimate({ pessimistic: 100, central: 200, optimistic: 300 }, 'central', 250),
).toEqual({ pessimistic: 100, central: 250, optimistic: 300 })
expect(violation?.highlightScenarios).toEqual(['optimistic', 'central'])
})
})