mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Migration 20260823_161905_add_municipality_update_deliberation + access por mutationKind + ilha client nas 3 superfícies + specs unit/int/e2e.
1041 lines
39 KiB
TypeScript
1041 lines
39 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
||
import { resolve } from 'node:path'
|
||
import { createElement } from 'react'
|
||
import { renderToStaticMarkup } from 'react-dom/server'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
|
||
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
|
||
|
||
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
|
||
import { formatAdvisorNamesTooltip } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||
import {
|
||
MunicipalityList,
|
||
municipalityListPickerColumns,
|
||
} from '@/components/campaign/municipality/MunicipalityList'
|
||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||
import { TseZoneBadge } from '@/components/campaign/municipality/TseZoneBadge'
|
||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||
import { Progress } from '@/components/ui/Progress'
|
||
import { Toggle } from '@/components/ui/Toggle'
|
||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||
import { createEmptyGoalCoverageByScenario } from '@/utilities/municipality/goalCoverage'
|
||
import { municipalityPriorityIndicatorLabel } from '@/utilities/municipality/municipalityLabels'
|
||
import type {
|
||
MunicipalityAdvisorSummary,
|
||
MunicipalityListViewModel,
|
||
} from '@/utilities/municipality/municipalityViewModels'
|
||
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
|
||
|
||
import { stub } from '../helpers/stub'
|
||
|
||
vi.mock('next/navigation', () => ({
|
||
useRouter: () => ({
|
||
replace: vi.fn(),
|
||
push: vi.fn(),
|
||
}),
|
||
}))
|
||
|
||
const noopListFormAction = async (
|
||
_state: CampaignFormActionState,
|
||
_formData: FormData,
|
||
): Promise<CampaignFormActionState> => ({})
|
||
|
||
const municipalityListDefaultProps = {
|
||
isCoordinator: false,
|
||
isCampaignUnrestricted: false,
|
||
columnVisibility: {
|
||
listId: 'municipios',
|
||
hiddenColumnIds: [],
|
||
} satisfies CampaignColumnVisibility,
|
||
canMoveEngagementLevel: false,
|
||
advisorOptions: [],
|
||
leadershipNamesById: new Map(),
|
||
leadershipOptions: [],
|
||
stateDeputyOptions: [],
|
||
stateDeputyCommitAction: noopListFormAction,
|
||
stateDeputyCreateAction: async () => ({}),
|
||
columnFilterOptions: { name: [], region: [], advisor: [] },
|
||
signalFormAction: noopListFormAction,
|
||
editingScope: 'tudo' as const,
|
||
portfolioIDs: null,
|
||
state: { page: 1 },
|
||
}
|
||
|
||
const responsiveMunicipality = stub<MunicipalityListViewModel>({
|
||
id: 1,
|
||
name: 'São Francisco do Conde com nome extenso',
|
||
slug: 'sao-francisco-do-conde',
|
||
kind: 'municipio',
|
||
city: 'São Francisco do Conde',
|
||
region: 'Metropolitano de Salvador',
|
||
ibgeCode: '2929206',
|
||
zoneNumber: null,
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'normal',
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'sem_base',
|
||
territorialClassFactors: [],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
})
|
||
|
||
const mockAppRouter = stub<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
|
||
push: () => {},
|
||
replace: () => {},
|
||
refresh: () => {},
|
||
forward: () => {},
|
||
back: () => {},
|
||
prefetch: () => Promise.resolve(),
|
||
})
|
||
|
||
// Cells render `CampaignHoverTooltip` (Radix), which refuses to render outside
|
||
// a provider; production mounts it in the `(app)` layout.
|
||
const renderWithTooltip = (element: React.ReactElement) =>
|
||
renderToStaticMarkup(createElement(TooltipProvider, null, element))
|
||
|
||
const renderWithAppRouter = (element: React.ReactElement) =>
|
||
renderToStaticMarkup(
|
||
createElement(
|
||
AppRouterContext.Provider,
|
||
{ value: mockAppRouter },
|
||
createElement(TooltipProvider, null, element),
|
||
),
|
||
)
|
||
|
||
describe('campaign visual foundation', () => {
|
||
it('exposes the current progress value to assistive technology', () => {
|
||
const html = renderToStaticMarkup(createElement(Progress, { value: 42 }))
|
||
|
||
expect(html).toContain('role="progressbar"')
|
||
expect(html).toContain('aria-valuenow="42"')
|
||
})
|
||
|
||
it.each(['sm', 'default', 'lg'] as const)('renders the %s toggle with a button role', (size) => {
|
||
const html = renderToStaticMarkup(createElement(Toggle, { size }, 'Filtro'))
|
||
|
||
expect(html).toContain('type="button"')
|
||
expect(html).toContain('Filtro')
|
||
})
|
||
|
||
it('renders toggle group items with the shared slot contract', () => {
|
||
const html = renderToStaticMarkup(
|
||
createElement(
|
||
ToggleGroup,
|
||
{ type: 'single' },
|
||
createElement(ToggleGroupItem, { value: 'engajado' }, 'Engajado'),
|
||
),
|
||
)
|
||
|
||
expect(html).toContain('data-slot="toggle-group-item"')
|
||
expect(html).toContain('Engajado')
|
||
})
|
||
|
||
it('renders campaign scope with a visible, accessible label', () => {
|
||
const html = renderToStaticMarkup(
|
||
createElement(CampaignScopeBadge, null, '3 municípios sob sua assessoria'),
|
||
)
|
||
|
||
expect(html).toContain('3 municípios sob sua assessoria')
|
||
expect(html).toContain('data-scope="campaign"')
|
||
})
|
||
|
||
it('keeps the authenticated shell fixed while its content pane scrolls', () => {
|
||
const layoutSource = readFileSync(
|
||
resolve(process.cwd(), 'src/app/(campaign)/campanha/(app)/layout.tsx'),
|
||
'utf8',
|
||
)
|
||
const scrollChromeSource = readFileSync(
|
||
resolve(process.cwd(), 'src/components/campaign/shell/CampaignQuickActionsHost.tsx'),
|
||
'utf8',
|
||
)
|
||
const sidebarSource = readFileSync(
|
||
resolve(process.cwd(), 'src/components/campaign/shell/CampaignSidebar.tsx'),
|
||
'utf8',
|
||
)
|
||
|
||
expect(layoutSource).toContain('CampaignAppScrollChrome')
|
||
expect(scrollChromeSource).toContain('data-slot="campaign-content-scroll"')
|
||
expect(sidebarSource).toContain('collapsible="offcanvas"')
|
||
expect(sidebarSource).toContain('print:hidden')
|
||
})
|
||
|
||
it('uses sidebar semantic tokens and readable logout states', () => {
|
||
const sidebarSource = readFileSync(
|
||
resolve(process.cwd(), 'src/components/campaign/shell/CampaignSidebar.tsx'),
|
||
'utf8',
|
||
)
|
||
|
||
expect(sidebarSource).toContain('text-sidebar-foreground')
|
||
expect(sidebarSource).toContain('hover:text-sidebar-accent-foreground')
|
||
})
|
||
|
||
it.each([
|
||
['engajado', 'Engajado', 'support-engaged'],
|
||
['a_abordar', 'A abordar', 'support-to-approach'],
|
||
['em_disputa', 'Em disputa', 'support-disputed'],
|
||
['lembranca', 'Lembrança', 'support-remembered'],
|
||
['negativo', 'Negativo', 'support-negative'],
|
||
] as const)(
|
||
'renders the %s support status with text, semantic state and variant',
|
||
(status, label, variant) => {
|
||
const html = renderToStaticMarkup(createElement(SupportStatusBadge, { status }))
|
||
|
||
expect(html).toContain(label)
|
||
expect(html).toContain(`data-support-status="${status}"`)
|
||
expect(html).toContain(`data-variant="${variant}"`)
|
||
},
|
||
)
|
||
|
||
it('qualifies TSE zones instead of showing an ambiguous number', () => {
|
||
const html = renderToStaticMarkup(createElement(TseZoneBadge, { zoneNumber: 12 }))
|
||
|
||
expect(html).toContain('ZE 12')
|
||
expect(html).toContain('aria-label="Zona Eleitoral TSE 12"')
|
||
})
|
||
|
||
it('renders municipality rows with geography, pledges, and advisor coverage for staff', () => {
|
||
const advisor: MunicipalityAdvisorSummary = { id: 7, name: 'Ana Bastos', phone: null }
|
||
const municipalities: MunicipalityListViewModel[] = [
|
||
{
|
||
id: 1,
|
||
name: 'Seabra',
|
||
slug: 'seabra',
|
||
kind: 'municipio',
|
||
city: 'Seabra',
|
||
region: 'Chapada Diamantina',
|
||
ibgeCode: '2929800',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
advisorIDs: [advisor.id],
|
||
leadershipIDs: [11],
|
||
stateDeputyIDs: [],
|
||
priority: 'alta',
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: { pessimistic: null, central: 1500, optimistic: null },
|
||
politicalTrendStatus: 'favoravel',
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: {
|
||
declaredTotal: 1200,
|
||
effectiveByScenario: { pessimistic: 1200, central: 1200, optimistic: 1200 },
|
||
pledgeCount: 2,
|
||
missingEstimateCount: 1,
|
||
lastPledgeAt: null,
|
||
},
|
||
votePosition2022: {
|
||
votes: 4200,
|
||
rank: 12,
|
||
share: 0.031,
|
||
totalUnits: 435,
|
||
},
|
||
territorialClass: 'reduto',
|
||
territorialClassFactors: [
|
||
{ id: 'dominance', value: 2.4 },
|
||
{ id: 'ownShare', value: 0.031 },
|
||
],
|
||
goalCoverageByScenario: {
|
||
pessimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
|
||
central: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
|
||
optimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
|
||
},
|
||
},
|
||
{
|
||
id: 2,
|
||
name: 'Salvador — ZE 3',
|
||
slug: 'salvador-ze-3',
|
||
kind: 'zona',
|
||
city: 'Salvador',
|
||
region: 'Metropolitano de Salvador',
|
||
ibgeCode: '2927408',
|
||
zoneNumber: 3,
|
||
isCity: false,
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'normal',
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'expansao',
|
||
territorialClassFactors: [{ id: 'field', value: 9000 }],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
},
|
||
]
|
||
|
||
const html = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities,
|
||
advisorNamesById: new Map([[advisor.id, advisor]]),
|
||
isStaffView: true,
|
||
...municipalityListDefaultProps,
|
||
leadershipNamesById: new Map([[11, { id: 11, name: 'Maria de Jesus' }]]),
|
||
}),
|
||
)
|
||
|
||
expect(html).toContain('Seabra')
|
||
expect(html).toContain('Chapada Diamantina')
|
||
expect(html).toContain('href="/campanha/territorios#ti-chapada-diamantina"')
|
||
expect(html).toContain('href="/campanha/municipios/seabra"')
|
||
expect(html).toContain('1.500')
|
||
expect(html).toContain('Cenários de estimativa')
|
||
expect(html).toContain(`aria-label="${municipalityPriorityIndicatorLabel}"`)
|
||
expect(html).toContain('lucide-flag')
|
||
expect(html).not.toMatch(/>\s*Prioritária\s*<\/span>/)
|
||
// The advisor column carries coverage on its own — no separate "Assessoria" column.
|
||
expect(html).toContain('Assessores')
|
||
expect(html).toContain('Ana Bastos')
|
||
expect(html).not.toContain('Assessoria')
|
||
expect(html).toContain('Tendência')
|
||
expect(html).toContain('Cobertura')
|
||
expect(html).not.toContain('Cobertura da meta')
|
||
expect(html).toContain('80%')
|
||
expect(html).toContain('Faltam 300 votos para a meta')
|
||
// B155: the Lideranças column chips the linked leaderships by name.
|
||
expect(html).toContain('Lideranças')
|
||
expect(html).toContain('Maria de Jesus')
|
||
// B22 + B176: every one of the 11 visible-by-default staff columns carries
|
||
// a header explanation — pinned inside `<thead>` itself, since `cellTooltip`
|
||
// (e.g. "Classe") and the mobile card's own class tooltip (B42) add
|
||
// triggers of their own. 11 = the 10 staff baseline + Dobradinha, which is
|
||
// now staff-wide (B176).
|
||
const [, tbodyHtml = ''] = html.split('<tbody')
|
||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||
|
||
// B158: the table is container-driven and clips instead of delegating to a
|
||
// horizontal scroller. Município remains pinned and owns the territory subline.
|
||
// D7: `clip` (paired with `overflow-y: visible`) creates NO scroll container,
|
||
// which is what lets the declared sticky `<th>` resolve against the shell
|
||
// scroller and actually engage.
|
||
expect(html).toContain('data-container="municipality-list"')
|
||
expect(html).toContain('@container/municipality-list')
|
||
expect(html).toContain('@min-[48rem]/municipality-list:block')
|
||
expect(html).toContain('@min-[48rem]/municipality-list:hidden')
|
||
expect(html).toContain('data-slot="table-container"')
|
||
expect(html).toContain('supports-[container-type:inline-size]:overflow-x-clip')
|
||
// overflow-x-auto remains the fallback for browsers without container
|
||
// queries; which branch actually wins at runtime is pinned by the E2E
|
||
// computed-style guard (campaignTableStickyHeader) and by
|
||
// expectNoHorizontalOverflow in the responsive-columns spec.
|
||
expect(html).toContain('overflow-x-auto')
|
||
expect(theadHtml).toContain('Município')
|
||
expect(tbodyHtml).toContain('Seabra')
|
||
})
|
||
|
||
it('renders the Dobradinhas column for unrestricted staff with the advisors-style display', () => {
|
||
const stateDeputyOptions: StateDeputyRelationOption[] = [
|
||
{ id: 11, name: 'Fulano (PT)', plainName: 'Fulano', party: 'PT', slug: 'fulano' },
|
||
{ id: 12, name: 'Beltrana (PSB)', plainName: 'Beltrana', party: 'PSB', slug: 'beltrana' },
|
||
]
|
||
const baseRow = stub<MunicipalityListViewModel>({
|
||
id: 1,
|
||
name: 'Seabra',
|
||
slug: 'seabra',
|
||
kind: 'municipio',
|
||
city: 'Seabra',
|
||
region: 'Chapada Diamantina',
|
||
ibgeCode: '2929800',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
priority: 'normal',
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
territorialClass: 'sem_base',
|
||
territorialClassFactors: [],
|
||
})
|
||
const municipalities: MunicipalityListViewModel[] = [
|
||
{ ...baseRow, id: 1, stateDeputyIDs: [11, 12] },
|
||
{ ...baseRow, id: 2, name: 'Uauá', slug: 'uaua', stateDeputyIDs: [] },
|
||
]
|
||
|
||
const html = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities,
|
||
advisorNamesById: new Map(),
|
||
...municipalityListDefaultProps,
|
||
isStaffView: true,
|
||
isCampaignUnrestricted: true,
|
||
stateDeputyOptions,
|
||
}),
|
||
)
|
||
|
||
// B157/B158: Dobradinha adds one explanation to the 10-column staff baseline.
|
||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||
expect(theadHtml).toContain('Dobradinha')
|
||
// Avatar-stack display: initials circles + the names in the trigger's
|
||
// accessible label (the closed cell reads WHO is assigned without opening).
|
||
expect(html).toContain('>F<') // Fulano
|
||
expect(html).toContain('>B<') // Beltrana
|
||
expect(html).toContain(
|
||
'aria-label="Editar dobradinhas em Seabra — Fulano (PT), Beltrana (PSB)"',
|
||
)
|
||
// Empty município reads "—".
|
||
expect(html).toContain('>—<')
|
||
})
|
||
|
||
it('shows the Dobradinhas column to the advisor and hides it from the leader', () => {
|
||
const municipalities: MunicipalityListViewModel[] = [
|
||
stub<MunicipalityListViewModel>({
|
||
id: 1,
|
||
name: 'Seabra',
|
||
slug: 'seabra',
|
||
kind: 'municipio',
|
||
city: 'Seabra',
|
||
region: 'Chapada Diamantina',
|
||
ibgeCode: '2929800',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
priority: 'normal',
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
territorialClass: 'sem_base',
|
||
territorialClassFactors: [],
|
||
}),
|
||
]
|
||
|
||
// B176 (2026-08-09): the Dobradinhas column is staff-wide — an advisor
|
||
// sees it (the write is scoped to their portfolio server-side), while
|
||
// the leader lockdown keeps it off the leader view.
|
||
const advisorHtml = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities,
|
||
advisorNamesById: new Map(),
|
||
...municipalityListDefaultProps,
|
||
isStaffView: true,
|
||
isCampaignUnrestricted: false,
|
||
}),
|
||
)
|
||
expect(advisorHtml).toContain('Dobradinhas')
|
||
|
||
const leaderHtml = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities,
|
||
advisorNamesById: new Map(),
|
||
...municipalityListDefaultProps,
|
||
isStaffView: false,
|
||
isCampaignUnrestricted: false,
|
||
}),
|
||
)
|
||
expect(leaderHtml).not.toContain('Dobradinhas')
|
||
})
|
||
|
||
/**
|
||
* E10: the class is a suggestion, and a suggestion without its reason reads
|
||
* as a verdict (research §6.4). The reason moved into the column's
|
||
* `cellTooltip` so the cell stays narrow, so what this pins is that it is
|
||
* still TEXT in the markup (`sr-only`) rather than a `title` attribute,
|
||
* which reached neither keyboard nor touch. A município without a TSE
|
||
* series must not wear a badge that implies a reading.
|
||
*/
|
||
it('never renders a territorial class without its reason, and dashes the ones with no series', () => {
|
||
const html = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [
|
||
stub<MunicipalityListViewModel>({
|
||
id: 1,
|
||
name: 'Seabra',
|
||
slug: 'seabra',
|
||
kind: 'municipio',
|
||
city: 'Seabra',
|
||
region: 'Chapada Diamantina',
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'normal',
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'reduto',
|
||
territorialClassFactors: [
|
||
{ id: 'dominance', value: 2.4 },
|
||
{ id: 'ownShare', value: 0.031 },
|
||
],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
}),
|
||
stub<MunicipalityListViewModel>({
|
||
id: 2,
|
||
name: 'Sem série',
|
||
slug: 'sem-serie',
|
||
kind: 'municipio',
|
||
city: 'Sem série',
|
||
region: 'Chapada Diamantina',
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'normal',
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'sem_base',
|
||
territorialClassFactors: [],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
}),
|
||
],
|
||
advisorNamesById: new Map(),
|
||
isStaffView: true,
|
||
...municipalityListDefaultProps,
|
||
}),
|
||
)
|
||
|
||
expect(html).toContain('Reduto')
|
||
// The factors ride along as text, in the mesa's phrasing — never a `title`.
|
||
expect(html).toContain('2,4× o padrão estadual do candidato')
|
||
expect(html).not.toContain('title="2,4×')
|
||
expect(html).not.toContain('Sem base')
|
||
expect(html).toContain('Sem série do TSE para este município.')
|
||
})
|
||
|
||
/**
|
||
* E9 allocation queue: the two row-level signals the queue is scanned for —
|
||
* a priority município nobody answers for, and a signal that went cold.
|
||
*/
|
||
it('names a priority município with no advisor and ages its last signal', () => {
|
||
const staleDays = 40
|
||
const staleSignal = new Date(Date.now() - staleDays * 86_400_000).toISOString()
|
||
|
||
const html = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [
|
||
{
|
||
id: 9,
|
||
name: 'Barreiras',
|
||
slug: 'barreiras',
|
||
kind: 'municipio',
|
||
city: 'Barreiras',
|
||
region: 'Bacia do Rio Grande',
|
||
ibgeCode: '2903201',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'alta',
|
||
lastUpdateAt: staleSignal,
|
||
lastSignalAt: staleSignal,
|
||
// B193 — the mobile card footer's "Última atualização" age comes
|
||
// from the LAST UPDATE (not the pledge-merged frescor): a stale
|
||
// update paints the footer cold, and the register CTA lives inside
|
||
// the collapsed expansion.
|
||
lastUpdate: {
|
||
id: 99,
|
||
authorName: 'Maria',
|
||
createdAt: staleSignal,
|
||
body: 'Visita ao comitê da região.',
|
||
polarity: 'boa',
|
||
urgent: false,
|
||
activeVolunteers: null,
|
||
newSupports: null,
|
||
adversarySignal: false,
|
||
responsibleId: null,
|
||
responsibleName: null,
|
||
resolvedAt: null,
|
||
resolvedByName: null,
|
||
comments: [],
|
||
},
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'expansao',
|
||
territorialClassFactors: [{ id: 'field', value: 9000 }],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
},
|
||
],
|
||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||
isStaffView: true,
|
||
...municipalityListDefaultProps,
|
||
}),
|
||
)
|
||
|
||
// "Sem assessor" still appears in the Assessores column (the avatar stack's
|
||
// empty state); the status badge is what escalates to "Sem responsável".
|
||
expect(html).toContain('Sem responsável')
|
||
// The mobile footer's age is the last UPDATE's age (B193): the update is
|
||
// 40 days old, so the footer reads "há 40 dias" and paints cold — and the
|
||
// expansion stays collapsed (the update body is not in the DOM).
|
||
expect(html).toContain('Última atualização há 40 dias')
|
||
// The cold class is applied ONLY on the mobile footer (the desktop column
|
||
// uses `data-signal="cold"` on the readout's own span), so this pins the
|
||
// footer's cold paint without relying on the desktop tree.
|
||
expect(html).toContain('text-estimate-pending-foreground')
|
||
expect(html).not.toContain('Visita ao comitê da região.')
|
||
expect(html).toContain('data-signal="cold"')
|
||
})
|
||
|
||
it('reads "Sem sinal" when nothing was ever recorded for the município', () => {
|
||
const html = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [
|
||
{
|
||
id: 10,
|
||
name: 'Uauá',
|
||
slug: 'uaua',
|
||
kind: 'municipio',
|
||
city: 'Uauá',
|
||
region: 'Semiárido Nordeste II',
|
||
ibgeCode: '2932002',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'normal',
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'expansao',
|
||
territorialClassFactors: [{ id: 'field', value: 9000 }],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
},
|
||
],
|
||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||
isStaffView: true,
|
||
...municipalityListDefaultProps,
|
||
}),
|
||
)
|
||
|
||
expect(html).toContain('Sem sinal')
|
||
expect(html).toContain('Sem assessor')
|
||
expect(html).not.toContain('Sem responsável')
|
||
})
|
||
|
||
it('hides leadership coverage subline when pledges only have declared votes', () => {
|
||
const html = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [
|
||
{
|
||
id: 3,
|
||
name: 'Itaberaba',
|
||
slug: 'itaberaba',
|
||
kind: 'municipio',
|
||
city: 'Itaberaba',
|
||
region: 'Centro Norte',
|
||
ibgeCode: '2914703',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'normal',
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: {
|
||
declaredTotal: 800,
|
||
effectiveByScenario: { pessimistic: 800, central: 800, optimistic: 800 },
|
||
pledgeCount: 1,
|
||
missingEstimateCount: 1,
|
||
lastPledgeAt: null,
|
||
},
|
||
votePosition2022: null,
|
||
territorialClass: 'expansao',
|
||
territorialClassFactors: [{ id: 'field', value: 9000 }],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
},
|
||
],
|
||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||
isStaffView: true,
|
||
...municipalityListDefaultProps,
|
||
}),
|
||
)
|
||
|
||
expect(html).toContain('Itaberaba')
|
||
expect(html).not.toContain('>Nas lideranças')
|
||
expect(html).not.toContain('Declarado nas lideranças')
|
||
})
|
||
|
||
it('hides staff-only pledge and coverage columns from the leader view', () => {
|
||
const html = renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [
|
||
{
|
||
id: 1,
|
||
name: 'Seabra',
|
||
slug: 'seabra',
|
||
kind: 'municipio',
|
||
city: 'Seabra',
|
||
region: 'Chapada Diamantina',
|
||
ibgeCode: '2929800',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
stateDeputyIDs: [],
|
||
priority: 'alta',
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'expansao',
|
||
territorialClassFactors: [{ id: 'field', value: 9000 }],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
},
|
||
],
|
||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||
isStaffView: false,
|
||
...municipalityListDefaultProps,
|
||
}),
|
||
)
|
||
|
||
expect(html).toContain('Seabra')
|
||
expect(html).not.toContain('Votos estimados')
|
||
expect(html).not.toContain('Cenários de estimativa')
|
||
expect(html).not.toContain('Faltam')
|
||
expect(html).not.toContain('Prioritária')
|
||
expect(html).not.toContain(`aria-label="${municipalityPriorityIndicatorLabel}"`)
|
||
// B22/B158: the leader view keeps only Município, 2022 and Atualização;
|
||
// all three carry header explanations.
|
||
const [theadHtml] = html.split('<tbody')
|
||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(3)
|
||
})
|
||
|
||
describe('B158 responsive municipality columns', () => {
|
||
const renderResponsiveList = (hiddenColumnIds: string[] = []) =>
|
||
renderWithAppRouter(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [responsiveMunicipality],
|
||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||
...municipalityListDefaultProps,
|
||
isStaffView: true,
|
||
isCampaignUnrestricted: true,
|
||
columnVisibility: { listId: 'municipios', hiddenColumnIds },
|
||
}),
|
||
)
|
||
|
||
it('keeps the table order, territory subline, and symmetric container-query classes', () => {
|
||
const html = renderResponsiveList()
|
||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||
const tbodyHtml = html.slice(html.indexOf('<tbody'), html.indexOf('</tbody>'))
|
||
const labels = [
|
||
'Município',
|
||
'2022',
|
||
'2026',
|
||
'Nível',
|
||
'Classe',
|
||
'Assessor',
|
||
'Tendência',
|
||
'Liderança',
|
||
'Dobradinha',
|
||
'Cobertura',
|
||
'Atualização',
|
||
]
|
||
|
||
let previousIndex = -1
|
||
for (const label of labels) {
|
||
const labelIndex = theadHtml.indexOf(label)
|
||
expect(labelIndex, `${label} deve aparecer depois da coluna anterior`).toBeGreaterThan(
|
||
previousIndex,
|
||
)
|
||
previousIndex = labelIndex
|
||
}
|
||
|
||
expect(theadHtml).not.toContain('Território')
|
||
const nameCellHtml = tbodyHtml.split('<td')[1] ?? ''
|
||
expect(nameCellHtml).toContain('São Francisco do Conde com nome extenso')
|
||
expect(nameCellHtml).toContain('Metropolitano de Salvador')
|
||
expect(nameCellHtml).toContain('href="/campanha/territorios#ti-metropolitano-de-salvador"')
|
||
|
||
for (const className of [
|
||
'hidden @min-[54rem]/municipality-list:table-cell',
|
||
'hidden @min-[60rem]/municipality-list:table-cell',
|
||
'hidden @min-[66rem]/municipality-list:table-cell',
|
||
'hidden @min-[72rem]/municipality-list:table-cell',
|
||
'hidden @min-[78rem]/municipality-list:table-cell',
|
||
]) {
|
||
expect(html.split(className)).toHaveLength(3)
|
||
}
|
||
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:hidden')
|
||
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:inline-flex')
|
||
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:hidden')
|
||
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:block')
|
||
|
||
const firstRowCells = tbodyHtml.split('<td').slice(1)
|
||
expect(firstRowCells[3]?.split('</td>')[0]).toContain('>—</span>')
|
||
expect(html).toContain('Sem nível')
|
||
})
|
||
|
||
it.each([
|
||
{ hidden: [], trendEditors: 2, actionsGated: true },
|
||
{ hidden: ['trend'], trendEditors: 1, actionsGated: false },
|
||
{ hidden: ['lastSignal'], trendEditors: 2, actionsGated: false },
|
||
{ hidden: ['trend', 'lastSignal'], trendEditors: 1, actionsGated: false },
|
||
])(
|
||
// B172 — the trend editor is mounted once per placement (its own column
|
||
// above 60rem, the compact backup in Ações below). The backup only
|
||
// exists while the picker still shows the column, so the total is 2 for
|
||
// the visible case and 1 for the manual-hide case; the signal editor
|
||
// never gains a second placement (option A). `actions` is always mounted
|
||
// for staff and CSS-gated by container width when nothing is hidden.
|
||
'keeps one signal editor and $trendEditors trend placements for $hidden',
|
||
({ hidden, trendEditors, actionsGated }) => {
|
||
const html = renderResponsiveList(hidden)
|
||
const tableHtml = html.slice(html.indexOf('<table'), html.indexOf('</table>'))
|
||
const theadHtml = tableHtml.slice(
|
||
tableHtml.indexOf('<thead'),
|
||
tableHtml.indexOf('</thead>'),
|
||
)
|
||
|
||
expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(trendEditors)
|
||
expect(tableHtml.match(/aria-label="Registrar atualização em/g)).toHaveLength(1)
|
||
expect(theadHtml.includes('Ações')).toBe(true)
|
||
if (actionsGated) {
|
||
expect(theadHtml).toContain('@min-[60rem]/municipality-list:hidden')
|
||
} else {
|
||
expect(theadHtml).not.toContain('@min-[60rem]/municipality-list:hidden')
|
||
}
|
||
},
|
||
)
|
||
|
||
it('keeps removed and internal columns out of the municipality picker', () => {
|
||
const columns = municipalityListPickerColumns({
|
||
isStaffView: true,
|
||
})
|
||
|
||
expect(columns.map((column) => column.id)).toEqual([
|
||
'name',
|
||
'votos',
|
||
'expectedVotes',
|
||
'level',
|
||
'classe',
|
||
'advisors',
|
||
'trend',
|
||
'leaderships',
|
||
'stateDeputies',
|
||
'goalCoverage',
|
||
'lastSignal',
|
||
])
|
||
expect(columns).not.toContainEqual(expect.objectContaining({ id: 'region' }))
|
||
expect(columns).not.toContainEqual(expect.objectContaining({ id: 'actions' }))
|
||
})
|
||
})
|
||
|
||
/**
|
||
* B23: the advisor stack only shows up to 3 initials (and initials
|
||
* collide), so the full names ride along as redundant text. Radix mounts
|
||
* tooltip content only once opened, so these pin the trigger half of the
|
||
* contract — the same shape `campaignTable.unit.spec.ts` pins generically.
|
||
*/
|
||
describe('B23 cell tooltips', () => {
|
||
it('formats one advisor name per line and returns null for an empty list', () => {
|
||
expect(formatAdvisorNamesTooltip([])).toBeNull()
|
||
|
||
const one = renderToStaticMarkup(formatAdvisorNamesTooltip([{ id: 1, name: 'Ana Bastos' }])!)
|
||
expect(one).toContain('Ana Bastos')
|
||
|
||
const three = renderToStaticMarkup(
|
||
formatAdvisorNamesTooltip([
|
||
{ id: 1, name: 'Ana Bastos' },
|
||
{ id: 2, name: 'Beto Lima' },
|
||
{ id: 3, name: 'Carla Dias' },
|
||
])!,
|
||
)
|
||
expect(three).toContain('Ana Bastos')
|
||
expect(three).toContain('Beto Lima')
|
||
expect(three).toContain('Carla Dias')
|
||
})
|
||
|
||
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
|
||
// The staff `<thead>` already carries 10 header-explanation tooltips
|
||
// (B22), regardless of row data. This baseline isolates the advisors
|
||
// CELL tooltip, the one this test actually exercises.
|
||
// 11 header explanations: the 10 staff columns + the now staff-wide
|
||
// Dobradinhas (B176).
|
||
const HEADER_TOOLTIP_COUNT = 11
|
||
const baseMunicipality = {
|
||
id: 1,
|
||
name: 'Seabra',
|
||
slug: 'seabra',
|
||
kind: 'municipio' as const,
|
||
city: 'Seabra',
|
||
region: 'Chapada Diamantina',
|
||
ibgeCode: '2929800',
|
||
zoneNumber: null,
|
||
isCity: false,
|
||
advisorIDs: [],
|
||
leadershipIDs: [],
|
||
priority: 'normal' as const,
|
||
lastUpdateAt: null,
|
||
lastSignalAt: null,
|
||
lastUpdate: null,
|
||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||
politicalTrendStatus: null,
|
||
politicalTrendNote: null,
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
levelChangedAt: null,
|
||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||
votePosition2022: null,
|
||
territorialClass: 'sem_base' as const,
|
||
territorialClassFactors: [],
|
||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||
}
|
||
|
||
const withAdvisors = renderWithTooltip(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [{ ...baseMunicipality, advisorIDs: [7], stateDeputyIDs: [8] }],
|
||
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
|
||
isStaffView: true,
|
||
...municipalityListDefaultProps,
|
||
}),
|
||
)
|
||
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(
|
||
HEADER_TOOLTIP_COUNT + 1,
|
||
)
|
||
|
||
const withoutAdvisors = renderWithTooltip(
|
||
createElement(MunicipalityList, {
|
||
municipalities: [{ ...baseMunicipality, advisorIDs: [], stateDeputyIDs: [] }],
|
||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||
isStaffView: true,
|
||
...municipalityListDefaultProps,
|
||
}),
|
||
)
|
||
expect(withoutAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(
|
||
HEADER_TOOLTIP_COUNT,
|
||
)
|
||
expect(withoutAdvisors).toContain('Sem assessor')
|
||
})
|
||
|
||
it("wraps the coordinator's advisor Popover trigger in a tooltip, never doubling the affordance", () => {
|
||
const withAdvisors = renderWithTooltip(
|
||
createElement(MunicipalityListAdvisorsControl, {
|
||
municipalityID: 1,
|
||
municipalityName: 'Feira de Santana',
|
||
currentAdvisorIDs: [7],
|
||
isPriority: false,
|
||
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
|
||
options: [],
|
||
variant: 'popover',
|
||
}),
|
||
)
|
||
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
|
||
// The tooltip wraps the SAME trigger — nesting `asChild` composition
|
||
// means only the outer wrapper's `data-slot` survives on the button,
|
||
// but Popover's own trigger wiring (still the innermost primitive)
|
||
// stays intact and functional.
|
||
expect(withAdvisors).toContain('aria-haspopup="dialog"')
|
||
|
||
const withoutAdvisors = renderWithTooltip(
|
||
createElement(MunicipalityListAdvisorsControl, {
|
||
municipalityID: 1,
|
||
municipalityName: 'Feira de Santana',
|
||
currentAdvisorIDs: [],
|
||
isPriority: false,
|
||
advisorNamesById: new Map(),
|
||
options: [],
|
||
variant: 'popover',
|
||
}),
|
||
)
|
||
expect(withoutAdvisors).not.toContain('data-slot="tooltip-trigger"')
|
||
expect(withoutAdvisors).toContain('data-slot="popover-trigger"')
|
||
expect(withoutAdvisors).toContain('aria-haspopup="dialog"')
|
||
})
|
||
|
||
it('shows the trend justification on hover only when one was recorded', () => {
|
||
const withNote = renderWithTooltip(
|
||
createElement(MunicipalityListTrendControl, {
|
||
municipalityID: 1,
|
||
municipalityName: 'Feira de Santana',
|
||
status: 'favoravel',
|
||
trendNote: 'Vereador migrou para a base',
|
||
variant: 'popover',
|
||
}),
|
||
)
|
||
expect(withNote.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
|
||
// Radix mounts tooltip content lazily — it never reaches the server
|
||
// markup, same contract as `campaignTable.unit.spec.ts`.
|
||
expect(withNote).not.toContain('Vereador migrou para a base')
|
||
expect(withNote).toContain('aria-haspopup="dialog"')
|
||
expect(withNote).not.toContain('Salvar')
|
||
|
||
const withoutNote = renderWithTooltip(
|
||
createElement(MunicipalityListTrendControl, {
|
||
municipalityID: 1,
|
||
municipalityName: 'Feira de Santana',
|
||
status: 'favoravel',
|
||
trendNote: null,
|
||
variant: 'popover',
|
||
}),
|
||
)
|
||
expect(withoutNote).not.toContain('data-slot="tooltip-trigger"')
|
||
})
|
||
})
|
||
})
|