diff --git a/docs/plans/busca-sem-sugestoes-estado-vazio-honesto-impl.md b/docs/plans/busca-sem-sugestoes-estado-vazio-honesto-impl.md new file mode 100644 index 00000000..47d83614 --- /dev/null +++ b/docs/plans/busca-sem-sugestoes-estado-vazio-honesto-impl.md @@ -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
renderiza HOME_SEARCH_SUGGEST_EMPTY_MESSAGE] + S -->|success + hits| G[HomeSearchMunicipalityGroup
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 `

` 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 `

` 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/`). 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 diff --git a/src/components/campaign/dashboard/HomeSearchResultsShell.tsx b/src/components/campaign/dashboard/HomeSearchResultsShell.tsx index 4e0297a6..bc946d36 100644 --- a/src/components/campaign/dashboard/HomeSearchResultsShell.tsx +++ b/src/components/campaign/dashboard/HomeSearchResultsShell.tsx @@ -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 }) =>

) : null} {showEmpty ?

Nenhum resultado.

: null} + {showSuggestEmpty ? ( +

{HOME_SEARCH_SUGGEST_EMPTY_MESSAGE}

+ ) : null} ) } diff --git a/src/lib/campaignHomeSearchMessages.ts b/src/lib/campaignHomeSearchMessages.ts index 4084fbf8..7009f579 100644 --- a/src/lib/campaignHomeSearchMessages.ts +++ b/src/lib/campaignHomeSearchMessages.ts @@ -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.' diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index d35966d0..ab2c7dc9 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -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)', () => { diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index c543d99b..f09080ed 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -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) + }) }) diff --git a/tests/unit/campaignHomeSearch.unit.spec.tsx b/tests/unit/campaignHomeSearch.unit.spec.tsx index 48c3f186..7d0e6442 100644 --- a/tests/unit/campaignHomeSearch.unit.spec.tsx +++ b/tests/unit/campaignHomeSearch.unit.spec.tsx @@ -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( + + + +

Group stub

+
+
+
, + ) + + 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( + + + +

Group stub

+
+
+
, + ) + + 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( + + + +

Group stub

+
+
+
, + ) + + 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( + + + +

Group stub

+
+
+
, + ) + + expect(screen.getByText('Nenhum resultado.')).toBeTruthy() + expect(screen.queryByText(HOME_SEARCH_SUGGEST_EMPTY_MESSAGE)).toBeNull() + }) })