mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
commit
74ccce432d
9 changed files with 397 additions and 5 deletions
94
docs/plans/sollinha-acoes-rapidas-chat-impl.md
Normal file
94
docs/plans/sollinha-acoes-rapidas-chat-impl.md
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
37
src/components/campaign/shell/ai/ChatChipGroup.tsx
Normal file
37
src/components/campaign/shell/ai/ChatChipGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
src/lib/sollinhaOpeningQuestions.ts
Normal file
48
src/lib/sollinhaOpeningQuestions.ts
Normal 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
|
||||
}
|
||||
126
tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts
Normal file
126
tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
56
tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts
Normal file
56
tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue