mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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
230 lines
7.3 KiB
TypeScript
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/)
|
|
})
|
|
})
|