teqo-backup/tests/unit/speechViewModels.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

259 lines
9.6 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { speechPosterHref } from '@/lib/speechPoster'
import {
toSpeechListItemViewModel,
toWebSpeechDetailViewModel,
toWebSpeechListItemViewModel,
type SpeechListRecord,
type WebSpeechDetailRecord,
type WebSpeechListRecord,
} from '@/utilities/speech/speechViewModels'
const viewModel = (overrides: Partial<SpeechListRecord> = {}) =>
toSpeechListItemViewModel({
speech: { id: 1, speechAt: '2026-08-11T18:48', ...overrides },
segments: [],
municipalityLabels: new Map(),
})
describe('toSpeechListItemViewModel thumbnail (C175)', () => {
it('carries the YouTube cover when the session link parses', () => {
const row = viewModel({ youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A' })
expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
})
it('has no thumbnail without a YouTube link', () => {
expect(viewModel().thumbnailUrl).toBeNull()
expect(viewModel({ youtubeUrl: null }).thumbnailUrl).toBeNull()
expect(viewModel({ youtubeUrl: 'https://camara.leg.br/discurso' }).thumbnailUrl).toBeNull()
})
it('keeps the source link independent from the thumbnail', () => {
const row = viewModel({
youtubeUrl: 'https://youtu.be/lLhRDkSPw0A',
officialTextUrl: 'https://camara.leg.br/discurso',
})
expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
expect(row.officialTextUrl).toBe('https://camara.leg.br/discurso')
})
it('has no source link without the official text, never the YouTube fallback (C177)', () => {
expect(viewModel().officialTextUrl).toBeNull()
expect(viewModel({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' }).officialTextUrl).toBeNull()
expect(viewModel({ officialTextUrl: 'https://camara.leg.br/discurso' }).officialTextUrl).toBe(
'https://camara.leg.br/discurso',
)
})
})
describe('toSpeechListItemViewModel thumbnail (C182 — frame of the speech)', () => {
const coordinates = { eventId: 67091, audioId: 558641, excerptTMs: 1675801808560 }
const cover = 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg'
const youtubeUrl = 'https://www.youtube.com/watch?v=lLhRDkSPw0A'
it('prefers the frame route over the session cover when the Câmara can resolve the excerpt', () => {
const row = viewModel({ id: 42, durationSeconds: 252, ...coordinates, youtubeUrl })
expect(row.thumbnailUrl).toBe(speechPosterHref(42))
})
it('falls back to the session cover without coordinates or without a duration', () => {
expect(viewModel({ durationSeconds: 252, youtubeUrl }).thumbnailUrl).toBe(cover)
expect(viewModel({ durationSeconds: 0, ...coordinates, youtubeUrl }).thumbnailUrl).toBe(cover)
expect(viewModel({ durationSeconds: 252, eventId: 67091 }).thumbnailUrl).toBeNull()
})
})
describe('toSpeechListItemViewModel semantic evidence (C229)', () => {
const segment = (text: string, startSeconds = 0) => ({
startSeconds,
endSeconds: startSeconds + 3,
text,
})
const semanticRow = ({
text,
query,
evidence,
keywords = [],
searchText,
}: {
text: string
query?: string
evidence?: { text: string; startSeconds: number | null } | null
keywords?: string[]
searchText?: string
}) =>
toSpeechListItemViewModel({
speech: { id: 1, speechAt: '2026-08-11T18:48', keywords, searchText },
segments: [segment(text)],
query,
municipalityLabels: new Map(),
semanticMatch: Boolean(evidence),
semanticEvidence: evidence,
})
it('shows the semantic passage with no highlight when no term matches', () => {
const row = semanticRow({
text: 'Uma fala sobre outro assunto',
query: 'combate à oposição',
evidence: { text: 'O embate com a oposição e o bolsonarismo', startSeconds: 42 },
})
expect(row.matchKind).toBe('theme')
expect(row.semanticMatch).toBe(true)
expect(row.matchedTextSearch).toBe(false)
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(false)
expect(row.excerpt.parts.map((part) => part.text).join('')).toContain('bolsonarismo')
expect(row.watchHref).toBe(
'/campanha/comunicacao/acervo/1?t=42&q=combate+%C3%A0+oposi%C3%A7%C3%A3o',
)
})
it('keeps the literal match flag when the speech also contains the query', () => {
const row = semanticRow({
text: 'A defesa do SUS e da saúde pública baiana',
searchText: 'a defesa do sus e da saude publica baiana',
query: 'SUS',
evidence: { text: 'A defesa do SUS', startSeconds: 0 },
})
expect(row.semanticMatch).toBe(true)
expect(row.matchedTextSearch).toBe(true)
// The evidence never highlights the term: the engine did not match it.
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(false)
})
it('stays literal without semantic evidence', () => {
const row = semanticRow({
text: 'A defesa do SUS',
searchText: 'a defesa do sus',
query: 'SUS',
})
expect(row.semanticMatch).toBe(false)
expect(row.matchKind).toBe('segment')
expect(row.matchedTextSearch).toBe(true)
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('seeks the evidence start when known and omits the seek without one', () => {
const window = semanticRow({
text: 'Outra fala',
query: 'tema',
evidence: { text: 'Trecho de janela', startSeconds: null },
})
expect(window.watchHref).toBe('/campanha/comunicacao/acervo/1?q=tema')
})
it('flags a degraded literal row without claiming the theme', () => {
const row = toSpeechListItemViewModel({
speech: { id: 1, speechAt: '2026-08-11T18:48', searchText: 'a defesa do sus' },
segments: [segment('A defesa do SUS')],
query: 'SUS',
municipalityLabels: new Map(),
literalFallback: true,
})
expect(row.literalFallback).toBe(true)
expect(row.semanticMatch).toBe(false)
expect(row.matchKind).toBe('segment')
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
})
// C216 — the web speeches view models: platform label, day-only date, the
// private cover href, the media kind of the detail and the origin attribution.
describe('web speech view models (C216)', () => {
const webListRow = (overrides: Partial<WebSpeechListRecord> = {}) =>
toWebSpeechListItemViewModel({
speech: {
id: 7,
speechAt: '2026-09-20T00:00',
title: 'Entrevista na rádio',
platform: 'radio',
durationSeconds: 90,
topics: ['saude'],
scopes: ['bahia'],
...overrides,
},
segments: [],
query: undefined,
})
it('labels the platform and the day-only publication date', () => {
const row = webListRow()
expect(row.platform).toEqual({ value: 'radio', label: 'Rádio' })
expect(row.dateLabel).toBe('20/09/2026')
expect(row.durationLabel).toBe('1min30s')
expect(row.title).toBe('Entrevista na rádio')
})
it('falls back for a row without a platform or title', () => {
const row = webListRow({ platform: null, title: ' ' })
expect(row.platform).toEqual({ value: null, label: 'Outra plataforma' })
expect(row.title).toBe('Fala da internet #7')
})
it('links the private cover only when the ingestion captured a thumbnail', () => {
expect(webListRow().thumbnailUrl).toBeNull()
expect(webListRow({ thumbnail: 12 }).thumbnailUrl).toBe(
'/campanha/comunicacao/acervo/internet/7/capa',
)
expect(webListRow({ thumbnail: { id: 12 } }).thumbnailUrl).toBe(
'/campanha/comunicacao/acervo/internet/7/capa',
)
})
it('builds the detail href with the segment seek and keeps the query', () => {
expect(webListRow().watchHref).toBe('/campanha/comunicacao/acervo/internet/7')
const row = toWebSpeechListItemViewModel({
speech: { id: 7, speechAt: '2026-09-20T00:00', title: 'Fala' },
segments: [{ startSeconds: 12.7, endSeconds: 15, text: 'A saúde pública importa' }],
query: 'saúde',
})
expect(row.watchHref).toBe('/campanha/comunicacao/acervo/internet/7?t=12&q=sa%C3%BAde')
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('picks the native control from the stored mime type', () => {
const detail = (mirroredMedia: WebSpeechDetailRecord['mirroredMedia']) =>
toWebSpeechDetailViewModel({
speech: {
id: 7,
speechAt: '2026-09-20T00:00',
title: 'Entrevista na rádio',
platform: 'radio',
channel: 'Rádio Metrópole',
sourceUrl: 'https://radio.example/entrevista',
durationSeconds: 87,
mirroredMedia,
},
segments: [{ startSeconds: 0, endSeconds: 4, text: 'A saúde pública' }],
})
const audio = detail({ id: 12, mimeType: 'audio/mpeg', filename: 'source.mp3' })
expect(audio.mediaKind).toBe('audio')
expect(audio.fileHref).toBe('/campanha/comunicacao/acervo/internet/7/arquivo')
expect(audio.downloadHref).toBe('/campanha/comunicacao/acervo/internet/7/arquivo?download=1')
expect(audio.channel).toBe('Rádio Metrópole')
expect(audio.sourceUrl).toBe('https://radio.example/entrevista')
expect(audio.dateLabel).toBe('20/09/2026')
expect(audio.durationSeconds).toBe(87)
expect(audio.durationLabel).toBe('1min27s')
expect(audio.segments[0]?.startLabel).toBe('00:00')
const video = detail({ id: 12, mimeType: 'video/mp4', filename: 'source.mp4' })
expect(video.mediaKind).toBe('video')
const missing = detail(null)
expect(missing.mediaKind).toBe('video')
expect(missing.fileHref).toBeNull()
expect(missing.downloadHref).toBeNull()
})
})