teqo-backup/tests/unit/campaignColumnPicker.unit.spec.ts
Francisco Ignácio Fontoura Solla ed0fb4065f B17 simplify: inherit header labels, split persist from refresh.
The four *SortableHead islands already fall back to their sort-label record,
so B17's new `label` made the header text a third copy instead of removing the
second one — 16 redundant `children` deleted, leaving only `frescor`, which
diverges on purpose and now reads the record too.

The 3 s "safety net" timer was committing like a close, refresh included, which
recreated the per-toggle route render the 400 ms debounce was rejected for: at a
reading pace every checkbox landed outside the window. The timer now writes the
cookie only; closing repaints. A session that ends where it started refreshes
nothing, and a menu open when the route unmounts saves from the cleanup, since
Radix reports no close event for a sidebar navigation. Both pinned red first.

Also: `columnVisibility` required rather than optional for the tests' benefit,
the redundant per-list cap dropped, `resolveVisibleColumns` no longer copies,
and B29's unused `LeadershipListAccessFilter` export un-exported — knip was
failing on main too.
2026-07-28 09:17:46 -03:00

186 lines
5.9 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: 'kind', label: 'Tipo' },
{ 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: 'Tipo' }))
// 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:kind~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: 'Tipo' }))
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:kind')
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(['kind']))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
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: 'Tipo' }))
unmount()
expect(cookieJar).toContain('campaign_columns=municipios:kind')
})
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: 'Tipo' }))
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 `kind` alone, dropping `trend`.
rerender(picker(['kind']))
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:kind~trend')
})
it('adopts the server payload once nothing is pending', async () => {
const { rerender } = render(picker([]))
rerender(picker(['kind']))
openMenu()
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'Tipo' }).getAttribute('aria-checked')).toBe(
'false',
),
)
expect(refresh).not.toHaveBeenCalled()
})
})