mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
|
||
import { formatSpeechDate } from '@/lib/speechClock'
|
||
import {
|
||
buildSpeechCutAudioFfmpegArgs,
|
||
buildSpeechCutFallbackMetadata,
|
||
buildSpeechCutFfmpegArgs,
|
||
buildSpeechCutShare,
|
||
originSpeechIdsOfCuts,
|
||
SPEECH_CUT_FAILURE_GENERATING,
|
||
SPEECH_CUT_FAILURE_INTERRUPTED,
|
||
SPEECH_CUT_FAILURE_MIRROR_MISSING,
|
||
SPEECH_CUT_FAILURE_SPEECH_GONE,
|
||
SPEECH_CUT_FAILURE_UNAVAILABLE,
|
||
SPEECH_CUT_FAILURE_UNPLAYABLE,
|
||
speechCutFailureMessage,
|
||
speechCutPublicPath,
|
||
speechCutSourceKind,
|
||
speechCutStepLabel,
|
||
speechCutStepStates,
|
||
toSpeechCutLibraryItemViewModel,
|
||
toSpeechCutSummaryViewModel,
|
||
toSpeechCutViewModel,
|
||
} from '@/lib/speechCut'
|
||
|
||
const decodeWaText = (url: string): string =>
|
||
decodeURIComponent(url.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ')
|
||
|
||
describe('buildSpeechCutFfmpegArgs', () => {
|
||
const args = buildSpeechCutFfmpegArgs({
|
||
inputPath: '/tmp/source.mp4',
|
||
outputPath: '/tmp/corte-1-43-130.mp4',
|
||
startSeconds: 43,
|
||
endSeconds: 130,
|
||
})
|
||
|
||
it('seeks before the input so the re-encode starts exactly at the picked second', () => {
|
||
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'))
|
||
expect(args[args.indexOf('-ss') + 1]).toBe('43')
|
||
})
|
||
|
||
it('carries the selection duration and re-encodes (never -c copy)', () => {
|
||
expect(args[args.indexOf('-t') + 1]).toBe('87')
|
||
expect(args).toContain('libx264')
|
||
expect(args).toContain('aac')
|
||
expect(args).toContain('+faststart')
|
||
expect(args).not.toContain('copy')
|
||
})
|
||
|
||
it('maps video always and audio when present, and ends with the output path', () => {
|
||
expect(args[args.indexOf('-map') + 1]).toBe('0:v:0')
|
||
expect(args).toContain('0:a?')
|
||
expect(args.at(-1)).toBe('/tmp/corte-1-43-130.mp4')
|
||
})
|
||
|
||
it('rounds seconds and never builds a negative duration', () => {
|
||
const rounded = buildSpeechCutFfmpegArgs({
|
||
inputPath: 'in',
|
||
outputPath: 'out',
|
||
startSeconds: 42.6,
|
||
endSeconds: 130.2,
|
||
})
|
||
expect(rounded[rounded.indexOf('-ss') + 1]).toBe('43')
|
||
expect(rounded[rounded.indexOf('-t') + 1]).toBe('87')
|
||
|
||
const reversed = buildSpeechCutFfmpegArgs({
|
||
inputPath: 'in',
|
||
outputPath: 'out',
|
||
startSeconds: 130,
|
||
endSeconds: 43,
|
||
})
|
||
expect(reversed[reversed.indexOf('-t') + 1]).toBe('0')
|
||
})
|
||
})
|
||
|
||
describe('buildSpeechCutAudioFfmpegArgs', () => {
|
||
const args = buildSpeechCutAudioFfmpegArgs({
|
||
inputPath: '/tmp/source.mp3',
|
||
outputPath: '/tmp/corte-1-43-130.mp4',
|
||
startSeconds: 43,
|
||
endSeconds: 130,
|
||
})
|
||
|
||
it('maps only the audio stream (an audio-only mirror has no video)', () => {
|
||
expect(args[args.indexOf('-map') + 1]).toBe('0:a:0')
|
||
expect(args).not.toContain('0:v:0')
|
||
expect(args).not.toContain('libx264')
|
||
})
|
||
|
||
it('keeps the exact window and the AAC MP4 contract', () => {
|
||
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'))
|
||
expect(args[args.indexOf('-ss') + 1]).toBe('43')
|
||
expect(args[args.indexOf('-t') + 1]).toBe('87')
|
||
expect(args).toContain('aac')
|
||
expect(args).toContain('+faststart')
|
||
expect(args.at(-1)).toBe('/tmp/corte-1-43-130.mp4')
|
||
})
|
||
|
||
it('rounds seconds and never builds a negative duration', () => {
|
||
const reversed = buildSpeechCutAudioFfmpegArgs({
|
||
inputPath: 'in',
|
||
outputPath: 'out',
|
||
startSeconds: 130,
|
||
endSeconds: 43,
|
||
})
|
||
expect(reversed[reversed.indexOf('-t') + 1]).toBe('0')
|
||
})
|
||
})
|
||
|
||
describe('speechCutSourceKind', () => {
|
||
it('reads a web speech from its mirrored file', () => {
|
||
expect(speechCutSourceKind({ origin: 'web', mirroredMedia: 9 })).toBe('web')
|
||
expect(speechCutSourceKind({ origin: 'web', mirroredMedia: { id: 9 } })).toBe('web')
|
||
})
|
||
|
||
it('never falls back to the Câmara VOD for a web speech without a mirror', () => {
|
||
expect(
|
||
speechCutSourceKind({
|
||
origin: 'web',
|
||
mirroredMedia: null,
|
||
vodPlaybackUrl: 'https://cdn.camara.leg.br/trecho.mp4',
|
||
eventId: 1,
|
||
audioId: 2,
|
||
excerptTMs: 3,
|
||
}),
|
||
).toBeNull()
|
||
})
|
||
|
||
it('reads a Câmara speech from the stored VOD coordinates', () => {
|
||
expect(
|
||
speechCutSourceKind({
|
||
origin: 'camara',
|
||
vodPlaybackUrl: 'https://cdn.camara.leg.br/trecho.mp4',
|
||
eventId: 1,
|
||
audioId: 2,
|
||
excerptTMs: 3,
|
||
}),
|
||
).toBe('camara')
|
||
expect(speechCutSourceKind({ eventId: 1, audioId: 2, excerptTMs: 3 })).toBeNull()
|
||
expect(
|
||
speechCutSourceKind({ vodPlaybackUrl: 'https://cdn.camara.leg.br/trecho.mp4' }),
|
||
).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('buildSpeechCutFallbackMetadata', () => {
|
||
it('names the excerpt with the type and the day, and uses the official summary', () => {
|
||
expect(
|
||
buildSpeechCutFallbackMetadata({
|
||
speechType: 'BREVES COMUNICAÇÕES',
|
||
dateLabel: '11/08/2026',
|
||
summary: 'Saúde e educação em Feira de Santana.',
|
||
}),
|
||
).toEqual({
|
||
title: 'Trecho de BREVES COMUNICAÇÕES — 11/08/2026',
|
||
description: 'Saúde e educação em Feira de Santana.',
|
||
})
|
||
})
|
||
|
||
it('falls back to "fala" and to a dated sentence when there is no type or summary', () => {
|
||
expect(
|
||
buildSpeechCutFallbackMetadata({ speechType: ' ', dateLabel: '11/08/2026', summary: null }),
|
||
).toEqual({
|
||
title: 'Trecho de fala — 11/08/2026',
|
||
description: 'Trecho de fala de 11/08/2026, na Câmara dos Deputados.',
|
||
})
|
||
})
|
||
|
||
it('clips an oversized summary to the field limit', () => {
|
||
const description = buildSpeechCutFallbackMetadata({
|
||
speechType: null,
|
||
dateLabel: '11/08/2026',
|
||
summary: 'a'.repeat(5000),
|
||
}).description
|
||
expect(description).toHaveLength(2000)
|
||
})
|
||
|
||
it('never claims the Câmara when the source is a web speech (C217)', () => {
|
||
expect(
|
||
buildSpeechCutFallbackMetadata({
|
||
speechType: null,
|
||
dateLabel: '24/09/2026',
|
||
summary: null,
|
||
source: 'web',
|
||
}),
|
||
).toEqual({
|
||
title: 'Trecho de fala — 24/09/2026',
|
||
description: 'Trecho de fala de 24/09/2026, publicado na internet.',
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('speechCutStepLabel', () => {
|
||
it('renames only the resolving step on the web source', () => {
|
||
expect(speechCutStepLabel('resolving')).toBe('Localizando o trecho na Câmara')
|
||
expect(speechCutStepLabel('resolving', 'camara')).toBe('Localizando o trecho na Câmara')
|
||
expect(speechCutStepLabel('resolving', 'web')).toBe('Localizando o arquivo da fala')
|
||
expect(speechCutStepLabel('cutting', 'web')).toBe('Cortando o trecho')
|
||
})
|
||
})
|
||
|
||
describe('speechCutStepStates', () => {
|
||
it('marks the previous steps done, the current one and the rest queued', () => {
|
||
expect(speechCutStepStates('metadata').map((entry) => [entry.step, entry.state])).toEqual([
|
||
['resolving', 'done'],
|
||
['cutting', 'done'],
|
||
['metadata', 'current'],
|
||
['publishing', 'queued'],
|
||
])
|
||
})
|
||
|
||
it('queues every step when there is no current step', () => {
|
||
expect(speechCutStepStates(null).every((entry) => entry.state === 'queued')).toBe(true)
|
||
})
|
||
|
||
it('uses the web label for the resolving step only', () => {
|
||
expect(speechCutStepStates('cutting', 'web').map((entry) => entry.label)).toEqual([
|
||
'Localizando o arquivo da fala',
|
||
'Cortando o trecho',
|
||
'Gerando título e descrição',
|
||
'Publicando a página do corte',
|
||
])
|
||
})
|
||
})
|
||
|
||
describe('toSpeechCutViewModel', () => {
|
||
const view = toSpeechCutViewModel({
|
||
id: 123,
|
||
status: 'published',
|
||
step: 'publishing',
|
||
title: 'Acesso a medicamentos',
|
||
description: 'Descrição',
|
||
startSeconds: 43,
|
||
endSeconds: 118,
|
||
durationSeconds: 75,
|
||
media: { url: '/api/media/file/corte-123-43-118.mp4', filename: 'corte-123-43-118.mp4' },
|
||
speech: { youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A' },
|
||
publishedAt: '2026-09-15T12:00:00.000Z',
|
||
})
|
||
|
||
it('exposes the public path, the stored file and the YouTube cover id', () => {
|
||
expect(view.publicPath).toBe('/corte/123')
|
||
expect(view.mediaUrl).toBe('/api/media/file/corte-123-43-118.mp4')
|
||
expect(view.mediaFilename).toBe('corte-123-43-118.mp4')
|
||
expect(view.youtubeVideoId).toBe('lLhRDkSPw0A')
|
||
expect(view.durationLabel).toBe('1min15s')
|
||
})
|
||
|
||
it('never lets an unknown status/step through, and falls back to end − start', () => {
|
||
const unknown = toSpeechCutViewModel({
|
||
id: 1,
|
||
status: 'weird',
|
||
step: 'weird',
|
||
title: 't',
|
||
description: 'd',
|
||
startSeconds: 10,
|
||
endSeconds: 30,
|
||
media: null,
|
||
speech: 7,
|
||
})
|
||
expect(unknown.status).toBe('failed')
|
||
expect(unknown.step).toBeNull()
|
||
expect(unknown.durationSeconds).toBe(20)
|
||
expect(unknown.mediaUrl).toBeNull()
|
||
expect(unknown.youtubeVideoId).toBeNull()
|
||
})
|
||
|
||
it('hands a failed cut its mapped cause and never the raw error', () => {
|
||
const failed = toSpeechCutViewModel({
|
||
id: 9,
|
||
status: 'failed',
|
||
step: 'cutting',
|
||
title: 't',
|
||
description: 'd',
|
||
startSeconds: 0,
|
||
endSeconds: 20,
|
||
error: 'HTTP 403 em https://cdn.camara.leg.br/trecho.mp4',
|
||
})
|
||
|
||
expect(failed.failureMessage).toBe('Não foi possível cortar o trecho.')
|
||
expect(JSON.stringify(failed)).not.toContain('HTTP 403')
|
||
expect(view.failureMessage).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('toSpeechCutSummaryViewModel', () => {
|
||
it('emits only the fields the discovery surfaces render', () => {
|
||
const summary = toSpeechCutSummaryViewModel({
|
||
id: 7,
|
||
status: 'published',
|
||
title: 'Acesso a medicamentos',
|
||
description: 'Descrição',
|
||
startSeconds: 43,
|
||
endSeconds: 118,
|
||
durationSeconds: 75,
|
||
})
|
||
expect(summary).toEqual({
|
||
id: 7,
|
||
status: 'published',
|
||
title: 'Acesso a medicamentos',
|
||
description: 'Descrição',
|
||
durationSeconds: 75,
|
||
})
|
||
expect(Object.keys(summary).sort()).toEqual([
|
||
'description',
|
||
'durationSeconds',
|
||
'id',
|
||
'status',
|
||
'title',
|
||
])
|
||
})
|
||
|
||
it('falls back to end − start and degrades an unknown status to failed', () => {
|
||
const summary = toSpeechCutSummaryViewModel({
|
||
id: 1,
|
||
status: 'weird',
|
||
title: 't',
|
||
description: 'd',
|
||
startSeconds: 10,
|
||
endSeconds: 30,
|
||
})
|
||
expect(summary.status).toBe('failed')
|
||
expect(summary.durationSeconds).toBe(20)
|
||
})
|
||
})
|
||
|
||
describe('toSpeechCutLibraryItemViewModel origin', () => {
|
||
const base = {
|
||
id: 5,
|
||
status: 'published',
|
||
title: 'Corte',
|
||
description: 'Descrição',
|
||
startSeconds: 10,
|
||
endSeconds: 40,
|
||
durationSeconds: 30,
|
||
createdAt: '2026-09-24T12:00:00.000Z',
|
||
} as const
|
||
|
||
it('keeps the Câmara label and href byte for byte', () => {
|
||
const view = toSpeechCutLibraryItemViewModel({
|
||
...base,
|
||
speech: {
|
||
id: 7,
|
||
type: 'BREVES COMUNICAÇÕES',
|
||
phase: 'Ordem do Dia',
|
||
speechAt: '2026-08-11T18:48',
|
||
origin: 'camara',
|
||
},
|
||
})
|
||
expect(view.origin).toEqual({
|
||
id: 7,
|
||
source: 'camara',
|
||
label: 'BREVES COMUNICAÇÕES · Ordem do Dia · 11/08/2026',
|
||
platform: null,
|
||
href: '/campanha/comunicacao/acervo/7',
|
||
})
|
||
})
|
||
|
||
it('links a web origin to its own detail and carries the platform (C217)', () => {
|
||
const view = toSpeechCutLibraryItemViewModel({
|
||
...base,
|
||
speech: {
|
||
id: 42,
|
||
origin: 'web',
|
||
platform: 'youtube',
|
||
title: 'Entrevista na rádio Metrópole',
|
||
speechAt: '2026-09-20T10:00',
|
||
},
|
||
})
|
||
expect(view.origin).toEqual({
|
||
id: 42,
|
||
source: 'web',
|
||
label: 'Entrevista na rádio Metrópole',
|
||
platform: 'youtube',
|
||
href: '/campanha/comunicacao/acervo/internet/42',
|
||
})
|
||
})
|
||
|
||
it('degrades a web speech without title or platform without breaking the link', () => {
|
||
const view = toSpeechCutLibraryItemViewModel({
|
||
...base,
|
||
speech: { id: 43, origin: 'web', title: ' ', platform: null },
|
||
})
|
||
expect(view.origin).toEqual({
|
||
id: 43,
|
||
source: 'web',
|
||
label: 'Fala da internet #43',
|
||
platform: null,
|
||
href: '/campanha/comunicacao/acervo/internet/43',
|
||
})
|
||
})
|
||
|
||
it('degrades to a null origin when the source speech was deleted', () => {
|
||
expect(toSpeechCutLibraryItemViewModel({ ...base, speech: null }).origin).toBeNull()
|
||
expect(toSpeechCutLibraryItemViewModel({ ...base, speech: 7 }).origin).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('originSpeechIdsOfCuts', () => {
|
||
it('dedupes resolved ids and skips null/missing relations', () => {
|
||
expect(
|
||
originSpeechIdsOfCuts([
|
||
{ speech: 7 },
|
||
{ speech: 7 },
|
||
{ speech: 9 },
|
||
{ speech: null },
|
||
{ speech: { id: 11 } },
|
||
{},
|
||
]),
|
||
).toEqual([7, 9, 11])
|
||
})
|
||
|
||
it('caps the list at the limit and returns nothing at limit 0', () => {
|
||
const cuts = Array.from({ length: 10 }, () => ({ speech: 1 }))
|
||
cuts.push({ speech: 2 }, { speech: 3 })
|
||
expect(originSpeechIdsOfCuts(cuts, 2)).toEqual([1, 2])
|
||
expect(originSpeechIdsOfCuts(cuts, 0)).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('speechCutFailureMessage', () => {
|
||
it('maps the named causes of the first stage to honest pt-BR copy', () => {
|
||
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_SPEECH_GONE })).toBe(
|
||
SPEECH_CUT_FAILURE_SPEECH_GONE,
|
||
)
|
||
expect(speechCutFailureMessage({ error: SPEECH_VOD_INELIGIBLE_MESSAGE })).toBe(
|
||
'Esta fala não tem trecho de vídeo para cortar.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_GENERATING })).toBe(
|
||
SPEECH_CUT_FAILURE_GENERATING,
|
||
)
|
||
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_UNAVAILABLE })).toBe(
|
||
'A Câmara não disponibiliza mais o arquivo deste trecho.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_UNPLAYABLE })).toBe(
|
||
'A Câmara não disponibilizou um arquivo válido deste trecho.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_INTERRUPTED })).toBe(
|
||
SPEECH_CUT_FAILURE_INTERRUPTED,
|
||
)
|
||
})
|
||
|
||
it('maps an unnamed cause by the step where the job died', () => {
|
||
const raw = 'HTTP 403 em https://cdn.camara.leg.br/trecho.mp4'
|
||
expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).toBe(
|
||
'Não foi possível localizar o trecho na Câmara.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: raw, step: 'resolving', source: 'web' })).toBe(
|
||
'Não foi possível localizar o arquivo da fala.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: 'ffmpeg failed', step: 'cutting' })).toBe(
|
||
'Não foi possível cortar o trecho.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: 'storage down', step: 'metadata' })).toBe(
|
||
'Não foi possível guardar o arquivo do corte.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: 'storage down', step: 'publishing' })).toBe(
|
||
'Não foi possível guardar o arquivo do corte.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: 'boom', step: null })).toBe(
|
||
'Não foi possível preparar o corte.',
|
||
)
|
||
expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).not.toContain('https://')
|
||
})
|
||
|
||
it('maps the gone mirror literal to itself (C217)', () => {
|
||
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_MIRROR_MISSING })).toBe(
|
||
SPEECH_CUT_FAILURE_MIRROR_MISSING,
|
||
)
|
||
expect(SPEECH_CUT_FAILURE_MIRROR_MISSING).not.toContain('Câmara')
|
||
})
|
||
|
||
it('stays null when the row stored no cause', () => {
|
||
expect(speechCutFailureMessage({ error: null, step: 'resolving' })).toBeNull()
|
||
expect(speechCutFailureMessage({ error: ' ', step: 'resolving' })).toBeNull()
|
||
expect(speechCutFailureMessage({})).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('buildSpeechCutShare', () => {
|
||
it('bundles the title with the public link and reuses the wa.me builder', () => {
|
||
const share = buildSpeechCutShare({
|
||
title: 'Acesso a medicamentos',
|
||
url: 'https://jorgesolla1313.com.br/corte/123',
|
||
})
|
||
expect(share.message).toBe('Acesso a medicamentos https://jorgesolla1313.com.br/corte/123')
|
||
expect(share.whatsAppUrl.startsWith('https://wa.me/?text=')).toBe(true)
|
||
expect(decodeWaText(share.whatsAppUrl)).toBe(share.message)
|
||
})
|
||
|
||
it('builds the public path from the numeric id', () => {
|
||
expect(speechCutPublicPath(42)).toBe('/corte/42')
|
||
})
|
||
})
|
||
|
||
describe('formatSpeechDate', () => {
|
||
it('reads the Câmara wall clock as a day-only label', () => {
|
||
expect(formatSpeechDate('2026-08-11T18:48')).toBe('11/08/2026')
|
||
expect(formatSpeechDate('not-a-date')).toBe('not-a-date')
|
||
})
|
||
})
|