fix(OPS29): estado vazio honesto na busca global sem sugestões curadas (Closes #585)

- HomeSearchResultsShell renderiza 'Nenhuma sugestão ainda — priorize
  municípios na planilha de projeção' quando o suggest responde success
  sem hits (Início e FAB overlay compartilham o shell; SSR initialSuggest
  e POST cobertos); gateada por !isFetching para não falar sob o skeleton
- B126 e2e determinístico: pin um município priority alta claimado;
  testes OPS29 novos usam advisor sem carteira (scoped, imune a alta
  global de workers paralelos); settle de animação do overlay no B126
- unit: 3 casos novos no shell + overlap skeleton/mensagem
This commit is contained in:
Francisco Solla 2026-08-10 12:54:16 -03:00
parent dc2f76e1a1
commit fb7f85cd59
6 changed files with 302 additions and 5 deletions

View file

@ -0,0 +1,93 @@
# Impl: Busca global sem sugestões mostra região em branco — estado vazio honesto
Status: aprovado
Atualizado em: 2026-08-10
Issue: #585
Intenção: docs/plans/busca-sem-sugestoes-estado-vazio-honesto.md
Appetite restante: ~0,5 dia eng (herdado — cabe folgado)
## Leitura da intenção
- **Outcome:** com zero municípios `priority: alta`, focar o search (Início e FAB overlay) mostra uma mensagem curta de estado vazio dentro da região `Resultados da busca` — nunca uma região muda. Com ≥1 `alta`, comportamento preservado. API `/campanha/home-search` intacta. E2E do FAB passa em qualquer estado de dados.
- **O que NÃO negociar:** sem mudança de API; sem sugestões falsas; sem componente pesado de onboarding; mensagem vinda do estado compartilhado (não uma cópia por superfície); leader lockdown intacto (leader nem tem search).
- **O que reavaliar:** a hipótese da intenção aponta `HomeSearchResultsShell.tsx` como dono do vazio — confirmei; também reavaliei a pergunta em aberto do assessor contra a regra real do ranking (ver Decisões).
## Abordagem recomendada
```mermaid
flowchart LR
F[Focus sem query] --> R[useHomeSearchResultsState]
R -->|mode: suggest| S[POST home-search / initialSuggest SSR]
S -->|success + 0 hits| E[HomeSearchResultsShell<br/>renderiza HOME_SEARCH_SUGGEST_EMPTY_MESSAGE]
S -->|success + hits| G[HomeSearchMunicipalityGroup<br/>seção Sugestões — inalterado]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — o empty entra no `HomeSearchResultsShell`, o mesmo componente que já decide skeleton/erro/`Nenhum resultado.` do modo busca. É o estado compartilhado por Início, FAB desktop e Drawer mobile (`CampaignStaffGlobalSearchBody`), então uma única condição cobre as três superfícies — exatamente o que a intenção pede ("mensagem do estado compartilhado, não cópia por superfície").
**Rejeitadas:**
- **B — empty dentro de `HomeSearchMunicipalityGroup`:** o grupo já retorna `null` sem hits e não renderiza heading no modo sugestão; o vazio é propriedade da _região inteira_ (todos os grupos somem), não do grupo de municípios. Colocar lá quebraria a simetria com o empty do modo busca, que já vive no shell.
- **C — componente novo de empty state:** DRY <3 call sites; a gramática de vazios do app é um `<p>` com `text-sm text-muted-foreground` (precedentes: `Nenhum resultado.` no shell e `emptyState` do `SuggestionsPanel` E11). Componente novo seria camada sem volatilidade.
### Componentes / mudanças
- **`HOME_SEARCH_SUGGEST_EMPTY_MESSAGE`** (`src/lib/campaignHomeSearchMessages.ts`): nova constante de copy — "Nenhuma sugestão ainda — priorize municípios na planilha de projeção." (texto recomendado A da intenção, que diz o que é, por quê e o que desbloqueia). Morar ao lado de `HOME_SEARCH_GENERIC_ERROR_MESSAGE`/`HOME_SEARCH_STAFF_ONLY_MESSAGE`, que já são a casa das strings do search.
- **`HomeSearchResultsShell`** (`src/components/campaign/dashboard/HomeSearchResultsShell.tsx`): nova condição `showSuggestEmpty` — `resultKind === 'suggest' && results.status === 'success' && !homeSearchHasAnyHits(results.data)` — renderizando `<p className="text-sm text-muted-foreground">` com a mensagem, no mesmo lugar e estilo do `Nenhum resultado.` do modo busca (simetria byte a byte com o padrão existente). O modo busca (`query.isActive`) e o estado com hits ficam intocados.
- **Migration:** nenhuma — sem schema.
- **Access / Consent:** nenhum — UI pura; nenhum dado novo exposto.
- **UI:** Impeccable A — só copy em superfície existente, sem rearranjo (a intenção já classifica como B mas restringe a "só copy/label"; o canvas diz N/A). Sem shape/craft/polish.
### Dados → forma (se aplicável)
N/A — estado vazio de UI, sem métrica nova (pergunta 3 de data-presentation não dispara).
## Decisões de engenharia
**D1 — Onde vive o empty (A vs B vs C):** ver "Opções consideradas" acima. Custo de reverter: nulo (uma condição no shell).
**D2 — Texto único compartilhado vs copy por papel:**
- **Opções:** A) mensagem única para todos os papéis | B) copy específica por papel (coordenador vs assessor).
- **Recomendação:** A — a regra real do ranking (`rankHomeSearchSuggestMunicipalities`) mostra que **ambos** os papéis podem nascer com lista vazia: unrestricted filtra `priority: alta` (vazio em banco novo — o caso da intenção); advisor rankeia a carteira, que é vazia se o coordenador ainda não atribuiu municípios (caso raro mas real). A intenção recomendava C ("assessor não tem empty") para a pergunta em aberto, mas a regra real contradiz a premissa — a carteira só "sempre lista algo" quando o assessor já tem atribuição. Com os dois papéis capazes de vazio, uma mensagem única honesta é mais simples que plumbing de role até o cliente para um edge raro.
- **Rejeitadas:** B (role-aware) porque exigiria expor o role no client state do search para um caso de canto; o núcleo honesto da mensagem ("não há sugestões ainda") vale para ambos.
**D3 — Estratégia do e2e (intenção: "asserção para o vazio ou expectativa tolerante a ambos, sem enfraquecer o contrato"):**
- **Opções:** A) estado determinístico por teste — o B126 existente ganha um município `alta` explícito (contrato original preservado: região `Sugestões` com dados) + dois testes novos de vazio (FAB e Início) | B) expectativa tolerante (`region 'Sugestões'` OR mensagem de vazio) no B126.
- **Recomendação:** A — cada estado vira contrato explícito e verificado, em qualquer ambiente. O banco de teste e2e nasce sem `alta` (migrações não seedam prioridade) e o cleanup do fixture reseta `priority: 'normal'` dos municípios tocados (campaignE2EFixtures.ts:431).
- **Refinamento pós-simplify (race paralelo):** a config roda `fullyParallel` com workers ≥2 num banco compartilhado — o município `alta` pinado pelo B126 fica vivo do `payload.update` ao cleanup e vazaria para testes zero-alta rodando em paralelo (a classe do miss #73). Os dois testes OPS29 novos usam então um **advisor sem municípios administrados**: o scope do suggest é a carteira (vazia), imune a qualquer `alta` global. O caso unrestricted (coordenador/candidato) continua coberto pelos unit tests do shell, que não ramificam por papel. O teste do FAB também aguarda o `suggestResponse` antes das asserções (o POST acontece no focus).
- **Rejeitadas:** B — OR tolerante é mais fraco: verifica "algum estado honesto" sem nunca provar nenhum dos dois por extenso; e o contrato da intenção ("passa em qualquer estado") já é atendido por A com asserções mais fortes. A variante "zero-alta via fixture default" sem o advisor foi rejeitada pelo race acima.
**D4 — Edge de exclusão de contexto (FAB em página de detalhe):** `filterHomeSearchResponseForContext` pode zerar a lista filtrada mesmo com hits no servidor (único `alta` = o município que você está vendo; FAB em `/campanha/municipios/<slug>`). Nesse caso o vazio renderiza com a mensagem A — o núcleo ("nenhuma sugestão [neste contexto]") continua verdadeiro; a segunda oração fica imperfeita. **Aceito como trade-off documentado:** hoje a região renderiza em branco (o bug); com a mudança renderiza a mensagem — estritamente melhor, e corrigir custaria expor o payload pré-filtro no estado compartilhado para um caso de canto (rejeitado em D2/D4 por depth check: pass-through de dados que 99% dos consumidores não usam).
## Fases verificáveis
1. **UI (shell + constante):** `campaignHomeSearchMessages.ts` + condição no `HomeSearchResultsShell`. Sem fase server — API e server loaders intocados.
2. **Testes unit:** `tests/unit/campaignHomeSearch.unit.spec.tsx` — caso novo no describe do shell: suggest success vazio → mensagem visível; sugestão com hits → sem mensagem; garantir que `Nenhum resultado.` do modo busca não regride (caso que hoje não está coberto — adicionar junto).
3. **Testes e2e:**
- `campaignMunicipalities.e2e.spec.ts` (describe B126): B126 existente ganha `fixtures.payload.update` setando `priority: 'alta'` no município claimado + `fixtures.touchMunicipality` (cleanup reseta) — região `Sugestões` passa a ser determinística em qualquer ambiente.
- Novo teste no mesmo describe: FAB em estado zero-alta → foca search → `region 'Resultados da busca'` contém a mensagem de vazio.
- `campaignHomeActions.e2e.spec.ts`: novo teste — Início zero-alta → foca search → mensagem de vazio (cobre o caminho SSR `initialSuggest`, que difere do POST do FAB).
4. **Gates:** `pnpm gate:fast` na iteração; entrega com `pnpm push` (não `git push` nu); CI green antes de merge.
## Rabbit holes / Não escopo (engenharia)
- Wizard municipality suggest (`loadWizardMunicipalitySuggestions`, B93) — superfície separada, fora da intenção; não tocar.
- Copy por papel / role-aware (D2) — decidido, não fazer.
- Expor payload pré-filtro para o edge de exclusão (D4) — não fazer.
- Mudar `Nenhum resultado.` ou qualquer comportamento do modo busca — fora de escopo.
- Texto da mensagem além da oração única — sem ilustrações, sem CTA (anti-goal da intenção).
- `HomeSearchSuggestSkeleton` — inalterado; o skeleton já cobre o fetch antes do success vazio.
## Riscos e mitigação
- **Regressão do caso com dados:** a condição nova só ativa com `resultKind === 'suggest'` + success + zero hits; com hits, `children` renderiza igual (só há uma asserção a mais, que falha se `homeSearchHasAnyHits` voltar true — testado em unit).
- **Flakiness e2e por estado do banco:** mitigado por D3-A refinado — B126 seta `alta` explicitamente e toca o município para o cleanup restaurar; os testes de vazio usam advisor sem carteira, imune a `alta` global de workers paralelos.
- **Exclusão de contexto zerando a lista:** aceito (D4) — estritamente melhor que a região em branco atual.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (vazio honesto nos dois fluxos; ≥1 `alta` byte a byte; API intacta)
- [x] Invariantes AGENTS/engineering-standards (sem schema/access/consent; copy pt-BR; identificadores em inglês)
- [x] Testes de domínio previstos (unit do shell + e2e dos dois fluxos)
- [x] Sem migration, sem `generate:types`, sem importmap

View file

@ -6,6 +6,7 @@ import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { HomeSearchSuggestSkeleton } from '@/components/campaign/dashboard/HomeSearchSuggestSkeleton'
import { homeSearchHasAnyHits } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_SUGGEST_EMPTY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) => {
const { query } = useHomeSearch()
@ -14,11 +15,12 @@ export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) =>
const hasRenderableHits = results.status === 'success' && homeSearchHasAnyHits(results.data)
const showSkeleton = isFetching && !hasRenderableHits && results.status !== 'error'
const showEmpty =
query.isActive &&
resultKind === 'search' &&
results.status === 'success' &&
!homeSearchHasAnyHits(results.data)
const showEmpty = query.isActive && resultKind === 'search' && !hasRenderableHits
// `isFetching` keeps the message out of the debounce/skeleton window: while
// the user starts typing from an empty suggest, the skeleton covers the
// transition and the stale empty payload must not also speak (OPS29).
const showSuggestEmpty = resultKind === 'suggest' && !hasRenderableHits && !isFetching
return (
<>
@ -30,6 +32,9 @@ export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) =>
</p>
) : null}
{showEmpty ? <p className="text-sm text-muted-foreground">Nenhum resultado.</p> : null}
{showSuggestEmpty ? (
<p className="text-sm text-muted-foreground">{HOME_SEARCH_SUGGEST_EMPTY_MESSAGE}</p>
) : null}
</>
)
}

View file

@ -3,3 +3,6 @@ export const HOME_SEARCH_STAFF_ONLY_MESSAGE =
'Somente a equipe de campanha pode usar a busca do Início.'
export const HOME_SEARCH_GENERIC_ERROR_MESSAGE = 'Não foi possível buscar. Tente novamente.'
export const HOME_SEARCH_SUGGEST_EMPTY_MESSAGE =
'Nenhuma sugestão ainda — priorize municípios na planilha de projeção.'

View file

@ -103,6 +103,29 @@ test.describe('Início — busca global (B47)', () => {
await group.locator(`a[href$="/campanha/municipios/${municipality.slug}"]`).click()
await page.waitForURL(new RegExp(`^https?://[^/]+/campanha/municipios/${municipality.slug}$`))
})
test('staff focused search without curated suggestions shows the honest empty state (OPS29)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
// Advisor with no administered municipality: the suggest scope is the
// portfolio (empty), so the empty state is deterministic even if a
// parallel worker pins an `alta` municipality elsewhere (B126 sibling).
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora OPS29'),
})
await campaign.login(page, advisor.email!, advisor.password)
// Início renders the suggest payload server-side (initialSuggest), so this
// also covers the SSR path — no POST needed for the empty state (OPS29).
const search = page.getByLabel('Buscar na campanha')
await search.focus()
const results = page.getByRole('region', { name: 'Resultados da busca' })
await expect(results).toContainText('Nenhuma sugestão ainda', { timeout: 10000 })
await expect(results.getByRole('region', { name: 'Sugestões' })).toHaveCount(0)
})
})
test.describe('Início — catálogo de ações (B45)', () => {

View file

@ -1068,6 +1068,17 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador B126'),
})
// The suggest region only renders with curated hits for unrestricted
// roles (B68), so pin one `alta` municipality — deterministic in any
// environment, not only where the projection was imported (OPS29).
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { priority: 'alta' },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
@ -1076,6 +1087,12 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
await page.getByRole('button', { name: 'Ações rápidas' }).click()
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
// The Drawer/Dialog slides in (~450ms); `toBeVisible` fires mid-flight and
// focusing the input during the entrance animation can drop the focus
// (and with it the suggest POST). Same settle wait as the B136 sibling.
await overlay.evaluate(async (el) => {
await Promise.all(el.getAnimations().map((animation) => animation.finished))
})
const registerSignal = overlay.getByRole('link', { name: 'Registrar atualização' })
await expect(registerSignal).toBeVisible()
@ -1094,4 +1111,42 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
// Overlay scopes search suggest — list page has no E11 SuggestionsPanel.
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toBeVisible()
})
test('overlay search without curated suggestions shows the honest empty state (OPS29)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
// Advisor with no administered municipality: the suggest scope is the
// portfolio (empty), so the empty state is deterministic even if a
// parallel worker pins an `alta` municipality (B126 sibling) — the
// unrestricted coordinator case stays covered by the unit shell tests.
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor OPS29'),
})
await campaign.login(page, advisor.email!, advisor.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: 'Ações rápidas' }).click()
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
const search = overlay.getByLabel('Buscar na campanha')
const suggestResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/campanha/home-search') &&
resp.request().method() === 'POST' &&
resp.request().postDataJSON()?.mode === 'suggest',
)
await search.focus()
await suggestResponse
// The advisor has no portfolio yet (OPS29) — the suggest region must say
// so instead of rendering blank.
const results = overlay.getByRole('region', { name: 'Resultados da busca' })
await expect(results).toContainText('Nenhuma sugestão ainda', { timeout: 10000 })
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toHaveCount(0)
})
})

View file

@ -5,6 +5,8 @@ import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHome
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
import { HomeSearchResultsProvider } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_SUGGEST_EMPTY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
const providerValue = {
query: { raw: 'ca', debounced: 'ca', isActive: true },
@ -16,6 +18,38 @@ const providerValue = {
uiFocused: true,
}
const suggestFocusedProviderValue = {
...providerValue,
query: { raw: '', debounced: '', isActive: false },
}
const suggestSuccessEmpty: HomeSearchSuccessResponse = {
status: 'success',
resultKind: 'suggest',
municipalities: [],
territories: [],
advisors: [],
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
scopeMunicipalities: [],
}
const suggestSuccessWithHits: HomeSearchSuccessResponse = {
...suggestSuccessEmpty,
municipalities: [
{
kind: 'municipality',
slug: 'salvador',
name: 'Salvador',
region: 'Região Metropolitana',
priority: 'alta',
votePosition2022: null,
},
],
}
afterEach(() => {
cleanup()
})
@ -83,4 +117,88 @@ describe('HomeSearchResultsShell', () => {
expect(screen.getByTestId('home-search-suggest-skeleton')).toBeTruthy()
expect(screen.queryByText('Group stub')).toBeNull()
})
it('shows the honest empty message when suggest succeeds without hits (OPS29)', () => {
render(
<HomeSearchProvider value={suggestFocusedProviderValue}>
<HomeSearchResultsProvider
value={{
results: { status: 'success', data: suggestSuccessEmpty },
isFetching: false,
resultKind: 'suggest',
}}
>
<HomeSearchResultsShell>
<p>Group stub</p>
</HomeSearchResultsShell>
</HomeSearchResultsProvider>
</HomeSearchProvider>,
)
expect(screen.getByText(HOME_SEARCH_SUGGEST_EMPTY_MESSAGE)).toBeTruthy()
expect(screen.queryByText('Nenhum resultado.')).toBeNull()
})
it('does not render the suggest empty message when suggest has hits (OPS29)', () => {
render(
<HomeSearchProvider value={suggestFocusedProviderValue}>
<HomeSearchResultsProvider
value={{
results: { status: 'success', data: suggestSuccessWithHits },
isFetching: false,
resultKind: 'suggest',
}}
>
<HomeSearchResultsShell>
<p>Group stub</p>
</HomeSearchResultsShell>
</HomeSearchResultsProvider>
</HomeSearchProvider>,
)
expect(screen.queryByText(HOME_SEARCH_SUGGEST_EMPTY_MESSAGE)).toBeNull()
expect(screen.getByText('Group stub')).toBeTruthy()
})
it('keeps the suggest empty message out of the debounce window (OPS29)', () => {
render(
<HomeSearchProvider value={suggestFocusedProviderValue}>
<HomeSearchResultsProvider
value={{
results: { status: 'success', data: suggestSuccessEmpty },
isFetching: true,
resultKind: 'suggest',
}}
>
<HomeSearchResultsShell>
<p>Group stub</p>
</HomeSearchResultsShell>
</HomeSearchResultsProvider>
</HomeSearchProvider>,
)
expect(screen.getByTestId('home-search-suggest-skeleton')).toBeTruthy()
expect(screen.queryByText(HOME_SEARCH_SUGGEST_EMPTY_MESSAGE)).toBeNull()
})
it('keeps the search-mode empty message for an active query without hits (OPS29)', () => {
render(
<HomeSearchProvider value={providerValue}>
<HomeSearchResultsProvider
value={{
results: { status: 'success', data: { ...suggestSuccessEmpty, resultKind: 'search' } },
isFetching: false,
resultKind: 'search',
}}
>
<HomeSearchResultsShell>
<p>Group stub</p>
</HomeSearchResultsShell>
</HomeSearchResultsProvider>
</HomeSearchProvider>,
)
expect(screen.getByText('Nenhum resultado.')).toBeTruthy()
expect(screen.queryByText(HOME_SEARCH_SUGGEST_EMPTY_MESSAGE)).toBeNull()
})
})