teqo/tests/unit/campaignColumnPicker.unit.spec.ts
2026-08-05 11:35:20 -03:00

197 lines
6.3 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColumnPicker'
const refresh = vi.fn()
// Spread the real module, like the sibling specs: replacing it wholesale would
// break the day the picker renders anything reading `usePathname`.
vi.mock('next/navigation', async (importActual) => ({
...(await importActual()),
useRouter: () => ({ refresh }),
}))
/**
* jsdom scopes `document.cookie` by the document URL, and the real cookie is
* written with `path=/campanha` — so a jar that ignores attributes is what
* lets these tests read back what the picker wrote.
*/
let cookieJar = ''
const COLUMNS = [
{ id: 'name', label: 'Município', mandatory: true },
{ id: 'goalCoverage', label: 'Cobertura' },
{ id: 'trend', label: 'Tendência' },
]
const picker = (hiddenColumnIds: string[]) =>
createElement(CampaignColumnPicker, {
listId: 'municipios' as const,
columns: COLUMNS,
hiddenColumnIds,
})
const menuTrigger = () => screen.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
const openMenu = () => fireEvent.click(menuTrigger())
/** Closing is the commit path — the idle timer is only a safety net. */
const closeMenu = () => fireEvent.click(menuTrigger())
beforeAll(() => {
Object.defineProperty(document, 'cookie', {
configurable: true,
get: () => cookieJar,
set: (value: string) => {
const [pair = ''] = value.split('; ')
const separator = pair.indexOf('=')
const name = pair.slice(0, separator)
const others = cookieJar.split('; ').filter((entry) => entry && !entry.startsWith(`${name}=`))
cookieJar = [...others, pair].join('; ')
},
})
})
beforeEach(() => {
cookieJar = ''
refresh.mockClear()
})
afterEach(cleanup)
afterAll(() => {
Reflect.deleteProperty(document, 'cookie')
})
describe('CampaignColumnPicker', () => {
it('batches a whole editing session into one cookie write and one refresh', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
// Reading the next label takes longer than any debounce worth having:
// a second between checkboxes must still be one refresh, not two.
await vi.advanceTimersByTimeAsync(1_000)
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
await vi.advanceTimersByTimeAsync(1_000)
expect(refresh).not.toHaveBeenCalled()
closeMenu()
expect(refresh).toHaveBeenCalledTimes(1)
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend')
} finally {
vi.useRealTimers()
}
})
it('saves without repainting when the menu is left open', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
await vi.advanceTimersByTimeAsync(3_000)
// The idle timer is durability, not the commit: a slow reader who takes
// more than the interval between checkboxes must not pay a route render
// per column, which is the failure the 400 ms debounce was rejected for.
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage')
expect(refresh).not.toHaveBeenCalled()
closeMenu()
expect(refresh).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('does not refresh a session that ends where it started', () => {
render(picker(['goalCoverage']))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
closeMenu()
expect(refresh).not.toHaveBeenCalled()
})
it('saves a pending edit when the list unmounts with the menu open', () => {
const { unmount } = render(picker([]))
openMenu()
// Radix reports no close event here, so without the unmount flush this
// choice would be lost to a sidebar navigation with no feedback at all.
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
unmount()
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage')
})
it('serializes restoring every municipality column as an explicit empty choice', () => {
render(picker(['trend']))
openMenu()
fireEvent.click(screen.getByRole('button', { name: 'Restaurar todas' }))
closeMenu()
expect(cookieJar).toContain('campaign_columns=municipios:__none__')
expect(refresh).toHaveBeenCalledTimes(1)
})
it('never lets the mandatory column be unchecked', () => {
render(picker([]))
openMenu()
const mandatory = screen.getByRole('checkbox', { name: /^Município/ })
expect(mandatory.getAttribute('aria-checked')).toBe('true')
expect(mandatory).toHaveProperty('disabled', true)
})
it('keeps an edit made while the previous one is still in flight', async () => {
const { rerender } = render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
closeMenu()
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
// The second toggle opens a new window …
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
// … and the RSC payload of the FIRST one lands inside it. Adopting it here
// would uncheck nothing and re-commit `goalCoverage` alone, dropping `trend`.
rerender(picker(['goalCoverage']))
expect(screen.getByRole('checkbox', { name: 'Tendência' }).getAttribute('aria-checked')).toBe(
'false',
)
closeMenu()
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2))
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend')
})
it('adopts the server payload once nothing is pending', async () => {
const { rerender } = render(picker([]))
rerender(picker(['goalCoverage']))
openMenu()
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'Cobertura' }).getAttribute('aria-checked')).toBe(
'false',
),
)
expect(refresh).not.toHaveBeenCalled()
})
})