teqo-backup/tests/unit/contentPieceMedia.unit.spec.tsx
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

250 lines
8.3 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ContentPieceMedia } from '@/components/conteudos/ContentPieceMedia'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
const item = (patch: Partial<ContentPiecePublicItem> = {}): ContentPiecePublicItem => ({
id: 7,
slug: 'peca-7',
title: 'Vídeo da peça',
type: 'video',
typeLabel: 'Vídeo',
origin: 'arquivo',
originLabel: 'Arquivo',
isLink: false,
sourceUrl: null,
topics: [],
topicLabels: [],
cityLabel: null,
regionLabel: null,
institution: null,
leaderNames: [],
publicFigures: [],
description: null,
excerpt: null,
durationLabel: '02:14',
pieceDateLabel: null,
metaLabel: 'Tema · Local',
searchText: '',
themeMatch: null,
publicPath: '/conteudos/peca-7',
file: {
id: 107,
path: '/conteudos/peca-7/midia',
mimeType: 'video/mp4',
downloadFilename: 'peca-7.mp4',
},
framePath: '/conteudos/peca-7/frame',
...patch,
})
const renderSlot = (
overrides: Partial<ContentPiecePublicItem>,
playing = false,
variant: 'card' | 'thumb' | 'detail' | 'home-thumb' = 'card',
) =>
render(
<ContentPieceMedia
item={item(overrides)}
playing={playing}
onToggle={vi.fn()}
onEnded={vi.fn()}
variant={variant}
/>,
)
/** The play state belongs to the host, so the slot is driven by re-rendering. */
const playSlot = (view: { rerender: (ui: React.ReactElement) => void }, playing: boolean) =>
view.rerender(
<ContentPieceMedia item={item()} playing={playing} onToggle={vi.fn()} onEnded={vi.fn()} />,
)
const frameImage = () =>
document.querySelector<HTMLImageElement>('img[src="/conteudos/peca-7/frame"]')
afterEach(cleanup)
describe('ContentPieceMedia — the video still before the play (C226)', () => {
it('asks for the frame, never for the video file, before the gesture', () => {
renderSlot({})
expect(frameImage()).not.toBeNull()
expect(document.querySelector('video')).toBeNull()
expect(document.querySelector('audio')).toBeNull()
// S27: the element that would fetch the file only exists after the tap.
expect(document.querySelector('[src="/conteudos/peca-7/midia"]')).toBeNull()
})
it('keeps the frame out of the slot once the piece is playing', () => {
renderSlot({}, true)
expect(frameImage()).toBeNull()
const video = document.querySelector('video')
expect(video).not.toBeNull()
expect(video?.getAttribute('preload')).toBe('none')
expect(video?.getAttribute('src')).toBe('/conteudos/peca-7/midia')
})
it('falls back to the neutral slot with the designed marker when there is no frame', () => {
renderSlot({ framePath: null })
expect(frameImage()).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
// The piece type still reads on the card body tag, not inside the slot.
expect(document.querySelector('video')).toBeNull()
})
it('never retries a broken frame: the marker replaces it for the life of the card', () => {
renderSlot({})
const image = frameImage()
if (!image) throw new Error('the still was not rendered')
fireEvent.error(image)
expect(frameImage()).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
// No broken image is left in the DOM and no second request was made.
expect(document.querySelector('img')).toBeNull()
})
it('keeps the still invisible until it decodes (no broken-image glyph ever)', () => {
renderSlot({})
const image = frameImage()
if (!image) throw new Error('the still was not rendered')
// Loading: the neutral surface is what the visitor sees — the image never
// paints over it before the browser decodes it.
expect(image.className).toContain('opacity-0')
expect(screen.queryByText('Frame indisponível')).toBeNull()
fireEvent.load(image)
expect(image.className).not.toContain('opacity-0')
})
/**
* The browser fires neither `load` nor `error` again for an image that
* already settled before hydration — the ref is the only thing that can tell
* a decoded still from a failed one, and both outcomes are the fast path.
*/
const withSettledImage = (naturalWidth: number, run: () => void) => {
const complete = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'complete')
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
configurable: true,
get: () => true,
})
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
configurable: true,
get: () => naturalWidth,
})
try {
run()
} finally {
if (complete) {
Object.defineProperty(HTMLImageElement.prototype, 'complete', complete)
} else {
delete (HTMLImageElement.prototype as unknown as Record<string, unknown>).complete
}
delete (HTMLImageElement.prototype as unknown as Record<string, unknown>).naturalWidth
}
}
it('shows a still that was already decoded before hydration (warm cache)', () => {
withSettledImage(640, () => {
renderSlot({})
const image = frameImage()
if (!image) throw new Error('the still was not rendered')
expect(image.className).not.toContain('opacity-0')
})
})
it('falls back when the still had already failed before hydration', () => {
withSettledImage(0, () => {
renderSlot({})
expect(frameImage()).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
})
})
it('never asks again after a play/stop cycle (the state outlives the layer)', () => {
const view = renderSlot({})
const still = frameImage()
if (!still) throw new Error('the still was not rendered')
fireEvent.error(still)
playSlot(view, true)
playSlot(view, false)
// The knowledge that this piece has no frame belongs to the card, not to
// the layer that remounts on every play — so the still is never requested
// a second time.
expect(document.querySelector('img')).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
})
it('keeps an audio piece exactly as it was (no frame, no marker)', () => {
renderSlot({
type: 'audio',
typeLabel: 'Áudio',
framePath: null,
file: {
id: 107,
path: '/conteudos/peca-7/midia',
mimeType: 'audio/mpeg',
downloadFilename: 'peca-7.mp3',
},
})
expect(document.querySelector('img')).toBeNull()
expect(screen.queryByText('Frame indisponível')).toBeNull()
expect(screen.getByText('Áudio')).toBeDefined()
})
it('drops the marker on the 124px home thumb and shrinks the play (CENA 05)', () => {
renderSlot({ framePath: null }, false, 'home-thumb')
// The neutral surface IS the message there: the marker would crowd the play.
// The compact classes stay in the DOM for the `sm` breakpoint, where that
// card becomes a full block and the full treatment returns.
const marker = screen.getByText('Frame indisponível').closest('span')
expect(marker?.className).toContain('hidden')
expect(marker?.className).toContain('sm:flex')
expect(document.querySelector('button[aria-label^="Reproduzir"]')?.className).toContain(
'size-11',
)
expect(document.querySelector('span.rounded.bg-black\\/70')?.className).toContain('text-[10px]')
})
it('keeps the full marker on the catalogue card', () => {
renderSlot({ framePath: null })
const marker = screen.getByText('Frame indisponível').closest('span')
expect(marker?.className).toContain('flex')
expect(marker?.className).not.toContain('hidden')
})
it('keeps the marker and its 28px glyph on the piece page', () => {
renderSlot({ framePath: null }, false, 'detail')
expect(screen.getByText('Frame indisponível')).toBeDefined()
expect(document.querySelector('span.grid.size-7')).not.toBeNull()
})
it('gives a photo piece no frame at all', () => {
renderSlot({
type: 'foto',
typeLabel: 'Foto',
framePath: null,
file: {
id: 107,
path: '/conteudos/peca-7/midia',
mimeType: 'image/png',
downloadFilename: 'peca-7.png',
},
})
expect(document.querySelectorAll('img')).toHaveLength(1)
expect(document.querySelector('img')?.getAttribute('src')).toBe('/conteudos/peca-7/midia')
})
})