mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(C181): player do acervo volta a ter o YouTube in-page e espera a Câmara gerar o trecho
- restaura o embed do YouTube como superfície opt-in no SpeechDetailPlayer (a Câmara segue a entrada padrão; o embed nunca é a porta automática, guardrail C178) - controle simétrico "Assistir no YouTube"/"Assistir na Câmara" no bloco de saída, com o ponto da fala preservado na troca (pendingSeekRef/youtubeStart) - SPEECH_VOD_PLAYER_POLICY passa a pollAttempts: 2 / pollDelayMs: 3000 — espera bounded dentro do clique em vez de tratar o primeiro GERANDO como falha - unit do resolver (pin + GERANDO→PRONTO) e do player (ida-e-volta + ponto); pin SSR do e2e mantendo o embed fora do HTML inicial Closes #1103
This commit is contained in:
parent
691490709b
commit
20d2f03226
7 changed files with 339 additions and 119 deletions
1
docs/changelog/2026-09-16-c181.md
Normal file
1
docs/changelog/2026-09-16-c181.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C181 — Acervo: o player volta a ter o YouTube in-page e espera a Câmara gerar o trecho (2026-09-16):** o aviso do dono era um defeito de duas cabeças na mesma fala. Primeira: depois do C178 a Câmara virou o caminho padrão, mas quem preferia o YouTube ficava sem volta na própria página (só o link externo "Abrir no YouTube", que sai da página). Segunda: o clique do player usava `SPEECH_VOD_PLAYER_POLICY` com `pollAttempts: 0`, então o primeiro `GERANDO` da Câmara já virava o estado "gerando" com "Tentar novamente" — a geração era curta e o retry "funcionava", provando que o app desistia cedo. A correção é pequena e simétrica. No `SpeechDetailPlayer`, o `<iframe>` do YouTube (`buildYoutubeSrc`, `playsinline=1&rel=0&start=<s>`), o estado `surface: 'camara' | 'youtube'`, `youtubeStart` e `pendingSeekRef` voltam **com a entrada padrão invertida para a Câmara** (guardrail do C178: o embed nunca é a porta de entrada automática, só a superfície de retorno sob clique); o bloco de saída ganha o controle simétrico "Assistir no YouTube" (Câmara→embed, no ponto da frase tocada) e "Assistir na Câmara" (embed→Câmara, preservando o ponto via `pendingSeekRef`), mantendo o link externo "Abrir no YouTube" e a `YoutubeFacade` do C178 para falas só-YouTube (evita recriar a parede de signin do P1/#1081). `seekTo` volta a mover a superfície YouTube e `inlineNotice` volta a mostrar o estado da Câmara enquanto o embed está ativo. No `speechVodResolver`, `SPEECH_VOD_PLAYER_POLICY` passa a `pollAttempts: 2`, `pollDelayMs: 3_000` — um orçamento próprio do clique (~6 s, entre o imediato antigo e os 45 s do corte), reusando o loop `resolveFromVodApi` já testado; estourou o orçamento, o estado honesto e o retry permanecem. Sem `Consent`, collection, migration, URL pública nova ou escrita; gate do acervo e crédito "Fonte: Câmara dos Deputados · CC BY 4.0" intocados. Testes: unit do resolver (pin da política + `GERANDO`→`PRONTO` sob a política do player + `GERANDO` bounded sem probe) e unit RTL do player (troca Câmara↔embed e ponto da transcrição preservado na volta); e2e browserless do acervo ganha o pin SSR do controle de volta (`data-slot="speech-youtube-exit-embed"`) mantendo a asserção de que o embed nunca é server-rendered.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Impl: Acervo: o player sempre deixa voltar ao YouTube e espera a Câmara gerar o trecho
|
||||
|
||||
Status: rascunho
|
||||
Status: em execução (GATE aprovado em 2026-09-16)
|
||||
Atualizado em: 2026-09-16
|
||||
Issue: #1103
|
||||
Intenção: docs/plans/acervo-player-volta-ao-youtube-e-vod-com-espera.md
|
||||
|
|
@ -8,124 +8,134 @@ Appetite restante: ~0,5–1 dia eng (herdado da intenção); nada mais entra.
|
|||
|
||||
## Leitura da intenção (outcome, não negociar, reavaliar)
|
||||
|
||||
- **Outcome:** trocar de superfície nos dois sentidos (Câmara ↔ YouTube) na mesma página, com ponto da fala preservado; esperar geração da Câmara dentro do clique do player (bounded) em vez de devolver "gerando" na primeira resposta.
|
||||
- **O que NÃO negociar:** nada de autenticar no YouTube nem embed novo; não inventar estado; superfície que o app controla segue preferida (C178); transcrição/posicionamento e seleção/corte intactos; sem Consent/collection/migration; URLs públicas do acervo intocadas; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido.
|
||||
- **O que reavaliar:** hipóteses de "Direção no codebase" que podem estar erradas.
|
||||
- **Outcome:** trocar de superfície nos dois sentidos na própria página (Câmara ↔ YouTube), com o ponto da fala preservado ao voltar; esperar a geração da Câmara dentro do clique do player (bounded) em vez de devolver "gerando" na primeira resposta.
|
||||
- **O que NÃO negociar:** não inventar estado; **a superfície que o app controla segue preferida/entrada padrão** (guardrail C178); transcrição/posicionamento e seleção/corte intactos; sem `Consent`/collection/migration; URLs públicas do acervo intocadas; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido.
|
||||
- **Decisão de produto (GATE, 2026-09-16):** o humano decidiu **restaurar o embed do YouTube**. A remoção do embed pelo C178 não era a intenção do dono. Esta entrega passa a ser **dona** da reversão da parte "embed removido" do C178 — reabre explicitamente a decisão anterior (precedente do AGENTS: a entrega que muda o contrato o possui).
|
||||
- **O que preservar do C178:** a **entrada padrão continua sendo a Câmara** quando há trecho (`vodResolvable`); o embed volta como **superfície navegável** (a "volta"), nunca como porta de entrada automática — é isso que evita recriar a parede de signin que originou o C178 (P1/#1081). Falas só-YouTube seguem na capa do C178.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[clica em "Assistir na Câmara"] --> R[resolveSpeechVod com política do player<br/>pollAttempts: 2, pollDelayMs: 3000]
|
||||
U[clica em "Assistir o trecho"] --> R[resolveSpeechVod com política do player<br/>pollAttempts: 2, pollDelayMs: 3000]
|
||||
R --> G{resposta da Câmara?}
|
||||
G -- PRONTO --> P[<video> toca o trecho]
|
||||
G -- GERANDO --> W[espera bounded<br/>polls curtos]
|
||||
G -- PRONTO --> P[<video> toca o trecho<br/>superfície Câmara]
|
||||
G -- GERANDO --> W[espera bounded<br/>2 polls de 3s]
|
||||
W --> G
|
||||
G -- INDISPONIVEL/estourou --> F[estado honesto + "Tentar novamente"]
|
||||
P --> E[bloco de saída "Assistir no YouTube" sempre visível]
|
||||
F --> E
|
||||
P --> E[bloco de saída]
|
||||
E -- "Assistir no YouTube" --> Y[superfície YouTube: iframe embed no ponto]
|
||||
Y -- "Assistir na Câmara" --> P
|
||||
Y -.-> X["Abrir no YouTube" externo, no ponto]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
**Opções consideradas (cabeça 2 — espera da Câmara):**
|
||||
|
||||
- **A — Trocar `pollAttempts: 0` por `pollAttempts: 2` com `pollDelayMs: 3000`** no `SPEECH_VOD_PLAYER_POLICY` (reusar a lógica de polling já existente no `resolveFromVodApi`).
|
||||
- **B — Criar uma política nova `SPEECH_VOD_PLAYER_POLLING_POLICY`** com valores próprios.
|
||||
- **C — Manter `pollAttempts: 0` e só melhorar a copy.**
|
||||
- **A —** ajustar `SPEECH_VOD_PLAYER_POLICY` (`pollAttempts: 0 → 2`, `pollDelayMs: 1_000 → 3_000`), reusando o loop já existente em `resolveFromVodApi`.
|
||||
- **B —** política nova só para o clique.
|
||||
- **C —** manter `pollAttempts: 0` e só melhorar a copy.
|
||||
|
||||
**Recomendação: A** — a lógica de polling já existe e é testada; basta ajustar os parâmetros da política do player. **Rejeitadas:** B (complexidade desnecessária; uma política por caller é suficiente), C (não resolve o defeito).
|
||||
**Recomendação: A** — o loop de polling já existe, é testado e é o dono da espera bounded; só os parâmetros mudam. **Rejeitadas:** B (uma política por caller já basta), C (não resolve — é o defeito).
|
||||
|
||||
**Opções consideradas (cabeça 1 — volta ao YouTube):** A) bloco externo já entregue | B) capa do C178 in-page | C) **restaurar o embed do YouTube in-page**.
|
||||
**Recomendação: C (decidida pelo humano)** — o dono quer o embed de volta; a superfície YouTube é o iframe `https://www.youtube.com/embed/<id>?playsinline=1&rel=0&start=<s>` (o contrato literal que a intenção manda preservar), restaurado do pré-C178 (`git show b378e1a9^:src/components/campaign/speech/SpeechDetailPlayer.tsx`) com a entrada padrão invertida para a Câmara. **Rejeitadas:** A/B (não devolvem o embed).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/utilities/speech/speechVodResolver.ts`** (editar o dono):
|
||||
- Trocar `SPEECH_VOD_PLAYER_POLICY.pollAttempts` de `0` para `2`.
|
||||
- Trocar `SPEECH_VOD_PLAYER_POLICY.pollDelayMs` de `1_000` para `3_000`.
|
||||
- Justificativa: 2 polls de 3s = até 6s de espera adicional (total ~9s com o primeiro fetch); é um orçamento curto e próprio do clique, entre o imediato de hoje e o do corte (45s/2 polls de 5s).
|
||||
- **`src/utilities/speech/speechVodResolver.ts`** (dono da política):
|
||||
- `SPEECH_VOD_PLAYER_POLICY.pollAttempts: 0 → 2`, `pollDelayMs: 1_000 → 3_000`. Orçamento ~6s adicionais (1 fetch + 2 polls), bounded; `statusTimeoutMs: 15s` por leitura.
|
||||
|
||||
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (editar o dono):
|
||||
- O bloco `renderYoutubeExit()` já existe e é sempre visível (C178); não precisa de mudança.
|
||||
- O estado `generating` já mostra "A Câmara está gerando o trecho" e "Tentar novamente"; com o polling ativo, o player vai transitar automaticamente de `generating` para `resolved` (ou `failed` se estourar o orçamento).
|
||||
- **Nenhuma mudança de UI necessária** — o defeito é na política de polling, não no componente.
|
||||
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (dono da superfície) — restaurar do pré-C178 com a entrada padrão da Câmara:
|
||||
- Voltar `buildYoutubeSrc(videoId, startSeconds)` (iframe `playsinline=1&rel=0[&start=N]`).
|
||||
- Voltar o estado `surface: 'camara' | 'youtube'` com **default `'camara'` quando `vodResolvable`** (e `'youtube'` só quando não há trecho), invertendo o default pré-C178.
|
||||
- `renderMedia`: quando `surface === 'youtube'` e há id, renderizar o `<iframe>` (título "Vídeo da sessão no YouTube", `loading="lazy"`, allow list do pré-C178); caso contrário o comportamento Câmara atual.
|
||||
- `youtubeStart` (offset da sessão + ponto) e `pendingSeekRef` (C171-F1) restaurados: o ponto clicado na transcrição sobrevive à troca de superfície até o `<video>` montar e seekar; `seekTo` volta a tratar a superfície YouTube.
|
||||
- Bloco de saída (`renderYoutubeExit`): manter o layout C178 e o link externo "Abrir no YouTube" (no ponto); adicionar o controle simétrico **"Assistir no YouTube"** (troca para a superfície embed) quando a superfície ativa é a Câmara e há id; **"Assistir na Câmara"** quando a superfície é o embed e `vodResolvable` (restaura o `<video>`/painel no ponto). Copiar o padrão de botão já usado no bloco.
|
||||
- `inlineNotice` do pré-C178 restaurado: estado `generating`/`failed`/download-não-verificado visível enquanto o embed está ativo.
|
||||
- Falas só-YouTube (`!vodResolvable`) seguem a `YoutubeFacade` do C178 (capa externa) — **não** entram em embed automático (evita a P1 de signin).
|
||||
- `seekable` volta a considerar a superfície YouTube (offset conhecido).
|
||||
|
||||
- **`src/app/(campaign)/campanha/actions/speech.ts`** (não mexer):
|
||||
- `resolveSpeechVodForActor` já usa `resolveSpeechVod(coordinates)` que por padrão usa `SPEECH_VOD_PLAYER_POLICY`; a mudança na política surte efeito automaticamente.
|
||||
- **`src/app/(campaign)/campanha/actions/speech.ts`** (não mexer): herda a política do player.
|
||||
- **`src/lib/speechVod.ts`** (não mexer): `resolveFromVodApi` já suporta `pollAttempts > 0`.
|
||||
- **`src/lib/speechShare.ts`** (não mexer): `buildSpeechExcerptYoutubeUrl` já entrega o ponto.
|
||||
- **Migration:** **sem migration** — constantes e UI, não schema.
|
||||
- **Access / Consent:** sem mudança (`canReadSpeechCatalog` + `overrideAccess: false` nas leituras).
|
||||
- **UI:** Impeccable A — o embed e o bloco de saída já foram desenhados (pré-C178/C178); a composição nova é o switch simétrico dentro do bloco já existente. Sem novo arquivo de design; reusa o markup removido. Se a crítica final (trigger c) reprovar a composição, escalar ao `designer`.
|
||||
|
||||
- **`src/lib/speechVod.ts`** (não mexer):
|
||||
- A função `resolveFromVodApi` já suporta `pollAttempts > 0`; não precisa de mudança.
|
||||
### Dados → forma
|
||||
|
||||
- **Migration:** **sem migration** — mudança de constante em código, não de schema.
|
||||
|
||||
- **Access / Consent:** sem mudança.
|
||||
|
||||
- **UI:** **Impeccable A — N/A** (sem UI nova; o bloco de saída já existe e é sempre visível).
|
||||
|
||||
- **Testes:**
|
||||
- `tests/unit/speechVodResolver.unit.spec.ts`: atualizar o pin de `SPEECH_VOD_PLAYER_POLICY` (linha 164-171) para refletir os novos valores.
|
||||
- `tests/unit/speechDetailPlayer.unit.spec.tsx`: adicionar teste para o estado `generating` com polling ativo (mockar fetch para retornar `GERANDO` na primeira chamada e `PRONTO` na segunda; verificar que o player transita para `resolved`).
|
||||
- `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`: o teste existente "search keeps the download off the card; the both-sources detail defaults to the Câmara excerpt" já cobre o estado idle; não precisa de mudança.
|
||||
|
||||
- **Changelog:** `docs/changelog/2026-09-16-c181.md`.
|
||||
|
||||
### Dados → forma (a resposta é a superfície)
|
||||
|
||||
- **Forma escolhida:** a mudança é transparente para o usuário; o player agora espera a geração da Câmara antes de anunciar "gerando/falhou".
|
||||
- **Rejeitadas:** UI nova (anti-goal), embed do YouTube (anti-goal), autenticação (anti-goal).
|
||||
- **Forma:** feedback de ação no player; a superfície ativa é `useState`, nunca persistido. Nenhum KPI/gráfico.
|
||||
- **Rejeitadas:** UI nova fora do bloco de saída; persistir a superfície; autenticar no YouTube.
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
### D1 — Política de polling do player: reusar a lógica existente com parâmetros próprios
|
||||
### D1 — Política de polling do player (cabeça 2)
|
||||
|
||||
- **Opções:** A) ajustar `SPEECH_VOD_PLAYER_POLICY` (pollAttempts: 2, pollDelayMs: 3000) | B) criar política nova | C) manter pollAttempts: 0.
|
||||
- **Recomendação: A** — reusa código testado, cumpre o orçamento curto do clique, não inventa estado.
|
||||
- **Rejeitadas:** B (complexidade desnecessária), C (não resolve o defeito).
|
||||
- **Opções:** A) ajustar `SPEECH_VOD_PLAYER_POLICY` (2 polls de 3s) | B) política nova | C) manter `pollAttempts: 0`.
|
||||
- **Recomendação: A** — reusa `resolveFromVodApi` testado, orçamento curto e bounded, sem inventar estado.
|
||||
- **Rejeitadas:** B (twin para <3 call sites), C (não resolve).
|
||||
|
||||
### D2 — Visibilidade do bloco de saída "Assistir no YouTube"
|
||||
### D2 — Restauração do embed e default da superfície (cabeça 1)
|
||||
|
||||
- **Opções:** A) manter como está (sempre visível quando youtubeWatchUrl existe) | B) mostrar só quando a superfície for `vod` e a mídia estiver resolvida.
|
||||
- **Recomendação: A** — a saída é justamente o que falta hoje; nunca deixar o usuário sem caminho.
|
||||
- **Rejeitadas:** B (restringe a saída no momento em que mais se precisa dela).
|
||||
- **Opções:** A) bloco externo já entregue | B) capa C178 in-page | C) restaurar o embed (default Câmara).
|
||||
- **Recomendação: C** — decisão do humano no GATE; restaura o contrato de embed que a intenção manda preservar e mantém a Câmara como entrada padrão (guardrail C178).
|
||||
- **Rejeitadas:** A/B (não devolvem o embed). O embed **não** vira default em fala só-YouTube (recriaria a parede de signin do P1).
|
||||
|
||||
### D3 — Localização do botão "Assistir no YouTube"
|
||||
### D3 — Localização do controle simétrico
|
||||
|
||||
- **Opções:** A) no bloco de saída já existente (`renderYoutubeExit`) | B) junto das ações do player.
|
||||
- **Recomendação: A** — simétrico a "Assistir na Câmara", mesmo bloco, mesmo desenho.
|
||||
- **Rejeitadas:** B (desconecta a saída do contexto de "outro caminho").
|
||||
- **Recomendação: A** — simétrico ao bloco "assista por outro caminho", mesmo desenho; foi onde o pré-C178 colocou "Assistir na Câmara".
|
||||
- **Rejeitadas:** B (desconecta a saída do contexto).
|
||||
|
||||
## Renegociação de pins (obrigatória — mesma entrega)
|
||||
|
||||
- **`tests/e2e/campaignSpeechAcervo.e2e.spec.ts`** (SSR, sem interação):
|
||||
- `not.toContain('youtube.com/embed')` e `not.toContain('<iframe')` **seguem válidos** no default Câmara (o embed só monta após o clique) — manter como regressão de que a entrada não é o embed.
|
||||
- Ajustar a asserção "old surface-switch button is gone": o bloco de saída passa a oferecer "Assistir no YouTube" (superfície Câmara). Adicionar interação (ou teste client/unit) cobrindo a ida Câmara→embed→Câmara com o ponto preservado.
|
||||
- Falas só-YouTube: `not.toContain('youtube.com/embed')`/`<iframe`/`Assistir na Câmara` seguem válidos (facade).
|
||||
- **`tests/unit/speechDetailPlayer.unit.spec.tsx`**: restaurar os testes de troca de superfície do pré-C178 (`git show b378e1a9^:tests/unit/speechDetailPlayer.unit.spec.tsx`) adaptados ao novo default Câmara; manter os testes de quadrantes do C178.
|
||||
- **`tests/unit/speechVodResolver.unit.spec.ts`**: atualizar o pin de `SPEECH_VOD_PLAYER_POLICY` (linhas 163–171) para os novos valores; adicionar caso `GERANDO → PRONTO` sob a política do player.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Ajuste da política (~0,1 dia):** editar `SPEECH_VOD_PLAYER_POLICY` em `src/utilities/speech/speechVodResolver.ts` (pollAttempts: 2, pollDelayMs: 3000). Prova: `pnpm test:unit` verde (pin atualizado no spec).
|
||||
2. **Teste do polling no player (~0,25 dia):** adicionar teste em `tests/unit/speechDetailPlayer.unit.spec.tsx` que mockia fetch para retornar GERANDO → PRONTO e verifica a transição. Prova: spec novo verde.
|
||||
3. **Verificação e2e (~0,1 dia):** rodar `pnpm test:e2e --grep "acervo"` para garantir que nenhum quadrante quebrou. Prova: e2e verde.
|
||||
4. **Changelog e lint (~0,05 dia):** criar `docs/changelog/2026-09-16-c181.md`, rodar `pnpm lint` e `pnpm format:check`.
|
||||
1. **Política de polling (~0,1 dia):** `SPEECH_VOD_PLAYER_POLICY` + pin/spec. Prova: `pnpm test:unit` verde.
|
||||
2. **Restauração do embed + switch (~0,5 dia):** markup pré-C178, default Câmara, controle simétrico no bloco de saída, `pendingSeekRef`. Prova: unit do player (ida-e-volta com ponto preservado) e e2e renegociado.
|
||||
3. **Teste do polling no player (~0,2 dia):** unit GERANDO → PRONTO resolve sozinho.
|
||||
4. **Gates (~0,1 dia):** `pnpm gate:fast`; changelog `docs/changelog/2026-09-16-c181.md`; `pnpm push`.
|
||||
5. **Fechamento:** crítica visual do `designer` (trigger c) no diff que muda UI antes do push.
|
||||
|
||||
**Quota total:** ~0,5 dia (dentro do appetite herdado).
|
||||
**Quota:** ~0,8–1,0 dia (no topo do appetite herdado).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Trocar a superfície do YouTube para embed.** Corte: a volta é um botão na nossa própria página; o YouTube continua embed anônimo.
|
||||
- **Espera infinita / fila de render.** Corte: orçamento curto dentro do clique; estourou → estado honesto + retry.
|
||||
- **"Retry resolve, então é só mandar tentar".** Corte: isso é o defeito, não a solução — o app espera antes de acusar falha.
|
||||
- **Redesenhar o player inteiro.** Corte: encaixe cirúrgico; transcrição, seleção e corte intactos.
|
||||
- **Autenticar no YouTube ou usar embed novo.** Corte: anti-goal explícito na intenção.
|
||||
- **Inventar estado ("sucesso" sem verificação de mídia).** Corte: a política de probing já verifica a mídia antes de devolver `pronto`.
|
||||
- **Mudar o bloco de saída "Assistir no YouTube".** Corte: já existe e é sempre visível (C178); não precisa de mudança.
|
||||
- **Tornar o embed a entrada padrão de novo.** Corte: default segue Câmara (guardrail C178); o embed é opt-in.
|
||||
- **Fazer o YouTube tocar quando ele bloqueia / OAuth / player próprio.** Corte: fora de escopo (intenção).
|
||||
- **Embed automático em falas só-YouTube.** Corte: mantém a facade do C178 (P1 de signin).
|
||||
- **Espera infinita / fila de render.** Corte: orçamento bounded; estourou → estado honesto + retry.
|
||||
- **Redesenhar o player inteiro.** Corte: restauração cirúrgica; transcrição, seleção e corte intactos.
|
||||
- **Inventar estado ("sucesso" sem verificação).** Corte: o probing de mídia decide o `pronto`.
|
||||
- **UI fora do bloco de saída.** Corte: simetria no bloco já desenhado.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **A Câmara demora mais que 9s para gerar o trecho:** o player entra no estado `generating` e mostra "Tentar novamente" (estado honesto); o usuário pode clicar retry manualmente. **Mitigação:** o orçamento de 9s cobre a maioria dos casos; se a Câmara ficar lenta frequentemente, reavaliar o `pollAttempts`.
|
||||
- **Regressão nos testes existentes:** o pin de `SPEECH_VOD_PLAYER_POLICY` no spec precisa ser atualizado. **Mitigação:** atualizar o pin antes de rodar os testes.
|
||||
- **Mudança de comportamento para usuários existentes:** o player agora espera 9s em vez de falhar imediatamente; isso é uma melhoria, não um defeito. **Mitigação:** nenhum usuário vai reclamar de "esperou demais" quando antes falhava de primeira.
|
||||
- **Regressão do P1 (parede de signin):** o embed não volta como entrada padrão; só após clique. Mitigação: manter o default Câmara e os pins SSR `not.toContain('youtube.com/embed')`/`<iframe`.
|
||||
- **E2E pina o oposto:** renegociar os pins na mesma entrega, sem reintroduzir embed server-rendered. Mitigação: teste de interação para a troca.
|
||||
- **Ponto perdido na troca de superfície:** restaurar `pendingSeekRef` (C171-F1) e o `youtubeStart`. Mitigação: unit cobrindo o seek após voltar.
|
||||
- **Estouro do orçamento da Câmara:** estado `generating` honesto + retry. Mitigação: reavaliar `pollAttempts` se crônico.
|
||||
- **Pin de `SPEECH_VOD_PLAYER_POLICY`:** atualizar antes do push.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: o player sempre deixa voltar ao YouTube (bloco de saída sempre visível) e espera a geração da Câmara dentro do clique (pollAttempts: 2, pollDelayMs: 3000).
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user` em toda leitura; gate `canReadSpeechCatalog`; sem migration/Consent; sem UI nova; identificadores em inglês, chaves/copy em pt-BR.
|
||||
- [ ] Testes previstos: unit `speechVodResolver` (pin atualizado) + `speechDetailPlayer` (transição generating→resolved) + e2e `campaignSpeechAcervo` (quadrantes intactos); `pnpm lint` e `pnpm format:check` verdes.
|
||||
- [ ] Volta ao YouTube **embutida** na própria página, preservando o ponto, com a Câmara como entrada padrão.
|
||||
- [ ] Espera bounded da Câmara dentro do clique (2 polls de 3s); estourou → estado honesto + retry.
|
||||
- [ ] Invariantes AGENTS: `overrideAccess: false` + `user`; gate `canReadSpeechCatalog`; sem migration/`Consent`; identificadores em inglês, copy em pt-BR.
|
||||
- [ ] Testes: unit resolver (pin + GERANDO→PRONTO), unit player (ida-e-volta + polling), e2e acervo renegociado.
|
||||
- [ ] Changelog: `docs/changelog/2026-09-16-c181.md`.
|
||||
|
||||
Self-score decision-quality: 5/5 — (1) fatia = um outcome verificável (volta simétrica ao YouTube + espera bounded na geração); (2) appetite ~0,5 dia comporta o ajuste de política e os testes, sem schema; (3) persona/job/aceite em linguagem de produto; (4) direção no codebase é precisa (arquivos nomeados, política existente, bloco de saída já implementado); (5) zero decisão dura de engenharia (política e UI já existem; só ajuste de parâmetros).
|
||||
Self-score decision-quality: 4/5 — decisões caras com rejeitadas (D1/D2/D3); cabe no appetite; rabbit holes nomeados; depth check reusa `resolveFromVodApi` + `YoutubeFacade` + bloco de saída + markup pré-C178; a renegociação de pins do C178 e o escopo do embed (só com trecho, opt-in) são o que impede o 5/5 sem confirmação explícita no GATE.
|
||||
|
||||
- Decisões caras com rejeitadas: 3/3 — D1 (política), D2 (visibilidade da saída), D3 (localização do botão) no formato Opções|Recomendação|Rejeitadas.
|
||||
- Cabe no appetite? 5/5 — ~0,5 dia (dentro do herdado); sem migration, sem UI, sem dependência nova.
|
||||
- Rabbit holes nomeados? 5/5 — embed do YouTube, espera infinita, retry como solução, redesign do player, autenticação, inventar estado, mudança de UI.
|
||||
- Depth check reusa? 5/5 — `resolveFromVodApi` já suporta polling, `renderYoutubeExit` já existe e é sempre visível, `speechVodResolver` já exporta a política, `speechDetailPlayer` já tem o estado `generating`.
|
||||
- Intenção permanece satisfeita? 5/5 — o outcome não foi reescrito: a volta ao YouTube é simétrica (bloco de saída sempre visível) e a espera é bounded (pollAttempts: 2, pollDelayMs: 3000).
|
||||
## Registro de execução
|
||||
|
||||
- **GATE (humano):** aprovado em 2026-09-16; decisão de produto: restaurar o embed (opção 1-C), com a Câmara como entrada padrão.
|
||||
- **Simplify (2 revisores):** sem blockers. Aplicados: `seekTo` da Câmara grava `activeStart` (ponto preservado na troca), `inlineNotice` corrige a condição para `!playbackUrl` e ganha `mt-3`, `YoutubeIcon` sem props mortas, comentários obsoletos (componente/C178, resolver/C162, e2e) atualizados. **Débitos:** os achados restantes são `cheap_polish` (score ≤2) — `buildYoutubeSrc` no componente vs. `speechShare`, extração de `renderSurfaceSwitch`, duplicação de strings do `inlineNotice`, duplicação de testes entre políticas — nenhum `expensive_lock`; não geram Issue.
|
||||
- **Design (trigger c):** crítica do `designer` (`openai/gpt-5.6-sol`) retornou **`DEGRADED`** — sem screenshots do app renderizado no worktree, não certifica. Observações não-certificadas: (2) a copy "o YouTube abre no ponto da fala" ficou incompleta com o embed ativo; (3) espaçamento do `inlineNotice` (corrigido com `mt-3`); (4) dois controles longos no bloco de saída podem comprimir a copy entre 640–1280px. Exige sign-off humano (ou nova rodada com evidência renderizada) antes do push.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { SpeechCutResultCard } from '@/components/campaign/speech/SpeechCutResul
|
|||
import { SpeechExcerptControls } from '@/components/campaign/speech/SpeechExcerptControls'
|
||||
import { SpeechExcerptShare } from '@/components/campaign/speech/SpeechExcerptShare'
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { YoutubeIcon } from '@/components/socialIcons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
|
|
@ -30,6 +31,15 @@ import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechView
|
|||
const RESOLVE_ENDPOINT = '/campanha/comunicacao/acervo/resolver-vod'
|
||||
const GENERATING_TITLE = 'A Câmara está gerando o trecho deste vídeo.'
|
||||
|
||||
/** C181 — the pre-C178 embed contract, preserved for the in-page YouTube surface. */
|
||||
const buildYoutubeSrc = (videoId: string, startSeconds: number | null): string => {
|
||||
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
|
||||
if (startSeconds !== null && startSeconds > 0) {
|
||||
params.set('start', String(Math.floor(startSeconds)))
|
||||
}
|
||||
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`
|
||||
}
|
||||
|
||||
type ResolutionState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'resolving' }
|
||||
|
|
@ -147,8 +157,10 @@ const YoutubeFacade = ({ videoId, href }: { videoId: string; href: string }) =>
|
|||
* default surface when the speech has one (resolved on click: `video-sob-demanda`,
|
||||
* exact `excerptTMs`, links probed before use), a clickable YouTube cover when it
|
||||
* only has a session link, and honest states when neither path has a playable
|
||||
* file. The YouTube embed is gone — it is the external exit, never the entry.
|
||||
* Transcript clicks seek the Câmara surface (native seconds on the MP4).
|
||||
* file. C181 — the YouTube embed returns as the opt-in in-page surface behind
|
||||
* "Assistir no YouTube" (never the automatic entry, so the C178 signin wall is
|
||||
* not re-created); transcript clicks seek the active surface (native seconds on
|
||||
* the MP4, session-offset seconds on the embed).
|
||||
*/
|
||||
export const SpeechDetailPlayer = ({
|
||||
speechId,
|
||||
|
|
@ -164,6 +176,9 @@ export const SpeechDetailPlayer = ({
|
|||
speechSummary,
|
||||
}: SpeechDetailPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
// C181 (C171-F1) — a transcript point clicked while the embed is active has to
|
||||
// survive until the Câmara `<video>` mounts and takes the first seek.
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
|
||||
const [activeStart, setActiveStart] = useState<number | null>(null)
|
||||
// C166: one state for the explicit selection mode — a non-null range means
|
||||
|
|
@ -173,9 +188,20 @@ export const SpeechDetailPlayer = ({
|
|||
// in session so the share kit stays visible after the dialog closes.
|
||||
const [cutDialogOpen, setCutDialogOpen] = useState(false)
|
||||
const [publishedCut, setPublishedCut] = useState<SpeechCutViewModel | null>(null)
|
||||
// C181 — the embed's seek point survives the surface switch (session offset +
|
||||
// the excerpt start), so "Assistir no YouTube" opens at the right second.
|
||||
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
|
||||
youtubeVideoId && youtubeOffsetSeconds !== null
|
||||
? youtubeOffsetSeconds + (initialSeconds ?? 0)
|
||||
: null,
|
||||
)
|
||||
// C181 — one player surface at a time, the Câmara kept as the entry (C178):
|
||||
// the YouTube embed is the way back, switched on click, never the default.
|
||||
const [surface, setSurface] = useState<'youtube' | 'vod'>('vod')
|
||||
|
||||
const resolving = resolution.kind === 'resolving'
|
||||
const playbackUrl = resolution.kind === 'resolved' ? resolution.playbackUrl : null
|
||||
const youtubeSurface = surface === 'youtube' && Boolean(youtubeVideoId)
|
||||
const youtubeWatchUrl = youtubeVideoId
|
||||
? buildSpeechExcerptYoutubeUrl(
|
||||
youtubeVideoId,
|
||||
|
|
@ -196,15 +222,18 @@ export const SpeechDetailPlayer = ({
|
|||
officialTextUrl ? 'abrir o Diário Oficial' : null,
|
||||
].filter((exit): exit is string => exit !== null)
|
||||
|
||||
// The deep link (`?t=`) seeks the already verified file on mount.
|
||||
// C181 — the pending transcript point wins over the ?t deep link; the surface
|
||||
// switch is a dep so the already verified file seeks when it mounts.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
if (initialSeconds === null || initialSeconds <= 0) return
|
||||
const target = pendingSeekRef.current ?? initialSeconds
|
||||
if (target === null || target <= 0) return
|
||||
|
||||
const seek = () => {
|
||||
video.currentTime = initialSeconds
|
||||
video.currentTime = target
|
||||
pendingSeekRef.current = null
|
||||
}
|
||||
if (video.readyState >= 1) {
|
||||
seek()
|
||||
|
|
@ -212,7 +241,7 @@ export const SpeechDetailPlayer = ({
|
|||
}
|
||||
video.addEventListener('loadedmetadata', seek, { once: true })
|
||||
return () => video.removeEventListener('loadedmetadata', seek)
|
||||
}, [initialSeconds, playbackUrl])
|
||||
}, [initialSeconds, playbackUrl, surface])
|
||||
|
||||
const requestResolution = async (deliverDownload: boolean) => {
|
||||
// Opened synchronously inside the click gesture so the popup blocker sees
|
||||
|
|
@ -249,10 +278,37 @@ export const SpeechDetailPlayer = ({
|
|||
}
|
||||
}
|
||||
|
||||
// C181 — the symmetric way back: "Assistir na Câmara" switches the surface and
|
||||
// asks the Câmara only when there is nothing verified yet and no request in flight.
|
||||
const watchVod = () => {
|
||||
setSurface('vod')
|
||||
if (playbackUrl || resolving || resolution.kind === 'generating') return
|
||||
void requestResolution(false)
|
||||
}
|
||||
|
||||
// C181 — "Assistir no YouTube" switches back to the embed, at the phrase being
|
||||
// played (or the deep link). It never touches the Câmara request.
|
||||
const watchYoutube = () => {
|
||||
if (youtubeOffsetSeconds !== null) {
|
||||
setYoutubeStart(youtubeOffsetSeconds + (activeStart ?? initialSeconds ?? 0))
|
||||
}
|
||||
setSurface('youtube')
|
||||
}
|
||||
|
||||
const seekTo = (seconds: number) => {
|
||||
if (youtubeSurface) {
|
||||
if (youtubeOffsetSeconds === null) return
|
||||
pendingSeekRef.current = seconds
|
||||
setYoutubeStart(youtubeOffsetSeconds + seconds)
|
||||
setActiveStart(seconds)
|
||||
return
|
||||
}
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.currentTime = seconds
|
||||
// Track the point here too: switching to the embed reads `activeStart`, and
|
||||
// playback can be blocked before a `timeupdate` lands.
|
||||
setActiveStart(seconds)
|
||||
void video.play().catch(() => {})
|
||||
}
|
||||
|
||||
|
|
@ -300,9 +356,21 @@ export const SpeechDetailPlayer = ({
|
|||
setSelection(extendRangeToSegment(selection, segments, index, selectionDuration))
|
||||
}
|
||||
|
||||
const seekable = Boolean(playbackUrl)
|
||||
const seekable = youtubeSurface ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
|
||||
|
||||
const renderMedia = (): ReactNode => {
|
||||
if (youtubeSurface && youtubeVideoId) {
|
||||
return (
|
||||
<iframe
|
||||
src={buildYoutubeSrc(youtubeVideoId, youtubeStart)}
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (playbackUrl) {
|
||||
return (
|
||||
<video
|
||||
|
|
@ -386,6 +454,25 @@ export const SpeechDetailPlayer = ({
|
|||
)
|
||||
}
|
||||
|
||||
// C181 — while the embed is the active surface the Câmara state stays visible
|
||||
// (the app never sees the iframe's inner state, so the honest notice is ours).
|
||||
const inlineNotice = youtubeSurface ? (
|
||||
resolution.kind === 'generating' ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{GENERATING_TITLE} Tente novamente em um momento.
|
||||
</p>
|
||||
) : resolution.kind === 'failed' ? (
|
||||
<p className="mt-3 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.playbackUrl ? (
|
||||
<p className="mt-3 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
|
||||
|
||||
// C178 — the exit block stays reachable in every state: the app never sees the
|
||||
// reason the video cannot play, so the way out cannot depend on detecting it.
|
||||
// The Câmara panel owns the primary CTA while it is idle ("Assistir o trecho");
|
||||
|
|
@ -416,17 +503,44 @@ export const SpeechDetailPlayer = ({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild variant={exitVariant} className="min-h-10 w-full sm:w-auto">
|
||||
<a
|
||||
href={youtubeWatchUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-slot="speech-youtube-exit-link"
|
||||
>
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir no YouTube
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
{vodResolvable && youtubeVideoId ? (
|
||||
youtubeSurface ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 w-full sm:w-auto"
|
||||
data-slot="speech-youtube-exit-camera"
|
||||
onClick={watchVod}
|
||||
>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Assistir na Câmara
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 w-full sm:w-auto"
|
||||
data-slot="speech-youtube-exit-embed"
|
||||
onClick={watchYoutube}
|
||||
>
|
||||
<YoutubeIcon />
|
||||
Assistir no YouTube
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
<Button asChild variant={exitVariant} className="min-h-10 w-full sm:w-auto">
|
||||
<a
|
||||
href={youtubeWatchUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-slot="speech-youtube-exit-link"
|
||||
>
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir no YouTube
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -517,6 +631,8 @@ export const SpeechDetailPlayer = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{inlineNotice}
|
||||
|
||||
{publishedCut ? <SpeechCutResultCard cut={publishedCut} /> : null}
|
||||
|
||||
{selection ? (
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ import {
|
|||
* owns the wait budget through a `SpeechVodPolicy`, and the media verification
|
||||
* (not the policy) decides what may reach the browser.
|
||||
*
|
||||
* The player keeps the bounded click of C162 (`SPEECH_VOD_PLAYER_POLICY`); the
|
||||
* cut job (C169) needs the measured first-request window (~30s, then short
|
||||
* polls) plus the stored links as a last verified candidate, and passes
|
||||
* `SPEECH_VOD_CUT_POLICY` + `cachedUrls`.
|
||||
* The player keeps a short click budget of its own (`SPEECH_VOD_PLAYER_POLICY`;
|
||||
* C181 widened it to poll a transcode in progress) and stops before the cut
|
||||
* job's window; the cut job (C169) needs the measured first-request window
|
||||
* (~30s, then short polls) plus the stored links as a last verified candidate,
|
||||
* and passes `SPEECH_VOD_CUT_POLICY` + `cachedUrls`.
|
||||
*/
|
||||
|
||||
export type SpeechVodPolicy = {
|
||||
|
|
@ -34,13 +35,19 @@ export type SpeechVodPolicy = {
|
|||
pollDelayMs: number
|
||||
}
|
||||
|
||||
/** C162's player click: short, no poll, immediate answer. */
|
||||
/**
|
||||
* C181's player click: a short, own budget that waits out the Câmara's
|
||||
* transcoding instead of treating the first `GERANDO` as a failure — 1 read +
|
||||
* 2 polls of 3 s (~6 s extra), between the old immediate answer and the cut
|
||||
* job's budget (a 45 s read timeout plus 2 polls of 5 s). The wait stays
|
||||
* bounded; past it the honest "gerando" state and the retry remain.
|
||||
*/
|
||||
export const SPEECH_VOD_PLAYER_POLICY: SpeechVodPolicy = {
|
||||
statusTimeoutMs: 15_000,
|
||||
statusRetries: 1,
|
||||
retryDelayMs: 1_000,
|
||||
pollAttempts: 0,
|
||||
pollDelayMs: 1_000,
|
||||
pollAttempts: 2,
|
||||
pollDelayMs: 3_000,
|
||||
}
|
||||
|
||||
/** C169's cut job: covers the measured ~30s first request and 1–2 short polls. */
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campa
|
|||
* the filter URL contract and the detail's player/transcript/actions. The
|
||||
* player picks the surface from the row coordinates — the Câmara excerpt as the
|
||||
* default when the speech has one (stored-VOD capa resolved on click), a
|
||||
* clickable YouTube cover when it only has a session link (C178: YouTube is the
|
||||
* external exit, never an embedded entry) — and honest states 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 cover href).
|
||||
* clickable YouTube cover when it only has a session link (C178: YouTube is
|
||||
* never an embedded entry; C181 restores the embed as the opt-in in-page surface
|
||||
* behind "Assistir no YouTube", keeping the Câmara as the default entry) — and
|
||||
* honest states 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 cover href; the embed is never server-rendered).
|
||||
*/
|
||||
|
||||
const YOUTUBE_VIDEO_ID = 'lLhRDkSPw0A'
|
||||
|
|
@ -176,11 +178,15 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(detailHtml).toContain('O trecho deste vídeo é gerado pela Câmara dos Deputados.')
|
||||
expect(detailHtml).toContain('Assistir o trecho')
|
||||
// C178 — the exit block is server-rendered with the Câmara panel: the
|
||||
// watch URL opens at the deep-linked point (never a signin dead end) and
|
||||
// the old surface-switch button is gone.
|
||||
// watch URL opens at the deep-linked point (never a signin dead end), and
|
||||
// the Câmara surface owns the switch button (C181).
|
||||
expect(detailHtml).toContain('Se o vídeo não abrir aqui, assista por outro caminho:')
|
||||
expect(detailHtml).toContain('Abrir no YouTube')
|
||||
expect(detailHtml).not.toContain('Assistir na Câmara')
|
||||
// C181 — the way back to the YouTube surface is server-rendered with the
|
||||
// Câmara default (the embed itself only mounts after the click).
|
||||
expect(detailHtml).toContain('Assistir no YouTube')
|
||||
expect(detailHtml).toContain('data-slot="speech-youtube-exit-embed"')
|
||||
expect(detailHtml).toContain(`watch?v=${YOUTUBE_VIDEO_ID}&t=${EXCERPT_OFFSET_SECONDS + 43}`)
|
||||
expect(detailHtml).toContain('data-slot="speech-youtube-exit-link"')
|
||||
expect(detailHtml).toContain('data-start-seconds="0"')
|
||||
|
|
|
|||
|
|
@ -350,6 +350,48 @@ describe('SpeechDetailPlayer — C178 the exit is always one click away', () =>
|
|||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — C181 the YouTube embed is one click away', () => {
|
||||
const embedSwitch = () => screen.getByRole('button', { name: /assistir no youtube/i })
|
||||
const cameraSwitch = () => screen.getByRole('button', { name: /assistir na câmara/i })
|
||||
|
||||
it('switches to the embed on click and back to the Câmara excerpt', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634, initialSeconds: 100 })
|
||||
|
||||
// C178 stays the entry: no iframe until the assessor asks for the YouTube.
|
||||
expect(iframeElement()).toBeNull()
|
||||
expect(embedSwitch()).toBeDefined()
|
||||
|
||||
fireEvent.click(embedSwitch())
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
`https://www.youtube.com/embed/${YOUTUBE_ID}?playsinline=1&rel=0&start=2734`,
|
||||
)
|
||||
expect(videoElement()).toBeNull()
|
||||
|
||||
fireEvent.click(cameraSwitch())
|
||||
await waitFor(() => expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL))
|
||||
expect(iframeElement()).toBeNull()
|
||||
})
|
||||
|
||||
it('carries the transcript point back to the Câmara video', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634, initialSeconds: 100 })
|
||||
|
||||
fireEvent.click(embedSwitch())
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
`https://www.youtube.com/embed/${YOUTUBE_ID}?playsinline=1&rel=0&start=2677`,
|
||||
)
|
||||
|
||||
fireEvent.click(cameraSwitch())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
await waitFor(() => {
|
||||
fireEvent.loadedMetadata(videoElement()!)
|
||||
expect(videoElement()?.currentTime).toBe(43)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — C178 the deep link seeks the Câmara surface', () => {
|
||||
// The seek effect attaches its `loadedmetadata` listener on the passive
|
||||
// effect pass, which can land after the video is in the DOM under load;
|
||||
|
|
|
|||
|
|
@ -125,12 +125,21 @@ describe('resolveSpeechVod', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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)
|
||||
it('maps GERANDO across the bounded polls and INDISPONIVEL without probing media', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const calls = stubFetch(async () => jsonResponse({ estado: 'GERANDO', video: null }))
|
||||
const assertion = resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
SPEECH_VOD_PLAYER_POLICY.pollDelayMs * SPEECH_VOD_PLAYER_POLICY.pollAttempts + 100,
|
||||
)
|
||||
|
||||
await expect(assertion).resolves.toEqual({ state: 'gerando' })
|
||||
expect(calls).toHaveLength(1 + SPEECH_VOD_PLAYER_POLICY.pollAttempts)
|
||||
expect(calls.every((call) => call.url.includes('video-sob-demanda'))).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
const unavailableCalls = stubFetch(async () =>
|
||||
|
|
@ -142,6 +151,35 @@ describe('resolveSpeechVod', () => {
|
|||
expect(unavailableCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('polls a GERANDO answer until PRONTO instead of failing on the first read', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let statusReads = 0
|
||||
const calls = stubFetch(async (url) => {
|
||||
if (url.includes('video-sob-demanda')) {
|
||||
statusReads += 1
|
||||
return statusReads === 1
|
||||
? jsonResponse({ estado: 'GERANDO', video: null })
|
||||
: jsonResponse(readyBody)
|
||||
}
|
||||
return mediaResponse(206, 'video/mp4')
|
||||
})
|
||||
|
||||
const assertion = resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })
|
||||
await vi.advanceTimersByTimeAsync(SPEECH_VOD_PLAYER_POLICY.pollDelayMs + 100)
|
||||
|
||||
await expect(assertion).resolves.toEqual({
|
||||
state: 'pronto',
|
||||
playbackUrl: VOD_PLAYBACK,
|
||||
downloadUrl: VOD_DOWNLOAD,
|
||||
})
|
||||
expect(statusReads).toBe(2)
|
||||
expect(calls[0]?.url).toContain('trecho=3')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('throws after the bounded retries when the Câmara is unreachable', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
|
@ -160,13 +198,13 @@ describe('resolveSpeechVod', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('keeps the C162 player click pinned to the short policy', () => {
|
||||
it('keeps the C162 player click pinned to the short policy with its own poll budget', () => {
|
||||
expect(SPEECH_VOD_PLAYER_POLICY).toEqual({
|
||||
statusTimeoutMs: 15_000,
|
||||
statusRetries: 1,
|
||||
retryDelayMs: 1_000,
|
||||
pollAttempts: 0,
|
||||
pollDelayMs: 1_000,
|
||||
pollAttempts: 2,
|
||||
pollDelayMs: 3_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue