mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Omnibox de /campanha/municipios ganha as dimensões Dobradinha (nome do contato), Liderança (nome do contato) e Partido (sigla), com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" no precedente de "Sem nível". Params aditivos stateDeputy/leadership/party na URL congelada; buildMunicipalityListWhere ganha catálogo request-scoped fail-closed (municipalityRelationSets) que vira as leituras reversa e cruzada em where — sem migration, paginação nativa mantida; facets estendidas e o mapa compartilha o catálogo. Coluna Dobradinhas liberada ao staff (servidor já scoped B37/B157; só o gate de UI mudou). Extrato do par sentinela+exists:false em campaignListUrl (5 call sites).
647 lines
26 KiB
TypeScript
647 lines
26 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||
import { formatMunicipalityConcentrationHint } from '@/utilities/municipality/municipalityLabels'
|
||
import {
|
||
buildMunicipalityFilterHref,
|
||
buildMunicipalityFilterOptionHref,
|
||
formatMunicipalityActiveFiltersSummary,
|
||
isMunicipalityColumnFilterActive,
|
||
toggleMunicipalityExclusiveFilterValue,
|
||
toggleMunicipalityMultiFilterValue,
|
||
toggleMunicipalityPriorityFilter,
|
||
type MunicipalityMultiFilterParam,
|
||
} from '@/utilities/municipality/municipalityListFilters'
|
||
import {
|
||
buildMunicipalityListHref,
|
||
buildMunicipalityListWhere,
|
||
buildMunicipalitySortHref,
|
||
formatMunicipalityListSortSummary,
|
||
municipalityListSortLabels,
|
||
parseMunicipalityListParams,
|
||
parseMunicipalitySortValue,
|
||
resolveMunicipalityListSort,
|
||
serializeMunicipalitySortValue,
|
||
} from '@/utilities/municipality/municipalityListUrl'
|
||
import {
|
||
formatMunicipalitySignalAgeLabel,
|
||
isMunicipalitySignalCold,
|
||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||
municipalitySignalAgeInDays,
|
||
resolveMunicipalityLastSignalAt,
|
||
} from '@/utilities/municipality/municipalitySignal'
|
||
|
||
describe('municipality list sort params (A11 + B15 + E9)', () => {
|
||
it('omits the default deficit/desc from the URL (E9 allocation queue order)', () => {
|
||
const state = parseMunicipalityListParams({ sort: 'deficit', dir: 'desc' })
|
||
expect(state).toMatchObject({ sort: 'deficit', dir: 'desc' })
|
||
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'deficit', dir: 'desc' })
|
||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios')
|
||
})
|
||
|
||
it('treats an empty query as deficit desc (biggest uncovered goal first)', () => {
|
||
const state = parseMunicipalityListParams({})
|
||
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'deficit', dir: 'desc' })
|
||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios')
|
||
})
|
||
|
||
it('keeps sort=votos in the URL now that it is no longer the default', () => {
|
||
const state = parseMunicipalityListParams({ sort: 'votos', dir: 'desc' })
|
||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios?sort=votos')
|
||
})
|
||
|
||
it('opens frescor on the coldest signal (desc default) and keeps asc explicit', () => {
|
||
const base = parseMunicipalityListParams({})
|
||
expect(buildMunicipalitySortHref(base, 'frescor')).toBe('/campanha/municipios?sort=frescor')
|
||
const cold = parseMunicipalityListParams({ sort: 'frescor' })
|
||
expect(resolveMunicipalityListSort(cold)).toEqual({ sort: 'frescor', dir: 'desc' })
|
||
expect(buildMunicipalitySortHref(cold, 'frescor')).toBe(
|
||
'/campanha/municipios?sort=frescor&dir=asc',
|
||
)
|
||
})
|
||
|
||
it('keeps sort=name when sorting alphabetically (dir=asc is the name default)', () => {
|
||
const state = parseMunicipalityListParams({ sort: 'name', dir: 'asc' })
|
||
expect(state.sort).toBe('name')
|
||
expect(state.dir).toBe('asc')
|
||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios?sort=name')
|
||
})
|
||
|
||
it('keeps sort=votos&dir=asc when ascending', () => {
|
||
const state = parseMunicipalityListParams({ sort: 'votos', dir: 'asc' })
|
||
expect(state).toMatchObject({ sort: 'votos', dir: 'asc' })
|
||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios?sort=votos&dir=asc')
|
||
})
|
||
|
||
it('toggles deficit from the default desc to asc', () => {
|
||
const base = parseMunicipalityListParams({})
|
||
const href = buildMunicipalitySortHref(base, 'deficit')
|
||
expect(href).toBe('/campanha/municipios?sort=deficit&dir=asc')
|
||
})
|
||
|
||
it('switches from name back to the default deficit desc (omitted URL)', () => {
|
||
const base = parseMunicipalityListParams({ sort: 'name' })
|
||
expect(buildMunicipalitySortHref(base, 'deficit')).toBe('/campanha/municipios')
|
||
})
|
||
|
||
it('parses mobile Ordenar values with | and rejects junk', () => {
|
||
expect(parseMunicipalitySortValue('votos|desc')).toEqual({ key: 'votos', dir: 'desc' })
|
||
expect(parseMunicipalitySortValue('name|asc')).toEqual({ key: 'name', dir: 'asc' })
|
||
expect(parseMunicipalitySortValue('priority|asc')).toBeNull()
|
||
expect(parseMunicipalitySortValue('votos')).toBeNull()
|
||
expect(serializeMunicipalitySortValue('votos', 'desc')).toBe('votos|desc')
|
||
})
|
||
|
||
it('summarizes the active sort in plain language', () => {
|
||
expect(formatMunicipalityListSortSummary('votos', 'desc')).toBe('Ordenado por 2022 ↓')
|
||
expect(formatMunicipalityListSortSummary('votos', 'asc')).toBe('Ordenado por 2022 ↑')
|
||
expect(formatMunicipalityListSortSummary('name', 'asc')).toBe('Ordenado por nome (A–Z)')
|
||
expect(formatMunicipalityListSortSummary('region', 'asc')).toBe('Ordenado por Território ↑')
|
||
expect(formatMunicipalityListSortSummary('deficit', 'desc')).toBe(
|
||
'Ordenado por Cobertura (maior déficit primeiro)',
|
||
)
|
||
expect(formatMunicipalityListSortSummary('frescor', 'desc')).toBe(
|
||
'Ordenado por frescor (sinal mais frio primeiro)',
|
||
)
|
||
})
|
||
|
||
it('formats the concentração header hint once for tooltip/caption', () => {
|
||
expect(formatMunicipalityConcentrationHint()).toMatch(/votação estadual/)
|
||
expect(formatMunicipalityConcentrationHint()).toMatch(/435/)
|
||
expect(formatMunicipalityConcentrationHint()).not.toMatch(/^%/)
|
||
expect(formatMunicipalityConcentrationHint(12)).toMatch(/12/)
|
||
})
|
||
|
||
it('disambiguates Assessores (coverage) from goal Cobertura (deficit)', () => {
|
||
expect(municipalityListSortLabels.coverage).toBe('Assessores')
|
||
expect(municipalityListSortLabels.deficit).toBe('Cobertura')
|
||
expect(formatMunicipalityListSortSummary('coverage', 'asc')).toBe('Ordenado por Assessores ↑')
|
||
})
|
||
|
||
it('opens the classe sort on reduto (desc default) and names the direction', () => {
|
||
const base = parseMunicipalityListParams({})
|
||
expect(buildMunicipalitySortHref(base, 'classe')).toBe('/campanha/municipios?sort=classe')
|
||
const byClass = parseMunicipalityListParams({ sort: 'classe' })
|
||
expect(resolveMunicipalityListSort(byClass)).toEqual({ sort: 'classe', dir: 'desc' })
|
||
expect(formatMunicipalityListSortSummary('classe', 'desc')).toBe(
|
||
'Ordenado por classe (reduto primeiro)',
|
||
)
|
||
expect(formatMunicipalityListSortSummary('classe', 'asc')).toBe(
|
||
'Ordenado por classe (marginal primeiro)',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('E10 classe territorial filter (derived, not a Payload constraint)', () => {
|
||
it('parses repeated ?class= params and keeps them out of the where clause', () => {
|
||
const state = parseMunicipalityListParams({ class: ['reduto', 'expansao', 'reduto'] })
|
||
expect(state.classes).toEqual(['reduto', 'expansao'])
|
||
expect(isMunicipalityColumnFilterActive(state, 'class')).toBe(true)
|
||
// The class lives in the committed TSE artifact, so `where` must stay clean —
|
||
// `municipalityPageData` applies it in memory over the unpaginated scope.
|
||
expect(buildMunicipalityListWhere(state)).toEqual({})
|
||
expect(buildMunicipalityFilterHref(state)).toBe(
|
||
'/campanha/municipios?class=reduto&class=expansao',
|
||
)
|
||
})
|
||
|
||
it('discards unknown classes and treats "todas" as absent', () => {
|
||
expect(parseMunicipalityListParams({ class: 'perdida' }).classes).toBeUndefined()
|
||
|
||
const all = parseMunicipalityListParams({
|
||
class: ['reduto', 'expansao', 'manutencao', 'marginal', 'sem_base'],
|
||
})
|
||
expect(all.classes).toBeUndefined()
|
||
expect(isMunicipalityColumnFilterActive(all, 'class')).toBe(false)
|
||
expect(formatMunicipalityActiveFiltersSummary(all)).toBeNull()
|
||
|
||
// Ticking the last unchecked box lands on that same canonical state.
|
||
const allButOne = parseMunicipalityListParams({
|
||
class: ['reduto', 'expansao', 'manutencao', 'marginal'],
|
||
})
|
||
expect(
|
||
toggleMunicipalityMultiFilterValue(allButOne, 'class', 'sem_base').classes,
|
||
).toBeUndefined()
|
||
})
|
||
|
||
it('names the class in the active-filters summary', () => {
|
||
expect(
|
||
formatMunicipalityActiveFiltersSummary(
|
||
parseMunicipalityListParams({ class: ['reduto', 'expansao'] }),
|
||
),
|
||
).toBe('Classe reduto, expansão')
|
||
})
|
||
|
||
it('toggles a class in and out while preserving the other filters', () => {
|
||
const state = parseMunicipalityListParams({ region: 'Irecê', sort: 'votos' })
|
||
const on = toggleMunicipalityMultiFilterValue(state, 'class', 'reduto')
|
||
expect(on.classes).toEqual(['reduto'])
|
||
expect(buildMunicipalityFilterHref(on)).toBe(
|
||
'/campanha/municipios?region=Irec%C3%AA&class=reduto&sort=votos',
|
||
)
|
||
expect(toggleMunicipalityMultiFilterValue(on, 'class', 'reduto').classes).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('E14 nível de envolvimento (stored: sort and filter both reach the query)', () => {
|
||
it('opens the nivel sort on N4 (desc default) and names the direction', () => {
|
||
const base = parseMunicipalityListParams({})
|
||
expect(buildMunicipalitySortHref(base, 'nivel')).toBe('/campanha/municipios?sort=nivel')
|
||
const byLevel = parseMunicipalityListParams({ sort: 'nivel' })
|
||
expect(resolveMunicipalityListSort(byLevel)).toEqual({ sort: 'nivel', dir: 'desc' })
|
||
expect(formatMunicipalityListSortSummary('nivel', 'desc')).toBe(
|
||
'Ordenado por nível (N4 primeiro)',
|
||
)
|
||
expect(formatMunicipalityListSortSummary('nivel', 'asc')).toBe(
|
||
'Ordenado por nível (N0 primeiro)',
|
||
)
|
||
})
|
||
|
||
it('constrains the query by the selected levels', () => {
|
||
const state = parseMunicipalityListParams({ level: ['n3', 'n4', 'n3'] })
|
||
expect(state.levels).toEqual(['n3', 'n4'])
|
||
expect(isMunicipalityColumnFilterActive(state, 'level')).toBe(true)
|
||
expect(buildMunicipalityListWhere(state)).toEqual({
|
||
and: [{ engagementLevel: { in: ['n3', 'n4'] } }],
|
||
})
|
||
expect(buildMunicipalityFilterHref(state)).toBe('/campanha/municipios?level=n3&level=n4')
|
||
})
|
||
|
||
/**
|
||
* "Sem nível" is the triage queue, so it must be selectable — and absence
|
||
* can't ride inside an `in`, which is why it becomes its own OR branch.
|
||
*/
|
||
it('expresses "sem nível" as absence, alone or beside real levels', () => {
|
||
expect(buildMunicipalityListWhere(parseMunicipalityListParams({ level: 'sem_nivel' }))).toEqual(
|
||
{ and: [{ engagementLevel: { exists: false } }] },
|
||
)
|
||
expect(
|
||
buildMunicipalityListWhere(parseMunicipalityListParams({ level: ['n0', 'sem_nivel'] })),
|
||
).toEqual({
|
||
and: [{ or: [{ engagementLevel: { in: ['n0'] } }, { engagementLevel: { exists: false } }] }],
|
||
})
|
||
})
|
||
|
||
it('discards unknown levels and treats "todos" as absent', () => {
|
||
expect(parseMunicipalityListParams({ level: 'n9' }).levels).toBeUndefined()
|
||
|
||
const all = parseMunicipalityListParams({
|
||
level: ['n0', 'n1', 'n2', 'n3', 'n4', 'sem_nivel'],
|
||
})
|
||
expect(all.levels).toBeUndefined()
|
||
expect(buildMunicipalityListWhere(all)).toEqual({})
|
||
expect(isMunicipalityColumnFilterActive(all, 'level')).toBe(false)
|
||
|
||
const allButOne = parseMunicipalityListParams({
|
||
level: ['n0', 'n1', 'n2', 'n3', 'n4'],
|
||
})
|
||
expect(
|
||
toggleMunicipalityMultiFilterValue(allButOne, 'level', 'sem_nivel').levels,
|
||
).toBeUndefined()
|
||
})
|
||
|
||
it('names the level in the active-filters summary', () => {
|
||
expect(
|
||
formatMunicipalityActiveFiltersSummary(
|
||
parseMunicipalityListParams({ level: ['n4', 'sem_nivel'] }),
|
||
),
|
||
).toBe('Nível N4, sem nível')
|
||
})
|
||
})
|
||
|
||
describe('municipality list header filters (B16+)', () => {
|
||
it('toggles multi region OR filters and preserves sort/dir', () => {
|
||
const state = parseMunicipalityListParams({
|
||
page: '3',
|
||
sort: 'votos',
|
||
dir: 'asc',
|
||
priority: 'alta',
|
||
})
|
||
const withRegion = toggleMunicipalityMultiFilterValue(state, 'region', 'Irecê')
|
||
expect(withRegion.regions).toEqual(['Irecê'])
|
||
expect(withRegion.page).toBe(1)
|
||
expect(buildMunicipalityFilterHref(withRegion)).toBe(
|
||
'/campanha/municipios?region=Irec%C3%AA&priority=alta&sort=votos&dir=asc',
|
||
)
|
||
|
||
const withTwo = toggleMunicipalityMultiFilterValue(withRegion, 'region', 'Recôncavo')
|
||
expect(withTwo.regions).toEqual(['Irecê', 'Recôncavo'])
|
||
expect(buildMunicipalityListHref(withTwo, 1)).toContain('region=Irec')
|
||
expect(buildMunicipalityListHref(withTwo, 1)).toContain('region=Rec')
|
||
|
||
const cleared = toggleMunicipalityMultiFilterValue(withRegion, 'region', 'Irecê')
|
||
expect(cleared.regions).toBeUndefined()
|
||
})
|
||
|
||
it('toggles the Prioritária filter in and out of the URL, keeping other filters', () => {
|
||
const state = parseMunicipalityListParams({ region: 'Irecê' })
|
||
|
||
const on = toggleMunicipalityPriorityFilter(state)
|
||
expect(on.priority).toBe('alta')
|
||
expect(buildMunicipalityFilterHref(on)).toBe(
|
||
'/campanha/municipios?region=Irec%C3%AA&priority=alta',
|
||
)
|
||
|
||
const off = toggleMunicipalityPriorityFilter(on)
|
||
expect(off.priority).toBeUndefined()
|
||
expect(buildMunicipalityFilterHref(off)).toBe('/campanha/municipios?region=Irec%C3%AA')
|
||
})
|
||
|
||
it('treats every trend selected as "todas" (no narrowing constraint)', () => {
|
||
const one = parseMunicipalityListParams({ trend: 'favoravel' })
|
||
expect(one.trends).toEqual(['favoravel'])
|
||
expect(isMunicipalityColumnFilterActive(one, 'trend')).toBe(true)
|
||
expect(buildMunicipalityListWhere(one)).toEqual({
|
||
and: [{ 'politicalTrend.status': { in: ['favoravel'] } }],
|
||
})
|
||
|
||
// "Todas" has a single encoding — absent — so the URL never carries params
|
||
// that filter nothing while the funnel reports itself inactive.
|
||
const all = parseMunicipalityListParams({ trend: ['favoravel', 'neutra', 'desfavoravel'] })
|
||
expect(all.trends).toBeUndefined()
|
||
expect(isMunicipalityColumnFilterActive(all, 'trend')).toBe(false)
|
||
expect(buildMunicipalityListWhere(all)).toEqual({})
|
||
expect(formatMunicipalityActiveFiltersSummary(all)).toBeNull()
|
||
expect(buildMunicipalityFilterHref(all)).toBe('/campanha/municipios')
|
||
|
||
// Ticking the last unchecked box lands on that same canonical state.
|
||
const twoSelected = parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] })
|
||
expect(
|
||
toggleMunicipalityMultiFilterValue(twoSelected, 'trend', 'desfavoravel').trends,
|
||
).toBeUndefined()
|
||
|
||
const none = toggleMunicipalityMultiFilterValue(one, 'trend', 'favoravel')
|
||
expect(none.trends).toBeUndefined()
|
||
expect(buildMunicipalityFilterHref(none)).toBe('/campanha/municipios')
|
||
})
|
||
|
||
it('toggles coverage by re-clicking the active value', () => {
|
||
const state = parseMunicipalityListParams({})
|
||
const on = toggleMunicipalityExclusiveFilterValue(state, 'coverage', 'com_assessor')
|
||
expect(on.coverage).toBe('com_assessor')
|
||
const off = toggleMunicipalityExclusiveFilterValue(on, 'coverage', 'com_assessor')
|
||
expect(off.coverage).toBeUndefined()
|
||
})
|
||
|
||
it('discards invalid filter values', () => {
|
||
const state = parseMunicipalityListParams({})
|
||
expect(
|
||
toggleMunicipalityMultiFilterValue(state, 'region', 'NotATerritory').regions,
|
||
).toBeUndefined()
|
||
expect(
|
||
toggleMunicipalityExclusiveFilterValue(state, 'coverage', 'maybe').coverage,
|
||
).toBeUndefined()
|
||
expect(toggleMunicipalityMultiFilterValue(state, 'trend', 'alta').trends).toBeUndefined()
|
||
expect(parseMunicipalityListParams({ slug: 'not-a-slug', advisor: 'x' }).slugs).toBeUndefined()
|
||
expect(parseMunicipalityListParams({ advisor: 'x' }).advisors).toBeUndefined()
|
||
})
|
||
|
||
it('parses repeated advisor and slug params as OR filters', () => {
|
||
const slug = municipalityCatalog[0]?.slug
|
||
expect(slug).toBeTruthy()
|
||
const state = parseMunicipalityListParams({
|
||
slug: [slug!, 'also-invalid'],
|
||
advisor: ['12', '12', '7'],
|
||
})
|
||
expect(state.slugs).toEqual([slug])
|
||
expect(state.advisors).toEqual([7, 12])
|
||
})
|
||
|
||
it('summarizes active filters for the slim bar', () => {
|
||
expect(formatMunicipalityActiveFiltersSummary(parseMunicipalityListParams({}))).toBeNull()
|
||
expect(
|
||
formatMunicipalityActiveFiltersSummary(
|
||
parseMunicipalityListParams({ region: 'Irecê', priority: 'alta', q: 'feira' }),
|
||
),
|
||
).toBe('Prioritária · Irecê · Busca "feira"')
|
||
})
|
||
})
|
||
|
||
describe('E9 frescor do sinal', () => {
|
||
const now = new Date('2026-07-24T12:00:00.000Z')
|
||
|
||
it('takes the latest of the staff update and the pledge dates', () => {
|
||
expect(
|
||
resolveMunicipalityLastSignalAt('2026-07-01T00:00:00.000Z', '2026-07-10T00:00:00.000Z'),
|
||
).toBe('2026-07-10T00:00:00.000Z')
|
||
expect(
|
||
resolveMunicipalityLastSignalAt('2026-07-20T00:00:00.000Z', '2026-07-10T00:00:00.000Z'),
|
||
).toBe('2026-07-20T00:00:00.000Z')
|
||
})
|
||
|
||
it('falls back to whichever side exists, and stays null when neither does', () => {
|
||
expect(resolveMunicipalityLastSignalAt(null, '2026-07-10T00:00:00.000Z')).toBe(
|
||
'2026-07-10T00:00:00.000Z',
|
||
)
|
||
expect(resolveMunicipalityLastSignalAt('2026-07-10T00:00:00.000Z', null)).toBe(
|
||
'2026-07-10T00:00:00.000Z',
|
||
)
|
||
expect(resolveMunicipalityLastSignalAt(null, null)).toBeNull()
|
||
})
|
||
|
||
it('floors the age in days and never goes negative on a future timestamp', () => {
|
||
expect(municipalitySignalAgeInDays('2026-07-24T00:00:00.000Z', now)).toBe(0)
|
||
expect(municipalitySignalAgeInDays('2026-07-23T00:00:00.000Z', now)).toBe(1)
|
||
expect(municipalitySignalAgeInDays('2026-07-01T00:00:00.000Z', now)).toBe(23)
|
||
expect(municipalitySignalAgeInDays('2026-08-01T00:00:00.000Z', now)).toBe(0)
|
||
expect(municipalitySignalAgeInDays(null, now)).toBeNull()
|
||
})
|
||
|
||
it('treats "no signal at all" as cold, alongside anything past the threshold', () => {
|
||
expect(isMunicipalitySignalCold(null)).toBe(true)
|
||
expect(isMunicipalitySignalCold(MUNICIPALITY_COLD_SIGNAL_DAYS)).toBe(true)
|
||
expect(isMunicipalitySignalCold(MUNICIPALITY_COLD_SIGNAL_DAYS - 1)).toBe(false)
|
||
expect(isMunicipalitySignalCold(0)).toBe(false)
|
||
})
|
||
|
||
it('labels the age in dense pt-BR copy', () => {
|
||
expect(formatMunicipalitySignalAgeLabel(null)).toBe('Sem sinal')
|
||
expect(formatMunicipalitySignalAgeLabel(0)).toBe('hoje')
|
||
expect(formatMunicipalitySignalAgeLabel(1)).toBe('há 1 dia')
|
||
expect(formatMunicipalitySignalAgeLabel(30)).toBe('há 30 dias')
|
||
})
|
||
})
|
||
|
||
describe('buildMunicipalityFilterOptionHref (B16+ fast path)', () => {
|
||
const [firstSlug, secondSlug, thirdSlug] = municipalityCatalog.map((entry) => entry.slug)
|
||
|
||
const cases: Array<{
|
||
name: string
|
||
state: Parameters<typeof buildMunicipalityFilterOptionHref>[0]
|
||
param: MunicipalityMultiFilterParam
|
||
value: string
|
||
}> = [
|
||
{
|
||
name: 'adds a slug',
|
||
state: parseMunicipalityListParams({}),
|
||
param: 'slug',
|
||
value: firstSlug!,
|
||
},
|
||
{
|
||
name: 'removes a selected slug',
|
||
state: parseMunicipalityListParams({ slug: [firstSlug!, secondSlug!] }),
|
||
param: 'slug',
|
||
value: secondSlug!,
|
||
},
|
||
{
|
||
name: 'keeps other filters and resets the page',
|
||
state: parseMunicipalityListParams({
|
||
q: 'santa',
|
||
page: '3',
|
||
sort: 'votos',
|
||
slug: [thirdSlug!],
|
||
priority: 'alta',
|
||
}),
|
||
param: 'slug',
|
||
value: firstSlug!,
|
||
},
|
||
{
|
||
name: 'sorts advisor ids numerically',
|
||
state: parseMunicipalityListParams({ advisor: ['20'] }),
|
||
param: 'advisor',
|
||
value: '5',
|
||
},
|
||
{
|
||
name: 'adds a region',
|
||
state: parseMunicipalityListParams({ region: ['Chapada Diamantina'] }),
|
||
param: 'region',
|
||
value: 'Sertão do São Francisco',
|
||
},
|
||
{
|
||
name: 'collapses the full trend set to "todas"',
|
||
state: parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] }),
|
||
param: 'trend',
|
||
value: 'desfavoravel',
|
||
},
|
||
{
|
||
name: 'removes a selected trend',
|
||
state: parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] }),
|
||
param: 'trend',
|
||
value: 'neutra',
|
||
},
|
||
{
|
||
name: 'adds "sem nível" beside a level',
|
||
state: parseMunicipalityListParams({ level: ['n0'] }),
|
||
param: 'level',
|
||
value: 'sem_nivel',
|
||
},
|
||
{
|
||
name: 'collapses the full level set to "todos"',
|
||
state: parseMunicipalityListParams({ level: ['n0', 'n1', 'n2', 'n3', 'n4'] }),
|
||
param: 'level',
|
||
value: 'sem_nivel',
|
||
},
|
||
]
|
||
|
||
it.each(cases)('matches the canonical toggle+href path: $name', ({ state, param, value }) => {
|
||
expect(buildMunicipalityFilterOptionHref(state, param, value)).toBe(
|
||
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, param, value)),
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('municipality list relation filters (B176)', () => {
|
||
it('canonicalizes stateDeputy/leadership ids ascending with the sentinel last', () => {
|
||
const state = parseMunicipalityListParams({
|
||
stateDeputy: ['5', 'sem_dobradinha', '3', '5', 'abc'],
|
||
leadership: ['sem_lideranca', '2', '8'],
|
||
})
|
||
expect(state.stateDeputies).toEqual([3, 5, 'sem_dobradinha'])
|
||
expect(state.leaderships).toEqual([2, 8, 'sem_lideranca'])
|
||
expect(buildMunicipalityListHref(state, 1)).toBe(
|
||
'/campanha/municipios?stateDeputy=3&stateDeputy=5&stateDeputy=sem_dobradinha&leadership=2&leadership=8&leadership=sem_lideranca',
|
||
)
|
||
})
|
||
|
||
it('parses parties by insertion order, deduped, with the sentinel last', () => {
|
||
const state = parseMunicipalityListParams({
|
||
party: ['PSD', 'PT', 'PSD', 'sem_partido'],
|
||
})
|
||
expect(state.parties).toEqual(['PSD', 'PT', 'sem_partido'])
|
||
expect(buildMunicipalityListHref(state, 1)).toBe(
|
||
'/campanha/municipios?party=PSD&party=PT&party=sem_partido',
|
||
)
|
||
// The sentinel canonicalizes LAST regardless of where it appeared, so one
|
||
// value set maps to one URL (same contract as the relationship dims).
|
||
const sentinelFirst = parseMunicipalityListParams({ party: ['sem_partido', 'PT', 'PT'] })
|
||
expect(sentinelFirst.parties).toEqual(['PT', 'sem_partido'])
|
||
expect(buildMunicipalityListHref(sentinelFirst, 1)).toBe(
|
||
'/campanha/municipios?party=PT&party=sem_partido',
|
||
)
|
||
})
|
||
|
||
it('serializes the three dimensions together with the rest of the state', () => {
|
||
const state = parseMunicipalityListParams({
|
||
region: 'Irecê',
|
||
stateDeputy: '4',
|
||
leadership: ['sem_lideranca', '9'],
|
||
party: 'PCdoB',
|
||
})
|
||
const href = buildMunicipalityListHref(state, 1)
|
||
expect(href).toContain('stateDeputy=4')
|
||
expect(href).toContain('leadership=9&leadership=sem_lideranca')
|
||
expect(href).toContain('party=PCdoB')
|
||
|
||
// URL → raw plain-record params → parse, mirroring what the page's
|
||
// `searchParams` object carries into `parseMunicipalityListParams`.
|
||
const query = new URLSearchParams(href.split('?')[1])
|
||
const raw: Record<string, string | string[] | undefined> = {}
|
||
for (const key of query.keys()) {
|
||
const values = query.getAll(key)
|
||
raw[key] = values.length === 1 ? values[0] : values
|
||
}
|
||
expect(parseMunicipalityListParams(raw)).toEqual(state)
|
||
})
|
||
|
||
it('builds the direct dobradinha where (in / exists), no catalog needed', () => {
|
||
expect(buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: '4' }))).toEqual({
|
||
and: [{ stateDeputies: { in: [4] } }],
|
||
})
|
||
expect(
|
||
buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: 'sem_dobradinha' })),
|
||
).toEqual({ and: [{ stateDeputies: { exists: false } }] })
|
||
expect(
|
||
buildMunicipalityListWhere(
|
||
parseMunicipalityListParams({ stateDeputy: ['4', 'sem_dobradinha'] }),
|
||
),
|
||
).toEqual({
|
||
and: [{ or: [{ stateDeputies: { in: [4] } }, { stateDeputies: { exists: false } }] }],
|
||
})
|
||
})
|
||
|
||
const catalog = {
|
||
leadershipMunicipalityIDsByLeadership: new Map([
|
||
[1, [10, 11]],
|
||
[2, [12]],
|
||
]),
|
||
allLeadershipMunicipalityIDs: new Set([10, 11, 12]),
|
||
stateDeputyIDsByParty: new Map([
|
||
['PT', [4, 5]],
|
||
['PSD', [6]],
|
||
]),
|
||
allPartyStateDeputyIDs: new Set([4, 5, 6]),
|
||
}
|
||
|
||
it('builds the reverse leadership where from the catalog (in / not_in)', () => {
|
||
expect(
|
||
buildMunicipalityListWhere(parseMunicipalityListParams({ leadership: ['1', '2'] }), catalog),
|
||
).toEqual({ and: [{ id: { in: [10, 11, 12] } }] })
|
||
|
||
expect(
|
||
buildMunicipalityListWhere(
|
||
parseMunicipalityListParams({ leadership: 'sem_lideranca' }),
|
||
catalog,
|
||
),
|
||
).toEqual({ and: [{ id: { not_in: [10, 11, 12] } }] })
|
||
})
|
||
|
||
it('builds the cross party where from the catalog (in / not_in)', () => {
|
||
expect(
|
||
buildMunicipalityListWhere(parseMunicipalityListParams({ party: ['PT'] }), catalog),
|
||
).toEqual({ and: [{ stateDeputies: { in: [4, 5] } }] })
|
||
|
||
expect(
|
||
buildMunicipalityListWhere(parseMunicipalityListParams({ party: 'sem_partido' }), catalog),
|
||
).toEqual({ and: [{ stateDeputies: { not_in: [4, 5, 6] } }] })
|
||
|
||
expect(
|
||
buildMunicipalityListWhere(
|
||
parseMunicipalityListParams({ party: ['PT', 'sem_partido'] }),
|
||
catalog,
|
||
),
|
||
).toEqual({
|
||
and: [{ or: [{ stateDeputies: { in: [4, 5] } }, { stateDeputies: { not_in: [4, 5, 6] } }] }],
|
||
})
|
||
})
|
||
|
||
it('fails closed: leadership/party filter without the catalog throws', () => {
|
||
expect(() =>
|
||
buildMunicipalityListWhere(parseMunicipalityListParams({ leadership: '1' })),
|
||
).toThrow(/missing relation catalog/)
|
||
expect(() => buildMunicipalityListWhere(parseMunicipalityListParams({ party: 'PT' }))).toThrow(
|
||
/missing relation catalog/,
|
||
)
|
||
// Dobradinha (direct) never needs the catalog.
|
||
expect(() =>
|
||
buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: '4' })),
|
||
).not.toThrow()
|
||
})
|
||
|
||
it('summarizes the three dimensions in the active-filters summary', () => {
|
||
expect(
|
||
formatMunicipalityActiveFiltersSummary(
|
||
parseMunicipalityListParams({ stateDeputy: ['1', '2'] }),
|
||
),
|
||
).toBe('2 dobradinhas')
|
||
expect(
|
||
formatMunicipalityActiveFiltersSummary(
|
||
parseMunicipalityListParams({ stateDeputy: ['1', 'sem_dobradinha'] }),
|
||
),
|
||
).toBe('1 dobradinha + sem dobradinha')
|
||
expect(
|
||
formatMunicipalityActiveFiltersSummary(
|
||
parseMunicipalityListParams({ leadership: 'sem_lideranca' }),
|
||
),
|
||
).toBe('Sem liderança')
|
||
expect(
|
||
formatMunicipalityActiveFiltersSummary(
|
||
parseMunicipalityListParams({ party: ['PT', 'PSD', 'sem_partido'] }),
|
||
),
|
||
).toBe('Partido PT, PSD + sem partido')
|
||
})
|
||
|
||
it('keeps the B176 toggles on the canonical fast-path href', () => {
|
||
const state = parseMunicipalityListParams({ party: 'PT' })
|
||
expect(buildMunicipalityFilterOptionHref(state, 'stateDeputy', '5')).toBe(
|
||
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, 'stateDeputy', '5')),
|
||
)
|
||
expect(buildMunicipalityFilterOptionHref(state, 'leadership', 'sem_lideranca')).toBe(
|
||
buildMunicipalityFilterHref(
|
||
toggleMunicipalityMultiFilterValue(state, 'leadership', 'sem_lideranca'),
|
||
),
|
||
)
|
||
expect(buildMunicipalityFilterOptionHref(state, 'party', 'PSD')).toBe(
|
||
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, 'party', 'PSD')),
|
||
)
|
||
})
|
||
})
|