teqo/tests/unit/campaignCellEditOverlay.unit.spec.ts
Francisco Solla b210939226 refactor(B193): simplify fixes pós-revisão — mapper único de updates, API de trigger uniforme, grupos de relação extraídos, hardening do sheet host
- Extrai toMunicipalityUpdateViewModel/loadUpdateAuthorNames compartilhados
  (feed + batch da lista); remove avatarClassName morto.
- MunicipalityRelationTriggerProps compartilhado nos 3 wrappers; triggerClassName
  no ExpectedVotesControl; ChipLabel/RelationGroup/CityDash no card.
- Sheet host: cleanup de unmount no overlay (sem drawer órfão), registerFooter
  valida open, branch nulo do handleOpenChange limpa footer; chrome freeze
  documentado.
- A11y/UX: ícone de alerta no rodapé frio (WCAG 1.4.1), iniciais de avatar
  aria-hidden, chip sem_base sem target morto, label da cidade com Tendência,
  aria-label da barra anuncia os 3 cenários, card layout 'colocação · votos'.
- E2E: settleStream no B184 (flake da cópia transitória do streaming), cards
  ancorados por hasText (q= casa prefixos), cor da borda via classe do token.
2026-08-10 11:23:49 -03:00

518 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement, type ReactElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { TooltipProvider } from '@/components/ui/tooltip'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
/**
* B42: the quick-edit cells of `/campanha/municipios` share one container
* (`CampaignCellEditOverlay`) that is a Popover in the desktop table and a
* bottom Drawer in the mobile card. These cases pin the part a call site can
* silently get wrong — which container it renders and whether the touch surface
* says which município is being edited — for all of them at once. Three
* auto-save; the level cell (E14) submits explicitly and is here for the
* container contract all four share.
*/
const MUNICIPALITY_NAME = 'Feira de Santana'
type OverlayCase = {
name: string
/**
* The accessible name of the trigger. It replaces the pill / avatar stack /
* number it wraps, so it has to carry that value — each case spells the value
* out, which is the part a refactor drops without noticing.
*/
triggerLabel: string
/** Text the Drawer header must carry beyond the município name. */
drawerTitle: string
/**
* Label of the sheet's primary footer action, for the cells whose commit is
* an explicit submit (B32+ F5). A footer REPLACES the default "Fechar", so
* the two assertions are exclusive.
*/
footerLabel?: string
element: (variant: CampaignCellEditOverlayVariant) => ReactElement
}
const overlayCases: OverlayCase[] = [
{
name: 'tendência',
triggerLabel: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
drawerTitle: 'Editar tendência',
element: (variant) =>
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
status: 'favoravel',
trendNote: 'Vereador migrou para a base',
variant,
}),
},
{
name: 'assessores',
triggerLabel: `Editar assessores em ${MUNICIPALITY_NAME} — Ana Bastos`,
drawerTitle: 'Atribuir assessores',
element: (variant) =>
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
currentAdvisorIDs: [7],
isPriority: false,
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
options: [{ id: 7, name: 'Ana Bastos', isCurrent: false }],
variant,
}),
},
{
name: 'lideranças',
triggerLabel: `Editar lideranças em ${MUNICIPALITY_NAME} — Maria de Jesus`,
drawerTitle: 'Gerenciar lideranças',
element: (variant) =>
createElement(MunicipalityListLeadershipsControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
currentLeadershipIDs: [11],
leadershipNamesById: new Map([[11, { id: 11, name: 'Maria de Jesus' }]]),
options: [{ id: 11, name: 'Maria de Jesus' }],
variant,
}),
},
{
name: 'votos estimados',
// B193 — the votes trigger announces the whole interval (the strip shows
// all three scenarios), not just the active one.
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Pessimista: sem estimativa; Média: 1.200; Otimista: sem estimativa`,
drawerTitle: 'Editar votos estimados',
element: (variant) =>
createElement(MunicipalityListExpectedVotesControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
expectedVotes: toVoteEstimateScenarioViewModel({ central: 1200 }),
pledgeCoverage: null,
variant,
}),
},
{
name: 'nível de envolvimento',
triggerLabel: `Nível de envolvimento de ${MUNICIPALITY_NAME}: N3 · Rede + agenda`,
drawerTitle: 'Registrar nível de envolvimento',
footerLabel: 'Registrar movimento',
element: (variant) =>
createElement(MunicipalityListLevelControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
level: 'n3',
levelNote: 'Rede montada e giro agendado',
levelChangedAt: '2026-07-01T12:00:00.000Z',
variant,
}),
},
{
name: 'sinal',
triggerLabel: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
drawerTitle: 'Registrar atualização',
footerLabel: 'Registrar atualização',
element: (variant) =>
// `createElement`'s typing puts a required `children` in props, so the
// rest-argument form this rule prefers does not type-check here.
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListUpdateControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant,
formAction: async () => ({}),
isStaff: true,
children: createElement('span', null, 'Sem sinal'),
}),
},
]
beforeAll(() => {
// The advisors control renders `cmdk`, which measures its list and keeps the
// selected item in view. jsdom implements neither. Same `ResizeObserver` stub
// as `campaignCompositionCleanup.unit.spec.ts`.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
afterEach(cleanup)
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
// The Popover variant wraps its trigger in `CampaignHoverTooltip` (Radix),
// which refuses to render outside a provider; production mounts it in the
// `(app)` layout.
const renderWithTooltip = (ui: ReactElement) => render(createElement(TooltipProvider, null, ui))
describe('campaign cell edit overlay', () => {
it.each(overlayCases)(
'opens $name in a Drawer that names the município on touch',
async ({ triggerLabel, drawerTitle, footerLabel, element }) => {
renderWithTooltip(element('sheet'))
fireEvent.click(screen.getByRole('button', { name: triggerLabel }))
const dialog = await screen.findByRole('dialog')
const title = dialog.querySelector('[data-slot="drawer-title"]')
expect(title?.textContent).toBe(drawerTitle)
expect(dialog.querySelector('[data-slot="drawer-description"]')?.textContent).toBe(
MUNICIPALITY_NAME,
)
// Focus lands on the title, never on a field: on touch the alternative is
// the virtual keyboard covering the sheet before it can be read. Awaited
// because the advisors sheet mounts `cmdk`, which delays it by a tick.
await waitFor(() => expect(document.activeElement).toBe(title))
const footer = dialog.querySelector('[data-slot="drawer-footer"]')
if (footerLabel) {
// A footer replaces the default dismissal: the commit is the primary
// action and "Cancelar" is what stands in for "Fechar".
expect(footer?.textContent).toContain(footerLabel)
expect(footer?.textContent).not.toContain('Fechar')
} else {
// Swiping down is the gesture, but it is not an affordance anyone sees.
expect(footer?.textContent).toContain('Fechar')
}
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull()
},
)
it.each(overlayCases)(
'keeps $name in a Popover on the desktop table',
async ({ triggerLabel, element }) => {
renderWithTooltip(element('popover'))
const trigger = screen.getByRole('button', { name: triggerLabel })
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
fireEvent.click(trigger)
await screen.findByRole('dialog')
expect(document.querySelector('[data-slot="popover-content"]')).not.toBeNull()
expect(document.querySelector('[data-slot="drawer-popup"]')).toBeNull()
// Header and subject line are Drawer chrome. A Popover is anchored to the
// trigger it came from, and that trigger's accessible name already carries
// both the município and the current value.
expect(document.querySelector('[data-slot="drawer-title"]')).toBeNull()
expect(document.querySelector('[data-slot="drawer-description"]')).toBeNull()
},
)
/**
* B32+ F4. The region used to live inside the overlay, and closing is exactly
* what commits a draft — so the announcement that mattered most was the one
* that unmounted before it could be read. It now sits beside the trigger,
* which outlives the dialog.
*/
/**
* The other half of the same policy: a region mounted on every row would
* register ~250 polite live regions on one município page (5 controls × the
* card and table trees), so it appears with the first open — early enough for
* "Salvando…", which cannot happen before one.
*/
it('does not mount the live region until the overlay has been opened once', () => {
renderWithTooltip(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
status: 'favoravel',
trendNote: null,
variant: 'popover',
}),
)
expect(document.querySelector('[aria-live="polite"]')).toBeNull()
fireEvent.click(
screen.getByRole('button', {
name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
}),
)
expect(document.querySelector('[aria-live="polite"]')).not.toBeNull()
})
it('keeps the live region mounted after the overlay closes', async () => {
const fetchMock = vi.fn(
() =>
new Promise(() => {
// Never settles: "Salvando…" is the state under test.
}),
)
vi.stubGlobal('fetch', fetchMock)
renderWithTooltip(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
status: 'favoravel',
trendNote: null,
variant: 'popover',
}),
)
const trigger = screen.getByRole('button', {
name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
})
fireEvent.click(trigger)
const select = await screen.findByLabelText('Tendência')
fireEvent.change(select, { target: { value: 'desfavoravel' } })
// Dismissing flushes the debounced save, and the dialog goes with it.
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull())
await waitFor(() => {
const region = document.querySelector('[aria-live="polite"]')
expect(region?.textContent).toBe('Salvando tendência.')
})
})
/**
* The sheet's submit sits in the footer, OUTSIDE the `<form>` it belongs to —
* associated by the standard `form` attribute. This case is what says that
* association still reaches React's action (B32+ F5); without it the button
* would look right and do nothing.
*/
it('submits the signal form from a button rendered in the sheet footer', async () => {
const formAction = vi.fn(async () => ({}))
renderWithTooltip(
// `createElement`'s typing puts a required `children` in props, so the
// rest-argument form this rule prefers does not type-check here.
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListUpdateControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant: 'sheet',
formAction,
isStaff: true,
children: createElement('span', null, 'Sem sinal'),
}),
)
fireEvent.click(
screen.getByRole('button', {
name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
}),
)
const dialog = await screen.findByRole('dialog')
// The polarity toggle (C107) is `single` with `neutra` as default; the
// sheet renders the list layout, so the label is visually hidden but the
// group keeps its accessible name. Switching the value keeps the form
// valid and exercises the control before submit.
fireEvent.click(within(screen.getByLabelText('Polaridade')).getByRole('radio', { name: 'Boa' }))
fireEvent.change(screen.getByLabelText('Texto'), {
target: { value: 'Atualização registrada no campo.' },
})
const submit = dialog.querySelector<HTMLButtonElement>('[data-slot="drawer-footer"] button')
expect(submit?.textContent).toContain('Registrar atualização')
expect(submit?.getAttribute('form')).toBe(
dialog.querySelector('form')?.getAttribute('id') ?? null,
)
fireEvent.click(submit as HTMLButtonElement)
await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1))
})
/**
* C109. The cases above render each cell WITHOUT the provider — they exercise
* the per-cell Drawer fallback, which never hit the crash. On the real mobile
* list a `CampaignListSheetProvider` wraps the cards, the overlay publishes
* into its single Drawer, and the custom footer used to be portaled from the
* cell: `createPortal` keeps the SOURCE tree's React context, so the
* `DrawerCloseButton` inside the footer resolved `useDialogRootContext()`
* as undefined and the sheet never rendered. These cases pin the shared path:
* the footer must render INSIDE the host's Drawer (context present), close
* via "Cancelar", and come back fresh on a reopen.
*/
describe('shared sheet host path (B42 provider)', () => {
it('opens the signal sheet with its custom footer inside the shared Drawer, and Cancelar closes it', async () => {
render(
createElement(
CampaignListSheetProvider,
null,
// `createElement`'s typing puts a required `children` in props, so
// the rest-argument form this rule prefers does not type-check here.
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListUpdateControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant: 'sheet',
formAction: async () => ({}),
isStaff: true,
children: createElement('span', null, 'Sem sinal'),
}),
),
)
fireEvent.click(
screen.getByRole('button', {
name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
}),
)
const dialog = await screen.findByRole('dialog')
const footer = dialog.querySelector('[data-slot="drawer-footer"]')
// The custom footer replaces the default "Fechar" — the two buttons the
// phone thumb expects, rendered in the host's Drawer context.
expect(footer?.textContent).toContain('Registrar atualização')
expect(footer?.textContent).toContain('Cancelar')
expect(footer?.textContent).not.toContain('Fechar')
fireEvent.click(within(footer as HTMLElement).getByRole('button', { name: 'Cancelar' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
/**
* F5 across the provider path: the form body is portaled into the shared
* Drawer and the submit button lives in the host-rendered footer — the
* standard `form` attribute has to reach across that boundary for the
* association to survive. The fallback-path twin above pins the same
* contract in the per-cell Drawer.
*/
it('submits the signal form from the custom footer across the shared Drawer', async () => {
const formAction = vi.fn(async () => ({}))
render(
createElement(
CampaignListSheetProvider,
null,
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListUpdateControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant: 'sheet',
formAction,
isStaff: true,
children: createElement('span', null, 'Sem sinal'),
}),
),
)
fireEvent.click(
screen.getByRole('button', {
name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
}),
)
const dialog = await screen.findByRole('dialog')
fireEvent.click(
within(screen.getByLabelText('Polaridade')).getByRole('radio', { name: 'Boa' }),
)
fireEvent.change(screen.getByLabelText('Texto'), {
target: { value: 'Atualização registrada no campo.' },
})
const submit = dialog.querySelector<HTMLButtonElement>('[data-slot="drawer-footer"] button')
expect(submit?.textContent).toContain('Registrar atualização')
expect(submit?.getAttribute('form')).toBe(
dialog.querySelector('form')?.getAttribute('id') ?? null,
)
fireEvent.click(submit as HTMLButtonElement)
await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1))
})
it('opens the engagement-level sheet, closes, and reopens with a fresh custom footer', async () => {
render(
createElement(
CampaignListSheetProvider,
null,
createElement(MunicipalityListLevelControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
level: 'n3',
levelNote: 'Rede montada e giro agendado',
levelChangedAt: '2026-07-01T12:00:00.000Z',
variant: 'sheet',
}),
),
)
const trigger = screen.getByRole('button', {
name: `Nível de envolvimento de ${MUNICIPALITY_NAME}: N3 · Rede + agenda`,
})
fireEvent.click(trigger)
let dialog = await screen.findByRole('dialog')
let footer = dialog.querySelector('[data-slot="drawer-footer"]')
expect(footer?.textContent).toContain('Registrar movimento')
expect(footer?.textContent).toContain('Cancelar')
expect(footer?.textContent).not.toContain('Fechar')
fireEvent.click(within(footer as HTMLElement).getByRole('button', { name: 'Cancelar' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
// Reopening mounts a fresh chrome — the custom footer must come back.
fireEvent.click(trigger)
dialog = await screen.findByRole('dialog')
footer = dialog.querySelector('[data-slot="drawer-footer"]')
expect(footer?.textContent).toContain('Registrar movimento')
})
})
/**
* B34's cell is the one caller that is a sheet at EVERY viewport: it picks its
* surface by `pointer:`, not by width, so it has no `variant` to sweep and
* cannot join the cases above. It is here for the half it does share — the
* sheet chrome and, above all, `initialFocus`: its own Drawer used to hand
* focus to the first chip, a link that navigates away from the row being
* edited.
*/
it('opens the município portfolio in a Drawer focused on its title', async () => {
renderWithTooltip(
createElement(MunicipalityPortfolioCell, {
ownerId: 1,
ownerName: 'Maria Souza',
municipalityIds: [11],
municipalityIndex: [{ id: 11, slug: 'feira-de-santana' }],
commitAction: async () => ({}),
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',
}),
)
fireEvent.click(screen.getByRole('button', { name: 'Editar municípios de Maria Souza' }))
const dialog = await screen.findByRole('dialog')
const title = dialog.querySelector('[data-slot="drawer-title"]')
expect(title?.textContent).toBe('Editar municípios')
expect(dialog.querySelector('[data-slot="drawer-description"]')?.textContent).toBe(
'Maria Souza',
)
await waitFor(() => expect(document.activeElement).toBe(title))
expect(dialog.textContent).toContain('Fechar')
})
})