mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge branch 'cursor/472940a5'
This commit is contained in:
commit
16852980b2
13 changed files with 719 additions and 14 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **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).
|
||||
- **B62 entregue em código (2026-07-29):** modelo de sinal simplificado — `municipalityUpdate` kind `sinal` agora é só `signalType` + `body`; migration `20260730_010601_simplify_municipality_signal_fields` dropa `signal_source`/`triangulated` (o gerador reemitiu `ADD VALUE 'adiada'` por drift de snapshot — removido à mão, e o snapshot novo sara a cadeia); labels/descriptions dos 5 tipos reescritas para a mesa (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — sem "broker" no modelo de sinal; `activity.origin` `pedido_broker` e a copy do suggestionCatalog ficaram fora por decisão da sessão). Forms: `MunicipalitySignalFields` (lista B26) e o bloco inline de `MunicipalityUpdateForm` perderam Fonte/Triangulado; feed/dossiê (`MunicipalityUpdateFeed` + `municipalityUpdatePageData`) só exibem o badge do tipo. **E11: presença de sinal de adversário lê como confirmada — triage 1** (decisão do usuário: "eles não vão adicionar sinais improváveis"), `adversarySignal: { present: true } | null`, fator único "Sinal de adversário registrado", glossário E18 alinhado. Fixa B63. Bônus: teste e2e `campaignMunicipalities` "advisor scopes…" estava obsoleto desde B43 (home da liderança) — atualizado para `/campanha/contatos` + redirect pós-B43, e o pin de bypass do `municipalityElectoralBaseline` baixado 9→8 (drift pré-existente). Gate: tsc/lint/format/cycles/knip(P3 pré-existente)/876 unit/489 int/e2e alvo verde/build (`/campanha` 233 kB); Aikido 0 findings. Plano: [`simplificar-modelo-sinal.md`](../../../docs/plans/simplificar-modelo-sinal.md). Desbloqueia **B63** (wizard registrar sinal).
|
||||
- **E10+ entregue em código (2026-07-29):** Escala e DRY pós-E10 — F1: `loadMunicipalityScopeFromDocs` no `municipalityPageData.ts` evita a segunda query de `municipality` quando `isPagedByPayload === false`; F2: `computeAllMunicipalityPotentials` memoizado por processo (retorna cache `Map<string, MunicipalityPotential>`); F3: `TooltipProvider` movido para o layout de `(app)`, restaurando o agrupamento de delay do Radix para os tooltips das listas; F4: DRY nos filtros multi do mobile em `MunicipalityFilters` via `.map` sobre `municipalityFilterDefinitions` (que ganhou `emptyLabel`). Sem migration/UI nova. Plano: [`escala-dry-pos-e10.md`](../../../docs/plans/escala-dry-pos-e10.md).
|
||||
- **B60 entregue em código (2026-07-29):** etapa de busca de município nos wizards UX-1 — `WizardMunicipalitySearchStep` (debounce B47, `POST /campanha/home-search` modo `wizard-municipality`, só municípios scoped); select = `wizardActionHref` com `?municipio=<slug>`; loader compartilhado `searchStaffMunicipalityHits` (extraído de B48); `HomeSearchMunicipalityVoteTrailing` + `HomeSearchHitRow` link; stub B61 `WizardMunicipalitySelectedStub` com caption sticky; `WizardMunicipalityPlaceholderStep` removido. Sem migration/Consent. Unit `campaignActionRoutes`; int `searchStaffMunicipalityHits`; e2e wizard → `?municipio=cairu`. Plano: [`busca-municipio-wizard.md`](../../../docs/plans/busca-municipio-wizard.md). Desbloqueia **B61** / **B63** / **B64** / **B70**.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,41 @@
|
|||
# Etapa de ajuste de votos no wizard (cenários + atalhos)
|
||||
|
||||
Status: rascunho
|
||||
Status: **entregue** (2026-07-29)
|
||||
Atualizado em: 2026-07-29
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B61 — UX-1 wizards)
|
||||
Impeccable: C — UI nova de passo numérico com coerência entre cenários
|
||||
Appetite: ~1–1,25 dia eng; 3 sub-passos de cenário + atalhos + navegação de violação; grava via action existente no wiring A1 (neste item: UI + validação client espelhando servidor)
|
||||
Responsável: —
|
||||
|
||||
## As-built (entrega)
|
||||
|
||||
### Contrato de URL e ordem de edição
|
||||
|
||||
- `?cenario=central|pessimistic|optimistic` em `/campanha/acoes/atualizar-votos?municipio=<slug>`; default `central`; valor inválido → redirect canônico para `central`.
|
||||
- Ordem ritual **média → pessimista → otimista** (`WIZARD_VOTE_SCENARIO_EDIT_ORDER` em `src/lib/wizardVoteEstimate.ts`); invariante de armazenamento continua `pessimistic ≤ central ≤ optimistic` via `getVoteEstimateOrderViolation`.
|
||||
- Helpers: `applyVoteShortcut`, `getWizardVoteViolation`, `wizardActionHref(slug, municipio?, scenario?)`.
|
||||
|
||||
### Gravação
|
||||
|
||||
- **Um POST batch** ao concluir o passo otimista (`POST /campanha/municipios/expected-votes` → `setMunicipalityExpectedVotes`), não três writes por cenário.
|
||||
- Sucesso: mensagem + placeholder do próximo passo A1 (B63) + link "Voltar ao Início".
|
||||
|
||||
### UI
|
||||
|
||||
- `WizardExpectedVotesStep` substitui `WizardMunicipalitySelectedStub` **somente** em `atualizar-votos` com município escolhido.
|
||||
- Um cenário visível; input grande (`text-2xl`/`text-3xl`); atalhos `2×` · `±50` · `±100`; CTA nomeado por cenário.
|
||||
- Violação: banner warning + `router.replace` para o cenário quebrado + botão "Voltar para [cenário editado]".
|
||||
- `CampaignWizardShell`: caption sticky; `previousHref` = cenário anterior ou busca B60.
|
||||
|
||||
### Testes
|
||||
|
||||
- Unit: `tests/unit/wizardVoteEstimate.unit.spec.ts`, `campaignActionRoutes.unit.spec.ts`.
|
||||
- E2e: `campaignHomeActions.e2e.spec.ts` — avanço média→pessimista; violação pessimista> média → banner + retorno.
|
||||
|
||||
### Fora (inalterado)
|
||||
|
||||
- Preview cobertura E8 no passo; wiring completo A1 (sinal/tendência/resumo B63/B64).
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (Feel the action; Clarity under pressure) / `DESIGN.md` · `VoteEstimateScenarioInputs` / `MunicipalityListExpectedVotesControl` · tema `campaign`.
|
||||
|
|
@ -102,6 +131,25 @@ Componentes:
|
|||
|
||||
- **Preview de cobertura E8 no rodapé do passo.** Revisitar se o CG pedir “isso fecha a meta?” durante o ajuste (sessão).
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- 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`.
|
||||
- Alias `WizardVoteEditScenario` = `VoteEstimateScenario`; `getWizardVoteViolation` lazy.
|
||||
- Constante `MUNICIPALITY_EXPECTED_VOTES_ENDPOINT` compartilhada com `MunicipalityListExpectedVotesControl`.
|
||||
|
||||
## 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 |
|
||||
|
||||
## Referências
|
||||
|
||||
- [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) · [cenarios-estimativa-votos.md](cenarios-estimativa-votos.md) · `src/lib/voteEstimate.ts` · `MunicipalityListExpectedVotesControl.tsx` · `VoteEstimateScenarioInputs.tsx` · collection `Municipality` hook de ordem
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/B61–B64.
|
||||
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B61**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/~~**B61**~~ ✓/B63–B64.
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -91,7 +91,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- **B67** Restaurar pan/scroll na strip de ações — **entregue 2026-07-29** (scrollbar oculta mantida; `pointer-fine` click+arrasta + touch pan nativo; limiar 8px; unit drag vs click) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md)
|
||||
- **B59** ~~Chassis do wizard~~ — **entregue 2026-07-29** (`CampaignWizardShell`; `/campanha/acoes/*` com layout staff-only; Voltar `md:hidden`; caption sticky de município; catálogo B45 com hrefs; passo ponte até B60) · [plano](plans/chassis-wizard-campanha.md)
|
||||
- **B60** ~~Etapa busca município nos wizards~~ — **entregue 2026-07-29** (`WizardMunicipalitySearchStep`; `searchStaffMunicipalityHits` + modo `wizard-municipality` no `POST /campanha/home-search`; select → `?municipio=<slug>`; stub B61 com caption sticky; chips recentes adiados — sem `recentVisits` no repo) · [plano](plans/busca-municipio-wizard.md)
|
||||
- **B61** Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning) · Depende de B59 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/ajuste-votos-wizard.md)
|
||||
- **B61** ~~Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning)~~ — **entregue 2026-07-29** (`WizardExpectedVotesStep`; `?cenario=`; batch POST expected-votes ao concluir otimista; stub B60 substituído só em `atualizar-votos`) · [plano](plans/ajuste-votos-wizard.md)
|
||||
- ~~**B62** Simplificar modelo de sinal~~ — **entregue 2026-07-29** (sinal = tipo + texto; migration drop `signal_source`/`triangulated`; labels mesa sem "broker" — `Rede esfriou`/`Adversário apareceu`/`Alguém pediu algo`; E11 sem dimensão triangulado: presença = confirmado, **triage 1**; forms/lista/detalhe/dossiê alinhados; e2e stale-B43 corrigido de bônus) · [plano](plans/simplificar-modelo-sinal.md)
|
||||
- **B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal) · Depende de B59 + B62 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-registro-sinal.md)
|
||||
- **B64** Wizard mudar tendência (grid colorido ≠ atual + Drawer info + justificativa com prefill/Limpar/Salvar + “Pular” sempre) · Depende de B59 · soft B60 · soft B24 ✓ · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-mudar-tendencia.md)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
|
||||
import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep'
|
||||
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
|
||||
import {
|
||||
CAMPAIGN_WIZARD_ACTION_SLUGS,
|
||||
isCampaignWizardActionSlug,
|
||||
parseWizardMunicipioParam,
|
||||
resolveWizardScenarioParam,
|
||||
WIZARD_MUNICIPIO_QUERY_KEY,
|
||||
WIZARD_SCENARIO_QUERY_KEY,
|
||||
wizardActionHref,
|
||||
} from '@/lib/campaignActionRoutes'
|
||||
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
|
||||
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import config from '@/payload.config'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
|
||||
|
|
@ -52,5 +57,25 @@ export default async function CampaignActionWizardPage({
|
|||
notFound()
|
||||
}
|
||||
|
||||
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
|
||||
const { scenario, invalid } = resolveWizardScenarioParam(
|
||||
resolvedSearchParams[WIZARD_SCENARIO_QUERY_KEY],
|
||||
)
|
||||
if (invalid) {
|
||||
redirect(wizardActionHref(slug, municipalitySlug, 'central'))
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardExpectedVotesStep
|
||||
actionSlug={slug}
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
|
||||
currentScenario={scenario}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
|
||||
export const MUNICIPALITY_EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' as const
|
||||
|
||||
export type MunicipalityListExpectedVotesResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import type { MunicipalityListExpectedVotesResponse } from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
|
||||
import {
|
||||
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
|
||||
type MunicipalityListExpectedVotesResponse,
|
||||
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
|
||||
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
|
||||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
|
||||
|
|
@ -18,7 +21,6 @@ import {
|
|||
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
|
||||
|
||||
const AUTOSAVE_MS = 600
|
||||
const EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes'
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.'
|
||||
|
||||
/**
|
||||
|
|
@ -52,7 +54,7 @@ export const MunicipalityListExpectedVotesControl = ({
|
|||
useCampaignCellAutosave<VoteEstimateScenarioViewModel, MunicipalityListExpectedVotesResponse>({
|
||||
value: expectedVotes,
|
||||
equals: voteEstimatesEqual,
|
||||
endpoint: EXPECTED_VOTES_ENDPOINT,
|
||||
endpoint: MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
|
||||
buildBody: (values) => ({ municipalityId: municipalityID, expectedVotes: values }),
|
||||
readSaved: (payload) => payload.savedExpectedVotes,
|
||||
errorMessage: SAVE_ERROR_MESSAGE,
|
||||
|
|
|
|||
294
src/components/campaign/shared/WizardExpectedVotesStep.tsx
Normal file
294
src/components/campaign/shared/WizardExpectedVotesStep.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
|
||||
import {
|
||||
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
|
||||
type MunicipalityListExpectedVotesResponse,
|
||||
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
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_SAVED_MESSAGE,
|
||||
wizardVoteReturnToScenarioLabel,
|
||||
} from '@/lib/campaignWizardCopy'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { voteEstimateScenarioLabels, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import {
|
||||
applyVoteShortcut,
|
||||
draftTextForVoteValue,
|
||||
getNextWizardVoteScenario,
|
||||
getPreviousWizardVoteScenario,
|
||||
getWizardVoteViolation,
|
||||
mergeWizardVoteEstimate,
|
||||
parseWizardVoteDraft,
|
||||
VOTE_SHORTCUTS,
|
||||
voteShortcutLabels,
|
||||
wizardVoteFinalCtaLabel,
|
||||
wizardVoteStepCtaLabel,
|
||||
wizardVoteStepTitle,
|
||||
type VoteShortcut,
|
||||
type WizardVoteEditScenario,
|
||||
} 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 [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
|
||||
setDraft(draftTextForVoteValue(confirmed[currentScenario]))
|
||||
setDraftError(null)
|
||||
setViolationEditedScenario(null)
|
||||
previousScenarioRef.current = currentScenario
|
||||
}, [confirmed, currentScenario])
|
||||
|
||||
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 handleShortcut = (shortcut: VoteShortcut) => {
|
||||
const currentValue = parseWizardVoteDraft(draft)
|
||||
const nextValue = applyVoteShortcut(currentValue, shortcut)
|
||||
setDraft(draftTextForVoteValue(nextValue))
|
||||
setDraftError(null)
|
||||
setSaveError(null)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListExpectedVotesResponse>(
|
||||
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
|
||||
{
|
||||
municipalityId,
|
||||
expectedVotes: nextConfirmed,
|
||||
},
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
setSaveError(payload.status === 'error' ? payload.message : WIZARD_VOTES_SAVE_ERROR_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
setConfirmed(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
|
||||
stepTitle="Votos atualizados"
|
||||
previousHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
>
|
||||
<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>
|
||||
<Button asChild className="min-h-11 w-full sm:w-auto">
|
||||
<Link href={CAMPAIGN_HOME}>Voltar ao Início</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CampaignWizardShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CampaignWizardShell
|
||||
stepTitle={wizardVoteStepTitle(currentScenario)}
|
||||
previousHref={previousHref}
|
||||
municipalityLabel={municipalityName}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col gap-6"
|
||||
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)
|
||||
}}
|
||||
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>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VOTE_SHORTCUTS.map((shortcut) => (
|
||||
<Button
|
||||
key={shortcut}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-[3.25rem] flex-1 px-2 sm:flex-none"
|
||||
disabled={isPending}
|
||||
onClick={() => handleShortcut(shortcut)}
|
||||
>
|
||||
{voteShortcutLabels[shortcut]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showViolationBanner && activeViolation ? (
|
||||
<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>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{saveError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="text-sm">{saveError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{isPending ? 'Salvando votos estimados.' : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
disabled={isPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="size-4" aria-hidden />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
ctaLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CampaignWizardShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
/** 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 =
|
||||
|
|
@ -29,12 +32,21 @@ export const campaignActionEntryHref = (id: CampaignWizardActionId): string =>
|
|||
|
||||
export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
|
||||
|
||||
export const wizardActionHref = (actionSlug: string, municipalitySlug?: string): string => {
|
||||
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
|
||||
|
||||
export const wizardActionHref = (
|
||||
actionSlug: string,
|
||||
municipalitySlug?: string,
|
||||
scenario?: WizardVoteEditScenario,
|
||||
): 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()}`
|
||||
}
|
||||
|
||||
|
|
@ -45,3 +57,17 @@ export const parseWizardMunicipioParam = (
|
|||
const trimmed = raw?.trim()
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
|
||||
|
||||
/** Shared copy for UX-1 wizard municipality step (B60). */
|
||||
/** Shared copy for UX-1 wizard steps (B60 municipality search, B61 vote adjustment). */
|
||||
export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const
|
||||
|
||||
export const WIZARD_MUNICIPALITY_SEARCH_LABEL = 'Buscar município' as const
|
||||
|
|
@ -9,9 +9,19 @@ export const WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER = 'Nome do município…' as
|
|||
|
||||
export const WIZARD_MUNICIPALITY_SEARCH_EMPTY = 'Nenhum município encontrado.' as const
|
||||
|
||||
/** Placeholder until B61 ships vote adjustment. */
|
||||
const WIZARD_NEXT_STEP_VOTES_PLACEHOLDER =
|
||||
'Próximo passo: ajuste de votos estimados neste município.' as const
|
||||
export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as const
|
||||
|
||||
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()}`
|
||||
|
||||
const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const
|
||||
|
||||
|
|
@ -24,7 +34,7 @@ export const wizardNextStepTitle = (actionSlug: string): string => {
|
|||
|
||||
export const wizardNextStepPlaceholder = (actionSlug: string): string => {
|
||||
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
|
||||
return WIZARD_NEXT_STEP_VOTES_PLACEHOLDER
|
||||
return WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER
|
||||
}
|
||||
return WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER
|
||||
}
|
||||
|
|
|
|||
145
src/lib/wizardVoteEstimate.ts
Normal file
145
src/lib/wizardVoteEstimate.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
|
||||
import {
|
||||
getVoteEstimateOrderViolation,
|
||||
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
|
||||
voteEstimateScenarioLabels,
|
||||
type VoteEstimateScenario,
|
||||
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[] = [
|
||||
'double',
|
||||
'+50',
|
||||
'-50',
|
||||
'+100',
|
||||
'-100',
|
||||
] as const
|
||||
|
||||
export const voteShortcutLabels: Record<VoteShortcut, string> = {
|
||||
double: '2×',
|
||||
'+50': '+50',
|
||||
'-50': '−50',
|
||||
'+100': '+100',
|
||||
'-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') {
|
||||
if (base < 1) return base
|
||||
return Math.min(base * 2, MAX_VOTE_COUNT)
|
||||
}
|
||||
|
||||
const delta =
|
||||
shortcut === '+50' ? 50 : shortcut === '-50' ? -50 : shortcut === '+100' ? 100 : -100
|
||||
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
|
||||
}
|
||||
|
||||
export const getWizardVoteViolation = (
|
||||
estimates: VoteEstimateScenarioViewModel,
|
||||
): WizardVoteViolation | null => {
|
||||
const violatingScenario = getVoteEstimateOrderViolation(estimates)
|
||||
if (!violatingScenario) return null
|
||||
|
||||
return {
|
||||
violatingScenario,
|
||||
message: buildWizardVoteViolationMessage(estimates, violatingScenario),
|
||||
}
|
||||
}
|
||||
|
||||
const buildWizardVoteViolationMessage = (
|
||||
estimates: VoteEstimateScenarioViewModel,
|
||||
violatingScenario: VoteEstimateScenario,
|
||||
): string => {
|
||||
const { pessimistic, central, optimistic } = estimates
|
||||
|
||||
if (
|
||||
violatingScenario === 'central' &&
|
||||
pessimistic != null &&
|
||||
central != null &&
|
||||
pessimistic > central
|
||||
) {
|
||||
return `${voteEstimateScenarioLabels.pessimistic} (${formatElectionNumber(pessimistic)}) não pode ser maior que ${voteEstimateScenarioLabels.central.toLowerCase()} (${formatElectionNumber(central)}).`
|
||||
}
|
||||
|
||||
if (
|
||||
violatingScenario === 'optimistic' &&
|
||||
central != null &&
|
||||
optimistic != null &&
|
||||
optimistic < central
|
||||
) {
|
||||
return `${voteEstimateScenarioLabels.optimistic} (${formatElectionNumber(optimistic)}) não pode ser menor que ${voteEstimateScenarioLabels.central.toLowerCase()} (${formatElectionNumber(central)}).`
|
||||
}
|
||||
|
||||
return VOTE_ESTIMATE_ORDER_ERROR_MESSAGE
|
||||
}
|
||||
|
||||
export const mergeWizardVoteEstimate = (
|
||||
base: VoteEstimateScenarioViewModel,
|
||||
scenario: WizardVoteEditScenario,
|
||||
value: number | null,
|
||||
): VoteEstimateScenarioViewModel => ({
|
||||
...base,
|
||||
[scenario]: value,
|
||||
})
|
||||
|
|
@ -145,6 +145,57 @@ 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: 'Ajustar votos estimados' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Wizard — ajuste de votos (B61)', () => {
|
||||
test('confirming média advances to pessimista step', 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')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const input = page.getByLabel(/Média em Cairu/i)
|
||||
await input.fill('500')
|
||||
await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click()
|
||||
|
||||
await page.waitForURL(/cenario=pessimistic/)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Qual a nova estimativa pessimista/i }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('incoherent pessimista shows warning and return link', 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')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).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/)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
isCampaignWizardActionId,
|
||||
isCampaignWizardActionSlug,
|
||||
parseWizardMunicipioParam,
|
||||
resolveWizardScenarioParam,
|
||||
wizardActionHref,
|
||||
} from '@/lib/campaignActionRoutes'
|
||||
|
||||
|
|
@ -34,6 +35,18 @@ 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('parses municipio search param', () => {
|
||||
|
|
|
|||
88
tests/unit/wizardVoteEstimate.unit.spec.ts
Normal file
88
tests/unit/wizardVoteEstimate.unit.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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'
|
||||
|
||||
describe('applyVoteShortcut', () => {
|
||||
it('doubles the current value', () => {
|
||||
expect(applyVoteShortcut(150, 'double')).toBe(300)
|
||||
})
|
||||
|
||||
it('no-ops double when value is below 1', () => {
|
||||
expect(applyVoteShortcut(0, 'double')).toBe(0)
|
||||
expect(applyVoteShortcut(null, 'double')).toBe(0)
|
||||
})
|
||||
|
||||
it('clamps at MAX_VOTE_COUNT', () => {
|
||||
expect(applyVoteShortcut(MAX_VOTE_COUNT - 10, '+50')).toBe(MAX_VOTE_COUNT)
|
||||
expect(applyVoteShortcut(MAX_VOTE_COUNT, 'double')).toBe(MAX_VOTE_COUNT)
|
||||
})
|
||||
|
||||
it('applies arithmetic shortcuts and floors at zero', () => {
|
||||
expect(applyVoteShortcut(200, '+50')).toBe(250)
|
||||
expect(applyVoteShortcut(200, '-50')).toBe(150)
|
||||
expect(applyVoteShortcut(200, '+100')).toBe(300)
|
||||
expect(applyVoteShortcut(40, '-100')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('flags otimista below média', () => {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue