teqo-backup/tests/unit/recording.unit.spec.ts

197 lines
7.2 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
canRetryRecording,
canServeRecordingMedia,
formatRecordingFileSize,
isRecordingStatus,
RECORDING_FAILURE_INTERRUPTED,
RECORDING_MAX_BYTES,
RECORDING_MAX_SIZE_LABEL,
RECORDING_STATUSES,
RECORDING_STEPS,
recordingFailureMessage,
recordingFileTypeAllowed,
recordingStatusLabels,
recordingTooLargeMessage,
recordingUploadTooLargeMessage,
toRecordingViewModel,
} from '@/lib/recording'
import {
buildRecordingAudioFfmpegArgs,
buildRecordingFullAudioFfmpegArgs,
mergeChunkTranscriptions,
RECORDING_AUDIO_CHUNK_SECONDS,
recordingSearchText,
} from '@/lib/recordingTranscription'
describe('recording vocabulary (C199)', () => {
it('pins the statuses, steps and their labels', () => {
expect(RECORDING_STATUSES).toEqual(['uploading', 'processing', 'ready', 'failed'])
expect(Object.keys(recordingStatusLabels).sort()).toEqual([...RECORDING_STATUSES].sort())
expect(RECORDING_STEPS).toEqual(['extracting', 'transcribing', 'saving'])
expect(isRecordingStatus('failed')).toBe(true)
expect(isRecordingStatus('published')).toBe(false)
})
it('serves media from processing on, never during the upload stream', () => {
expect(canServeRecordingMedia('uploading')).toBe(false)
expect(canServeRecordingMedia('processing')).toBe(true)
expect(canServeRecordingMedia('failed')).toBe(true)
expect(canServeRecordingMedia('ready')).toBe(true)
})
it('only offers retry for a failed transcription', () => {
expect(canRetryRecording('failed')).toBe(true)
expect(canRetryRecording('ready')).toBe(false)
expect(canRetryRecording('processing')).toBe(false)
})
it('maps the failure cause to honest copy', () => {
expect(
recordingFailureMessage({ error: RECORDING_FAILURE_INTERRUPTED, step: 'transcribing' }),
).toBe(RECORDING_FAILURE_INTERRUPTED)
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'extracting' })).toBe(
'Não foi possível preparar o áudio da gravação.',
)
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'transcribing' })).toBe(
'Não foi possível transcrever a gravação.',
)
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'saving' })).toBe(
'Não foi possível guardar a transcrição.',
)
expect(recordingFailureMessage({ error: 'raw detail', step: null })).toBe(
'Não foi possível transcrever a gravação.',
)
expect(recordingFailureMessage({ error: null, step: 'transcribing' })).toBeNull()
expect(recordingFailureMessage({ error: ' ', step: 'transcribing' })).toBeNull()
})
it('accepts videos and unknown types, refuses every other type', () => {
expect(recordingFileTypeAllowed('video/mp4')).toBe(true)
expect(recordingFileTypeAllowed('video/quicktime')).toBe(true)
expect(recordingFileTypeAllowed('')).toBe(true)
expect(recordingFileTypeAllowed(null)).toBe(true)
expect(recordingFileTypeAllowed('text/plain')).toBe(false)
expect(recordingFileTypeAllowed('image/png')).toBe(false)
})
it('formats sizes in pt-BR and names the limit', () => {
expect(formatRecordingFileSize(4.6 * 1024 ** 3)).toContain('GB')
expect(formatRecordingFileSize(812 * 1024 ** 2)).toBe('812 MB')
expect(recordingTooLargeMessage(4.6 * 1024 ** 3)).toContain('4,6 GB')
expect(recordingTooLargeMessage(4.6 * 1024 ** 3)).toContain(RECORDING_MAX_SIZE_LABEL)
expect(recordingUploadTooLargeMessage).toContain(RECORDING_MAX_SIZE_LABEL)
expect(RECORDING_MAX_BYTES).toBe(4 * 1024 ** 3)
})
it('formats the byte range below 1 KB honestly', () => {
expect(formatRecordingFileSize(0)).toBe('0 B')
expect(formatRecordingFileSize(900)).toBe('900 B')
expect(formatRecordingFileSize(2048)).toBe('2 KB')
})
it('builds the view model with labels and hrefs', () => {
const view = toRecordingViewModel({
id: 7,
title: ' Plenária da Comissão ',
status: 'ready',
step: null,
recordedAt: '2026-09-01T00:00:00.000Z',
durationSeconds: 3725,
})
expect(view.title).toBe('Plenária da Comissão')
expect(view.recordedAtLabel).toBe('01/09/2026')
expect(view.durationLabel).toBe('1:02:05')
expect(view.detailHref).toBe('/campanha/comunicacao/acervo/gravacoes/7')
expect(view.fileHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/arquivo')
expect(view.downloadHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/arquivo?download=1')
expect(view.retryHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/retry')
expect(view.deleteHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/apagar')
const degraded = toRecordingViewModel({
id: 8,
title: '',
status: 'stray',
step: 'stray',
recordedAt: null,
durationSeconds: null,
})
expect(degraded.title).toBe('Gravação 8')
expect(degraded.status).toBe('failed')
expect(degraded.step).toBeNull()
expect(degraded.recordedAtLabel).toBeNull()
expect(degraded.durationLabel).toBeNull()
})
})
describe('recording transcription (C199)', () => {
it('builds the extract-and-split ffmpeg command', () => {
const args = buildRecordingAudioFfmpegArgs({
inputPath: '/tmp/in.mkv',
outputPattern: '/tmp/chunk-%03d.mp3',
})
expect(args).toContain('-vn')
expect(args).toContain('libmp3lame')
expect(args[args.indexOf('-segment_time') + 1]).toBe(String(RECORDING_AUDIO_CHUNK_SECONDS))
expect(args.at(-1)).toBe('/tmp/chunk-%03d.mp3')
})
it('merges chunks with absolute offsets, contiguous order and clamped spans', () => {
const merged = mergeChunkTranscriptions([
{
offsetSeconds: 0,
segments: [
{ start: 1, end: 4, text: 'primeiro trecho' },
{ start: 4, end: 2, text: 'clamp aqui' },
{ start: 5, end: 6, text: ' ' },
],
},
{
offsetSeconds: 1200,
segments: [{ start: 0.5, end: 3, text: 'segundo chunk' }],
},
])
expect(merged).toHaveLength(3)
expect(merged[0]).toEqual({
order: 0,
startSeconds: 1,
endSeconds: 4,
text: 'primeiro trecho',
})
expect(merged[1]).toEqual({ order: 1, startSeconds: 4, endSeconds: 4, text: 'clamp aqui' })
expect(merged[2]).toMatchObject({
order: 2,
startSeconds: 1200.5,
endSeconds: 1203,
text: 'segundo chunk',
})
})
it('normalizes the concatenated search text', () => {
expect(recordingSearchText([{ text: 'Ação da Comissão' }, { text: ' de EDUCAÇÃO ' }])).toBe(
'acao da comissao de educacao',
)
})
it('returns nothing for an empty transcription', () => {
expect(mergeChunkTranscriptions([])).toEqual([])
expect(recordingSearchText([])).toBe('')
})
it('builds the single-file extraction for diarization (C200)', () => {
const args = buildRecordingFullAudioFfmpegArgs({
inputPath: '/tmp/in.mkv',
outputPath: '/tmp/full.mp3',
})
expect(args).toContain('-vn')
expect(args).toContain('libmp3lame')
// One output, no segmentation: speaker numbering needs the whole audio.
expect(args).not.toContain('-f')
expect(args).not.toContain('-segment_time')
expect(args.at(-1)).toBe('/tmp/full.mp3')
})
})