feat(C162): player do acervo resolve o trecho sob demanda e YouTube vira default (Closes #999)

This commit is contained in:
Francisco Solla 2026-09-14 06:34:22 -03:00
parent 302783dc18
commit fbe67f0358
20 changed files with 1564 additions and 177 deletions

View file

@ -0,0 +1 @@
**C162 — VOD do acervo: player resolve o trecho sob demanda e YouTube vira default quando existe (2026-09-14):** o detalhe da fala deixa de depender do MP4 efêmero gravado (`vod.camara.leg.br`, apagado pelo CDN) e escolhe a superfície pelas coordenadas da linha: **YouTube default** quando `youtubeUrl` parseia (embed `youtube-nocookie`, sem chamada à Câmara no render) e **capa "Assistir o trecho"** quando só há VOD armazenado, resolvendo no clique o trecho exato (`video-sob-demanda?idAudio=&trecho=<excerptTMs>`) com links **verificados antes de tocar/baixar**. Dono novo das regras puras: `src/lib/speechVod.ts` recebe `CAMARA_USER_AGENT`/`buildVodUrl`/`parseVodStatus`/`parseDurationToSeconds` (movidos de `scripts/lib/camaraSpeeches.mjs`, que passa a re-exportar; `camara:pilot` ganha o mesmo loader tsx do `camara:import`) e adiciona `parseYoutubeVideoId` (watch/`youtu.be`/embed/live/shorts) e `excerptOffsetSeconds` (epoch ms → BRT `Intl` menos o naive `eventStartAt`, literal da intenção 15:43:54−15:00=2634). Resolver server-only `speechVodResolver` faz **1 chamada bounded** (2 tentativas/15s, UA da Câmara) e probe Range `bytes=0-1023` em paralelo no playback e no download (fallback HEAD em 405/501; `text/html` = hash morto), devolvendo `pronto`/`gerando`/`indisponivel`; a action `resolveSpeechVodForActor` gateia `canReadSpeechCatalog` antes da leitura (`overrideAccess: false`, select só dos campos de elegibilidade) e recusa fala sem VOD armazenado sem tocar a rede; a rota `POST /campanha/comunicacao/acervo/resolver-vod` usa `campaignJsonMutationRoute` (mesma origem) com mensagens constantes em `src/lib/schemas/speechVod.ts`. O view model perde `downloadUrl`/`vodDownloadUrl` da lista e do detalhe (o link armazenado nunca é renderizado — é só sinal de elegibilidade) e ganha `youtubeVideoId`/`youtubeOffsetSeconds`/`vodResolvable`; `SpeechDetailPlayer` reescrito com superfície única por quadrante (iframe com seek por reload `start=offset+segmento` — degradação C admitida no gate, gatilho registrado —, `<video>` nativo 0-based pós-PRONTO, painéis honestos de `GERANDO`/falha/indisponível, `aria-busy`+live region no pending) e "Baixar vídeo (MP4)" abre aba síncrona e só aponta para URL verificada (nunca arquivo morto); o "Baixar" direto do card da lista sai (com o campo morto do view model). Sem migration/collection/`Consent`/S3. Testes: unit `speechVod` (11), `speechVodResolver` (6, fetch mockado), `speechDetailPlayer` (9, jsdom: quadrantes, pending, retry, seek) + `camaraSpeeches` verde pela re-exportação; int `speechAcervo` estendido (offset/vodResolvable, lista sem download); e2e `campaignSpeechAcervo` renegociado (11 verdes: card sem download e sem link VOD, ambos→YouTube com `start` somado, só VOD→capa, sem vídeo→indisponível sem retry, rota 401/403/400 sem rede). `gate:fast` (3061 unit) + e2e local verdes.

View file

@ -0,0 +1,165 @@
# Impl: Acervo de falas: player resolve o trecho sob demanda e YouTube vira default quando existe
Status: em execução
Atualizado em: 2026-09-14
Issue: #999
Intenção: docs/plans/acervo-vod-sob-demanda.md
Appetite restante: herdado (~1,5–2 dias eng; sem corte proposto — a fatia cabe porque não há schema, persistência nem IFrame API)
## Leitura da intenção
- **Outcome:** com o CDN da Câmara vazio, abrir uma fala ainda toca (YouTube quando existir; VOD do trecho exato resolvido no clique quando só houver VOD) e "Baixar" nunca entrega arquivo inexistente — o MP4 é resolvido e verificado na Câmara no clique.
- **O que NÃO negociar:** render/RSC não chama a Câmara (resolução só no clique); `excerptTMs` verbatim (nunca vizinho); link do banco é cache (pode ser ignorado; nunca requisito); sem S3/espelho/transcode; sem segundo player/editor; sem migration/collection/`Consent` novos; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido; gate `canReadSpeechCatalog` vale para a resolução; URLs públicas do acervo intocadas; "Baixar" direto do card da lista sai; `PRONTO` com hash morto vira estado honesto, nunca arquivo morto.
- **O que reavaliar (hipóteses da intenção):**
- Os helpers de `scripts/lib/camara*.mjs` não são importáveis de `src/` (`.mjs`, `node:crypto`, pilot em node puro) → decisão 1 extrai as regras puras para `src/lib/speechVod.ts`.
- `youtubeUrl` é `event.urlRegistro` do open data (formato variável) → parser defensivo novo (`parseYoutubeVideoId`), não um `slice` de querystring.
- A intenção **recomenda** IFrame API para o seek na transcrição, mas admite a degradação C (recarregar o iframe com `start=`) se não couber no appetite. Este plano **escolhe a degradação C** (decisão 4) e registra o gatilho de revisitação — divergência de engenharia permitida pela própria intenção, a destacar no gate.
- O draft usa "Não foi possível carregar o vídeo deste trecho" (falha) e a intenção usa "vídeo indisponível neste momento" (quadrante nenhum). O plano preserva as duas: quadrante sem oferta = indisponível sem "Tentar novamente"; falha/GERANDO após oferta = cena 4 com "Tentar novamente".
## Abordagem recomendada
```mermaid
flowchart LR
A[Detalhe da fala] --> B{youtubeVideoId?}
B -- sim --> Y[iframe youtube-nocookie<br/>start = offset + t]
B -- não --> C{VOD armazenado +<br/>eventId/audioId/excerptTMs?}
C -- não --> N[Bloco indisponível<br/>+ Abrir fonte]
C -- sim --> P[Capa só VOD<br/>'Assistir o trecho']
Y --> D[Baixar vídeo MP4]
P --> R[POST resolver-vod<br/>só no clique]
D --> R
R --> G{Câmara video-sob-demanda}
G -- PRONTO --> V[probe Range 206 video/mp4<br/>playback e download]
V -- verificado --> S[video nativo no trecho 0-based<br/>+ download na aba aberta]
V -- falha --> F[Estado honesto<br/>+ Tentar novamente]
G -- GERANDO --> E[Pendência visível<br/>+ Tentar novamente]
G -- INDISPONIVEL/erro --> F
```
**Opções consideradas (arquitetura):** A) helpers puros em `src/lib/speechVod.ts` (dono único) + resolver server-only na vertical + rota JSON; B) duplicar helpers em `src/` e deixar `scripts/` intocado; C) `src/` importar `scripts/lib/camaraSpeeches.mjs` em runtime.
**Recomendação:** A — o app fica com o dono das regras de contrato (URL de trecho, parse do estado, offset BRT), os scripts re-exportam via tsx (precedente `camara:import`) e a resolução interativa nasce server-only com política própria de latência.
**Rejeitadas:** B porque é o twin que a engineering-standards proíbe (drift no contrato `trecho`/`estado`); C porque inverte a fronteira (runtime de produção dependendo de dev tooling), arrasta `node:crypto` para o bundle e não é o dono natural das regras.
### Decisões de engenharia
**1. Onde vive a resolução VOD (extração dos helpers).**
Opções: A) extrair as regras puras de VOD (`CAMARA_USER_AGENT`, `buildVodUrl`, `parseVodStatus`, `parseDurationToSeconds`) para `src/lib/speechVod.ts`; `scripts/lib/camaraSpeeches.mjs` passa a importar/re-exportar de lá (define só o que é de script) e `camara:pilot` ganha o mesmo loader tsx do `camara:import`; B) duplicar em `src/lib/speechVod.ts` e congelar os scripts; C) `src/utilities/speech/` importar `scripts/lib/camaraSpeeches.mjs` por caminho relativo.
Recomendação: **A** — dono único, sem tocar no contrato dos scripts; o pilot é o único ponto de atrito e migra para `cross-env NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/pilot-camara-speeches.mjs` (o `camara:import` já roda assim; nenhum módulo do pilot importa `server-only`, então não precisa do seed-loader).
Rejeitadas: B porque duplica o contrato mais sensível do item (qualquer mudança de `estado`/`trecho` passaria a ter dois donos); C porque o Docker copia `scripts/`, mas produção passaria a depender de tooling de dev, o alias `@/*` não cobre `scripts/` e `node:crypto` entraria no bundle do servidor.
Nota: a política HTTP **não** é unificada — `probeLink`/`getJson` dos scripts têm contrato de relatório em lote (retry 3×/2s, nunca lança); o resolver do app tem contrato interativo (bounded, mapeia falha para estado honesto). Fork de política deliberado, com gatilho de revisitação: se um terceiro call-site interativo surgir, extrair `probeLink` para um módulo compartilhado.
**2. Rota de resolução.**
Opções: A) `POST` JSON em `src/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/route.ts` via `campaignJsonMutationRoute`, corpo `{ speechId }` (segmento estático convive com `acervo/[id]`; o wrapper não entrega params de rota, então o id vai no corpo, como `municipios/political-trend`); B) server action + `useActionState` no player; C) rota sob `campanha/api/**`.
Recomendação: **A** — a convenção do repo (teste `codebaseConventions` recusa `POST` fora do wrapper) e a proteção estrutural de mesma origem já existem; o `(app)/comunicacao` está no manifesto de e2e; a rota é fine-grained (`dynamic = 'force-dynamic'`), autentica por cookie de campanha e responde JSON com estados discriminados.
Rejeitadas: B porque o ladder de actions devolve `CampaignFormErrorState` (shape de formulário) para um resultado rico (URLs verificadas), e a fronteira de CSRF do wrapper é o mecanismo canônico para mutação JSON sob `/campanha`; C porque fica fora do prefixo do manifesto e exigiria entrada de allowlist no guard de convenções.
**3. Player default com ambos (4 quadrantes sem segundo player).**
Opções: A) um único `SpeechDetailPlayer` client com máquina de estados e **uma** superfície por vez (`youtube` quando `youtubeVideoId`; `prompt`/`native` só quando não há YouTube; `unavailable` quando não há nenhum caminho); B) dois componentes irmãos (YouTube + VOD) com um orquestrador; C) manter `<video>` e iframe soltos no page/actions server.
Recomendação: **A** — YouTube é default quando existe (decisão de produto sinalizada na intenção), o MP4 do "ambos" não troca a superfície (só o `Baixar` resolve), e o "só VOD" mostra capa → resolvendo → `<video>` nativo (0-based, como hoje). Baixar/Abrir fonte passam para dentro do player porque o pending e a URL verificada são acoplados ao estado dele (`sourceUrl` entra por prop); a página segue RSC.
Rejeitadas: B porque multiplica arquivos e reintroduz o orquestrador que o player único elimina; C porque pending/seleção de URL são interativos (client boundary).
**4. Seek na transcrição com YouTube.**
Opções: A) IFrame API (`enablejsapi`, `postMessage`/`seekTo`); B) recarregar o iframe com `start = offset + segment.startSeconds` (degradação C da intenção); C) link `?t=` abrindo o YouTube; D) desabilitar o clique.
Recomendação: **B** — determinística, sem script de terceiro, o contrato vive no `src` do iframe (asserção de e2e por HTTP), e o clique continua posicionando no orador certo; o reload interrompe a reprodução, degradação explicitamente aceitável pela intenção.
Rejeitadas: A porque o plumbing de script/mensagens não cabe com folga no appetite e não tem como ser provado no e2e browserless atual; C porque perde o posicionamento na página (regressão do C154); D porque a intenção marca como regressão.
Com offset (`youtubeOffsetSeconds`) nulo (dados ausentes), a transcrição no YouTube não vira affordance de clique (sem "clique para posicionar") — melhor inerte que pular para outro orador. Gatilho de revisitação: se a assessoria relatar o "pisca/volta ao início" do reload, promover para A em item próprio (o view model já carrega o offset; nenhum dado muda).
**5. Persistência do link resolvido.**
Opções: A) não persistir (estado só no client); B) persistir best-effort quando o ator tem `canUpdateSpeech` (coordinator/candidate), engolindo falha; C) persistir com `overrideAccess: true`.
Recomendação: **A** — o link é cache declaradamente não confiável; a resolução por clique é o fluxo desenhado; nenhuma escrita nova, nenhum teste de write path, nenhum risco de ouroboros (link morto regravado).
Rejeitadas: B porque amplia superfície de escrita e cria semântica "cache às vezes" sem ganho de produto medido; C porque amplia a escrita (proibido pela intenção).
Gatilho de revisitação: latência p50 medida da Câmara tornar o clique repetidamente penoso → item próprio com `canUpdateSpeech` e invalidação explícita.
**6. "Baixar" do card da lista.**
Opções: A) remover `downloadUrl` do `SpeechListItemViewModel`/`SpeechResultCard` (e do `speechListSelect`), preservando `sourceUrl`; B) manter o card baixando o link cru; C) trocar o botão do card por link para o detalhe.
Recomendação: **A** — a intenção manda sair (link efêmero direto); `sourceUrl` continua no card; o detalhe já é o dono do "Ver fala/Assistir no trecho"; menos select na lista.
Rejeitadas: B porque entrega arquivo possivelmente morto; C porque o card já tem "Assistir no trecho" como CTA primário.
**7. Como o estado `GERANDO` é tratado.**
Opções: A) uma chamada `getJson` limitada (2 tentativas, timeout ~15s, backoff curto) que devolve `gerando` e a UI mostra pendência + "Tentar novamente" sem poll; B) poll curto no servidor (ex.: 3×5s) antes de responder; C) poll automático no client.
Recomendação: **A** — o clique tem orçamento de latência; a regeneração observada vai de segundos a minutos, e o convite a tentar de novo é o desenho da intenção ("limite + convite, sem travar a página").
Rejeitadas: B porque segura a request por 15s mesmo quando a Câmara está presa (o `resolveVod` de 200s dos scripts é inadequado aqui); C porque adiciona timers/rate e um retry escondido que a intenção não pede.
**8. Verificação do MP4 (probe) e entrega do download.**
Opções: A) `PRONTO` → probe em paralelo de `linkParaReproducao` e `linkParaDownload` (GET `Range: bytes=0-1023`, fallback HEAD em 405/501, ~10s, nunca lança; verificado = HTTP ok e content-type presente ≠ `text/html`); `<video>` só com playback verificado; "Baixar" só entrega download verificado; B) confiar no `PRONTO` sem probe; C) verificar só no download.
Recomendação: **A** — "Baixar só entrega arquivo verificado" e o player nativo não aponta para hash morto (o `PRONTO` velho medido devolve 403/hash morto). Se playback falha mas download passa, não há `<video>` (estado honesto com retry) mas o download verificado continua ofertado. Na entrega, o clique em "Baixar" abre uma aba em branco **sincronamente** (evita popup blocker) e a aponta para `downloadUrl` só depois de verificado; falha → fecha a aba e mostra o estado honesto. No quadrante ambos o YouTube continua tocando: a falha vira aviso inline ao lado das ações, **não** troca a superfície.
Rejeitadas: B porque viola o aceite (arquivo morto entregue); C porque deixaria o player nativo servir hash morto (draft cena 5 diz "verificado antes de tocar").
Rejeitadas na entrega: `window.open(downloadUrl)` depois do await (popup blocker) e `location.assign` (perde a página da campanha/perde o retry).
**9. Cache/estado no client.**
Opções: A) resultado da resolução só em `useState` do player; reload volta ao default (YouTube no "ambos", capa no "só VOD"); B) `sessionStorage` com o link resolvido; C) revalidar/gravar no servidor.
Recomendação: **A** — sem link morto cacheado, sem estado compartilhado entre abas/sessões; "Tentar novamente" limpa o erro e re-POSTa.
Rejeitadas: B porque ressuscita link efêmero (o problema que o item resolve) e duplica a fonte da verdade; C porque colide com a decisão 5 (sem escrita) e com o render sem Câmara.
### Componentes / mudanças
- **`src/lib/speechVod.ts`** (novo, puro, client-safe, sem imports): `CAMARA_USER_AGENT`, `buildVodUrl`, `parseVodStatus`, `parseDurationToSeconds` (movidos, dono novo), `parseYoutubeVideoId` (watch/`youtu.be`/embed/live/shorts; id `[A-Za-z0-9_-]{6,20}`; domínios YouTube), `excerptOffsetSeconds(excerptTMs, eventStartAt)` (epoch → hora BRT via `Intl` `America/Sao_Paulo` com `hour12:false`, menos o naive parseado como frame UTC; null se não parseável ou negativo; piso inteiro).
- **`scripts/lib/camaraSpeeches.mjs`**: remove as definições movidas e as re-exporta de `../../src/lib/speechVod.ts`; `scripts/lib/camaraFetch.mjs` segue importando de `camaraSpeeches.mjs` (sem mudança).
- **`package.json`**: `camara:pilot` ganha `cross-env NODE_OPTIONS="--no-deprecation --import=tsx/esm"` (espelha `camara:import`).
- **`src/lib/schemas/speechVod.ts`** (novo): `speechVodRequestSchema = { speechId: positiveRelationshipId }` + constantes de mensagem (`SPEECH_VOD_INELIGIBLE_MESSAGE`, `SPEECH_VOD_FORBIDDEN_MESSAGE`, `SPEECH_VOD_NOT_FOUND_MESSAGE`, `SPEECH_VOD_GENERIC_ERROR_MESSAGE`) — exigidas pelo guard de convenções (sem literais em `safeMessages`/`throw new Error` sob `(campaign)`).
- **`src/utilities/speech/speechVodResolver.ts`** (novo, `server-only`): `resolveSpeechVod({ eventId, audioId, excerptTms })` — `buildVodUrl` + uma chamada `getJson`-like bounded (2×, timeout 15s, UA da Câmara) + probe A em paralelo; devolve `{ state: 'pronto', playbackUrl, downloadUrl }` (URLs nulas quando o probe falha), `{ state: 'gerando' }`, `{ state: 'indisponivel' }`; lança só em falha de transporte/parse (o route colapsa no genérico).
- **`src/app/(campaign)/campanha/actions/speech.ts`** (novo, ladder): `resolveSpeechVodForActor({ speechId })` — `getCampaignActionContext`, fast-fail `canReadSpeechCatalog(actor.role)`, `payload.find` da fala com `overrideAccess: false` e `select: { eventId, audioId, excerptTMs, vodPlaybackUrl, vodDownloadUrl }`; sem os campos/`vod` armazenado lança `SPEECH_VOD_INELIGIBLE_MESSAGE`; chama o resolver.
- **`src/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/route.ts`** (novo) + **`types.ts`**: `export const dynamic = 'force-dynamic'`; `POST = campaignJsonMutationRoute({ bodySchema, safeMessages, genericMessage }, handler)`; resposta `{ status: 'success', state, playbackUrl?, downloadUrl? }`.
- **`src/utilities/speech/speechViewModels.ts`**: lista perde `downloadUrl`; detalhe perde `vodDownloadUrl` e ganha `youtubeVideoId`, `youtubeOffsetSeconds`, `vodResolvable` (vod armazenado **e** `eventId`/`audioId`/`excerptTMs`), calculados com os helpers puros.
- **`src/utilities/speech/speechPageData.ts`**: `speechListSelect` perde `vodDownloadUrl`; `speechDetailSelect` ganha `vodDownloadUrl`, `eventId`, `audioId`, `excerptTMs`, `eventStartAt` (nenhum campo novo no banco — todos já existem).
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`**: reescrito (client) com props `{ speechId, youtubeVideoId, youtubeOffsetSeconds, vodResolvable, segments, initialSeconds, sourceUrl }` e estados `idle | resolving | generating | ready | unavailable`; ações dentro do player.
- **`src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx`**: passa as props novas e remove o bloco `<a>` de MP4 (o "Abrir fonte" passa ao player); asides/transcrição oficial intocados.
- **`src/components/campaign/speech/SpeechResultCard.tsx`**: remove o `ActionLink` de download (e o import `DownloadIcon` morto).
- **Migration:** nenhuma — sem schema change, sem `payload-types` novo, `push: false` intocado.
- **Access / Consent:** nenhum `Consent`; leitura da fala com `overrideAccess: false` + gate `canReadSpeechCatalog` na action; nenhuma escrita; sem S3.
- **UI:** Impeccable B — reusar `Button`/`Badge`, `aria-busy` + live region polite no pending (campanha-action-feedback), copy do draft; shape→craft→critique→polish no detalhe.
### Dados → forma
**Não — não se aplica (feedback de ação).** Nenhum KPI/gráfico novo: "resolvendo/gerando/indisponível" é o estado da ação no player (draft cenas 3–4), não dado de acervo; a única forma nova é a superfície do player, já coberta pela direção de UI acima.
## Fases verificáveis
1. **Fase 1 — Tracer puro + extração (dono único).** `src/lib/speechVod.ts` + re-exports em `scripts/lib/camaraSpeeches.mjs` + loader tsx do pilot.
- Testes: `tests/unit/speechVod.unit.spec.ts` (node) — `buildVodUrl`/`parseVodStatus`/`parseDurationToSeconds` preservados, `excerptOffsetSeconds` com o literal da intenção (`1786473834650` + 15:00 → 2634), o fixture do import (`1675801808560` + `2023-02-07T15:00` → 9008), caso DST antigo, negativo/não-parseável → null; `parseYoutubeVideoId` (watch/`youtu.be`/embed/lixo). `tests/unit/camaraSpeeches.unit.spec.ts` segue verde **pela re-exportação** (prova que a extração preservou o contrato).
- Verificação: `pnpm test:unit`, `pnpm typecheck`, `node --import=tsx/esm scripts/pilot-camara-speeches.mjs --help`.
2. **Fase 2 — Server (view fields + resolver + rota).** Selects/view model, resolver server-only, schema/mensagens, action e rota.
- Testes: `tests/unit/speechVodResolver.unit.spec.ts` (fetch mockado: PRONTO+206 → URLs verificadas; PRONTO+403 → sem URL; GERANDO; INDISPONIVEL; timeout lança); `tests/int/speechAcervo.int.spec.ts` estendido (detalhe expõe `youtubeVideoId`, `youtubeOffsetSeconds` calculado, `vodResolvable` true/false; lista sem `downloadUrl`).
- Verificação: `pnpm test:unit`, `pnpm test:int`, `pnpm typecheck`, `pnpm build` (convivência `acervo/resolver-vod` × `acervo/[id]`).
3. **Fase 3 — Player.** Máquina de estados, iframe com `start=` e capa só VOD, `<video>` nativo pós-PRONTO, aviso inline de falha no quadrante ambos.
- Testes: `tests/unit/speechDetailPlayer.unit.spec.tsx` (jsdom + testing-library, fetch mockado) — só VOD: capa → clique → pending/`aria-busy` → `<video src>` no trecho; falha → bloco honesto + "Tentar novamente" re-POSTa; ambos: iframe com `start=offset+t`, Baixar pendente/desabilitado e depois entrega, transcrição atualiza o `src`; sem YouTube sem VOD → bloco sem retry.
- Verificação: `pnpm test:unit`.
4. **Fase 4 — Card/lista.** Remoção do download do card/view model e do select da lista.
- Testes: unit/componente do card + int da lista.
- Verificação: `pnpm test:unit`, `pnpm test:int`, `pnpm exec knip` (import órfão do `DownloadIcon` morto).
5. **Fase 5 — E2E renegociado + gates.** `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`:
- fixtures novas com `youtubeUrl` + `eventStartAt`/`eventId`/`audioId`/`excerptTMs` (ambos e só VOD) e sem vídeo;
- ambos: `youtube-nocookie.com/embed/<id>` com `start=<offset>` e "Baixar vídeo (MP4)" e `data-start-seconds`, sem `<video>` no load;
- só VOD: capa "Assistir o trecho", sem iframe/`<video>` no load, "Baixar vídeo (MP4)" presente (e não contém o link `vod.camara.leg.br` na lista do card);
- sem vídeo: "Vídeo indisponível neste momento" + "Abrir fonte", sem "Tentar novamente"; falha pós-clique coberta no unit da fase 3;
- rota: POST sem sessão → 401; advisor → mensagem de acesso (400); origem estranha → 403; fala inelegível → 400 (sem rede).
- Verificação: `pnpm gate:fast`, `pnpm test:e2e:affected`, `pnpm push`; changelog `docs/changelog/2026-09-14-c162-vod-sob-demanda.md`.
## Rabbit holes / Não escopo (engenharia)
- IFrame API do YouTube (seek sem reload) — degradação C aceita; gatilho na decisão 4.
- Persistir/aquecer o link resolvido, re-verificação em background/cron, "gerar novamente" manual, regenerar os 924 em lote.
- Espelhar/transcodificar MP4 (S3/Garage), download em lote, editor/corte.
- Poll de `GERANDO` (servidor ou client) e qualquer chamada à Câmara no render/RSC.
- Segundo player, redesign da lista/detalhe além da saída do "Baixar" do card, "Abrir no YouTube" no lugar do MP4.
- Migration/collection/`Consent` novos; unificar a política de `probeLink` dos scripts com a do app (fork deliberado, decisão 1).
## Riscos e mitigação
- **`youtubeUrl` fora do formato esperado** (`urlRegistro` varia) → `parseYoutubeVideoId` defensivo com unit tests; null cai no caminho só VOD/indisponível (nunca iframe quebrado).
- **Latência/anti-bot da Câmara** (400 "Acesso via bot", timeouts) → UA compartilhado, 2 tentativas com timeout curto, estado honesto com "Tentar novamente"; nenhuma página depende da Câmara para renderizar.
- **Migração do pilot para tsx** (script dormente) → smoke `--help` + unit tests da re-exportação; rollback é uma linha de `package.json`.
- **E2E browserless não clica** → interação (resolvendo → `<video>`, falha, retry, seek) provada no unit jsdom; e2e prova contrato HTTP, gates e o HTML inicial.
- **Colisão de rota** `acervo/resolver-vod` × `acervo/[id]` → ids são numéricos e `pnpm build` valida; e2e cobre o POST.
- **Manifesto de e2e**: `(app)/comunicacao`, `src/components/campaign/speech`, `src/utilities/speech` e `src/lib/speech*` já mapeiam para `campaignSpeechAcervo`; `src/lib/schemas/speechVod.ts` cai no prefixo `src/lib/speech`.
- **Knip `exports:error`**: re-exports dos scripts cobertos pelas entradas `scripts/*.mjs`; símbolos novos usados por action/route/player; rodar `pnpm exec knip` na fase 4.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (quadrantes, `excerptTMs` verbatim, Baixar verificado, estado honesto, crédito CC BY, gate do acervo).
- [ ] Invariantes AGENTS/engineering-standards (dono único, sem twin, sem migration/collection/Consent, sem escrita nova, `overrideAccess: false`, client boundary, zero warnings, dead code).
- [ ] Testes de domínio previstos (unit/resolver/player/int/e2e) onde o access/write path e os contratos de URL mudam.
## Self-score (decision-quality, gate ≥4)
5/5 — (1) todas as decisões caras (extração, rota, player único, persistência, verificação) têm opções + rejeitadas explícitas; (2) cabe no appetite herdado porque corta IFrame API, persistência e poll; (3) rabbit holes nomeados com corte; (4) depth check: reusa `campaignJsonMutationRoute`, ladder de actions, `getCampaignActionContext`, `speechPageData`/`speechViewModels` e o padrão de feedback de ação — nada de shell/abstração paralela; (5) o outcome da intenção permanece intacto, com a degradação 4-C explicitamente permitida e sinalizada ao gate humano.

View file

@ -81,7 +81,7 @@
"test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts",
"images:resize": "node scripts/resize-images.mjs",
"images:fill": "node scripts/fill-image.mjs",
"camara:pilot": "node scripts/pilot-camara-speeches.mjs",
"camara:pilot": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/pilot-camara-speeches.mjs",
"camara:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-camara-speeches.mjs",
"typecheck": "tsc --noEmit",
"typecheck:tsc6": "tsc6 --noEmit",

View file

@ -11,13 +11,18 @@
*/
import { createHash } from 'node:crypto'
// The VOD/YouTube contract moved to its production owner (C162) and is
// re-exported here so the C152/C153 scripts keep the same import surface.
export {
buildVodUrl,
CAMARA_USER_AGENT,
parseDurationToSeconds,
parseVodStatus,
} from '../../src/lib/speechVod.ts'
/** Câmara's open-data deputy id for Jorge Solla (used by the pilot). */
export const SOLLA_DEPUTY_ID = 178857
/** Browser-like UA — the trecho/evento pages answer 400 "Acesso via bot" to curl. */
export const CAMARA_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
/** Deep Infra OpenAI-compatible transcription endpoint (returns segments with `verbose_json`). */
export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/audio/transcriptions'
@ -139,40 +144,6 @@ export const legislatureForDate = (iso) => {
return null
}
/**
* Parses the Câmara duration spellings into seconds: `0h04'03"` (excerpt card)
* and `0:04:07` / `4:07` (VOD status). Null when unparseable.
*
* @param {unknown} value
* @returns {number | null}
*/
export const parseDurationToSeconds = (value) => {
const raw = String(value ?? '').trim()
if (raw === '') return null
const card = /^(\d+)h(\d{1,2})'(\d{1,2})"?$/.exec(raw)
if (card) {
const minutes = Number(card[2])
const seconds = Number(card[3])
if (minutes > 59 || seconds > 59) return null
return Number(card[1]) * 3600 + minutes * 60 + seconds
}
const parts = raw.split(':')
if (!parts.every((part) => /^\d{1,2}$/.test(part))) return null
if (parts.length === 3) {
const [hours, minutes, seconds] = parts.map(Number)
if (minutes > 59 || seconds > 59) return null
return hours * 3600 + minutes * 60 + seconds
}
if (parts.length === 2) {
const [minutes, seconds] = parts.map(Number)
if (seconds > 59) return null
return minutes * 60 + seconds
}
return null
}
const integerOrNull = (value) => {
const n = Number(value)
return Number.isInteger(n) && n > 0 ? n : null
@ -222,38 +193,6 @@ export const parseEventExcerpts = (html, eventId) => {
return excerpts
}
/** The async VOD-generation status URL for one excerpt. */
export const buildVodUrl = (eventId, audioId, tMs) =>
`https://www.camara.leg.br/evento-legislativo/${eventId}/video-sob-demanda?idAudio=${audioId}&trecho=${tMs}`
/**
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
* `INDISPONIVEL` when there is none.
*
* @param {unknown} json
* @returns {{ state: string, video: { title: string | null, subtitle: string | null, duration: string | null, clock: string | null, downloadUrl: string | null, playbackUrl: string | null } | null }}
*/
export const parseVodStatus = (json) => {
const data = json && typeof json === 'object' ? json : {}
const estado = typeof data.estado === 'string' ? data.estado : 'DESCONHECIDO'
const raw = data.video && typeof data.video === 'object' ? data.video : null
const pick = (key) => (typeof raw?.[key] === 'string' ? raw[key] : null)
return {
state: estado,
video: raw
? {
title: pick('titulo'),
subtitle: pick('subtitulo'),
duration: pick('duracao'),
clock: pick('horario'),
downloadUrl: pick('linkParaDownload'),
playbackUrl: pick('linkParaReproducao'),
}
: null,
}
}
/**
* Events of the speech's date, nearest-first by start time. The open-data
* `uriEvento` comes empty, so the event is inferred from the date/time.

View file

@ -1,5 +1,5 @@
import config from '@payload-config'
import { ArrowLeftIcon, DownloadIcon, ExternalLinkIcon } from 'lucide-react'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
@ -103,31 +103,16 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
<div className="mt-4">
<SpeechDetailPlayer
vodPlaybackUrl={view.vodPlaybackUrl}
speechId={view.id}
youtubeVideoId={view.youtubeVideoId}
youtubeOffsetSeconds={view.youtubeOffsetSeconds}
vodResolvable={view.vodResolvable}
segments={view.segments}
initialSeconds={initialSeconds}
sourceUrl={sourceUrl}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{view.vodDownloadUrl ? (
<Button asChild className="min-h-10">
<a href={view.vodDownloadUrl} target="_blank" rel="noreferrer">
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Baixar vídeo (MP4)
</a>
</Button>
) : null}
{sourceUrl ? (
<Button asChild variant="outline" className="min-h-10">
<a href={sourceUrl} target="_blank" rel="noreferrer">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir fonte
</a>
</Button>
) : null}
</div>
{view.officialTranscript ? (
<details className="mt-5 rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">

View file

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { resolveSpeechVodForActor } from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_VOD_FORBIDDEN_MESSAGE,
SPEECH_VOD_GENERIC_ERROR_MESSAGE,
SPEECH_VOD_INELIGIBLE_MESSAGE,
SPEECH_VOD_NOT_FOUND_MESSAGE,
speechVodRequestSchema,
} from '@/lib/schemas/speechVod'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { SpeechVodResolveResponse } from './types'
export type { SpeechVodResolveResponse } from './types'
export const dynamic = 'force-dynamic'
export const POST = campaignJsonMutationRoute(
{
bodySchema: speechVodRequestSchema,
safeMessages: [
SPEECH_VOD_FORBIDDEN_MESSAGE,
SPEECH_VOD_INELIGIBLE_MESSAGE,
SPEECH_VOD_NOT_FOUND_MESSAGE,
],
genericMessage: SPEECH_VOD_GENERIC_ERROR_MESSAGE,
},
async ({ speechId }) => {
const resolution = await resolveSpeechVodForActor({ speechId })
return NextResponse.json<SpeechVodResolveResponse>({ status: 'success', resolution })
},
)

View file

@ -0,0 +1,6 @@
import type { SpeechVodResolution } from '@/lib/speechVod'
/** Wire contract of `POST /campanha/comunicacao/acervo/resolver-vod` (C162). */
export type SpeechVodResolveResponse =
| { status: 'success'; resolution: SpeechVodResolution }
| { status: 'error'; message: string }

View file

@ -0,0 +1,56 @@
'use server'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import {
SPEECH_VOD_FORBIDDEN_MESSAGE,
SPEECH_VOD_INELIGIBLE_MESSAGE,
SPEECH_VOD_NOT_FOUND_MESSAGE,
speechVodRequestSchema,
} from '@/lib/schemas/speechVod'
import type { SpeechVodResolution } from '@/lib/speechVod'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
/**
* C162 — resolves the exact excerpt MP4 for one speech through the Câmara
* `video-sob-demanda` endpoint. The catalog gate is checked BEFORE the read so
* a denied actor gets the domain message (the read itself runs with
* `overrideAccess: false` and would only yield "not found"). The stored VOD
* link is an eligibility signal, never a URL handed to the client: the
* resolution owns the truth, and the response only carries probed links.
*/
export const resolveSpeechVodForActor = async (input: {
speechId: number
}): Promise<SpeechVodResolution> => {
const { speechId } = speechVodRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_VOD_FORBIDDEN_MESSAGE)
const result = await payload.find({
collection: 'speech',
where: { id: { equals: speechId } },
depth: 0,
limit: 1,
pagination: false,
select: {
eventId: true,
audioId: true,
excerptTMs: true,
vodPlaybackUrl: true,
vodDownloadUrl: true,
},
user: actor,
overrideAccess: false,
})
const speech = result.docs[0]
if (!speech) throw new Error(SPEECH_VOD_NOT_FOUND_MESSAGE)
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
const { eventId, audioId, excerptTMs } = speech
if (!hasStoredVod || !eventId || !audioId || !excerptTMs) {
throw new Error(SPEECH_VOD_INELIGIBLE_MESSAGE)
}
return resolveSpeechVod({ eventId, audioId, excerptTms: excerptTMs })
}

View file

@ -1,29 +1,108 @@
'use client'
import { FilmIcon } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import type { SpeechVodResolution } from '@/lib/speechVod'
import { cn } from '@/lib/utils'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
/**
* C154 — native `<video>` plus the clickable ASR transcript. The VOD is the
* Câmara excerpt MP4 and the segment `startSeconds` are relative to it, so a
* click is a plain `currentTime` seek. No autoplay (browsers block it): the
* user presses play and the video starts at the requested fragment.
*/
export const SpeechDetailPlayer = ({
vodPlaybackUrl,
segments,
initialSeconds,
}: {
vodPlaybackUrl: string | null
const RESOLVE_ENDPOINT = '/campanha/comunicacao/acervo/resolver-vod'
type ResolutionState =
| { kind: 'idle' }
| { kind: 'resolving' }
| { kind: 'generating' }
| { kind: 'resolved'; playbackUrl: string | null; downloadUrl: string | null }
| { kind: 'failed'; message: string | null }
const toResolutionState = (resolution: SpeechVodResolution): ResolutionState =>
resolution.state === 'pronto'
? {
kind: 'resolved',
playbackUrl: resolution.playbackUrl,
downloadUrl: resolution.downloadUrl,
}
: resolution.state === 'gerando'
? { kind: 'generating' }
: { kind: 'failed', message: null }
type SpeechDetailPlayerProps = {
speechId: number
/** C162 — YouTube default when the session link parses; null falls back to the VOD. */
youtubeVideoId: string | null
/** Seconds from the session start to the excerpt; null makes the YouTube seek inert. */
youtubeOffsetSeconds: number | null
/** Stored VOD + excerpt coordinates: the Câmara may be asked on click. */
vodResolvable: boolean
segments: readonly SpeechDetailSegmentViewModel[]
initialSeconds: number | null
}) => {
sourceUrl: string | null
}
const StatusPanel = ({
title,
detail,
busy = false,
children,
}: {
title: string
detail: string
busy?: boolean
children?: ReactNode
}) => (
<div
role="status"
aria-live="polite"
className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border bg-muted/40 px-6 text-center text-muted-foreground"
>
{busy ? (
<Spinner
className="size-6 text-muted-foreground"
aria-label="Resolvendo o trecho na Câmara"
/>
) : (
<FilmIcon className="size-8" aria-hidden="true" />
)}
<p className="text-sm font-medium text-foreground/90">{title}</p>
<p className="max-w-md text-xs">{detail}</p>
{children}
</div>
)
/**
* C162 — one player for the four acervo quadrants: YouTube default when the
* session link exists (no Câmara call on render), the stored VOD otherwise
* resolved on click (`video-sob-demanda`, exact `excerptTMs`, links probed
* before use), and honest states when neither path has a playable file.
* Transcript clicks seek the current surface: native seconds on the MP4,
* session-offset seconds on the YouTube embed.
*/
export const SpeechDetailPlayer = ({
speechId,
youtubeVideoId,
youtubeOffsetSeconds,
vodResolvable,
segments,
initialSeconds,
sourceUrl,
}: SpeechDetailPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
const [activeStart, setActiveStart] = useState<number | null>(null)
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
youtubeVideoId && youtubeOffsetSeconds !== null && initialSeconds !== null
? youtubeOffsetSeconds + initialSeconds
: null,
)
const resolving = resolution.kind === 'resolving'
const playbackUrl = resolution.kind === 'resolved' ? resolution.playbackUrl : null
useEffect(() => {
const video = videoRef.current
@ -38,9 +117,50 @@ export const SpeechDetailPlayer = ({
}
video.addEventListener('loadedmetadata', seek, { once: true })
return () => video.removeEventListener('loadedmetadata', seek)
}, [initialSeconds, vodPlaybackUrl])
}, [initialSeconds, playbackUrl])
const requestResolution = async (deliverDownload: boolean) => {
// Opened synchronously inside the click gesture so the popup blocker sees
// a user-initiated tab; pointed at the verified URL only after the probe.
const pendingTab = deliverDownload ? window.open('', '_blank') : null
setResolution({ kind: 'resolving' })
try {
const { ok, payload } = await postCampaignJson<SpeechVodResolveResponse>(RESOLVE_ENDPOINT, {
speechId,
})
if (!ok || payload.status !== 'success') {
pendingTab?.close()
setResolution({
kind: 'failed',
message: payload.status === 'error' ? payload.message : null,
})
return
}
const next = toResolutionState(payload.resolution)
setResolution(next)
if (!deliverDownload) return
if (next.kind === 'resolved' && next.downloadUrl) {
if (pendingTab) pendingTab.location.href = next.downloadUrl
else window.open(next.downloadUrl, '_blank')
return
}
pendingTab?.close()
} catch {
pendingTab?.close()
setResolution({ kind: 'failed', message: null })
}
}
const seekTo = (seconds: number) => {
if (youtubeVideoId) {
if (youtubeOffsetSeconds === null) return
setYoutubeStart(youtubeOffsetSeconds + seconds)
setActiveStart(seconds)
return
}
const video = videoRef.current
if (!video) return
video.currentTime = seconds
@ -57,30 +177,150 @@ export const SpeechDetailPlayer = ({
setActiveStart(active?.startSeconds ?? null)
}
const youtubeSrc = useMemo(() => {
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
if (youtubeStart !== null && youtubeStart > 0)
params.set('start', String(Math.floor(youtubeStart)))
return `https://www.youtube-nocookie.com/embed/${youtubeVideoId}?${params.toString()}`
}, [youtubeVideoId, youtubeStart])
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
const downloadAvailable = vodResolvable
const media = youtubeVideoId ? (
<iframe
src={youtubeSrc}
title="Vídeo da sessão no YouTube"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowFullScreen
className="aspect-video w-full rounded-lg border bg-black"
/>
) : playbackUrl ? (
<video
ref={videoRef}
controls
preload="metadata"
src={playbackUrl}
onTimeUpdate={onTimeUpdate}
className="aspect-video w-full rounded-lg border bg-black"
>
<track kind="captions" />
</video>
) : resolution.kind === 'resolving' ? (
<StatusPanel
busy
title="Resolvendo o trecho na Câmara…"
detail="Isso pode levar alguns segundos. Se demorar, tente novamente em instantes — a página não trava."
/>
) : resolution.kind === 'generating' ? (
<StatusPanel
title="A Câmara está gerando o trecho deste vídeo."
detail="A geração pode levar alguns instantes. Tente novamente em um momento."
>
<Button
variant="outline"
className="mt-1 min-h-10"
onClick={() => void requestResolution(false)}
>
Tentar novamente
</Button>
</StatusPanel>
) : resolution.kind === 'failed' ||
(resolution.kind === 'resolved' && !resolution.playbackUrl) ? (
<StatusPanel
title="Não foi possível carregar o vídeo deste trecho."
detail={
resolution.kind === 'failed' && resolution.message
? resolution.message
: 'A Câmara não entregou o arquivo agora. A transcrição e a fonte oficial continuam disponíveis.'
}
>
<Button
variant="outline"
className="mt-1 min-h-10"
onClick={() => void requestResolution(false)}
>
Tentar novamente
</Button>
</StatusPanel>
) : vodResolvable ? (
<StatusPanel
title="O trecho deste vídeo é gerado pela Câmara dos Deputados."
detail="Clique para resolver o arquivo exato desta fala — ele é verificado antes de tocar."
>
<Button className="mt-1 min-h-10" onClick={() => void requestResolution(false)}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Assistir o trecho
</Button>
</StatusPanel>
) : (
<StatusPanel
title="Vídeo indisponível neste momento."
detail="Esta fala não tem vídeo no YouTube nem trecho gerado pela Câmara disponível agora."
/>
)
const inlineNotice = youtubeVideoId ? (
resolution.kind === 'generating' ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
A Câmara está gerando o trecho deste vídeo. Tente novamente em um momento.
</p>
) : resolution.kind === 'failed' ? (
<p className="text-xs text-destructive" role="status" aria-live="polite">
{resolution.message ??
'Não foi possível resolver o arquivo deste trecho na Câmara. Tente novamente.'}
</p>
) : resolution.kind === 'resolved' && !resolution.downloadUrl ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
O arquivo deste trecho não pôde ser verificado na Câmara agora.
</p>
) : null
) : null
return (
<div data-slot="speech-player">
{vodPlaybackUrl ? (
<video
ref={videoRef}
controls
preload="metadata"
src={vodPlaybackUrl}
onTimeUpdate={onTimeUpdate}
className="aspect-video w-full rounded-lg border bg-black"
>
<track kind="captions" />
</video>
) : (
<div className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border bg-muted/40 text-muted-foreground">
<FilmIcon className="size-8" aria-hidden="true" />
<p className="text-sm">VOD indisponível para esta fala.</p>
</div>
)}
<div data-slot="speech-player" aria-busy={resolving || undefined}>
{media}
<div className="mt-3 flex flex-wrap items-center gap-2">
{downloadAvailable ? (
<Button
className="min-h-10"
disabled={resolving}
aria-busy={resolving || undefined}
onClick={() => {
if (resolution.kind === 'resolved' && resolution.downloadUrl) {
window.open(resolution.downloadUrl, '_blank')
return
}
void requestResolution(true)
}}
>
{resolving ? (
<Spinner data-icon="inline-start" aria-label="Resolvendo o trecho" />
) : (
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
)}
Baixar vídeo (MP4)
</Button>
) : null}
{sourceUrl ? (
<Button asChild variant="outline" className="min-h-10">
<a href={sourceUrl} target="_blank" rel="noreferrer">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir fonte
</a>
</Button>
) : null}
</div>
{inlineNotice}
{segments.length > 0 ? (
<section className="mt-5" aria-label="Transcrição">
<p className="text-xs tracking-wide text-muted-foreground uppercase">
Transcrição (clique para posicionar)
{seekable ? 'Transcrição (clique para posicionar)' : 'Transcrição'}
</p>
<ol className="mt-2 space-y-0.5">
{segments.map((segment) => {
@ -91,8 +331,11 @@ export const SpeechDetailPlayer = ({
type="button"
data-start-seconds={segment.startSeconds}
onClick={() => seekTo(segment.startSeconds)}
disabled={!seekable}
className={cn(
'flex w-full gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'flex w-full gap-3 rounded-lg px-2 py-1.5 text-left transition-colors',
seekable && 'hover:bg-muted',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-muted',
)}
>

View file

@ -1,4 +1,4 @@
import { DownloadIcon, ExternalLinkIcon, PlayIcon } from 'lucide-react'
import { ExternalLinkIcon, PlayIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
@ -116,12 +116,6 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
</Link>
</Button>
{speech.downloadUrl ? (
<ActionLink href={speech.downloadUrl} variant="outline">
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Baixar
</ActionLink>
) : null}
{speech.sourceUrl ? (
<ActionLink href={speech.sourceUrl} variant="ghost">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />

View file

@ -0,0 +1,28 @@
import { z } from 'zod'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
/** The detail page asks the server to resolve one speech's excerpt VOD. */
export const speechVodRequestSchema = z.object({
speechId: positiveRelationshipId,
})
/**
* Safe messages of the resolution route — shared constants on purpose:
* `mapCampaignFormActionError` matches by exact string, so a literal at one
* end only would silently collapse a real refusal into the generic error.
*/
/** The catalog gate (`canReadSpeechCatalog`) refused the actor. */
export const SPEECH_VOD_FORBIDDEN_MESSAGE = 'Você não tem acesso ao acervo de falas.'
/** The speech does not exist (or the actor cannot read it). */
export const SPEECH_VOD_NOT_FOUND_MESSAGE = 'Fala não encontrada.'
/** No stored VOD or missing `eventId`/`audioId`/`excerptTMs` — never call the Câmara. */
export const SPEECH_VOD_INELIGIBLE_MESSAGE =
'Esta fala não tem trecho de vídeo para resolver na Câmara.'
/** Transport/parse failure at the Câmara (or anything unmapped). */
export const SPEECH_VOD_GENERIC_ERROR_MESSAGE =
'Não foi possível resolver o trecho. Verifique seu acesso e tente novamente.'

193
src/lib/speechVod.ts Normal file
View file

@ -0,0 +1,193 @@
/**
* Pure VOD rules shared by the Câmara speech scripts (C152 pilot/C153 import)
* and the acervo player (C162): the `video-sob-demanda` URL/status contract,
* the crawl UA, the session-offset math and the YouTube video id.
*
* No I/O here — the scripts and the server resolver own the fetch/timeout
* policy, so the fragile rules stay unit-testable without network.
*/
/** Browser-like UA — the trecho/evento pages answer 400 "Acesso via bot" to curl. */
export const CAMARA_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
/** The async VOD-generation status URL for one excerpt. */
export const buildVodUrl = (eventId: number, audioId: number, tMs: number): string =>
`https://www.camara.leg.br/evento-legislativo/${eventId}/video-sob-demanda?idAudio=${audioId}&trecho=${tMs}`
export type CamaraVodVideo = {
title: string | null
subtitle: string | null
duration: string | null
clock: string | null
downloadUrl: string | null
playbackUrl: string | null
}
export type CamaraVodStatus = {
state: string
video: CamaraVodVideo | null
}
/**
* What the acervo player can do after asking the Câmara about one excerpt:
* `pronto` carries only URLs that passed the media probe (either can still be
* null when its own probe failed), `gerando` means the server is transcoding,
* `indisponivel` means there is nothing usable.
*/
export type SpeechVodResolution =
| { state: 'pronto'; playbackUrl: string | null; downloadUrl: string | null }
| { state: 'gerando' }
| { state: 'indisponivel' }
/**
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
* `INDISPONIVEL` when there is none.
*/
export const parseVodStatus = (json: unknown): CamaraVodStatus => {
const data = json && typeof json === 'object' ? (json as Record<string, unknown>) : {}
const estado = typeof data.estado === 'string' ? data.estado : 'DESCONHECIDO'
const raw =
data.video && typeof data.video === 'object' ? (data.video as Record<string, unknown>) : null
const pick = (key: string): string | null =>
typeof raw?.[key] === 'string' ? (raw[key] as string) : null
return {
state: estado,
video: raw
? {
title: pick('titulo'),
subtitle: pick('subtitulo'),
duration: pick('duracao'),
clock: pick('horario'),
downloadUrl: pick('linkParaDownload'),
playbackUrl: pick('linkParaReproducao'),
}
: null,
}
}
/**
* Parses the Câmara duration spellings into seconds: `0h04'03"` (excerpt card)
* and `0:04:07` / `4:07` (VOD status). Null when unparseable.
*/
export const parseDurationToSeconds = (value: unknown): number | null => {
const raw = String(value ?? '').trim()
if (raw === '') return null
const card = /^(\d+)h(\d{1,2})'(\d{1,2})"?$/.exec(raw)
if (card) {
const minutes = Number(card[2])
const seconds = Number(card[3])
if (minutes > 59 || seconds > 59) return null
return Number(card[1]) * 3600 + minutes * 60 + seconds
}
const parts = raw.split(':')
if (!parts.every((part) => /^\d{1,2}$/.test(part))) return null
if (parts.length === 3) {
const [hours, minutes, seconds] = parts.map(Number)
if (minutes > 59 || seconds > 59) return null
return hours * 3600 + minutes * 60 + seconds
}
if (parts.length === 2) {
const [minutes, seconds] = parts.map(Number)
if (seconds > 59) return null
return minutes * 60 + seconds
}
return null
}
const YOUTUBE_VIDEO_ID = /^[A-Za-z0-9_-]{6,20}$/
const YOUTUBE_HOSTS = new Set(['youtube.com', 'youtube-nocookie.com', 'youtu.be'])
const YOUTUBE_ID_PATH_PREFIXES = new Set(['embed', 'live', 'shorts', 'v'])
/**
* YouTube video id of an `urlRegistro` link, or null when the URL is not a
* recognized YouTube form (`watch?v=`, `youtu.be/<id>`, `embed`/`live`/
* `shorts`/`v`) or the id does not look like one. Defensive on purpose: the
* open-data field varies, and a null simply falls back to the VOD path.
*/
export const parseYoutubeVideoId = (raw: unknown): string | null => {
const value = String(raw ?? '').trim()
if (value === '') return null
let url: URL
try {
url = new URL(value)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
const host = url.hostname.toLowerCase().replace(/^(www|m|music)\./, '')
if (!YOUTUBE_HOSTS.has(host)) return null
const segments = url.pathname.split('/').filter(Boolean)
let candidate: string | null = null
if (host === 'youtu.be') {
candidate = segments[0] ?? null
} else if (segments[0] === 'watch') {
candidate = url.searchParams.get('v')
} else if (segments[0] && YOUTUBE_ID_PATH_PREFIXES.has(segments[0])) {
candidate = segments[1] ?? null
}
return candidate && YOUTUBE_VIDEO_ID.test(candidate) ? candidate : null
}
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo'
const NAIVE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/
const brtWallClock = new Intl.DateTimeFormat('en-CA', {
timeZone: SAO_PAULO_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
/** BRT wall-clock of an epoch instant, as seconds since epoch-treated-as-UTC. */
const wallClockSeconds = (epochMs: number): number | null => {
const parts = brtWallClock.formatToParts(new Date(epochMs))
const get = (type: Intl.DateTimeFormatPartTypes): number =>
Number(parts.find((part) => part.type === type)?.value)
const [year, month, day, hour, minute, second] = [
get('year'),
get('month'),
get('day'),
get('hour'),
get('minute'),
get('second'),
]
if (![year, month, day, hour, minute, second].every(Number.isFinite)) return null
return Date.UTC(year, month - 1, day, hour % 24, minute, second) / 1000
}
/**
* Seconds from the session start (`eventStartAt`, naive Câmara wall clock) to
* the excerpt (`excerptTMs`, epoch ms). Both sides are read as BRT wall clock,
* so a session crossing midnight still measures the right distance. Null when
* either side is unparseable or the excerpt precedes the session start.
*/
export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown): number | null => {
const epochMs = Number(excerptTMs)
if (!Number.isFinite(epochMs) || epochMs <= 0) return null
const match = NAIVE_DATETIME.exec(String(eventStartAt ?? '').trim())
if (!match) return null
const [, year, month, day, hour, minute, second = '0'] = match
const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number)
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
return null
}
const startSeconds = Date.UTC(y, mo - 1, d, h, mi, s) / 1000
const excerptSeconds = wallClockSeconds(epochMs)
if (excerptSeconds === null) return null
const offset = Math.floor(excerptSeconds - startSeconds)
return offset >= 0 ? offset : null
}

View file

@ -37,7 +37,6 @@ const speechListSelect = {
scopes: true,
mentionedMunicipalities: true,
presidingOfficer: true,
vodDownloadUrl: true,
officialTextUrl: true,
youtubeUrl: true,
} as const
@ -46,6 +45,11 @@ const speechDetailSelect = {
...speechListSelect,
phase: true,
vodPlaybackUrl: true,
vodDownloadUrl: true,
eventId: true,
audioId: true,
excerptTMs: true,
eventStartAt: true,
} as const
const segmentSelect = {

View file

@ -15,6 +15,7 @@ import {
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { normalizeForSearch } from '@/lib/speechSearch'
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import type { Municipality } from '@/payload-types'
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
@ -33,7 +34,6 @@ export type SpeechListRecord = {
scopes?: SpeechScope[] | null
mentionedMunicipalities?: (number | Municipality)[] | null
presidingOfficer?: string | null
vodDownloadUrl?: string | null
officialTextUrl?: string | null
youtubeUrl?: string | null
}
@ -53,7 +53,6 @@ export type SpeechListItemViewModel = {
keywords: string[]
municipalities: { id: number; name: string }[]
watchHref: string
downloadUrl: string | null
sourceUrl: string | null
}
@ -80,8 +79,12 @@ export type SpeechDetailViewModel = {
scopes: { value: SpeechScope; label: string }[]
municipalities: { id: number; name: string }[]
segments: SpeechDetailSegmentViewModel[]
vodPlaybackUrl: string | null
vodDownloadUrl: string | null
/** YouTube default source (C162): id when the session link parses, else null. */
youtubeVideoId: string | null
/** Session offset in seconds (excerpt epoch − session start), null when unknowable. */
youtubeOffsetSeconds: number | null
/** Stored VOD + excerpt coordinates — the Câmara may be asked on click. */
vodResolvable: boolean
}
const pad = (value: number): string => String(value).padStart(2, '0')
@ -215,7 +218,6 @@ export const toSpeechListItemViewModel = ({
keywords: speech.keywords ?? [],
municipalities: municipalityViewModels(speech, municipalityLabels),
watchHref: buildWatchHref(speech.id, matchedSegment, q),
downloadUrl: speech.vodDownloadUrl ?? null,
sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null,
}
}
@ -223,6 +225,11 @@ export const toSpeechListItemViewModel = ({
export type SpeechDetailRecord = SpeechListRecord & {
phase?: string | null
vodPlaybackUrl?: string | null
vodDownloadUrl?: string | null
eventId?: number | null
audioId?: number | null
excerptTMs?: number | null
eventStartAt?: string | null
}
export const toSpeechDetailViewModel = ({
@ -235,27 +242,39 @@ export const toSpeechDetailViewModel = ({
segments: readonly SpeechSegmentRecord[]
query?: string
municipalityLabels: ReadonlyMap<number, string>
}): SpeechDetailViewModel => ({
id: speech.id,
speechAtLabel: formatSpeechAt(speech.speechAt),
type: speech.type ?? null,
phase: speech.phase ?? null,
durationLabel: formatSpeechDuration(speech.durationSeconds),
presidingOfficer: speech.presidingOfficer ?? null,
summary: speech.summary ?? null,
officialTranscript: speech.officialTranscript ?? null,
officialTextUrl: speech.officialTextUrl ?? null,
youtubeUrl: speech.youtubeUrl ?? null,
keywords: speech.keywords ?? [],
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
municipalities: municipalityViewModels(speech, municipalityLabels),
segments: segments.map((segment) => ({
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
startLabel: formatSpeechClock(segment.startSeconds),
parts: splitHighlightedParts(segment.text, query ?? ''),
})),
vodPlaybackUrl: speech.vodPlaybackUrl ?? null,
vodDownloadUrl: speech.vodDownloadUrl ?? null,
})
}): SpeechDetailViewModel => {
// The stored VOD link is a cache and never a URL handed to the client; it
// only signals that this record had a generated excerpt to re-resolve.
const vodResolvable = Boolean(
(speech.vodPlaybackUrl || speech.vodDownloadUrl) &&
speech.eventId &&
speech.audioId &&
speech.excerptTMs,
)
return {
id: speech.id,
speechAtLabel: formatSpeechAt(speech.speechAt),
type: speech.type ?? null,
phase: speech.phase ?? null,
durationLabel: formatSpeechDuration(speech.durationSeconds),
presidingOfficer: speech.presidingOfficer ?? null,
summary: speech.summary ?? null,
officialTranscript: speech.officialTranscript ?? null,
officialTextUrl: speech.officialTextUrl ?? null,
youtubeUrl: speech.youtubeUrl ?? null,
keywords: speech.keywords ?? [],
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
municipalities: municipalityViewModels(speech, municipalityLabels),
segments: segments.map((segment) => ({
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
startLabel: formatSpeechClock(segment.startSeconds),
parts: splitHighlightedParts(segment.text, query ?? ''),
})),
youtubeVideoId: parseYoutubeVideoId(speech.youtubeUrl),
youtubeOffsetSeconds: excerptOffsetSeconds(speech.excerptTMs, speech.eventStartAt),
vodResolvable,
}
}

View file

@ -0,0 +1,102 @@
import 'server-only'
import {
buildVodUrl,
CAMARA_USER_AGENT,
parseVodStatus,
type SpeechVodResolution,
} from '@/lib/speechVod'
/**
* C162 — interactive VOD resolution for the acervo detail. Deliberately NOT a
* reuse of `scripts/lib/camaraFetch.mjs` (whose `resolveVod` polls for ~200s
* and whose `probeLink` reports instead of deciding): the player's contract is
* a bounded click, so this module owns its own short retry, timeout and the
* media verification that decides what may reach the browser.
*/
const VOD_STATUS_ATTEMPTS = 2
const VOD_STATUS_TIMEOUT_MS = 15_000
const VOD_STATUS_RETRY_DELAY_MS = 1_000
const MEDIA_PROBE_TIMEOUT_MS = 10_000
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/** One bounded fetch of the `video-sob-demanda` JSON; throws after the retries. */
const fetchVodStatus = async (url: string): Promise<unknown> => {
let lastError: unknown
for (let attempt = 1; attempt <= VOD_STATUS_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url, {
headers: { 'User-Agent': CAMARA_USER_AGENT, Accept: 'application/json' },
signal: AbortSignal.timeout(VOD_STATUS_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`)
return await response.json()
} catch (error) {
lastError = error
if (attempt < VOD_STATUS_ATTEMPTS) await sleep(VOD_STATUS_RETRY_DELAY_MS)
}
}
throw lastError
}
type MediaProbe = { verified: boolean; retryWithHead: boolean }
/**
* A media link counts as verified when the ranged GET answers 2xx with a real
* content type — a `text/html` body is the CDN's error page wearing a 200.
*/
const probeMediaOnce = async (url: string, method: 'GET' | 'HEAD'): Promise<MediaProbe> => {
const response = await fetch(url, {
method,
headers: {
'User-Agent': CAMARA_USER_AGENT,
...(method === 'GET' ? { Range: 'bytes=0-1023' } : {}),
},
signal: AbortSignal.timeout(MEDIA_PROBE_TIMEOUT_MS),
})
const contentType = response.headers.get('content-type')
const verified = response.ok && contentType !== null && !contentType.includes('text/html')
await response.body?.cancel().catch(() => undefined)
return { verified, retryWithHead: response.status === 405 || response.status === 501 }
}
/** Never throws: a transport failure is just an unverified link. */
const probeSpeechMedia = async (url: string | null): Promise<boolean> => {
if (!url) return false
try {
const first = await probeMediaOnce(url, 'GET')
if (first.verified) return true
if (first.retryWithHead) return (await probeMediaOnce(url, 'HEAD')).verified
return false
} catch {
return false
}
}
export const resolveSpeechVod = async ({
eventId,
audioId,
excerptTms,
}: {
eventId: number
audioId: number
excerptTms: number
}): Promise<SpeechVodResolution> => {
const status = parseVodStatus(await fetchVodStatus(buildVodUrl(eventId, audioId, excerptTms)))
if (status.state === 'GERANDO') return { state: 'gerando' }
if (status.state !== 'PRONTO' || !status.video) return { state: 'indisponivel' }
const [playback, download] = await Promise.all([
probeSpeechMedia(status.video.playbackUrl),
probeSpeechMedia(status.video.downloadUrl),
])
return {
state: 'pronto',
playbackUrl: playback ? status.video.playbackUrl : null,
downloadUrl: download ? status.video.downloadUrl : null,
}
}

View file

@ -1,16 +1,27 @@
import { request as playwrightRequest } from '@playwright/test'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
/**
* C154 — the communication vertical over real HTTP (browserless, OPS35/OPS87):
* role gates, the acervo search rendering (highlight + excerpt), the filter
* URL contract and the detail's player/transcript/actions. Playback itself is
* browser territory and is not asserted here — the HTML carries the contract
* (`<video src>`, `data-start-seconds`).
* C154/C162 — the communication vertical over real HTTP (browserless,
* OPS35/OPS87): role gates, the acervo search rendering (highlight + excerpt),
* the filter URL contract and the detail's player/transcript/actions. The
* player picks the surface from the row coordinates — YouTube default when the
* session link parses, stored-VOD capa resolved on click otherwise, honest
* unavailable state when neither exists — and the stored VOD link itself is
* never rendered (C162). Playback is browser territory and is not asserted
* here; the HTML carries the contract (`<video src>` only after a click,
* `data-start-seconds`, the iframe `start`).
*/
const YOUTUBE_VIDEO_ID = 'lLhRDkSPw0A'
const EXCERPT_TMS = 1786473834650
const EXCERPT_SESSION = '2026-08-11T15:00'
const EXCERPT_OFFSET_SECONDS = 2634
const createSpeech = async (
campaign: { fixtures: CampaignE2EOwnership },
input: {
@ -21,6 +32,8 @@ const createSpeech = async (
keywords?: string[]
durationSeconds?: number
withVideo?: boolean
youtubeUrl?: string | null
eventStartAt?: string
},
) => {
const marker = input.marker
@ -42,6 +55,11 @@ const createSpeech = async (
searchText: `a retomada da saude ${marker} na bahia`,
presidingOfficer: 'Pompeo de Mattos',
officialTextUrl: 'https://camara.leg.br/discurso',
eventId: 67091,
audioId: 558641,
excerptTMs: EXCERPT_TMS,
eventStartAt: input.eventStartAt ?? EXCERPT_SESSION,
youtubeUrl: input.youtubeUrl ?? null,
...(input.withVideo === false
? {}
: {
@ -66,7 +84,7 @@ const createSpeech = async (
return speech
}
test.describe('communication vertical (C154)', () => {
test.describe('communication vertical (C154/C162)', () => {
test('communicator lands on the acervo and sees only the vertical nav, with her scoped Sollinha', async ({
campaign,
campaignRequest,
@ -111,12 +129,15 @@ test.describe('communication vertical (C154)', () => {
expect(rendered(await advisorHome.text())).not.toContain('href="/campanha/comunicacao"')
})
test('search renders the highlighted excerpt and the detail carries the player', async ({
test('search keeps the download off the card; the both-sources detail defaults to YouTube', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('falateste')
const speech = await createSpeech(campaign, { marker })
const speech = await createSpeech(campaign, {
marker,
youtubeUrl: `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`,
})
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
@ -128,16 +149,46 @@ test.describe('communication vertical (C154)', () => {
expect(resultsHtml).toContain('<mark')
expect(resultsHtml).toContain('Assistir no trecho')
expect(resultsHtml).toContain('1 fala encontrada')
// C162 — the direct download left the card, and the stored ephemeral link
// never reaches any list HTML.
expect(resultsHtml).not.toContain('Baixar')
expect(resultsHtml).not.toContain('vod.camara.leg.br')
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}?t=0&q=${marker}`)
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}?t=43&q=${marker}`)
expect(detail.status()).toBe(200)
const detailHtml = rendered(await detail.text())
expect(detailHtml).toContain('data-slot="speech-player"')
expect(detailHtml).toContain('<video')
// Default surface is the YouTube embed, positioned by the session offset
// (2634) plus the deep-linked transcript second (43).
expect(detailHtml).toContain(`youtube-nocookie.com/embed/${YOUTUBE_VIDEO_ID}`)
expect(detailHtml).toContain(`start=${EXCERPT_OFFSET_SECONDS + 43}`)
expect(detailHtml).toContain('data-start-seconds="0"')
expect(detailHtml).toContain('Baixar vídeo (MP4)')
expect(detailHtml).toContain('Abrir fonte')
expect(detailHtml).toContain('Fonte: Câmara dos Deputados')
expect(detailHtml).not.toContain('<video')
expect(detailHtml).not.toContain('vod.camara.leg.br')
})
test('a VOD-only speech offers the on-click resolution instead of a player', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('sovod')
const speech = await createSpeech(campaign, { marker })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
expect(detail.status()).toBe(200)
const html = rendered(await detail.text())
expect(html).toContain('Assistir o trecho')
expect(html).toContain('Baixar vídeo (MP4)')
expect(html).not.toContain('<video')
expect(html).not.toContain('<iframe')
// The stored link is an eligibility signal, never a rendered URL.
expect(html).not.toContain('vod.camara.leg.br')
})
test('facet filters narrow the results and the empty state suggests clearing', async ({
@ -186,7 +237,7 @@ test.describe('communication vertical (C154)', () => {
)
})
test('a speech without VOD renders the unavailable block instead of a player', async ({
test('a speech without video renders the unavailable block instead of a player', async ({
campaign,
campaignRequest,
}) => {
@ -198,7 +249,66 @@ test.describe('communication vertical (C154)', () => {
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
const html = rendered(await detail.text())
expect(html).toContain('VOD indisponível')
expect(html).toContain('Vídeo indisponível neste momento')
expect(html).toContain('Abrir fonte')
// No stored VOD means nothing to resolve: no MP4 button and no retry.
expect(html).not.toContain('Baixar vídeo (MP4)')
expect(html).not.toContain('Tentar novamente')
})
test.describe('POST /campanha/comunicacao/acervo/resolver-vod (C162)', () => {
const endpoint = '/campanha/comunicacao/acervo/resolver-vod'
test('rejects a request without a campaign session', async ({ campaign }) => {
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
const response = await anonymous.post(endpoint, { data: { speechId: 1 } })
expect(response.status()).toBe(401)
} finally {
await anonymous.dispose()
}
})
test('rejects a cross-origin request before any read', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('origem')
const speech = await createSpeech(campaign, { marker })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.post(endpoint, {
data: { speechId: speech.id },
headers: { Origin: 'https://evil.example' },
})
expect(response.status()).toBe(403)
expect(await response.text()).toContain('Requisição inválida.')
})
test('refuses an actor outside the catalog gate', async ({ campaign, campaignRequest }) => {
const marker = campaign.fixtures.value('assessor')
const speech = await createSpeech(campaign, { marker })
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const request = await campaignRequest(advisor, advisor.password)
const response = await request.post(endpoint, { data: { speechId: speech.id } })
expect(response.status()).toBe(400)
expect(await response.text()).toContain('Você não tem acesso ao acervo de falas.')
})
test('refuses a speech without a stored VOD without calling the Câmara', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('inelegivel')
const speech = await createSpeech(campaign, { marker, withVideo: false })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.post(endpoint, { data: { speechId: speech.id } })
expect(response.status()).toBe(400)
expect(await response.text()).toContain('não tem trecho de vídeo')
})
})
})

View file

@ -196,6 +196,38 @@ describe('speech acervo (C154)', () => {
expect(view.presidingOfficer).toBe('Pompeo de Mattos')
})
it('derives the YouTube default and the resoluble VOD from the detail row (C162)', async () => {
const withBoth = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
vodPlaybackUrl: 'https://vod.camara.leg.br/old.mp4',
vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4',
excerptTMs: 1786473834650,
eventStartAt: '2026-08-11T15:00',
})
const onlyYoutube = await createSpeech({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' })
const { communicator } = await createUsers()
const both = await loadSpeechDetailPageData(payload, communicator, withBoth)
expect(both.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(both.youtubeOffsetSeconds).toBe(2634)
expect(both.vodResolvable).toBe(true)
const youtubeOnly = await loadSpeechDetailPageData(payload, communicator, onlyYoutube)
expect(youtubeOnly.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(youtubeOnly.vodResolvable).toBe(false)
})
it('keeps the direct download link off the list view model (C162)', async () => {
const id = await createSpeech({ vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4' })
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, {})
const row = data.rows.find((item) => item.id === id)
expect(row).toBeDefined()
expect('downloadUrl' in (row as object)).toBe(false)
})
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
const id = await createSpeech()
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()

View file

@ -0,0 +1,246 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { SpeechDetailPlayer } from '@/components/campaign/speech/SpeechDetailPlayer'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
const PLAYBACK_URL = 'https://vod.camara.leg.br/trecho.mp4'
const DOWNLOAD_URL = 'https://vod.camara.leg.br/trecho-download.mp4'
const SEGMENTS: readonly SpeechDetailSegmentViewModel[] = [
{
startSeconds: 0,
endSeconds: 5,
startLabel: '00:00',
parts: [{ text: 'Primeiro trecho', highlighted: false }],
},
{
startSeconds: 43,
endSeconds: 50,
startLabel: '00:43',
parts: [{ text: 'O trecho sobre a saúde', highlighted: false }],
},
]
const pronto = (playbackUrl: string | null, downloadUrl: string | null) => ({
status: 'success' as const,
resolution: { state: 'pronto' as const, playbackUrl, downloadUrl },
})
const gerando = { status: 'success' as const, resolution: { state: 'gerando' as const } }
const indisponivel = { status: 'success' as const, resolution: { state: 'indisponivel' as const } }
const fetchMock = vi.fn()
const respondWith = (payload: unknown, ok = true) => {
fetchMock.mockResolvedValue({ ok, json: async () => payload })
}
const deferred = <T,>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve
})
return { promise, resolve }
}
const renderPlayer = (props: Partial<Parameters<typeof SpeechDetailPlayer>[0]> = {}) =>
render(
<SpeechDetailPlayer
speechId={42}
youtubeVideoId={null}
youtubeOffsetSeconds={null}
vodResolvable
segments={SEGMENTS}
initialSeconds={null}
sourceUrl={null}
{...props}
/>,
)
const videoElement = () => document.querySelector('video')
const iframeElement = () => document.querySelector('iframe')
const playerRoot = () => document.querySelector('[data-slot="speech-player"]')
beforeAll(() => {
// jsdom does not implement media playback; the seek contract is what matters.
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value: () => Promise.resolve(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'currentTime', {
configurable: true,
writable: true,
value: 0,
})
})
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('SpeechDetailPlayer — VOD-only quadrant', () => {
it('resolves on click, shows the pending state and plays the exact excerpt', async () => {
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
fetchMock.mockReturnValue(pending.promise)
renderPlayer()
expect(screen.getByText(/O trecho deste vídeo é gerado pela Câmara/)).toBeDefined()
expect(videoElement()).toBeNull()
expect(iframeElement()).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
await waitFor(() => {
expect(playerRoot()?.getAttribute('aria-busy')).toBe('true')
})
expect(screen.getByText(/Resolvendo o trecho na Câmara/)).toBeDefined()
const busyDownload = screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i })
expect((busyDownload as HTMLButtonElement).disabled).toBe(true)
await act(async () => {
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
})
await waitFor(() => {
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
})
expect(playerRoot()?.getAttribute('aria-busy')).toBeNull()
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
expect(videoElement()?.currentTime).toBe(43)
})
it('shows the honest state after a failure and re-posts on retry', async () => {
respondWith(indisponivel)
renderPlayer()
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
respondWith(pronto(PLAYBACK_URL, null))
fireEvent.click(screen.getByRole('button', { name: /tentar novamente/i }))
await waitFor(() => {
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ speechId: 42 })
})
it('shows the generating state with a retry and no player', async () => {
respondWith(gerando)
renderPlayer()
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
expect(await screen.findByText(/A Câmara está gerando o trecho deste vídeo/)).toBeDefined()
expect(screen.getByRole('button', { name: /tentar novamente/i })).toBeDefined()
expect(videoElement()).toBeNull()
})
it('keeps the verified download offered when only the playback probe failed', async () => {
respondWith(pronto(null, DOWNLOAD_URL))
renderPlayer()
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
expect(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i })).toBeDefined()
expect(videoElement()).toBeNull()
})
})
describe('SpeechDetailPlayer — YouTube quadrant', () => {
it('embeds the session at the offset and seeks the transcript by reloading start', async () => {
renderPlayer({
youtubeVideoId: 'lLhRDkSPw0A',
youtubeOffsetSeconds: 2634,
initialSeconds: 100,
})
expect(videoElement()).toBeNull()
expect(iframeElement()?.getAttribute('src')).toBe(
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2734',
)
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
await waitFor(() => {
expect(iframeElement()?.getAttribute('src')).toContain('start=2677')
})
})
it('leaves the transcript inert when the session offset is unknown', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
expect(screen.queryByText(/clique para posicionar/i)).toBeNull()
const segment = document.querySelector('button[data-start-seconds="43"]') as HTMLButtonElement
expect(segment.disabled).toBe(true)
})
it('resolves the MP4 on download and opens the verified URL in a pre-opened tab', async () => {
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
fetchMock.mockReturnValue(pending.promise)
const tab = { location: { href: '' }, close: vi.fn() }
const openSpy = vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
renderPlayer({
youtubeVideoId: 'lLhRDkSPw0A',
youtubeOffsetSeconds: 2634,
initialSeconds: null,
})
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
expect(openSpy).toHaveBeenCalledWith('', '_blank')
expect(
(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }) as HTMLButtonElement).disabled,
).toBe(true)
await act(async () => {
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
})
await waitFor(() => {
expect(tab.location.href).toBe(DOWNLOAD_URL)
})
expect(tab.close).not.toHaveBeenCalled()
expect(iframeElement()).not.toBeNull()
})
it('closes the tab and shows the honest notice when the resolution fails', async () => {
respondWith({ status: 'error', message: 'Não foi possível resolver o trecho.' }, false)
const tab = { location: { href: '' }, close: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
await waitFor(() => {
expect(tab.close).toHaveBeenCalled()
})
expect(await screen.findByText('Não foi possível resolver o trecho.')).toBeDefined()
expect(iframeElement()).not.toBeNull()
})
})
describe('SpeechDetailPlayer — no-video quadrant', () => {
it('renders the unavailable block without retry or download', () => {
renderPlayer({ vodResolvable: false, sourceUrl: 'https://imagem.camara.leg.br/diario.pdf' })
expect(screen.getByText('Vídeo indisponível neste momento.')).toBeDefined()
expect(screen.queryByRole('button', { name: /tentar novamente/i })).toBeNull()
expect(screen.queryByRole('button', { name: /baixar vídeo/i })).toBeNull()
expect(screen.getByRole('link', { name: /abrir fonte/i })).toBeDefined()
expect(videoElement()).toBeNull()
expect(iframeElement()).toBeNull()
})
})

View file

@ -0,0 +1,84 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
/**
* The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs`
* to `@/lib/speechVod` (C162) stay pinned by `camaraSpeeches.unit.spec.ts`, which
* imports them through the script's re-export — this file owns the new pure rules.
*/
describe('excerptOffsetSeconds', () => {
it('measures the offset between the excerpt and the session start (intent literal)', () => {
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T15:00')).toBe(2634)
})
it('matches the C153 import fixture (import literal)', () => {
expect(excerptOffsetSeconds(1675801808560, '2023-02-07T15:00')).toBe(9008)
})
it('reads both sides as BRT wall clock across an old DST transition', () => {
// 2018-11-04 00:00 BRT: clocks move to -02:00; 14:00Z reads as 12:00 BRT.
expect(excerptOffsetSeconds(1541340000000, '2018-11-04T11:00')).toBe(3600)
// The day before the transition still reads at -03:00: 14:00Z = 11:00 BRT.
expect(excerptOffsetSeconds(1541253600000, '2018-11-03T10:00')).toBe(3600)
})
it('crosses midnight without losing the date', () => {
// 2024-05-15 02:30Z = 2024-05-14 23:30 BRT.
expect(excerptOffsetSeconds(1715740200000, '2024-05-14T23:00')).toBe(1800)
})
it('accepts the space spelling of the naive datetime', () => {
expect(excerptOffsetSeconds(1786473834650, '2026-08-11 15:00')).toBe(2634)
})
it('returns null when the excerpt precedes the session start', () => {
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T16:00')).toBeNull()
})
it('returns null on unparseable or out-of-range input', () => {
expect(excerptOffsetSeconds(null, '2026-08-11T15:00')).toBeNull()
expect(excerptOffsetSeconds(0, '2026-08-11T15:00')).toBeNull()
expect(excerptOffsetSeconds(1786473834650, null)).toBeNull()
expect(excerptOffsetSeconds(1786473834650, 'sem data')).toBeNull()
expect(excerptOffsetSeconds(1786473834650, '2026-13-11T15:00')).toBeNull()
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T25:00')).toBeNull()
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T15:75')).toBeNull()
})
})
describe('parseYoutubeVideoId', () => {
const videoId = 'lLhRDkSPw0A'
it('parses the open-data watch link', () => {
expect(parseYoutubeVideoId(`https://www.youtube.com/watch?v=${videoId}`)).toBe(videoId)
})
it('parses the shortened, embed, live, shorts and mobile forms', () => {
expect(parseYoutubeVideoId(`https://youtu.be/${videoId}?t=30`)).toBe(videoId)
expect(parseYoutubeVideoId(`https://www.youtube.com/embed/${videoId}`)).toBe(videoId)
expect(parseYoutubeVideoId(`https://www.youtube-nocookie.com/embed/${videoId}`)).toBe(videoId)
expect(parseYoutubeVideoId(`https://www.youtube.com/live/${videoId}?feature=share`)).toBe(
videoId,
)
expect(parseYoutubeVideoId(`https://www.youtube.com/shorts/${videoId}`)).toBe(videoId)
expect(parseYoutubeVideoId(`https://m.youtube.com/watch?v=${videoId}`)).toBe(videoId)
})
it('rejects non-YouTube hosts, unparseable values and non-video links', () => {
expect(parseYoutubeVideoId(`https://example.com/watch?v=${videoId}`)).toBeNull()
expect(parseYoutubeVideoId(`youtube.com/watch?v=${videoId}`)).toBeNull()
expect(parseYoutubeVideoId('https://www.youtube.com/playlist?list=PL123')).toBeNull()
expect(parseYoutubeVideoId('https://www.youtube.com/')).toBeNull()
expect(parseYoutubeVideoId('')).toBeNull()
expect(parseYoutubeVideoId(null)).toBeNull()
})
it('rejects an id that does not look like a YouTube id', () => {
expect(parseYoutubeVideoId('https://www.youtube.com/watch?v=ab')).toBeNull()
expect(parseYoutubeVideoId('https://www.youtube.com/watch?v=../etc/passwd')).toBeNull()
})
})

View file

@ -0,0 +1,146 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
const VOD_PLAYBACK = 'https://vod.camara.leg.br/trecho.mp4'
const VOD_DOWNLOAD = 'https://vod.camara.leg.br/trecho-download.mp4'
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
const mediaResponse = (status: number, contentType: string | null): Response =>
new Response(status >= 200 && status < 300 ? 'video-bytes' : '', {
status,
...(contentType ? { headers: { 'content-type': contentType } } : {}),
})
type FetchCall = { url: string; method: string; range: string | null; userAgent: string | null }
const stubFetch = (
handler: (url: string, init?: RequestInit) => Promise<Response>,
): FetchCall[] => {
const calls: FetchCall[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>
calls.push({
url: String(input),
method: (init?.method ?? 'GET').toUpperCase(),
range: headers.Range ?? null,
userAgent: headers['User-Agent'] ?? null,
})
return handler(String(input), init)
}),
)
return calls
}
const readyBody = {
estado: 'PRONTO',
video: {
titulo: 'Trecho',
linkParaReproducao: VOD_PLAYBACK,
linkParaDownload: VOD_DOWNLOAD,
},
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('resolveSpeechVod', () => {
it('returns both URLs only after the ranged probes verify them', async () => {
const calls = stubFetch(async (url) =>
url.includes('video-sob-demanda') ? jsonResponse(readyBody) : mediaResponse(206, 'video/mp4'),
)
await expect(
resolveSpeechVod({ eventId: 67091, audioId: 558641, excerptTms: 3 }),
).resolves.toEqual({
state: 'pronto',
playbackUrl: VOD_PLAYBACK,
downloadUrl: VOD_DOWNLOAD,
})
const probes = calls.filter((call) => call.method === 'GET' && call.range === 'bytes=0-1023')
expect(probes).toHaveLength(2)
expect(probes.every((probe) => probe.userAgent?.includes('Mozilla'))).toBe(true)
expect(calls[0]?.url).toContain('trecho=3')
})
it('drops a playback link whose probe fails but keeps the verified download', async () => {
stubFetch(async (url) =>
url.includes('video-sob-demanda')
? jsonResponse(readyBody)
: url === VOD_PLAYBACK
? mediaResponse(403, null)
: mediaResponse(206, 'video/mp4'),
)
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
state: 'pronto',
playbackUrl: null,
downloadUrl: VOD_DOWNLOAD,
})
})
it('rejects a link that answers 200 with an HTML error page', async () => {
stubFetch(async (url) =>
url.includes('video-sob-demanda')
? jsonResponse(readyBody)
: mediaResponse(200, 'text/html; charset=utf-8'),
)
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
state: 'pronto',
playbackUrl: null,
downloadUrl: null,
})
})
it('falls back to HEAD when the ranged GET is refused', async () => {
stubFetch(async (url, init) => {
if (url.includes('video-sob-demanda')) return jsonResponse(readyBody)
return (init?.method ?? 'GET').toUpperCase() === 'GET'
? mediaResponse(405, null)
: mediaResponse(200, 'video/mp4')
})
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
state: 'pronto',
playbackUrl: VOD_PLAYBACK,
downloadUrl: VOD_DOWNLOAD,
})
})
it('maps GERANDO and INDISPONIVEL without probing anything', async () => {
const calls = stubFetch(async () => jsonResponse({ estado: 'GERANDO', video: null }))
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
state: 'gerando',
})
expect(calls).toHaveLength(1)
vi.unstubAllGlobals()
stubFetch(async () => jsonResponse({ estado: 'INDISPONIVEL', video: null }))
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
state: 'indisponivel',
})
})
it('throws after the bounded retries when the Câmara is unreachable', async () => {
const calls = stubFetch(async () => {
throw new Error('connect ETIMEDOUT')
})
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).rejects.toThrow(
'connect ETIMEDOUT',
)
expect(calls).toHaveLength(2)
})
})