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

332 lines
10 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import type {
BahiaMunicipalityFeature,
BahiaTerritoryFeature,
MunicipalityZoneFeature,
} from '@/lib/bahiaGeometriesTypes'
import { toContentPiecePublicItem, type ContentPiecePublicSource } from '@/lib/contentPieceCatalog'
import {
resolveContentPieceHomeVisitor,
selectContentPieceHomeItems,
toContentPieceHomeItem,
type ContentPieceHomeItem,
} from '@/lib/contentPieceHomeSelection'
const source = (patch: Partial<ContentPiecePublicSource> = {}): ContentPiecePublicSource => ({
id: 1,
slug: 'fim-da-escala-6x1',
title: 'Fim da escala 6x1 é saúde',
type: 'video',
status: 'publicado',
origin: 'arquivo',
topics: ['economia-trabalho'],
cityLabel: 'Feira de Santana',
region: 'Portal do Sertão',
description: 'Solla explica a jornada.',
transcript: 'A redução da jornada é saúde.',
durationSeconds: 134,
searchText: 'fim da escala 6x1 e saude feira de santana',
media: { id: 7, filename: 'Fim da Escala.MP4' },
...patch,
})
const homeItem = (patch: Partial<ContentPiecePublicSource> = {}): ContentPieceHomeItem => {
const item = toContentPiecePublicItem(source(patch))
if (!item) throw new Error('fixture should be public')
return toContentPieceHomeItem(item)
}
/** Axis-aligned square in degrees — small enough that planar reasoning holds. */
const square = <Properties extends Record<string, unknown>>(
properties: Properties,
{ west, south, size }: { west: number; south: number; size: number },
) => ({
type: 'Feature' as const,
properties,
geometry: {
type: 'Polygon' as const,
coordinates: [
[
[west, south],
[west + size, south],
[west + size, south + size],
[west, south + size],
[west, south],
],
],
},
})
const municipalitySquare = (
codarea: string,
name: string,
box: { west: number; south: number; size: number },
): BahiaMunicipalityFeature => square({ codarea, name }, box)
const zoneSquare = (
municipalitySlug: string,
name: string,
ibgeCode: string,
box: { west: number; south: number; size: number },
): MunicipalityZoneFeature => square({ municipalitySlug, name, ibgeCode }, box)
const territorySquare = (
code: string,
name: string,
box: { west: number; south: number; size: number },
): BahiaTerritoryFeature => square({ code, name }, box)
describe('content piece home selection', () => {
describe('toContentPieceHomeItem', () => {
it('projects exactly the card fields — the search haystack never rides along', () => {
const item = homeItem()
expect(Object.keys(item).sort()).toEqual([
'cityLabel',
'durationLabel',
'excerpt',
'file',
'framePath',
'id',
'isLink',
'metaLabel',
'origin',
'originLabel',
'publicPath',
'regionLabel',
'slug',
'title',
'type',
'typeLabel',
])
expect('searchText' in item).toBe(false)
expect('description' in item).toBe(false)
expect('topics' in item).toBe(false)
})
it('carries the frame of a video piece into the home card (C226)', () => {
// The field-by-field map is what stops a new public field from leaking by
// spread, so the copy is pinned here on purpose.
expect(
homeItem({
id: 3,
slug: 'peca-video',
type: 'video',
media: { id: 7, filename: 'video.mp4', mimeType: 'video/mp4' },
}).framePath,
).toBe('/conteudos/peca-video/frame')
expect(
homeItem({
id: 4,
slug: 'peca-foto',
type: 'foto',
media: { id: 8, filename: 'foto.png', mimeType: 'image/png' },
}).framePath,
).toBeNull()
})
it('keeps a link piece with no archived file — the platform handoff', () => {
const item = homeItem({
id: 9,
slug: 'peca-link',
origin: 'instagram',
media: null,
sourceUrl: 'https://www.instagram.com/p/abc/',
})
expect(item.isLink).toBe(true)
expect(item.file).toBeNull()
})
})
describe('selectContentPieceHomeItems', () => {
const local = homeItem({ id: 1, slug: 'local', cityLabel: 'Feira de Santana' })
const regional = homeItem({
id: 2,
slug: 'regional',
cityLabel: 'Serrinha',
region: 'Portal do Sertão',
})
const other = homeItem({ id: 3, slug: 'outro', cityLabel: 'Ilhéus', region: 'Litoral Sul' })
const newest = homeItem({ id: 4, slug: 'recente', cityLabel: null, region: null })
const visitor = { municipalityName: 'Feira de Santana', region: 'Portal do Sertão' }
it('orders município → região → recentes and annotates every match', () => {
const selection = selectContentPieceHomeItems([other, regional, local], visitor)
expect(selection.map((entry) => [entry.item.slug, entry.match])).toEqual([
['local', 'municipality'],
['regional', 'region'],
['outro', 'recent'],
])
})
it('never repeats a piece that matches more than one bucket', () => {
const both = homeItem({ id: 5, slug: 'both' })
const selection = selectContentPieceHomeItems([both, local], visitor)
expect(selection.map((entry) => entry.item.slug)).toEqual(['both', 'local'])
expect(selection[0]?.match).toBe('municipality')
})
it('keeps the newest-first order inside each bucket and caps at the limit', () => {
const localNewer = homeItem({ id: 11, slug: 'local-nova' })
const localOlder = homeItem({ id: 12, slug: 'local-antiga' })
const selection = selectContentPieceHomeItems([localNewer, localOlder, regional], visitor, 2)
expect(selection.map((entry) => entry.item.slug)).toEqual(['local-nova', 'local-antiga'])
expect(
selectContentPieceHomeItems([localNewer, localOlder, regional], visitor, 1).map(
(entry) => entry.item.slug,
),
).toEqual(['local-nova'])
})
it('shows fewer pieces when the catalogue is smaller than the limit', () => {
expect(selectContentPieceHomeItems([local], visitor)).toHaveLength(1)
})
it('defaults to three pieces when the catalogue is larger than the sample', () => {
const many = [1, 2, 3, 4, 5].map((id) => homeItem({ id, slug: `peca-${id}` }))
expect(selectContentPieceHomeItems(many, null)).toHaveLength(3)
})
it('falls back to the newest pieces without a visitor or without any label', () => {
const items = [newest, other, local]
expect(selectContentPieceHomeItems(items, null).map((entry) => entry.item.slug)).toEqual([
'recente',
'outro',
'local',
])
expect(
selectContentPieceHomeItems(items, { municipalityName: null, region: null }).map(
(entry) => entry.match,
),
).toEqual(['recent', 'recent', 'recent'])
})
it('matches a Salvador ZE by its exact name and degrades to the region without one', () => {
const zonePiece = homeItem({
id: 6,
slug: 'ze',
cityLabel: 'Salvador — ZE 3',
region: 'Metropolitano de Salvador',
})
expect(
selectContentPieceHomeItems([zonePiece], {
municipalityName: 'Salvador — ZE 3',
region: 'Metropolitano de Salvador',
})[0]?.match,
).toBe('municipality')
expect(
selectContentPieceHomeItems([zonePiece], {
municipalityName: 'Salvador',
region: 'Metropolitano de Salvador',
})[0]?.match,
).toBe('region')
})
})
describe('resolveContentPieceHomeVisitor', () => {
const feira = municipalitySquare('2910800', 'Feira de Santana', {
west: -39.2,
south: -12.4,
size: 0.4,
})
const portal = territorySquare('20', 'Portal do Sertão', {
west: -40,
south: -13,
size: 3,
})
const point = { lat: -12.2, lng: -39 }
it('resolves the município name and the identity territory', () => {
expect(
resolveContentPieceHomeVisitor({
point,
municipalityGeometry: { features: [feira] },
territoryGeometry: { features: [portal] },
}),
).toEqual({ municipalityName: 'Feira de Santana', region: 'Portal do Sertão' })
})
it('prefers the Salvador zone name when the zone mesh contains the point', () => {
const salvador = municipalitySquare('2927408', 'Salvador', {
west: -38.6,
south: -13.1,
size: 0.6,
})
const ze = zoneSquare('salvador-ze-3', 'Salvador — ZE 3', '2927408', {
west: -38.55,
south: -13.05,
size: 0.2,
})
expect(
resolveContentPieceHomeVisitor({
point: { lat: -13, lng: -38.5 },
municipalityGeometry: { features: [salvador] },
zoneGeometry: { features: [ze] },
territoryGeometry: { features: [portal] },
}),
).toEqual({ municipalityName: 'Salvador — ZE 3', region: 'Portal do Sertão' })
})
it('falls back to the whole município when no zone contains the point', () => {
const salvador = municipalitySquare('2927408', 'Salvador', {
west: -38.6,
south: -13.1,
size: 0.6,
})
const ze = zoneSquare('salvador-ze-3', 'Salvador — ZE 3', '2927408', {
west: -38.55,
south: -13.05,
size: 0.05,
})
expect(
resolveContentPieceHomeVisitor({
point: { lat: -12.7, lng: -38.2 },
municipalityGeometry: { features: [salvador] },
zoneGeometry: { features: [ze] },
territoryGeometry: { features: [portal] },
}),
).toEqual({ municipalityName: 'Salvador', region: 'Portal do Sertão' })
})
it('keeps only the region on a coarse fix', () => {
expect(
resolveContentPieceHomeVisitor({
point,
coarse: true,
municipalityGeometry: { features: [feira] },
territoryGeometry: { features: [portal] },
}),
).toEqual({ municipalityName: null, region: 'Portal do Sertão' })
})
it('is null outside the municipal mesh — outside Bahia or without a signal', () => {
expect(
resolveContentPieceHomeVisitor({
point: { lat: -23.5, lng: -46.6 },
municipalityGeometry: { features: [feira] },
territoryGeometry: { features: [portal] },
}),
).toBeNull()
expect(
resolveContentPieceHomeVisitor({
point: { lat: -23.5, lng: -46.6 },
coarse: true,
municipalityGeometry: { features: [feira] },
territoryGeometry: { features: [portal] },
}),
).toBeNull()
})
})
})