mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Substitui o modelo de MunicipalityUpdate (kind/semanal/urgente/nota/sinal + signalType) por um modelo unificado: body obrigatório + polarity (boa/neutra/ruim) + urgent + adversarySignal. - Schema: polarity enum obrigatório, urgent boolean default false, adversarySignal boolean - Wizard: novo WizardUpdateBodyStep com campo de texto + polaridade + urgente - Lista: controle unificado de atualização com polaridade inline - Feed: exibe polaridade como badge colorido + indicador de urgência - V2 status strip: atualização rápida de polaridade - E11: queries atualizadas para polarity + adversarySignal - Migração: 20260807_030000_unify_municipality_update (backfill polarity=neutra, urgent de kind, adversarySignal de kind+signalType) - Testes: 18/18 integração C87, 593/593 total, 1399/1399 unitários - Fix: testes de state deputy usavam nomes fixos que colidiam entre execuções
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { BarChart3 } from 'lucide-react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
CampaignHomeActionButton,
|
|
type CampaignHomeActionButtonProps,
|
|
} from '@/components/campaign/dashboard/CampaignHomeActionButton'
|
|
import {
|
|
CampaignHomeActionStrip,
|
|
HOME_ACTION_STRIP_DRAG_THRESHOLD_PX,
|
|
} from '@/components/campaign/dashboard/CampaignHomeActionStrip'
|
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer'
|
|
import { CAMPAIGN_LONG_PRESS_MS } from '@/lib/campaignLongPress'
|
|
|
|
vi.mock('next/link', () => ({
|
|
default: ({
|
|
children,
|
|
href,
|
|
onClick,
|
|
...props
|
|
}: {
|
|
children: React.ReactNode
|
|
href: string
|
|
onClick?: (event: React.MouseEvent) => void
|
|
}) => (
|
|
<a href={href} onClick={onClick} {...props}>
|
|
{children}
|
|
</a>
|
|
),
|
|
}))
|
|
|
|
const matchMediaMock = vi.fn()
|
|
|
|
beforeEach(() => {
|
|
resetCampaignCoarsePointerForTests()
|
|
matchMediaMock.mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}))
|
|
vi.stubGlobal('matchMedia', matchMediaMock)
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
const renderActionButton = (props: CampaignHomeActionButtonProps) =>
|
|
render(
|
|
<TooltipProvider>
|
|
<CampaignHomeActionButton {...props} />
|
|
</TooltipProvider>,
|
|
)
|
|
|
|
describe('CampaignHomeActionButton', () => {
|
|
it('exposes the label as the accessible name', () => {
|
|
renderActionButton({ label: 'Registrar atualização', icon: BarChart3 })
|
|
expect(screen.getByRole('button', { name: 'Registrar atualização' })).toBeTruthy()
|
|
})
|
|
|
|
it('fires onClick on a short tap when there is no description', () => {
|
|
const onClick = vi.fn()
|
|
renderActionButton({ label: 'Registrar', icon: BarChart3, onClick })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
|
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('renders an href on the link when provided', () => {
|
|
renderActionButton({
|
|
label: 'Ver cobertura',
|
|
icon: BarChart3,
|
|
description: 'Abre a lista filtrada.',
|
|
href: '/campanha/municipios',
|
|
})
|
|
|
|
const link = screen.getByRole('link', { name: 'Ver cobertura' })
|
|
expect(link.getAttribute('href')).toBe('/campanha/municipios')
|
|
})
|
|
|
|
it('marks disabled actions without a focusable control', () => {
|
|
renderActionButton({ label: 'Em breve', icon: BarChart3, disabled: true })
|
|
expect(screen.getByLabelText('Em breve').getAttribute('aria-disabled')).toBe('true')
|
|
})
|
|
|
|
it('applies the shared action control class contract (B109)', () => {
|
|
const onClick = vi.fn()
|
|
renderActionButton({ label: 'Registrar', icon: BarChart3, onClick })
|
|
const control = screen.getByRole('button', { name: 'Registrar' })
|
|
expect(control.className).toContain('min-h-11')
|
|
expect(control.className).toContain('w-[5.5rem]')
|
|
})
|
|
|
|
it('uses full-width grid sizing when layout is grid (B136)', () => {
|
|
renderActionButton({ label: 'Registrar', icon: BarChart3, layout: 'grid' })
|
|
const control = screen.getByRole('button', { name: 'Registrar' })
|
|
expect(control.className).toContain('w-full')
|
|
expect(control.className).toContain('min-w-0')
|
|
expect(control.className).not.toContain('box-content')
|
|
expect(control.className).not.toContain('w-[5.5rem]')
|
|
})
|
|
|
|
it('opens the description drawer on long-press when the pointer is coarse', () => {
|
|
resetCampaignCoarsePointerForTests()
|
|
matchMediaMock.mockImplementation((query: string) => ({
|
|
matches: query.includes('coarse'),
|
|
media: query,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}))
|
|
|
|
vi.useFakeTimers()
|
|
const onClick = vi.fn()
|
|
renderActionButton({
|
|
label: 'Ver cobertura',
|
|
icon: BarChart3,
|
|
description: 'Lista filtrada.',
|
|
onClick,
|
|
})
|
|
|
|
const target = screen.getByRole('button', { name: 'Ver cobertura' })
|
|
fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
|
|
act(() => {
|
|
vi.advanceTimersByTime(CAMPAIGN_LONG_PRESS_MS)
|
|
})
|
|
expect(screen.getByText('Lista filtrada.')).toBeTruthy()
|
|
|
|
fireEvent.pointerUp(target)
|
|
fireEvent.click(target)
|
|
expect(onClick).not.toHaveBeenCalled()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('sets data-pressing on coarse pointer down before long-press fires', () => {
|
|
resetCampaignCoarsePointerForTests()
|
|
matchMediaMock.mockImplementation((query: string) => ({
|
|
matches: query.includes('coarse'),
|
|
media: query,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}))
|
|
|
|
renderActionButton({
|
|
label: 'Ver cobertura',
|
|
icon: BarChart3,
|
|
description: 'Lista filtrada.',
|
|
href: '/campanha/municipios',
|
|
})
|
|
|
|
const target = screen.getByRole('link', { name: 'Ver cobertura' })
|
|
expect(target.getAttribute('data-pressing')).toBeNull()
|
|
fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
|
|
expect(target.getAttribute('data-pressing')).toBe('true')
|
|
fireEvent.pointerUp(target)
|
|
expect(target.getAttribute('data-pressing')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('CampaignHomeActionStrip', () => {
|
|
const manyActions = Array.from({ length: 8 }, (_, index) => ({
|
|
label: `Ação ${index + 1}`,
|
|
icon: BarChart3,
|
|
}))
|
|
|
|
const getScroller = (container: HTMLElement) =>
|
|
container.querySelector('[aria-label="Ações rápidas"]') as HTMLDivElement
|
|
|
|
const mockScrollerOverflow = (scroller: HTMLDivElement) => {
|
|
Object.defineProperty(scroller, 'scrollWidth', { value: 800, configurable: true })
|
|
Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true })
|
|
scroller.scrollLeft = 0
|
|
}
|
|
|
|
it('renders a horizontal scroller with list semantics', () => {
|
|
const { container } = render(
|
|
<CampaignHomeActionStrip
|
|
variant="strip"
|
|
actions={[
|
|
{ label: 'Um', icon: BarChart3 },
|
|
{ label: 'Dois', icon: BarChart3 },
|
|
]}
|
|
/>,
|
|
)
|
|
|
|
const scroller = getScroller(container)
|
|
expect(scroller).toBeTruthy()
|
|
const list = scroller?.querySelector('ul[role="list"]')
|
|
expect(list).toBeTruthy()
|
|
expect(list?.className).toMatch(/flex/)
|
|
})
|
|
|
|
it('renders a 3-column grid on mobile when variant is responsive', () => {
|
|
const { container } = render(
|
|
<CampaignHomeActionStrip
|
|
variant="responsive"
|
|
actions={[
|
|
{ label: 'Um', icon: BarChart3 },
|
|
{ label: 'Dois', icon: BarChart3 },
|
|
]}
|
|
/>,
|
|
)
|
|
|
|
const region = container.querySelector('[aria-label="Ações rápidas"]')
|
|
expect(region).toBeTruthy()
|
|
const list = region?.querySelector('ul[role="list"]')
|
|
expect(list).toBeTruthy()
|
|
expect(list?.className).toMatch(/grid-cols-3/)
|
|
expect(list?.getAttribute('data-layout')).toBe('grid-3')
|
|
expect(list?.className).toMatch(/md:flex/)
|
|
expect(list?.querySelector('li')?.className).toContain('min-w-0')
|
|
const regionClasses = region?.className.split(/\s+/) ?? []
|
|
expect(regionClasses).not.toContain('overflow-x-auto')
|
|
expect(regionClasses.some((token) => token.includes('md:overflow-x-auto'))).toBe(true)
|
|
})
|
|
|
|
it('renders a 3-column grid when variant is grid', () => {
|
|
const { container } = render(
|
|
<CampaignHomeActionStrip
|
|
variant="grid"
|
|
actions={[
|
|
{ label: 'Um', icon: BarChart3 },
|
|
{ label: 'Dois', icon: BarChart3 },
|
|
]}
|
|
/>,
|
|
)
|
|
|
|
const region = container.querySelector('[aria-label="Ações rápidas"]')
|
|
const list = region?.querySelector('ul[role="list"]')
|
|
expect(list?.className).toMatch(/grid-cols-3/)
|
|
expect(list?.getAttribute('data-layout')).toBe('grid-3')
|
|
expect(list?.className).not.toMatch(/md:flex/)
|
|
expect(region?.className).not.toMatch(/overflow-x-auto/)
|
|
})
|
|
|
|
it('scrolls horizontally on pointer-fine drag past the threshold', () => {
|
|
const { container } = render(<CampaignHomeActionStrip variant="strip" actions={manyActions} />)
|
|
const scroller = getScroller(container)
|
|
mockScrollerOverflow(scroller)
|
|
|
|
const startX = 120
|
|
const dragDelta = HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 20
|
|
|
|
fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 1 })
|
|
fireEvent.pointerMove(scroller, {
|
|
button: 0,
|
|
clientX: startX - dragDelta,
|
|
pointerId: 1,
|
|
})
|
|
expect(scroller.scrollLeft).toBe(dragDelta)
|
|
expect(scroller.dataset.dragging).toBe('true')
|
|
|
|
fireEvent.pointerUp(scroller, { pointerId: 1 })
|
|
expect(scroller.dataset.dragging).toBeUndefined()
|
|
})
|
|
|
|
it('does not pan on coarse pointer — touch uses native overflow scroll', () => {
|
|
resetCampaignCoarsePointerForTests()
|
|
matchMediaMock.mockImplementation((query: string) => ({
|
|
matches: query.includes('coarse'),
|
|
media: query,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}))
|
|
|
|
const onClick = vi.fn()
|
|
const { container } = render(
|
|
<CampaignHomeActionStrip
|
|
variant="strip"
|
|
actions={[{ label: 'Registrar', icon: BarChart3, onClick }, ...manyActions]}
|
|
/>,
|
|
)
|
|
const scroller = getScroller(container)
|
|
mockScrollerOverflow(scroller)
|
|
|
|
fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 4 })
|
|
fireEvent.pointerMove(scroller, { button: 0, clientX: 50, pointerId: 4 })
|
|
fireEvent.pointerUp(scroller, { pointerId: 4 })
|
|
expect(scroller.scrollLeft).toBe(0)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
|
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('suppresses child click after a drag gesture', () => {
|
|
const onClick = vi.fn()
|
|
const { container } = render(
|
|
<CampaignHomeActionStrip
|
|
variant="strip"
|
|
actions={[{ label: 'Registrar', icon: BarChart3, onClick }, ...manyActions.slice(0, 3)]}
|
|
/>,
|
|
)
|
|
const scroller = getScroller(container)
|
|
mockScrollerOverflow(scroller)
|
|
|
|
fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 2 })
|
|
fireEvent.pointerMove(scroller, { button: 0, clientX: 100, pointerId: 2 })
|
|
fireEvent.pointerUp(scroller, { pointerId: 2 })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
|
|
expect(onClick).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows child click when movement stays below the drag threshold', () => {
|
|
const onClick = vi.fn()
|
|
const { container } = render(
|
|
<CampaignHomeActionStrip
|
|
variant="strip"
|
|
actions={[{ label: 'Registrar', icon: BarChart3, onClick }]}
|
|
/>,
|
|
)
|
|
const scroller = getScroller(container)
|
|
mockScrollerOverflow(scroller)
|
|
|
|
const startX = 80
|
|
fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 3 })
|
|
fireEvent.pointerMove(scroller, {
|
|
button: 0,
|
|
clientX: startX - HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 2,
|
|
pointerId: 3,
|
|
})
|
|
fireEvent.pointerUp(scroller, { pointerId: 3 })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
|
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('allows wizard link navigation after pointerdown/up without a drag gesture', () => {
|
|
const onLinkClick = vi.fn()
|
|
const { container } = render(
|
|
<CampaignHomeActionStrip
|
|
variant="strip"
|
|
actions={[
|
|
{
|
|
label: 'Ajustar votos',
|
|
icon: BarChart3,
|
|
description: 'Atualizar projeção.',
|
|
href: '/campanha/acoes/atualizar-votos',
|
|
},
|
|
]}
|
|
/>,
|
|
)
|
|
const scroller = getScroller(container)
|
|
mockScrollerOverflow(scroller)
|
|
const link = screen.getByRole('link', { name: 'Ajustar votos' })
|
|
link.onclick = onLinkClick
|
|
|
|
fireEvent.pointerDown(link, { button: 0, clientX: 40, pointerId: 5 })
|
|
fireEvent.pointerUp(link, { pointerId: 5 })
|
|
fireEvent.click(link)
|
|
|
|
expect(onLinkClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|