feat(C125): facet de ausência completo (qualquer_ausencia) e descoberta de ordenação no mobile

- PEOPLE_ABSENCES ganha o 4º valor qualquer_ausencia ("Qualquer ausência"),
  a união exata das três ausências em UM chip — o 3º clique dos específicos
  deixa de colapsar o facet (3 de 4 membros) e o parse absorve o guarda-chuva
  sobre os específicos, sem tocar no B18 genérico de capacity/status
- peopleListSortPrimaryOptions: seeds do grupo Ordenação viram as 7 direções
  primárias com emptyQueryVisible, então o mobile descobre as 7 chaves ao
  tocar no bar (e "ordenar" não corta aliada/assessorado/base no cap 8)
- CampaignListOmnibox reabre o popover no mousedown: clicar num input já
  focado (logo após commitar busca com Enter) não disparava focus, deixando
  a descoberta morta no mobile até digitar
- tests: contrato de URL (absorção + não-colapso), predicado + AND, novo spec
  de omnibox, e2e do 3º clique e da descoberta mobile (390×844)
This commit is contained in:
Francisco Solla 2026-08-11 20:34:51 -03:00
parent b604157fc7
commit 7fabb72df8
9 changed files with 425 additions and 16 deletions

View file

@ -0,0 +1,83 @@
# Impl: Pessoas pós-C117 — descoberta de ordenação no mobile e facet de ausência completo
Status: aprovado
Atualizado em: 2026-08-11
Issue: #681
Intenção: docs/plans/pessoas-pos-c117-ux.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** (1) a consulta "quem está sem _algum_ dado" (fichas incompletas) é expressível no facet de ausência de `/campanha/pessoas` e o terceiro clique do facet não faz os chips sumirem; (2) no mobile (sem headers sortáveis) o usuário descobre que pode ordenar e alcança as 7 chaves de ordenação.
- **O que NÃO negociar:** canonicalização B18 intacta (`parseExhaustiveEnumParam` — selecionar todos = "todas" = ausente); mesma superfície (omnibox de pessoas, sem rota nova); sem migration/collection; copy pt-BR / identificadores EN; achados S2/S3/S4/S6/S9/S10/S11/S13 do simplify continuam fora (gatilhos registrados na intenção).
- **O que reavaliar:** a intenção deixa a forma do "qualquer ausência" em aberto (4º valor no enum OU toggle tri-state) e o desenho da descoberta mobile em aberto ("7 seeds de direção primária visíveis com query vazia, ou busca por coluna"). Decisões abaixo.
## Abordagem recomendada
```mermaid
flowchart LR
S14[PEOPLE_ABSENCES + qual_ausencia] --> URL[parse/serialize canônico]
URL --> DATA[peopleData: branch OR no filterPeopleRows]
URL --> OMNI[peopleOmnibox: seeds Ausência + chips]
S12[peopleListSortPrimaryOptions 7] --> OMNI2[peopleOmnibox: seeds Ordenação emptyQueryVisible]
OMNI2 --> UI[CampaignListOmnibox: grupo visível no focus]
```
**S14 — facet de ausência completo:** 4º valor `qualquer_ausencia` ("Qualquer ausência") no enum `PEOPLE_ABSENCES` (append-last). O colapso B18 é por contagem de membros (`parseExhaustiveEnumParam`), então com 4 membros:
- marcar os 3 específicos **não colapsa mais** (3 < 4) → os chips ficam e o filtro vale "alguma das três" — o bug do terceiro clique some sozinho, sem tocar no B18 genérico;
- `ausencia=qualquer_ausencia` sozinho = o mesmo conjunto de resultados, em UM chip;
- todos os 4 (URL digitada à mão) → colapso canônico B18, como sempre.
Predicado novo em `filterPeopleRows`: `qualquer_ausencia` casa `!hasAdvisor || city === null || phone === null` (a união exata dos três), OR dentro do facet como os demais valores. **Absorção no parse (pós-review /simplify):** `parsePeopleListParams` para `ausencia` usa um parser próprio em vez de `parseExhaustiveEnumParam` — com 4 membros, o colapso genérico B18 ("todos os membros = ausente") dispararia no 4º clique (guarda-chuva + 3 específicos) — o bug dos chips sumirem um clique adiante. Regra: `qualquer_ausencia` presente ⇒ estado canônico `['qualquer_ausencia']` (o guarda-chuva absorve os específicos; o combo redundante vira 1 chip, e "todos os 4" deixa de colapsar). As 3 específicas seguem composicionais (cada chip removível); capacity/status continuam no parser genérico B18.
**Opções consideradas:** A (4º valor no enum, OR) | B (toggle tri-state no chip) | C (normalizar "3 específicos" → `qualquer_ausencia` no serializer)
**Recomendação:** A — um valor no enum é a menor forma que mantém B18, é unit-testável no contrato de URL (parse/serialize) e no predicado, e o omnibox ganha o chip de graça via `peopleAbsenceFilterOptions`/labels derivados.
**Rejeitadas:** B porque inventa estado de UI não-representável na URL (o facet vive todo na URL neste repo — precedente B18); C porque quebra a composicionalidade dos chips (3º clique trocaria 3 chips por 1) e é uma regra canônica nova específica de pessoas fora do B18 genérico — sem ganho de aceite. **Defer pós-review /simplify:** record de predicados por enum (`Record<PeopleAbsence, (row) => boolean>` com o guarda-chuva derivado) — o OR explícito em `filterPeopleRows` está ao lado das definições dos três termos (o código é o contrato); revisitar quando houver um 4º predicado de ausência (aí o guarda-chuva vira derivação real).
**S12 — descoberta de ordenação no mobile:** o cap de 8 por grupo (`campaignListOmnibox.ts`, compartilhado por 8+ listas) corta as 14 seeds (7 chaves × 2 direções) em 8, escondendo `aliada|assessorado|base`. Solução: as seeds do grupo "Ordenação" de pessoas passam a ser **as 7 direções primárias** (`dir === defaultPeopleListSortDir(key)`) — novo export `peopleListSortPrimaryOptions` em `peopleListUrl.ts` (derivação, não duplicação: `peopleListSortOptions` filtrado) — com `emptyQueryVisible: true`. Com query vazia o grupo aparece ao tocar no bar (padrão já usado por Capacidade/Apoio/Ausência — atalhos de dimensão); digitando "ordenar" → 7 hits, abaixo do cap, todas as chaves visíveis. `peopleListSortOptions` (14) continua sendo o catálogo canônico para chips/labels/apply/URL/headers.
**Opções consideradas:** A (7 seeds primárias + emptyQueryVisible) | B (manter 14, subir o cap do grupo no lib compartilhado) | C (keywords por coluna nas seeds)
**Recomendação:** A — fixa o achado (7 chaves alcançáveis por keyword E visíveis no toque mobile), zero mudança no lib compartilhado (cap/ordem afetam todas as listas — municípios precisam do cap 8), e é exatamente o exemplo da intenção ("7 seeds de direção primária visíveis com query vazia").
**Rejeitadas:** B porque o cap é compartilhado por design (grupo Município com centenas de opções) e um cap-por-grupo adiciona API ao lib para um caso; C porque não resolve o achado — digitando o genérico "ordenar" as 14 continuam batendo no cap 8 e as 3 chaves somem mesmo com keyword por coluna.
**Trade-off aceito (S12):** direções secundárias (ex. `name desc` Z–A, `lidera asc`) deixam de ser alcançáveis via omnibox; no desktop o header continua fazendo o flip (segundo clique), e no mobile a leitura secundária é a menos relevante (cards sem contexto direcional; a direção primária é a leitura pretendida — contagens `desc` "quem tem mais rede primeiro", textuais `asc`). Gatilho de revisitação: se a mesa pedir controle de direção no mobile, um padrão de seed por direção volta ao desenho.
### Componentes / mudanças
- **`peopleListUrl.ts`**: `PEOPLE_ABSENCES` ganha `qualquer_ausencia` (último) + label "Qualquer ausência"; novo export `peopleListSortPrimaryOptions` (7, direção primária por chave). Parse/serialize/toggle/set ganham o 4º valor de graça (tudo derivado).
- **`peopleData.ts`**: branch `qualquer_ausencia` no `filterPeopleRows` (união exata dos três predicados, OR dentro do facet).
- **`peopleOmnibox.ts`**: seeds "Ausência" — keywords do grupo ganham `incompleto`/`incompleta` (linguagem natural de "ficha incompleta", achado S14); seeds "Ordenação" — loop passa a usar `peopleListSortPrimaryOptions` com `emptyQueryVisible: true`. Nada mais muda (chips/apply/remove são derivados dos mesmos options).
- **Migration:** sem migration (param de URL + enum de código).
- **Access / Consent:** nenhum — mesma superfície staff, nenhuma chave nova.
- **UI:** Impeccable B — encaixe no omnibox existente; zero componente novo; `CampaignListOmnibox` já renderiza grupos com query vazia no focus.
### Dados → forma (se aplicável)
- Nenhum dado novo: `qualquer_ausencia` é um valor de facet (predicado), não uma métrica; o group "Ordenação" com 7 itens no popover é a forma escolhida por caber no `max-h-72` scrollável do popover compartilhado e por ser o mesmo padrão visual dos atalhos de dimensão existentes (Capacidade/Apoio/Ausência já são `emptyQueryVisible`).
## Fases verificáveis
1. **Puro (contrato + filtro + omnibox)** — `peopleListUrl` (4º valor + primary sort options), `filterPeopleRows` (qualquer_ausencia), `peopleOmnibox` (seeds/chips/apply); testes unitários novos/ajustados: `peopleListUrl.unit.spec.ts` (o teste de colapso "todas" passa a usar os 4 membros; 3 específicos não colapsam mais), `peopleMerge.unit.spec.ts` (união do qualquer_ausencia + AND com outros facets), **novo** `tests/unit/peopleOmnibox.unit.spec.ts` ("ordenar" → 7 seeds primárias; query vazia → grupo visível; apply/toggle/chips, incluindo chip de sort secundária aplicada via header com label completo).
2. **E2E** — `campaignPeople.e2e.spec.ts`: (a) facet de ausência via omnibox com "Qualquer ausência" (chip único, pessoa sem contato visível, ficha completa oculta) e o fluxo "marcar os três" sem sumir chips; (b) mobile (`390×844`): foco no omnibox → grupo "Ordenação" visível → escolher "Lidera (maior → menor)" → URL `sort=lidera`.
3. **Gates** — `pnpm gate:fast` (tsc, lint zero-warnings, format:check, knip, check:cycles, unit+int, e2e) + `pnpm build` local; commit e PR com auto-merge em main.
## Rabbit holes / Não escopo (engenharia)
- Cap-por-grupo no `campaignListOmnibox.ts` — o cap 8 é compartilhado de propósito; as 7 seeds primárias resolvem sem tocá-lo.
- Normalizar "3 específicos" → `qualquer_ausencia` — ver rejeitadas (C).
- Seeds de direção secundária no mobile — trade-off aceito, gatilho registrado.
- S2/S3/S4/S6/S9/S10/S11/S13 do simplify — fora, gatilhos na intenção.
- Outras listas (municípios/lideranças têm o mesmo gap mobile de descoberta de sort) — escopo é pessoas (a intenção é do recorte de pessoas; abrir para outras listas é item novo).
## Riscos e mitigação
- **B18 fica intacto?** O colapso agora exige os 4 membros; testes de `parseExhaustiveEnumParam` e o teste atualizado de pessoas garantem que selecionar todos = ausente continua sendo a regra genérica (o teste do contrato muda de 3 para 4 membros).
- **Cap corta as seeds primárias?** 7 < 8 — e o teste novo de omnibox fixa "todas as 7 chaves aparecem com 'ordenar'".
- **Chip de sort secundária (aplicada no desktop) perde o label?** `buildPeopleOmniboxChips` continua usando `peopleListSortOptions` completo — coberto por teste unitário novo.
- **e2e mobile flaky (stream dinâmico)?** reuso do padrão `campaignUpdatesMobile.e2e.spec.ts` (`MOBILE_VIEWPORT` + locators visíveis); asserções por URL/chip, não por ordem visual onde possível.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (fichas incompletas expressível; 7 chaves alcançáveis e visíveis no mobile; B18 intacto)
- [x] Invariantes AGENTS/engineering-standards (sem migration/collection, sem access novo, identificadores EN / copy pt-BR)
- [x] Testes de domínio previstos (unit do contrato + filtro + omnibox; e2e do facet e da descoberta mobile)

View file

@ -217,7 +217,10 @@ export const CampaignListOmnibox = ({
'md:focus-within:border-ring md:focus-within:ring-3 md:focus-within:ring-ring/50',
)}
onMouseDown={(event) => {
if (event.target === event.currentTarget) inputRef.current?.focus()
if (event.target === event.currentTarget) {
inputRef.current?.focus()
setOpen(true)
}
}}
>
{chips.map((chip) => (
@ -267,6 +270,11 @@ export const CampaignListOmnibox = ({
setOpen(true)
}}
onFocus={() => setOpen(true)}
// C125 — a click on an already-focused input (e.g. right after
// committing a search with Enter) fires no focus event, which
// left the popover closed on mobile until a keystroke: the
// mousedown always fires, so this is the reliable reopen path.
onMouseDown={() => setOpen(true)}
onKeyDown={onKeyDown}
className="min-h-8 min-w-[8rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>

View file

@ -353,10 +353,16 @@ export const filterPeopleRows = (
if (state.ausencias?.length) {
const wanted = new Set(state.ausencias)
const hasAdvisor = row.leadershipAdvisorIDs.length > 0 || row.deputyAdvisorIDs.length > 0
const semAssessor = !hasAdvisor
const semBase = row.city === null
const semContato = row.phone === null
const matches =
(wanted.has('sem_assessor') && !hasAdvisor) ||
(wanted.has('sem_base') && row.city === null) ||
(wanted.has('sem_contato') && row.phone === null)
(wanted.has('sem_assessor') && semAssessor) ||
(wanted.has('sem_base') && semBase) ||
(wanted.has('sem_contato') && semContato) ||
// C125 — "Qualquer ausência": the exact union of the three predicates,
// one chip for "fichas incompletas" (same OR-within-facet semantics).
(wanted.has('qualquer_ausencia') && (semAssessor || semBase || semContato))
if (!matches) return false
}
return true

View file

@ -43,15 +43,24 @@ export type PeopleListSortDirection = 'asc' | 'desc'
* C117 — absence facets ("Sem assessor", "Sem base", "Sem contato"). Each
* value is a pure absence predicate over the merged row; the facet is OR
* within itself (same semantics as every multi-select facet) and AND with the
* other filters.
* other filters. C125 — `qualquer_ausencia` is the umbrella value: the union
* of the three predicates, so "fichas incompletas" is a single chip instead of
* the three-specific combination collapsing under B18 (with 4 members,
* selecting the 3 specifics no longer equals "all members").
*/
export const PEOPLE_ABSENCES = ['sem_assessor', 'sem_base', 'sem_contato'] as const
export const PEOPLE_ABSENCES = [
'sem_assessor',
'sem_base',
'sem_contato',
'qualquer_ausencia',
] as const
export type PeopleAbsence = (typeof PEOPLE_ABSENCES)[number]
export const peopleAbsenceLabels: Record<PeopleAbsence, string> = {
sem_assessor: 'Sem assessor',
sem_base: 'Sem base',
sem_contato: 'Sem contato',
qualquer_ausencia: 'Qualquer ausência',
}
export type PeopleListState = {
@ -101,6 +110,23 @@ const peopleListSortKeySet = new Set<string>(Object.keys(peopleListSortLabels))
const peopleListSortDirSet = new Set<PeopleListSortDirection>(['asc', 'desc'])
const peopleAbsenceSet = new Set<string>(PEOPLE_ABSENCES)
/**
* C125 — ausência parse with the umbrella absorbing the specifics. This facet
* deliberately does NOT use `parseExhaustiveEnumParam`: once
* `qualquer_ausencia` exists, selecting every member is NOT the same filter as
* selecting none (the umbrella means "any absence"), so the generic B18
* collapse would fire on the 4th click — the exact "chips vanish" bug this
* issue fixes, one click further. The umbrella subsumes the three specifics
* (one canonical "any absence" chip) while the 3-specific combination stays
* compositional (each chip individually removable).
*/
const parsePeopleAbsenceParam = (raw: string | string[] | undefined): PeopleAbsence[] => {
const values = allParamValues(raw).filter((token): token is PeopleAbsence =>
peopleAbsenceSet.has(token),
)
return values.includes('qualquer_ausencia') ? ['qualquer_ausencia'] : values
}
/** Categorical/textual keys open A–Z; count keys open on the biggest first. */
const peopleSortKeysWithDescDefault = new Set<PeopleListSortKey>([
'assessora',
@ -132,7 +158,7 @@ export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleLis
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => typeof id === 'number' && id > 0)
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
const ausencias = parseExhaustiveEnumParam<PeopleAbsence>(params.ausencia, peopleAbsenceSet)
const ausencias = parsePeopleAbsenceParam(params.ausencia)
const rawSort = firstValue(params.sort)
const sort =
rawSort && peopleListSortKeySet.has(rawSort) ? (rawSort as PeopleListSortKey) : undefined
@ -217,6 +243,17 @@ export const peopleListSortOptions = (
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: peopleSortOptionLabel(key, dir) })),
)
/**
* C125 — the omnibox sort catalog: ONE option per key, in the key's default
* direction. Mobile has no sortable headers, and the shared omnibox cap (8 per
* group) cuts the 14-key×dir catalog before `aliada`/`assessorado`/`base`;
* the primary direction is the intended reading (rankings biggest first, text
* A–Z), and desktop headers keep flipping to the secondary one.
*/
export const peopleListSortPrimaryOptions = peopleListSortOptions.filter(
({ key, dir }) => dir === defaultPeopleListSortDir(key),
)
export const resolvePeopleListUrl = (
params: PeopleListSearchParams,
totalPages?: number,

View file

@ -24,6 +24,7 @@ import {
isDefaultPeopleListSort,
peopleAbsenceLabels,
peopleListSortOptions,
peopleListSortPrimaryOptions,
resolvePeopleListSort,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
@ -138,21 +139,35 @@ export const buildPeopleOmniboxSuggestionSeeds = ({
id: `ausencia:${option.value}`,
group: 'Ausência',
label: option.label,
keywords: ['sem', 'ausencia', 'faltando', 'faltante', 'vazio'],
keywords: [
'sem',
'ausencia',
'faltando',
'faltante',
'vazio',
'incompleto',
'incompleta',
],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of peopleListSortOptions) {
// C125 — primary-direction seeds only, visible on an empty query: on mobile
// the sortable headers do not exist (cards), so the omnibox group is the
// whole discoverability surface for the 7 keys (and 7 < the shared 8/group cap).
for (const option of peopleListSortPrimaryOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `sort:${option.key}|${option.dir}`,
group: 'Ordenação',
label: option.label,
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
}),
createOmniboxSuggestionSeed(
{
id: `sort:${option.key}|${option.dir}`,
group: 'Ordenação',
label: option.label,
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
},
{ emptyQueryVisible: true },
),
)
}

View file

@ -268,5 +268,80 @@ test.describe('Pessoas — lista unificada', () => {
await expect(page.getByText('Ausência: Sem contato')).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(noPhoneName) })).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeHidden()
// C125 — the third absence click no longer collapses the facet: with the
// umbrella value in the enum, "all three" is 3 of 4 members, so B18 keeps
// the chips and the union ("any absence") stays applied.
await omnibox.fill('sem base')
await page.getByRole('option', { name: 'Sem base', exact: true }).click()
await expect(page).toHaveURL(
/q=C117&ausencia=sem_contato&ausencia=sem_base&sort=lidera&dir=asc$/,
)
await expect(page.getByText('Ausência: Sem base')).toBeVisible()
await omnibox.fill('sem assessor')
await page.getByRole('option', { name: 'Sem assessor', exact: true }).click()
await expect(page).toHaveURL(
/q=C117&ausencia=sem_contato&ausencia=sem_base&ausencia=sem_assessor&sort=lidera&dir=asc$/,
)
await expect(page.getByText('Ausência: Sem contato')).toBeVisible()
await expect(page.getByText('Ausência: Sem base')).toBeVisible()
await expect(page.getByText('Ausência: Sem assessor')).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(noPhoneName) })).toBeVisible()
// The union widened: the staff leaderships have no advisors, so they match
// `sem_assessor` — the row hidden by `sem_contato` alone is back in.
await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeVisible()
})
test('mobile discovers ordering in the omnibox and applies a sort (C125)', async ({
campaign,
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Mobile C125'),
})
const first = await fixtures.claimMunicipality()
const second = await fixtures.claimMunicipality()
const oneCity = await fixtures.createStaffLeadership({
namePrefix: 'C125 Mobile Uma',
municipalities: [first],
})
const twoCities = await fixtures.createStaffLeadership({
namePrefix: 'C125 Mobile Duas',
municipalities: [first, second],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
// Narrow the recorte to this spec's rows, so the card order is ours.
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
await omnibox.fill('C125 Mobile')
await omnibox.press('Enter')
await expect(page).toHaveURL(/q=C125/)
// The Ordenação group is discoverable on focus with an empty query —
// mobile cards have no sortable headers, the omnibox is the whole surface.
await omnibox.click()
await expect(page.getByRole('group', { name: 'Ordenação' })).toBeVisible()
await page.getByRole('option', { name: 'Lidera (maior → menor)', exact: true }).click()
await expect(page).toHaveURL(/q=C125.*&sort=lidera$/)
await expect(page.getByText('Ordenação: Lidera (maior → menor)')).toBeVisible()
// The mobile cards follow the sort: most municipalities first.
const cards = page.locator('[data-view="mobile-cards"] li')
const indexOfCard = async (name: string) => {
const texts = await cards.allTextContents()
return texts.findIndex((text) => text.includes(name))
}
await expect(cards.filter({ hasText: twoCities.contactName })).toBeVisible()
await expect(cards.filter({ hasText: oneCity.contactName })).toBeVisible()
const twoCitiesIndex = await indexOfCard(twoCities.contactName)
const oneCityIndex = await indexOfCard(oneCity.contactName)
expect(twoCitiesIndex).toBeGreaterThanOrEqual(0)
expect(oneCityIndex).toBeGreaterThanOrEqual(0)
expect(twoCitiesIndex).toBeLessThan(oneCityIndex)
})
})

View file

@ -6,7 +6,9 @@ import {
buildPeopleListHref,
buildPeopleSortHref,
buildPeopleStaffSourceWhere,
defaultPeopleListSortDir,
parsePeopleListParams,
peopleListSortPrimaryOptions,
resolvePeopleListUrl,
serializeCanonicalPeopleListSearchParams,
} from '@/utilities/people/peopleListUrl'
@ -70,9 +72,27 @@ describe('people list URL contract', () => {
parsePeopleListParams({ ausencia: ['sem_base', 'sem_assessor', 'sem_base', 'nada'] }),
).toEqual({ page: 1, ausencias: ['sem_base', 'sem_assessor'] })
expect(parsePeopleListParams({ ausencia: 'sem_e_mail' })).toEqual({ page: 1 })
expect(parsePeopleListParams({ ausencia: 'qualquer_ausencia' })).toEqual({
page: 1,
ausencias: ['qualquer_ausencia'],
})
// C125 — the three specifics are 3 of 4 members, so they no longer
// collapse: the "mark all three" interaction keeps its chips and means
// "any absence" (the same result set as `qualquer_ausencia`).
expect(
parsePeopleListParams({ ausencia: ['sem_assessor', 'sem_base', 'sem_contato'] }),
).toEqual({ page: 1 })
).toEqual({ page: 1, ausencias: ['sem_assessor', 'sem_base', 'sem_contato'] })
// C125 — the umbrella absorbs the specifics (one canonical "any absence"
// chip), so neither the redundant combo nor selecting all four members can
// fire B18's generic collapse and wipe the facet mid-interaction.
expect(
parsePeopleListParams({ ausencia: ['qualquer_ausencia', 'sem_contato', 'sem_base'] }),
).toEqual({ page: 1, ausencias: ['qualquer_ausencia'] })
expect(
parsePeopleListParams({
ausencia: ['sem_assessor', 'sem_base', 'sem_contato', 'qualquer_ausencia'],
}),
).toEqual({ page: 1, ausencias: ['qualquer_ausencia'] })
})
it('canonicalizes selecting every status member to the absent filter (C119)', () => {
@ -104,6 +124,13 @@ describe('people list URL contract', () => {
}).toString(),
).toBe('ausencia=sem_base&ausencia=sem_contato')
expect(
serializeCanonicalPeopleListSearchParams({
page: 1,
ausencias: ['qualquer_ausencia'],
}).toString(),
).toBe('ausencia=qualquer_ausencia')
expect(
serializeCanonicalPeopleListSearchParams({ page: 1, sort: 'name', dir: 'asc' }).toString(),
).toBe('')
@ -168,6 +195,22 @@ describe('people list URL contract', () => {
)
})
it('derives the omnibox primary sort options: one per key, in its default direction', () => {
expect(peopleListSortPrimaryOptions).toHaveLength(7)
expect(peopleListSortPrimaryOptions.map((option) => option.key)).toEqual([
'name',
'contact',
'assessora',
'lidera',
'aliada',
'assessorado',
'base',
])
for (const option of peopleListSortPrimaryOptions) {
expect(option.dir).toBe(defaultPeopleListSortDir(option.key))
}
})
it('clamps an out-of-range page to totalPages', () => {
const resolved = resolvePeopleListUrl({ page: '9' }, 3)
expect(resolved.state.page).toBe(3)

View file

@ -237,6 +237,39 @@ describe('filterPeopleRows', () => {
).toEqual([101, 102, 103])
})
it('filters "qualquer ausência" as the exact union of the three predicates (C125)', () => {
expect(
filterPeopleRows(rows, { page: 1, ausencias: ['qualquer_ausencia'] })
.map((row) => row.contactID)
.sort(),
).toEqual([101, 102, 103])
expect(
filterPeopleRows(rows, {
page: 1,
ausencias: ['qualquer_ausencia', 'sem_contato'],
})
.map((row) => row.contactID)
.sort(),
).toEqual([101, 102, 103])
})
it('combines "qualquer ausência" with the other filters (AND across facets)', () => {
expect(
filterPeopleRows(rows, {
page: 1,
municipalities: [5],
ausencias: ['qualquer_ausencia'],
}).map((row) => row.contactID),
).toEqual([101])
expect(
filterPeopleRows(rows, {
page: 1,
statuses: ['engajado'],
ausencias: ['qualquer_ausencia'],
}),
).toEqual([])
})
it('combines absence facets with the other filters (AND across facets)', () => {
expect(
filterPeopleRows(rows, { page: 1, capacities: ['assessora'], ausencias: ['sem_base'] }).map(

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { parsePeopleListParams } from '@/utilities/people/peopleListUrl'
import {
applyPeopleOmniboxSuggestion,
buildPeopleOmniboxChips,
buildPeopleOmniboxSuggestionSeeds,
filterPeopleOmniboxSuggestions,
removePeopleOmniboxChip,
} from '@/utilities/people/peopleOmnibox'
const seeds = buildPeopleOmniboxSuggestionSeeds({ municipalityFilterOptions: [] })
const sortSeedsIn = (query: string) =>
filterPeopleOmniboxSuggestions(seeds, query).filter((entry) => entry.group === 'Ordenação')
describe('people omnibox — sort discovery (C125)', () => {
it('typing "ordenar" surfaces every sort key, one seed each in the primary direction', () => {
const suggestions = sortSeedsIn('ordenar')
expect(suggestions.map((entry) => entry.id)).toEqual([
'sort:name|asc',
'sort:contact|asc',
'sort:assessora|desc',
'sort:lidera|desc',
'sort:aliada|desc',
'sort:assessorado|desc',
'sort:base|asc',
])
expect(suggestions.map((entry) => entry.label)).toContain('Aliada em (maior → menor)')
expect(suggestions.map((entry) => entry.label)).toContain('Base (A–Z)')
})
it('shows the Ordenação group on an empty query (mobile discovery)', () => {
const suggestions = filterPeopleOmniboxSuggestions(seeds, '')
expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true)
expect(sortSeedsIn('')).toHaveLength(7)
})
it('matches a sort column by its label, e.g. "aliada"', () => {
const suggestions = sortSeedsIn('aliada')
expect(suggestions.map((entry) => entry.id)).toEqual(['sort:aliada|desc'])
})
it('applies a sort suggestion and resets to page 1', () => {
const applied = applyPeopleOmniboxSuggestion({
state: parsePeopleListParams({ page: '3', q: 'ana' }),
suggestionId: 'sort:aliada|desc',
})
expect(applied.kind).toBe('url')
if (applied.kind === 'url') {
expect(applied.state).toEqual({ page: 1, q: 'ana', sort: 'aliada', dir: 'desc' })
}
})
it('keeps the full option catalog for the chip label of a header-applied secondary direction', () => {
const chips = buildPeopleOmniboxChips({
state: parsePeopleListParams({ sort: 'name', dir: 'desc' }),
municipalityLabelsById: new Map(),
})
expect(chips.some((chip) => chip.id === 'sort' && chip.label === 'Ordenação: Nome (Z–A)')).toBe(
true,
)
})
})
describe('people omnibox — "Qualquer ausência" facet (C125)', () => {
it('suggests the umbrella absence with the natural "incompleto" keyword', () => {
const byKeyword = filterPeopleOmniboxSuggestions(seeds, 'incompleto').filter(
(entry) => entry.group === 'Ausência',
)
expect(byKeyword.some((entry) => entry.id === 'ausencia:qualquer_ausencia')).toBe(true)
expect(byKeyword.map((entry) => entry.id).sort()).toEqual([
'ausencia:qualquer_ausencia',
'ausencia:sem_assessor',
'ausencia:sem_base',
'ausencia:sem_contato',
])
const byLabel = filterPeopleOmniboxSuggestions(seeds, 'qualquer')
expect(byLabel.some((entry) => entry.id === 'ausencia:qualquer_ausencia')).toBe(true)
})
it('toggles the umbrella value like any other facet value', () => {
const added = applyPeopleOmniboxSuggestion({
state: parsePeopleListParams({}),
suggestionId: 'ausencia:qualquer_ausencia',
})
expect(added.kind).toBe('url')
if (added.kind === 'url') {
expect(added.state.ausencias).toEqual(['qualquer_ausencia'])
}
const removed = removePeopleOmniboxChip({
state: parsePeopleListParams({ ausencia: 'qualquer_ausencia' }),
chipId: 'ausencia:qualquer_ausencia',
})
expect(removed.kind).toBe('url')
if (removed.kind === 'url') expect(removed.state.ausencias).toBeUndefined()
})
it('renders the umbrella chip with its pt-BR label', () => {
const chips = buildPeopleOmniboxChips({
state: parsePeopleListParams({ ausencia: 'qualquer_ausencia' }),
municipalityLabelsById: new Map(),
})
expect(chips.map((chip) => chip.id)).toEqual(['ausencia:qualquer_ausencia'])
expect(chips[0]?.label).toBe('Ausência: Qualquer ausência')
})
})