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

283 lines
9.6 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ContentPieceHomeBoard } from '@/components/conteudos/ContentPieceHomeBoard'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import type {
BahiaFeature,
BahiaMunicipalityFeature,
BahiaTerritoryFeature,
MunicipalityGeometryModule,
MunicipalityZoneGeometryModule,
TerritoryGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
import type { ContentPieceHomeItem } from '@/lib/contentPieceHomeSelection'
import {
readGeolocationPermissionState,
requestCurrentPosition,
} from '@/utilities/campaignGeolocation'
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}))
vi.mock('@/lib/bahiaGeometries', () => ({
loadMunicipalityGeometryModule: vi.fn(),
loadMunicipalityZoneGeometryModule: vi.fn(),
loadTerritoryGeometryModule: vi.fn(),
}))
vi.mock('@/utilities/campaignGeolocation', () => ({
COARSE_ACCURACY_M: 10_000,
readGeolocationPermissionState: vi.fn(),
requestCurrentPosition: vi.fn(),
}))
const readPermission = vi.mocked(readGeolocationPermissionState)
const readPosition = vi.mocked(requestCurrentPosition)
const readMunicipalityGeometry = vi.mocked(loadMunicipalityGeometryModule)
const readZoneGeometry = vi.mocked(loadMunicipalityZoneGeometryModule)
const readTerritoryGeometry = vi.mocked(loadTerritoryGeometryModule)
const item = (id: number, patch: Partial<ContentPieceHomeItem> = {}): ContentPieceHomeItem => ({
id,
slug: `peca-${id}`,
title: `Peça ${id}`,
type: 'foto',
typeLabel: 'Foto',
origin: 'arquivo',
originLabel: 'Arquivo',
isLink: false,
cityLabel: null,
regionLabel: null,
excerpt: null,
durationLabel: null,
metaLabel: 'Tema · Local',
publicPath: `/conteudos/peca-${id}`,
file: {
id: 100 + id,
path: `/conteudos/peca-${id}/midia`,
mimeType: 'image/png',
downloadFilename: `peca-${id}.png`,
},
framePath: null,
...patch,
})
/** Axis-aligned squares over the committed-mesh contract the resolver reads. */
const square = <Properties extends Record<string, unknown>>(
properties: Properties,
box: { west: number; south: number; size?: number },
): BahiaFeature<Properties> => {
const size = box.size ?? 0.4
return {
type: 'Feature',
properties,
geometry: {
type: 'Polygon',
coordinates: [
[
[box.west, box.south],
[box.west + size, box.south],
[box.west + size, box.south + size],
[box.west, box.south + size],
[box.west, box.south],
],
],
},
}
}
const feira = square(
{ codarea: '2910800', name: 'Feira de Santana' },
{ west: -39.2, south: -12.4 },
)
const portal = square({ code: '20', name: 'Portal do Sertão' }, { west: -40, south: -13, size: 3 })
const municipalityModule = (
features: readonly BahiaMunicipalityFeature[],
): MunicipalityGeometryModule => ({
topology: {
type: 'Topology',
objects: { municipalities: { type: 'GeometryCollection', geometries: [] } },
arcs: [],
},
features,
getMunicipalityFeature: (codarea) =>
features.find((feature) => feature.properties.codarea === codarea),
})
const zoneModule = (): MunicipalityZoneGeometryModule => ({
topology: {
type: 'Topology',
objects: { municipalityZones: { type: 'GeometryCollection', geometries: [] } },
arcs: [],
},
features: [],
})
const territoryModule = (features: readonly BahiaTerritoryFeature[]): TerritoryGeometryModule => ({
topology: {
type: 'Topology',
objects: { territories: { type: 'GeometryCollection', geometries: [] } },
arcs: [],
},
features,
})
const feiraPoint = { lat: -12.2, lng: -39 }
const localPiece = item(1, { cityLabel: 'Feira de Santana', regionLabel: 'Portal do Sertão' })
const otherPiece = item(2, { cityLabel: 'Ilhéus', regionLabel: 'Litoral Sul' })
const items = [localPiece, otherPiece]
const locateButton = () => screen.queryByRole('button', { name: /Usar minha localização/ })
/** The section tag is split by the decorative glyph, so the hook is the attribute. */
const sampleTag = () =>
document.querySelector('[data-sample-tag]')?.getAttribute('data-sample-tag') ?? null
beforeEach(() => {
vi.clearAllMocks()
readMunicipalityGeometry.mockResolvedValue(municipalityModule([feira]))
readZoneGeometry.mockResolvedValue(zoneModule())
readTerritoryGeometry.mockResolvedValue(territoryModule([portal]))
readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 80 } })
})
afterEach(cleanup)
describe('ContentPieceHomeBoard', () => {
it('renders the recent selection and the affordance while permission is undecided', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={items} />)
await waitFor(() => expect(locateButton()).not.toBeNull())
expect(screen.getByText('Seleção recente')).toBeDefined()
expect(screen.getAllByText('Mais recente')).toHaveLength(items.length)
expect(screen.getByRole('link', { name: /Ver todas as peças/ }).getAttribute('href')).toBe(
'/conteudos',
)
// No surprise dialog on load and nothing stored anywhere (LGPD contract).
expect(readPosition).not.toHaveBeenCalled()
expect(sessionStorage.length).toBe(0)
expect(localStorage.length).toBe(0)
})
it('hides the affordance when the browser already refused the permission', async () => {
readPermission.mockResolvedValue('denied')
render(<ContentPieceHomeBoard items={items} />)
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(locateButton()).toBeNull()
expect(screen.getByText('Seleção recente')).toBeDefined()
})
it('upgrades to the visitor territory without a dialog when permission is granted', async () => {
readPermission.mockResolvedValue('granted')
render(<ContentPieceHomeBoard items={items} />)
await waitFor(() => expect(sampleTag()).toBe('municipality'))
expect(screen.getByText('Do seu município')).toBeDefined()
expect(locateButton()).toBeNull()
expect(readPosition).toHaveBeenCalledTimes(1)
})
it('upgrades to the visitor territory when the affordance is tapped', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={items} />)
await waitFor(() => expect(locateButton()).not.toBeNull())
fireEvent.click(locateButton()!)
await waitFor(() => expect(sampleTag()).toBe('municipality'))
expect(locateButton()).toBeNull()
expect(readPosition).toHaveBeenCalledTimes(1)
})
it('offers the affordance when the browser cannot tell the permission (Safari)', async () => {
readPermission.mockResolvedValue('unknown')
render(<ContentPieceHomeBoard items={items} />)
await waitFor(() => expect(locateButton()).not.toBeNull())
expect(readPosition).not.toHaveBeenCalled()
})
it('mounts the media only after the tap and unmounts it when the play ends', async () => {
readPermission.mockResolvedValue('denied')
const video = item(7, {
title: 'Vídeo da home',
type: 'video',
typeLabel: 'Vídeo',
file: {
id: 107,
path: '/conteudos/peca-7/midia',
mimeType: 'video/mp4',
downloadFilename: 'peca-7.mp4',
},
})
render(<ContentPieceHomeBoard items={[video]} />)
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(document.querySelector('video')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Reproduzir Vídeo da home' }))
const element = document.querySelector('video')
expect(element).not.toBeNull()
fireEvent.ended(element!)
await waitFor(() => expect(document.querySelector('video')).toBeNull())
})
it('keeps the recent selection when the position fails, without an error or a retry', async () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: false, reason: 'timeout' })
render(<ContentPieceHomeBoard items={items} />)
await waitFor(() => expect(readPosition).toHaveBeenCalledTimes(1))
expect(screen.getByText('Seleção recente')).toBeDefined()
expect(locateButton()).toBeNull()
expect(screen.queryByText(/Não foi possível/)).toBeNull()
})
it('keeps the recent selection for a coarse fix (no município claim)', async () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 50_000 } })
render(<ContentPieceHomeBoard items={items} />)
// The coarse fix keeps the region sample, never the município claim.
await waitFor(() => expect(screen.getByText('Da sua região')).toBeDefined())
expect(readPosition).toHaveBeenCalledTimes(1)
expect(screen.getByText('Seleção recente')).toBeDefined()
expect(screen.queryByText('Do seu município')).toBeNull()
})
it('does not offer the affordance for a single published piece', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={[localPiece]} />)
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(locateButton()).toBeNull()
expect(
screen.getByText('Uma peça oficial já está pronta para você compartilhar.'),
).toBeDefined()
// Cena 05 — the single-piece section ends on the CTA, with no footnote.
expect(screen.queryByText(/Nenhum card personalizável/)).toBeNull()
expect(screen.queryByText(/A localização não é guardada/)).toBeNull()
})
})