Merge pull request #554 from fsolla/B191-sollinha-acoes-rapidas-de-abertura-no-chat-chips-de-per

feat(B191): chips de pergunta de abertura no chat Sollinha
This commit is contained in:
Francisco Solla 2026-08-10 04:15:38 -03:00 committed by GitHub
commit 74ccce432d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 397 additions and 5 deletions

View file

@ -0,0 +1,94 @@
# Impl: Sollinha: ações rápidas de abertura no chat (chips de pergunta)
Status: aprovado
Atualizado em: 2026-08-10
Issue: #532
Intenção: docs/plans/sollinha-acoes-rapidas-chat.md
Appetite restante: herdado (~0,5–1 dia eng) — cabe folgado
## Leitura da intenção
- **Outcome:** com a conversa vazia, o chat mostra chips de pergunta acima do input; tocar num chip envia aquela pergunta como mensagem do usuário e remove os chips de abertura; chips curados ao que o Sollinha responde bem hoje; leader não vê chip que aciona ferramenta eleitoral travada (fail-closed, B180); funciona no painel desktop e no drawer mobile, tema claro e escuro.
- **O que NÃO negociar:** chips não navegam (só enviam); aparecem só com conversa vazia (`messages.length === 0`); somem no primeiro envio; curadoria por capacidade real das ferramentas; respeito ao lockdown de leader (nada de tool eleitoral / staff).
- **O que reavaliar:** a hipótese "lista como dado de configuração em `src/lib/`" (mantém, com molde `sollinhaChatPanelWidth.ts`); o canvas mostrou um chip de leader "Quais apoiadores eu cadastrei?" — **não há tool de supporter no chat** (o toolset é `buildCampaignLinks`, `calculate`, `getDobradinhas`, `getLeaderships`, `getLeadingMunicipalities`, `getMunicipalityOverview`, `getMunicipalityVotes`, `getOrganizations`, `getTopDeputies`, `searchEntities`) — esse chip violaria a própria regra de curadoria da intenção (nada de sugerir o que não se responde bem). O conjunto de leader é re-curado pelo que as tools de leader **de fato** respondem.
## Abordagem recomendada
```mermaid
flowchart LR
L[layout (app) — user.role] -->|prop serializable| S[CampaignAISidebarShell]
S --> P[CampaignAISidebarProvider + role no context]
P --> C[CampaignAIChat]
CAT[src/lib/sollinhaOpeningQuestions.ts — catálogo puro] --> C
C -->|messages 0 + status ready| SLOT[Slot de chips acima do input]
SLOT --> CHIP[ChatChipGroup — botões pill]
CHIP -->|sendMessage text| CHAT[useChat]
CHAT -->|messages cresce| SLOT["chips somem (estado não vazio)"]
```
**Opções consideradas:**
- A — Catálogo curado por papel (`staff` vs `leader`) × viewport em `src/lib/sollinhaOpeningQuestions.ts`, função pura `getOpeningQuestions(role, isMobile)`, chips renderizados no `CampaignAIChat` via um `ChatChipGroup` presentacional.
- B — Catálogo hardcoded dentro do `CampaignAIChat.tsx`.
- C — Chips iguais para todos os papéis (uma lista única).
**Recomendação: A** — o catálogo é dado de configuração (regra da intenção), a função pura é unit-testável e o `ChatChipGroup` é o primitivo que o B192 reusa no mesmo slot. O `role` chega por prop do server layout → shell → context (o `CampaignAISidebarShell` já é cliente e o `CampaignPageChromeProvider` fica **fora** do alcance do chat — ele envolve só o conteúdo principal, não o painel/drawer do Sollinha; o chat não pode ler `useCampaignPageChromeRole()`).
**Rejeitadas:** B porque a curadoria vazaria para dentro de um componente de render (regra da intenção: "a lista vive como dado de configuração, não em código espalhado") e dificulta o teste; C porque viola o fail-closed de leader (B180) que a intenção explicitamente herda.
### Componentes / mudanças
- **`src/lib/sollinhaOpeningQuestions.ts`** (novo): catálogo puro e imutável + `getOpeningQuestions(role, isMobile): string[]`:
- `staff` (coordinator/advisor/candidate) — 4 chips (3 no mobile): perguntas com resposta garantida pelo toolset atual:
1. "Quem foi o deputado mais votado em Feira de Santana?" → `getTopDeputies`
2. "Quantos votos tivemos em Ilhéus em 2022?" → `getMunicipalityVotes`
3. "Quais dobradinhas temos em Salvador?" → `getDobradinhas`
4. "Como está o município de Vitória da Conquista?" → `getMunicipalityOverview`
- `leader` — 3 chips (todos seguros e respondíveis hoje):
1. "O que você sabe fazer?" → o modelo responde do próprio prompt (meta, sem tool)
2. "Me manda o link dos meus contatos" → `buildCampaignLinks` (`leaderContacts` — allowlist de leader)
3. "Me manda o link do meu perfil" → `buildCampaignLinks` (`perfil` — allowlist de leader)
- Fonte única da divisão de papéis: `isStaffCampaignRole` de `src/lib/campaignRoles.ts` (client-safe, já usado pelo shell).
- Nada de chips que dependam de tool eleitoral para leader (`getMunicipalityVotes`/`getTopDeputies`/`getLeadingMunicipalities` têm `electionDataGate` → negam).
- **`src/components/campaign/shell/ai/ChatChipGroup.tsx`** (novo, client): primitivo presentacional `{ questions, onPick, className? }` — botões pill (border, rounded-full, tokens do tema, `text-sm`, hover/focus visível), `aria-label` = próprio texto; sem estado próprio. O B192 alimenta o mesmo componente com os follow-ups extraídos da resposta.
- **`CampaignAISidebarContext.tsx`**: context ganha `role: CampaignUser['role']`; o provider recebe `role` como prop e repassa no `value`.
- **`CampaignAISidebarShell.tsx`**: aceita `role: CampaignUser['role']` (client — prop serializável) e repassa ao provider.
- **`src/app/(campaign)/campanha/(app)/layout.tsx`**: `<CampaignAISidebarShell role={user.role}>`.
- **`CampaignAIChat.tsx`**: no bloco da área do input, **acima do `<form>`**, renderiza o slot: quando `messages.length === 0 && status === 'ready'` → `ChatChipGroup` com `getOpeningQuestions(role, isMobile)` e `onPick={(q) => { sendMessage({ text: q }); }}`. Chips somem naturalmente quando `sendMessage` adiciona a mensagem do usuário (`messages.length > 0`) e durante `busy`/recording (estado não-ready não renderiza o slot).
- **Migration:** sem migration (nenhuma mudança de schema/collection).
- **Access / Consent:** nenhum — o catálogo respeita o lockdown pelo **lado da sugestão** (B180 continua sendo o enforcement no `execute` das tools; o chip é só curadoria de sugestão, fail-closed por não existir).
- **UI:** Impeccable B — shape → craft → critique → polish. Shape vem do canvas (`plan-b191-ui-draft.canvas.tsx`): pills acima do input, 4 no desktop / 3 no drawer. Craft: tokens `campaign` (border, background secundária, hover, `focus-visible`), sem sombra/animação nova, acessível (botões reais, contraste claro/escuro). Critique/polish só no que o gate de B apontar — nada de motion novo.
### Dados → forma
- Sem dados novos: chips são textos estáticos. A "forma" é o catálogo `string[]` + o comportamento de envio — nenhuma métrica, KPI ou série nova (pergunta 3 de data-presentation: N/A).
## Fases verificáveis
1. **Catálogo + testes unit (quota ~30%)** — `sollinhaOpeningQuestions.ts` + `tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts` (staff 4/3; leader nunca contém chip eleitoral; determinismo; papel desconhecido → fallback seguro).
2. **Plumbing de role** — context + shell + layout (passo pequeno, sem teste próprio; coberto por tsc/gates).
3. **UI** — `ChatChipGroup` + slot no `CampaignAIChat` (tema claro/escuro nativo, sem `dark:` manual — tokens do tema).
4. **E2E** — `tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts` (mock do `/campanha/api/ai-chat` como no `campaignAiChatResize.e2e.spec.ts`): chat vazio mostra chips (coordinator: 4; mobile: 3); tocar num chip envia a mensagem e os chips somem; leader não vê chip eleitoral e vê o conjunto seguro.
5. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não criar componente "chip" genérico além do `ChatChipGroup` presentacional (não é design system novo).
- Não personalizar por histórico/uso (fora de escopo da intenção).
- Não adicionar chips de "prioridades do momento"/"demandas em aberto" (tools inexistentes — fora de escopo explícito).
- Não tocar `systemPrompt.ts`, `route.ts`, tools ou `electionDataGate` (B180 já entregue; nada a mudar).
- Não mexer no B192 (follow-ups) — apenas deixar o `ChatChipGroup` reusável.
- Nenhuma coluna/coleção nova; nenhum endpoint novo.
## Riscos e mitigação
- **Conflito de merge com B187/B188** (mesmo `CampaignAIChat.tsx`): B187 (links) e B188 (sessão) estão `in-progress` em outras branches; este item muda o bloco do input (adição de slot acima do form) — conflito mecânico, resolvível no rebase; não mudamos render de mensagens nem persistência.
- **Advisor com escopo restrito vê chip de município fora do seu escopo** (ex.: "Como está o município de Vitória da Conquista?" para um assessor de outra região): a tool retorna "Município não encontrado"/vazio com resposta honesta (não quebra, não inventa) — aceito no v1 (mesma tolerância da curadoria por capacidade; a intenção não pede escopo no catálogo v1). Gatilho de revisitação: se virar atrito recorrente, o B192 pode instruir o modelo a sugerir só o escopo do usuário.
- **Leader pergunta algo fora do conjunto** (digitação livre): comportamento atual inalterado — tools negam com fail-closed; chips só curam a sugestão.
- **`status` não-`ready` com `messages.length === 0`**: slot não renderiza (busy/erro), sem estado morto visível.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (chips vazios, envio via sendMessage, some no primeiro envio, papel respeitado, desktop+mobile, claro/escuro)
- [x] Invariantes AGENTS/engineering-standards (identificadores em inglês, copy pt-BR, sem schema, sem Consent novo, client boundary respeitado — catálogo puro sem imports server)
- [x] Testes de domínio previstos: unit do catálogo + e2e do fluxo (role leader e staff, viewport)
- [x] B192 (follow-ups) pode reusar `ChatChipGroup` no mesmo slot sem mudança no primitivo

View file

@ -74,7 +74,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<CampaignAISidebarShell>
<CampaignAISidebarShell role={user.role}>
<CampaignPageChromeProvider role={user.role}>
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>

View file

@ -14,7 +14,9 @@ import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
import { ChatChipGroup } from '@/components/campaign/shell/ai/ChatChipGroup'
import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript'
import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions'
/**
* B188: app-internal destinations — the B162 link catalog only ever emits
@ -69,7 +71,7 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
if (!ctx) return null
const { sendMessage, status } = ctx
const { sendMessage, status, role, isMobile } = ctx
const busy = status !== 'ready'
const handleSubmit = (e: React.FormEvent) => {
@ -83,6 +85,12 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
const isTranscribing = mic.status === 'transcribing'
const micDisabled = busy || isTranscribing
// B191 — the empty conversation offers curated opening questions as chips
// above the input. They disappear on the first send because `messages` grows
// past zero (the slot renders only while empty).
const showOpeningChips = messages.length === 0 && status === 'ready'
const openingQuestions = getSollinhaOpeningQuestions(role, isMobile)
return (
<div className={cn('grid min-h-0 grid-rows-[1fr_auto]', className)}>
{/* Messages area */}
@ -174,6 +182,16 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
{/* Input area */}
<div className="shrink-0 border-t border-border px-3 py-3">
{showOpeningChips && (
<ChatChipGroup
questions={openingQuestions}
onPick={({ text }) => {
if (busy) return
sendMessage({ text })
}}
className="mb-2"
/>
)}
<form onSubmit={handleSubmit}>
<InputGroup className="h-auto min-h-10">
<textarea

View file

@ -16,6 +16,7 @@ import {
import type { PanelImperativeHandle } from 'react-resizable-panels'
import { useIsMobileMeasured } from '@/hooks/use-mobile'
import type { CampaignRole } from '@/lib/campaignRoles'
import { readSollinhaChatSession, writeSollinhaChatSession } from '@/lib/sollinhaChatSession'
type AISidebarContextValue = {
@ -24,6 +25,8 @@ type AISidebarContextValue = {
isMobile: boolean
/** The first matchMedia measurement has landed (false during the hydration frame). */
measured: boolean
/** Authenticated campaign role — drives the opening-chip curation (B191). */
role: CampaignRole
messages: UIMessage[]
status: ChatStatus
sendMessage: AbstractChat<UIMessage>['sendMessage']
@ -36,9 +39,11 @@ type AISidebarContextValue = {
const AISidebarContext = createContext<AISidebarContextValue | null>(null)
export const CampaignAISidebarProvider = ({
role,
panelRef,
children,
}: {
role: CampaignRole
panelRef: MutableRefObject<PanelImperativeHandle | null>
children: ReactNode
}) => {
@ -105,13 +110,14 @@ export const CampaignAISidebarProvider = ({
open,
isMobile,
measured,
role,
messages,
status,
sendMessage: chat.sendMessage,
setOpen,
toggle,
}),
[open, isMobile, measured, messages, status, chat.sendMessage, setOpen, toggle],
[open, isMobile, measured, role, messages, status, chat.sendMessage, setOpen, toggle],
)
return <AISidebarContext.Provider value={value}>{children}</AISidebarContext.Provider>

View file

@ -9,6 +9,7 @@ import {
type PanelImperativeHandle,
} from 'react-resizable-panels'
import type { CampaignRole } from '@/lib/campaignRoles'
import {
CHAT_MIN_PX,
getSavedChatWidthPx,
@ -28,11 +29,17 @@ import {
const CHAT_DEFAULT_PCT = '25'
const MAIN_MIN_PCT = '35'
export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) => {
export const CampaignAISidebarShell = ({
role,
children,
}: {
role: CampaignRole
children: ReactNode
}) => {
const panelRef = usePanelRef()
return (
<CampaignAISidebarProvider panelRef={panelRef}>
<CampaignAISidebarProvider role={role} panelRef={panelRef}>
<CampaignAIFab />
<AISidebarSurfaces panelRef={panelRef}>{children}</AISidebarSurfaces>
</CampaignAISidebarProvider>

View file

@ -0,0 +1,37 @@
'use client'
import { cn } from '@/lib/utils'
import type { SollinhaOpeningQuestion } from '@/lib/sollinhaOpeningQuestions'
/**
* Presentational chip group for the chat's quick-question slot (B191). Renders
* each question as a pill button; picking one sends the text through `onPick`.
* B192 feeds follow-ups from the latest answer into this same slot/component.
*/
export const ChatChipGroup = ({
questions,
onPick,
className,
}: {
questions: readonly SollinhaOpeningQuestion[]
onPick: (question: SollinhaOpeningQuestion) => void
className?: string
}) => {
if (questions.length === 0) return null
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{questions.map((question) => (
<button
key={question.text}
type="button"
onClick={() => onPick(question)}
className="rounded-full border border-border bg-secondary/50 px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
>
{question.text}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,48 @@
/**
* Curated Sollinha opening-question chips (B191).
*
* The catalog is configuration data, not UI: each question is only included
* when the current AI toolset answers it well for the actor's role. Staff see
* election/dobradinha/overview questions backed by their tools; leaders get a
* minimal safe set (meta + links via `buildCampaignLinks`, whose leader
* allowlist is home/perfil/leaderContacts) — never an election tool, which
* fails closed for the leader (B180).
*/
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
export type SollinhaOpeningQuestion = {
/** The exact text sent as the user message when the chip is picked. */
text: string
}
/** Staff (coordinator/advisor/candidate): 4 chips on wide surfaces, 3 on mobile. */
const STAFF_OPENING_QUESTIONS: readonly SollinhaOpeningQuestion[] = [
{ text: 'Quem foi o deputado mais votado em Feira de Santana?' },
{ text: 'Quantos votos tivemos em Ilhéus em 2022?' },
{ text: 'Quais dobradinhas temos em Salvador?' },
{ text: 'Como está o município de Vitória da Conquista?' },
]
/** Leader: 3 chips, all answerable by the leader-facing toolset today. */
const LEADER_OPENING_QUESTIONS: readonly SollinhaOpeningQuestion[] = [
{ text: 'O que você sabe fazer?' },
{ text: 'Me manda o link dos meus contatos' },
{ text: 'Me manda o link do meu perfil' },
]
const MOBILE_LIMIT = 3
/**
* Opening-question chips for an empty conversation. Staff get the full catalog
* (capped by viewport); any non-staff role (leader — and unknown roles as a
* fail-closed fallback) gets the safe set. Deterministic and client-safe.
*/
export const getSollinhaOpeningQuestions = (
role: CampaignRole,
isMobile: boolean,
): readonly SollinhaOpeningQuestion[] => {
const catalog = isStaffCampaignRole(role) ? STAFF_OPENING_QUESTIONS : LEADER_OPENING_QUESTIONS
return isMobile ? catalog.slice(0, MOBILE_LIMIT) : catalog
}

View file

@ -0,0 +1,126 @@
import type { Page } from '@playwright/test'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* Mock the AI endpoint with a minimal SSE stream so sending a chip neither
* needs a DeepSeek API key nor hits the real rate limiter — the user message is
* still recorded client-side, which is all these assertions need.
*/
const mockAiChat = (page: Page) =>
page.route('**/campanha/api/ai-chat', async (route) => {
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: 'data: {"type":"text","text":"Resposta mockada da Sollinha."}\n\n',
})
})
const STAFF_DESKTOP_CHIPS = [
'Quem foi o deputado mais votado em Feira de Santana?',
'Quantos votos tivemos em Ilhéus em 2022?',
'Quais dobradinhas temos em Salvador?',
'Como está o município de Vitória da Conquista?',
]
const STAFF_MOBILE_CHIPS = STAFF_DESKTOP_CHIPS.slice(0, 3)
const LEADER_CHIPS = [
'O que você sabe fazer?',
'Me manda o link dos meus contatos',
'Me manda o link do meu perfil',
]
const chipButton = (page: Page, text: string) =>
page.getByRole('button', { name: text, exact: true })
/**
* The opening chips exist in the SSR HTML, so a click that lands BEFORE
* hydration is a silent no-op (the SSR button has no handler) — the B13/B17
* flake class. Retry until the send actually lands: a sent message empties the
* slot (`messages.length > 0` unmounts the chips), which is the one
* unambiguous observable.
*/
const pickChip = async (page: Page, text: string) => {
await expect(async () => {
await chipButton(page, text).click({ timeout: 1_000 })
await expect(chipButton(page, text)).toHaveCount(0, { timeout: 1_000 })
}).toPass({ timeout: 15_000 })
}
test.describe('B191 — ações rápidas de abertura no chat Sollinha (chips de pergunta)', () => {
test.beforeEach(async ({ page }) => {
test.slow()
await mockAiChat(page)
})
test('staff no desktop vê 4 chips; tocar envia a pergunta e os chips somem', async ({
page,
campaign,
}) => {
await page.setViewportSize({ width: 1280, height: 800 })
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Chips Coordenador'),
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
for (const text of STAFF_DESKTOP_CHIPS) {
await expect(chipButton(page, text)).toBeVisible()
}
const firstChip = STAFF_DESKTOP_CHIPS[0]!
await pickChip(page, firstChip)
// The sent message is now in the conversation.
await expect(page.getByText(firstChip)).toBeVisible({ timeout: 20_000 })
for (const text of STAFF_DESKTOP_CHIPS) {
await expect(chipButton(page, text)).toHaveCount(0)
}
})
test('staff no drawer mobile vê 3 chips e eles somem após o envio', async ({
page,
campaign,
}) => {
await page.setViewportSize({ width: 500, height: 800 })
const user = await campaign.fixtures.createCampaignUser('advisor', {
name: campaign.fixtures.value('Chips Assessor'),
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })
await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click()
await expect(drawer).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
for (const text of STAFF_MOBILE_CHIPS) {
await expect(chipButton(page, text)).toBeVisible()
}
await expect(chipButton(page, STAFF_DESKTOP_CHIPS[3]!)).toHaveCount(0)
await pickChip(page, STAFF_MOBILE_CHIPS[0]!)
await expect(page.getByText(STAFF_MOBILE_CHIPS[0]!)).toBeVisible({ timeout: 20_000 })
for (const text of STAFF_MOBILE_CHIPS) {
await expect(chipButton(page, text)).toHaveCount(0)
}
})
test('leader vê o conjunto seguro — nunca uma pergunta eleitoral', async ({ page, campaign }) => {
await page.setViewportSize({ width: 1280, height: 800 })
const user = await campaign.fixtures.createCampaignUser('leader', {
name: campaign.fixtures.value('Chips Liderança'),
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
for (const text of LEADER_CHIPS) {
await expect(chipButton(page, text)).toBeVisible()
}
for (const text of STAFF_DESKTOP_CHIPS) {
await expect(chipButton(page, text)).toHaveCount(0)
}
})
})

View file

@ -0,0 +1,56 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions'
const STAFF_TEXT = [
'Quem foi o deputado mais votado em Feira de Santana?',
'Quantos votos tivemos em Ilhéus em 2022?',
'Quais dobradinhas temos em Salvador?',
'Como está o município de Vitória da Conquista?',
]
const LEADER_TEXT = [
'O que você sabe fazer?',
'Me manda o link dos meus contatos',
'Me manda o link do meu perfil',
]
describe('getSollinhaOpeningQuestions', () => {
it('offers the full curated catalog to staff on desktop', () => {
for (const role of ['coordinator', 'advisor', 'candidate'] as const) {
const questions = getSollinhaOpeningQuestions(role, false)
expect(questions.map((q) => q.text)).toEqual(STAFF_TEXT)
}
})
it('caps staff catalog to 3 chips on mobile', () => {
for (const role of ['coordinator', 'advisor', 'candidate'] as const) {
const questions = getSollinhaOpeningQuestions(role, true)
expect(questions.map((q) => q.text)).toEqual(STAFF_TEXT.slice(0, 3))
}
})
it('gives leaders the safe set — never an election question', () => {
const questions = getSollinhaOpeningQuestions('leader', false)
expect(questions.map((q) => q.text)).toEqual(LEADER_TEXT)
for (const question of questions) {
expect(question.text).not.toMatch(
/deputado mais votado|votos tivemos|dobradinhas temos|como está o município/i,
)
}
})
it('keeps the leader safe set on mobile (3 chips)', () => {
expect(getSollinhaOpeningQuestions('leader', true).map((q) => q.text)).toEqual(LEADER_TEXT)
})
it('every text is non-empty and trimmed', () => {
for (const role of ['coordinator', 'leader'] as const) {
for (const question of getSollinhaOpeningQuestions(role, false)) {
expect(question.text.trim().length).toBeGreaterThan(0)
}
}
})
})