teqo-backup/tests/unit/speechResultCards.unit.spec.ts
Francisco Solla 3e8429daae feat(C229): busca por sentido no acervo de falas
Troca o motor do modo tema (Câmara + internet) de expansão lexical para
embeddings (Deep Infra, BAAI/bge-m3), com índice próprio (speechEmbedding),
ranking por proximidade em memória, evidência real do trecho mais próximo,
vazio honesto por limiar e degradação para a busca literal. Inclui a
collection oculta + migration, o CLI pnpm acervo:index (--dry-run/--limit/
--source/--force/--probe), o port do design aprovado e a suíte de testes.

Pós-rebase: o snapshot da migration seguinte (20260926_062039_add_archive_photo,
do C231 já na main) foi regenerado para incluir speech_embedding — sem ele o
próximo migrate:create recriaria a tabela.

Closes #1360
2026-09-26 11:59:21 -03:00

230 lines
7.3 KiB
TypeScript

import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { SpeechResultCard } from '@/components/campaign/speech/SpeechResultCard'
import { WebSpeechResultCard } from '@/components/campaign/speech/WebSpeechResultCard'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
import type {
SpeechListItemViewModel,
WebSpeechListItemViewModel,
} from '@/utilities/speech/speechViewModels'
import { stub } from '../helpers/stub'
const mockAppRouter = stub<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
push: () => {},
replace: () => {},
refresh: () => {},
forward: () => {},
back: () => {},
prefetch: () => Promise.resolve(),
})
const renderWithAppRouter = (element: Parameters<typeof renderToStaticMarkup>[0]) =>
renderToStaticMarkup(createElement(AppRouterContext.Provider, { value: mockAppRouter }, element))
const excerpt = (overrides: Partial<SpeechHighlightedExcerpt> = {}): SpeechHighlightedExcerpt => ({
parts: [
{ text: 'um trecho sobre ', highlighted: false },
{ text: 'saúde', highlighted: true },
],
truncatedStart: true,
truncatedEnd: true,
...overrides,
})
const speech = (overrides: Partial<SpeechListItemViewModel> = {}): SpeechListItemViewModel => ({
id: 1,
speechAtLabel: '11/08/2026',
type: 'Discurso',
durationLabel: '2min',
presidingOfficer: null,
excerpt: excerpt(),
matchKind: 'segment',
topics: [
{ value: 'saude', label: 'Saúde' },
{ value: 'educacao', label: 'Educação' },
{ value: 'cultura', label: 'Cultura' },
{ value: 'esporte', label: 'Esporte' },
],
scopes: [
{ value: 'bahia', label: 'Bahia' },
{ value: 'brasil', label: 'Brasil' },
{ value: 'internacional', label: 'Internacional' },
],
keywords: ['sus', 'vacina', 'atenção básica', 'orçamento'],
municipalities: [],
thumbnailUrl: null,
watchHref: '/campanha/comunicacao/acervo/1',
officialTextUrl: null,
cuts: [],
matchedTextSearch: true,
semanticMatch: false,
literalFallback: false,
...overrides,
})
const webSpeech = (
overrides: Partial<WebSpeechListItemViewModel> = {},
): WebSpeechListItemViewModel => ({
id: 9,
title: 'Fala na internet',
platform: { value: 'youtube', label: 'YouTube' },
dateLabel: '12/09/2026',
durationLabel: '5min',
excerpt: excerpt(),
topics: [
{ value: 'saude', label: 'Saúde' },
{ value: 'educacao', label: 'Educação' },
{ value: 'cultura', label: 'Cultura' },
{ value: 'esporte', label: 'Esporte' },
],
scopes: [
{ value: 'bahia', label: 'Bahia' },
{ value: 'brasil', label: 'Brasil' },
{ value: 'internacional', label: 'Internacional' },
],
thumbnailUrl: null,
watchHref: '/campanha/comunicacao/acervo/internet/9',
semanticMatch: false,
literalFallback: false,
matchedTextSearch: false,
...overrides,
})
describe('speech result cards excerpt', () => {
it('renders the truncated excerpt with the highlighted part', () => {
const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
expect(html).toContain('text-sm leading-relaxed text-foreground/90')
expect(html).toContain('… ')
expect(html).toContain(' …')
expect(html).toContain('<mark')
expect(html).toContain('saúde')
})
it('omits the excerpt when there is nothing to show', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, {
speech: speech({
excerpt: { parts: [], truncatedStart: false, truncatedEnd: false },
}),
}),
)
expect(html).not.toContain('um trecho sobre')
expect(html).not.toContain('text-sm leading-relaxed')
})
it('shows the semantic evidence block with the seal and without a highlight', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, {
speech: speech({
semanticMatch: true,
excerpt: {
parts: [{ text: 'O embate com a oposição', highlighted: false }],
truncatedStart: false,
truncatedEnd: true,
},
}),
}),
)
expect(html).toContain('Trecho mais próximo do tema')
expect(html).toContain('Tema')
expect(html).toContain('“')
expect(html).toContain('”')
expect(html).not.toContain('<mark')
// The evidence block replaces the common excerpt — they never coexist.
expect(html).not.toContain('text-sm leading-relaxed')
})
it('shows the term seal alongside the theme seal when the query also matches', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, {
speech: speech({ semanticMatch: true, matchedTextSearch: true }),
}),
)
expect(html).toContain('Tema')
expect(html).toContain('Termo exato')
})
it('shows the same semantic evidence on the web card', () => {
const html = renderWithAppRouter(
createElement(WebSpeechResultCard, {
speech: webSpeech({
semanticMatch: true,
excerpt: {
parts: [{ text: 'O embate com a oposição', highlighted: false }],
truncatedStart: false,
truncatedEnd: true,
},
}),
}),
)
expect(html).toContain('Trecho mais próximo do tema')
expect(html).toContain('Tema')
expect(html).not.toContain('<mark')
expect(html).toContain('Fala na internet')
})
it('labels a degraded literal card with the term seal', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, { speech: speech({ literalFallback: true }) }),
)
expect(html).toContain('data-testid="speech-literal-badge"')
expect(html).toContain('Termo exato')
expect(html).not.toContain('Trecho mais próximo do tema')
})
it('keeps the web card own excerpt classes', () => {
const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
expect(html).toContain('mt-2 text-sm leading-6 text-foreground/90')
})
})
describe('speech result cards chips', () => {
it('caps each group and sums every hidden chip on the Câmara card', () => {
const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
expect(html).toContain('Saúde')
expect(html).toContain('Bahia')
expect(html).toContain('sus')
expect(html).not.toContain('Esporte')
expect(html).not.toContain('Internacional')
expect(html).not.toContain('orçamento')
expect(html).toContain('+3')
})
it('sums only the groups the web card has', () => {
const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
expect(html).toContain('Saúde')
expect(html).toContain('Bahia')
expect(html).not.toContain('Esporte')
expect(html).not.toContain('Internacional')
expect(html).toContain('+2')
expect(html).not.toContain('sus')
})
it('omits the +N badge when every chip fits', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, {
speech: speech({
topics: [{ value: 'saude', label: 'Saúde' }],
scopes: [{ value: 'bahia', label: 'Bahia' }],
keywords: [],
}),
}),
)
expect(html).not.toMatch(/\+\d/)
})
})