teqo-backup/tests/unit/contentPieceFrame.unit.spec.ts
Francisco Solla dbe77f08cc
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
feat(C226): frame estático nos vídeos da Central antes do play
Peça de vídeo publicada e com arquivo passa a mostrar um frame estático
leve no card público antes do play, sem nenhum request do arquivo antes
do gesto (`preload="none"` do S27 intacto).

O frame é um derivado privado em `contentMedia` (nome determinístico),
gerado por um dono só — `ensureContentPieceFrame` — chamado pelo job
C211 (que já tem o arquivo em disco: a extração sai de graça) e pela
rota `GET /conteudos/<slug>/frame` em miss, que cura o acervo anterior
sem backfill, fila, collection nova ou migration.

A porta reusa o gate de slug publicado e o proxy de mídia privada do
`/midia`: o kill switch fecha as duas de uma vez. Sem frame possível, o
slot mostra o estado neutro honesto com play e o marcador — nunca
imagem quebrada, nunca retry.

A projeção declara o frame por elegibilidade (vídeo com arquivo), não
pela existência do derivado, então a cura é invisível para o cache da
listagem e não há tag para bustar. O frame também aparece na página da
peça e no card da home (variante compacta de 124px, cena 05).

A passagem de design (tier primário) achou dois bugs que só o browser
mostra: o glyph de imagem quebrada numa requisição que falha, e — ao
esconder a imagem até decodificar — o frame sumindo para sempre no
caminho mais rápido (uma imagem que carrega antes da hidratação não
dispara `load` nem `error` de novo; o `ref` decide os dois desfechos).

Também: o guard de falha de e2e aceita `RegExp` e confere os paths
declarados antes do caso do favicon (todo 404 do browser traz a mesma
mensagem), o manifest de e2e mapeia os módulos novos, e o job C211
passa o arquivo que já tem em disco em vez de rebaixar o vídeo.

Design tier: central-conteudos-preview-frame-video
Closes #1352
2026-09-25 14:56:06 -03:00

98 lines
3.2 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { contentPieceFramePath } from '@/lib/contentPieceCatalog'
import {
buildContentPieceFrameFfmpegArgs,
CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES,
CONTENT_PIECE_FRAME_SEEK_ATTEMPTS,
CONTENT_PIECE_FRAME_WAIT_MS,
contentPieceFrameFilename,
contentPieceFrameMediaAlt,
contentPieceFrameSizeAllowed,
} from '@/lib/contentPieceFrame'
describe('contentPieceFrameFilename / contentPieceFramePath', () => {
it('is deterministic and unique per piece (the idempotence key)', () => {
expect(contentPieceFrameFilename(42)).toBe('content-piece-frame-42.jpg')
expect(contentPieceFrameFilename(43)).toBe('content-piece-frame-43.jpg')
})
it('lives beside the media route of the same public piece', () => {
expect(contentPieceFramePath('peca-de-video')).toBe('/conteudos/peca-de-video/frame')
})
})
describe('contentPieceFrameMediaAlt', () => {
it('describes the derived row (contentMedia.alt is required)', () => {
expect(contentPieceFrameMediaAlt('Voto 1313')).toBe('Frame do vídeo «Voto 1313»')
})
})
describe('contentPieceFrameSizeAllowed', () => {
it('refuses a source the self-heal would have to download whole', () => {
expect(contentPieceFrameSizeAllowed(1024)).toBe(true)
expect(contentPieceFrameSizeAllowed(CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES)).toBe(true)
expect(contentPieceFrameSizeAllowed(CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES + 1)).toBe(false)
})
it('does not skip on an unknown size (a projection limit is not a licence)', () => {
expect(contentPieceFrameSizeAllowed(null)).toBe(true)
expect(contentPieceFrameSizeAllowed(undefined)).toBe(true)
expect(contentPieceFrameSizeAllowed(Number.NaN)).toBe(true)
})
})
describe('buildContentPieceFrameFfmpegArgs', () => {
it('seeks to the second and takes a single scaled jpeg frame', () => {
const args = buildContentPieceFrameFfmpegArgs({
inputPath: '/tmp/source.mp4',
outputPath: '/tmp/content-piece-frame-42.jpg',
atSeconds: 1,
})
expect(args).toEqual([
'-nostdin',
'-hide_banner',
'-y',
'-ss',
'1',
'-i',
'/tmp/source.mp4',
'-frames:v',
'1',
'-vf',
'scale=640:-2',
'-q:v',
'3',
'-an',
'/tmp/content-piece-frame-42.jpg',
])
// The seek is an input option: before `-i`, never after — and the command
// is an argv array, never a shell string (the `toEqual` above is the pin).
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'))
})
it('never seeks to a negative instant', () => {
const args = buildContentPieceFrameFfmpegArgs({
inputPath: 'in.mp4',
outputPath: 'out.jpg',
atSeconds: -3,
})
expect(args[args.indexOf('-ss') + 1]).toBe('0')
})
})
describe('CONTENT_PIECE_FRAME_SEEK_ATTEMPTS', () => {
it('avoids the black opening frame and retries once at zero', () => {
expect([...CONTENT_PIECE_FRAME_SEEK_ATTEMPTS]).toEqual([1, 0])
})
})
describe('CONTENT_PIECE_FRAME_WAIT_MS', () => {
it('is shorter than the acervo poster wait (here the heal may download the file)', () => {
expect(CONTENT_PIECE_FRAME_WAIT_MS).toBeLessThan(20_000)
})
})