mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
fix(C178): player do acervo nunca abre a parede de signin do YouTube — Câmara vira default e YouTube só a saída externa (Closes #1081)
This commit is contained in:
parent
f6a6d8e42b
commit
760cb60b94
7 changed files with 506 additions and 430 deletions
1
docs/changelog/2026-09-16-c178.md
Normal file
1
docs/changelog/2026-09-16-c178.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C178 — Acervo: o player do YouTube deixa de ser uma parede de signin (2026-09-16):** o defeito continuava após o C171/#1047: abrir uma fala com vídeo ainda caía primeiro no embed do YouTube, que responde "faça login para exibir o vídeo" — e o app não vê o erro interno do iframe (sem `onError`/`enablejsapi`), então a saída não podia depender de detectar o bloqueio. A resolução é remover a porta de entrada embutida: no `SpeechDetailPlayer`, o `<iframe>` do YouTube sai de cena e a superfície passa a ser derivada do dado — com trecho da Câmara (`vodResolvable`) o caminho padrão é a própria Câmara (painel "Assistir o trecho" resolvido sob clique, reuso integral do C162/C169/C171, sem POST no render), sem trecho a fala ganha uma **capa clicável** (thumbnail real do YouTube sobre gradiente, `data-slot="speech-youtube-facade"`) que abre `watch?v=<id>&t=<segundos>` em nova aba, e sem nenhum dos dois permanece o estado honesto "Vídeo indisponível neste momento". O bloco de saída "Abrir no YouTube" (`buildSpeechExcerptYoutubeUrl`, `t=` omitido quando o offset é desconhecido) fica **sempre visível**, inclusive em resolvendo/gerando/falha, e vira a ação primária assim que a Câmara sai do estado ocioso (o download recua para outline, como no design); os links externos usam `target="_blank" rel="noreferrer"`. A transcrição passa a posicionar apenas a superfície que o app controla (`seekable = Boolean(playbackUrl)`; sem `<video>` as frases ficam inertes fora do modo de seleção), e seleção/share/corte/download (C166/C167) seguem intactos. Código morto removido no mesmo delivery: `surface`, `youtubeStart`, `buildYoutubeSrc`, `watchVod`, `inlineNotice` e `pendingSeekRef`. 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 RTL do player reescrito nos quadrantes (nunca iframe, Câmara default com ambos, capa + link externo no ponto, saída visível em loading/falha, deep link) e e2e browserless do acervo ajustado (both-sources sem `youtube.com/embed`, caso só-YouTube novo, VOD-only e sem-vídeo). Design: crítica do `designer` (tier `openai/gpt-5.6-sol`) não certificou a paridade — divergências materiais aceitas pelo humano: a transcrição permanece no estado só-YouTube e o aviso C166 "Compartilhar por link exige o vídeo no YouTube" permanece no só-VOD (superfícies pré-existentes fora da cobertura das cenas do artefato). Débitos registrados: **C179 (#1093)** helper único do thumbnail `hqdefault` (4 módulos; destrava com o C172) e **OPS124 (#1094)** knip local quebra com o `importMap` gitignored no working tree.
|
||||
133
docs/plans/acervo-player-youtube-sem-parede-de-signin-impl.md
Normal file
133
docs/plans/acervo-player-youtube-sem-parede-de-signin-impl.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Impl: Acervo: o player do YouTube deixa de ser uma parede de signin
|
||||
|
||||
Status: executado
|
||||
Atualizado em: 2026-09-16
|
||||
Issue: #1081
|
||||
Intenção: docs/plans/acervo-player-youtube-sem-parede-de-signin.md
|
||||
Appetite restante: herdado — ~0,5–1 dia eng (um outcome verificável; sem schema)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** abrir uma fala com vídeo **nunca** termina numa parede de signin do YouTube; a superfície que o app controla (trecho da Câmara) é o caminho padrão quando existe, e o YouTube vira saída externa em um clique, no ponto exato.
|
||||
- **O que NÃO negociar:** gate do acervo intacto (communicator/coordinator/candidate; advisor/leader negados fail-closed); crédito "Fonte: Câmara dos Deputados · CC BY 4.0"; **sem** `Consent`/collection/migration; URLs públicas intocadas; transcrição/posicionamento (C162) e seleção/corte (C166/C167) intactos; sem baixar/espelhar/transcodificar; nenhuma cena com a parede de signin.
|
||||
- **O que reavaliar:** a hipótese de "superfície" como estado (`surface` + `watchVod`) — com o embed removido não há duas superfícies tocáveis, só uma derivável do dado; e a premissa de que a transcrição precisa de ramo de seek no YouTube (o embed morre, o ramo morre).
|
||||
- **Invariante herdado que o plano não pode furar:** renderizar o detalhe **nunca** chama a Câmara (resolução é sempre sob clique); é o pin `fetchMock` do unit.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[SpeechDetailPlayer] --> D{vodResolvable?}
|
||||
D -- sim --> C["Superfície Câmara<br/>painel 'Assistir o trecho' → <video>"]
|
||||
D -- não --> Y{youtubeVideoId?}
|
||||
Y -- sim --> F[Capa clicável<br/>thumbnail + gradiente + 'Abrir no YouTube']
|
||||
Y -- não --> U[Estado honesto<br/>'Vídeo indisponível neste momento.']
|
||||
C --> X[Bloco de saída: 'Abrir no YouTube' no ponto]
|
||||
F --> X
|
||||
X -. sempre visível .-> L[resolving / generating / failed / resolved]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) remover o embed e derivar a superfície do dado (Câmara default quando há VOD; capa clicável quando só há YouTube) | B) click-to-load mantendo o iframe atrás de um clique | C) detecção de bloqueio via IFrame API
|
||||
**Recomendação:** A — porque o iframe não emite `onError` (não há como detectar o bloqueio) e qualquer embed ainda é a parede a um clique; o aceite literal é "nunca apresenta a parede".
|
||||
**Rejeitadas:** B porque o clique de play é justamente o gesto que cai na parede — o beco continua; C porque exige `enablejsapi` + polling + estados condicionais e o produto **não** depende de detectar (rabbit hole nomeado na intenção).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
- **D1 — Superfície derivada do dado vs estado `surface`.**
|
||||
Opções: A) derivar de `vodResolvable`/`youtubeVideoId` e apagar o estado `surface` | B) manter `surface` com valor inicial ajustado.
|
||||
Recomendação: A porque, sem embed, só resta uma superfície tocável (a Câmara) e um fallback de capa; o estado seria máquina de 2 estados com **produtor morto** (`watchVod`).
|
||||
Alternativas rejeitadas: B porque mantém `watchVod`, o terceiro estado impossível (`youtube` "superfície") e um `seekTo` ramificado morto.
|
||||
|
||||
- **D2 — Semântica da capa.**
|
||||
Opções: A) `<a href={youtubeWatchUrl} target="_blank" rel="noreferrer">` (via `Button asChild`) estilizado como facade | B) `<button>` + `window.open`.
|
||||
Recomendação: A porque a capa **é** um link externo; `<a>` dá role link, atalhos e menu de contexto, e é o padrão do repo (`SpeechDetailPlayer.tsx:411-421`, `:508-513`).
|
||||
Alternativas rejeitadas: B porque perde semântica/atalhos e divergiria do bloco de saída.
|
||||
|
||||
- **D3 — Camada da capa (thumbnail real vs gradiente).**
|
||||
Opções: A) `background-image` CSS com `https://i.ytimg.com/vi/<id>/hqdefault.jpg` **sobre** gradiente, em `<span>` | B) `<img onError>` | C) só gradiente, sem asset.
|
||||
Recomendação: A porque reusa o precedente de capa YouTube (`SpeechCutPlayer.tsx:16-17`, `SpeechCutResultCard.tsx:68-73`), degrada para o gradiente se o asset faltar **sem ícone quebrado** e não exige estado cliente.
|
||||
Alternativas rejeitadas: B porque requer estado de erro que o artefato não pede; C porque a cena B aprovada usa a capa real (o selo "NEEDS ASSET" é anotação do design, não UI).
|
||||
|
||||
- **D4 — Bloco de saída sem o botão "Assistir na Câmara".**
|
||||
Opções: A) remover o botão; manter copy + link no ponto (variante Câmara) e portar a caixa da cena B (variante capa) | B) manter o botão como "tentar a Câmara de novo".
|
||||
Recomendação: A porque a Câmara **já é** a superfície padrão; um botão que não troca superfície é ruído, e sem VOD ele prometeria uma superfície inexistente.
|
||||
Alternativas rejeitadas: B porque duplica o próprio estado atual / mente quando `vodResolvable` é falso.
|
||||
|
||||
- **D5 — Transcrição/seek sem embed.**
|
||||
Opções: A) `seekable = Boolean(playbackUrl)`; clique só age na superfície da Câmara; frases inertes fora do modo de seleção quando não há `<video>` | B) clique na frase abre o YouTube externo no ponto.
|
||||
Recomendação: A porque a seleção (C166) já é a forma de marcar trecho e o link externo já existe no bloco de saída; B mudaria o contrato C162/C166 (clique = posicionar).
|
||||
Alternativas rejeitadas: B porque navegar a partir de uma frase é surpreendente e fura o modo de seleção. **Não escopo com gatilho:** se produto pedir "clique na frase abre o YouTube", revisitar.
|
||||
**`activeStart`/`onTimeUpdate` permanecem** (destaque da frase + ponto do link externo via `youtubeWatchUrl`).
|
||||
|
||||
- **D6 — Componente da capa: inline vs arquivo novo.**
|
||||
Opções: A) inline no owner `SpeechDetailPlayer.tsx` | B) `SpeechYoutubeFacade.tsx`.
|
||||
Recomendação: A (depth check) porque há **1 call site** e nenhuma volatilidade — extrair seria pass-through raso; o owner do player já existe.
|
||||
Alternativas rejeitadas: B porque cria artefato sem 3º uso ("edit the owner, don't twin").
|
||||
|
||||
- **D7 — Código morto morre no mesmo delivery.**
|
||||
Opções: A) remover `buildYoutubeSrc`, `youtubeStart`, `watchVod`, `inlineNotice`, `pendingSeekRef` e o ramo `youtube` de `seekTo` | B) deixar e marcar.
|
||||
Recomendação: A porque knip exige **0 exports mortos** e `engineering-standards` manda "dead code dies immediately".
|
||||
Alternativas rejeitadas: B porque deixaria o player com dois caminhos de seek, um inalcançável.
|
||||
_Nota:_ com o embed removido, `pendingSeekRef` fica sem produtor (clique na frase exige `playbackUrl`, logo o `<video>` já existe); o efeito de seek preserva o deep link via `initialSeconds`.
|
||||
|
||||
- **D8 — Serialização de merge com C172/C173.**
|
||||
Opções: A) C178 rebasa **depois** de C172/C173 e resolve o conflito preservando o ponto do link externo (C172) e a seleção/playback (C173) | B) C178 entra primeiro e C172/C173 rebasam.
|
||||
Recomendação: A porque C178 remove o start do embed que C172 altera; quem entra depois sabe o que morre e preserva `buildSpeechExcerptYoutubeUrl`/`youtubeOffsetSeconds` (que C178 **continua** usando na capa e no bloco de saída).
|
||||
Alternativas rejeitadas: B porque C172 passaria a alterar linhas removidas e o rebase encheria de conflito artificial.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`SpeechDetailPlayer`** (`src/components/campaign/speech/SpeechDetailPlayer.tsx`): owner único da mudança. Deriva a superfície de `vodResolvable`/`youtubeVideoId`; `renderMedia` perde o ramo `iframe` e passa a servir (a) painel/`<video>` da Câmara quando `vodResolvable`, (b) capa quando só YouTube, (c) estado honesto quando nenhum; `seekTo` Câmara-only; `seekable = Boolean(playbackUrl)`; bloco de saída simplificado (sem botão "Assistir na Câmara"; visível em todos os estados). Remoções: `surface`, `youtubeStart`, `buildYoutubeSrc`, `watchVod`, `inlineNotice`, `pendingSeekRef`, ramo `youtube` de `seekTo`. Reusa `SpeechCutPlayer`/`SpeechCutResultCard` como precedente de capa, `YoutubeIcon`, `Button asChild` e `StatusPanel`.
|
||||
- **Capa (cena B)**: `<a>` estilizado (`aspect-video`, `rounded-lg border bg-stone-900`, gradiente de fallback, thumbnail como camada de fundo, badge "Vídeo no YouTube", play circular, rodapé "Abrir no YouTube"), `data-slot="speech-youtube-facade"`, `aria-label="Abrir este trecho no YouTube"`. **Não portar** os selos de artefato ("NEEDS ASSET", "Caminho removido / aqui ficava a parede de signin").
|
||||
- **Migration:** sem migration (nenhum campo/collection muda).
|
||||
- **Access / Consent:** sem mudança — gate do acervo e crédito CC BY 4.0 permanecem no `page.tsx`; sem nova chave `Consent`, sem fail-closed novo.
|
||||
- **UI:** Impeccable B — encaixe na superfície do player; port direto do artefato aprovado (cenas A/B/loading), sem shell novo e sem redesenho. O `designer` é dono da estrutura visual; a engenharia liga dados/rotas/copy no markup aprovado.
|
||||
- **Testes:** `tests/unit/speechDetailPlayer.unit.spec.tsx` reescrito nos quadrantes YouTube/C171/C171-F1/C166 (nunca `iframe`; capa + link externo no ponto; Câmara default com ambos; saída visível em loading/falha) e `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` ajustado (both-sources sem `youtube.com/embed`; caso YouTube-only com capa; VOD-only; sem vídeo). Alvo por `data-slot`/role — **não** usar `getByText` com múltiplos "Abrir no YouTube". E2E é browserless (não vê a parede real).
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- **Forma escolhida:** a superfície do player é **feedback de ação**, não apresentação de dado de acervo — dois estados honestos derivados da disponibilidade da fonte (Câmara tocável / capa com saída externa). Por isso: sem badge de dado, sem KPI, sem gráfico.
|
||||
- **Rejeitadas:** card de "status do vídeo" (seria dado novo não pedido); indicador de origem em `%`/métrica (não há dado); tooltip explicativo do bloqueio (o app não detecta o motivo — anti-goal).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer de superfície (≈40% do appetite, ~0,2–0,4 dia).** Derivar a superfície do dado; remover o embed e o código morto (D1, D5, D7); portar a capa (D2, D3, D6) e simplificar o bloco de saída (D4), sempre visível em `resolving`/`generating`/`failed`/`resolved`. Prova: unit do quadrante VOD-only verde sem mudança e novo unit "nunca iframe / Câmara default com ambos / capa + link no ponto / saída visível em loading e falha / no-video".
|
||||
2. **Testes de quadro (≈40%, ~0,2–0,4 dia).** Reescrever os quadrantes YouTube do unit e os blocos C171/C171-F1/C166 que dependiam do embed; ajustar o e2e both-sources (sem `youtube.com/embed`, com Câmara + exits, `watch?v=...&t=2677`); adicionar e2e YouTube-only (capa server-rendered, sem `<iframe>`/`<video>`); manter VOD-only e no-video. Alvo por `data-slot`/role.
|
||||
3. **Gates (≈20%).** `pnpm gate:fast` (lint + typecheck + unit) e `pnpm push` (`gate:ci`). Sem migration a rodar. Evidência final de aceite no **staging** com a fala 997 (responsabilidade do dono).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **IFrame API / detecção de bloqueio** (`enablejsapi`, eventos, polling, estados condicionais) — o plano não depende de detectar.
|
||||
- **Click-to-load / facade que ainda monta o iframe** — a parede continua a um clique.
|
||||
- **Seek do clique na frase para o YouTube externo** — não escopo; gatilho: produto pedir explicitamente "clique na frase abre o YouTube".
|
||||
- **OAuth/SSO no YouTube, proxy de mídia, re-hospedar/baixar/transcodificar.**
|
||||
- **Auto-resolve da Câmara no render** — violaria o invariante "render nunca chama a Câmara" (pin do unit).
|
||||
- **Componente/shell/rota nova** para a capa — owner único, 1 call site.
|
||||
- **Redesenhar paleta/estrutura/hierarquia** além do port — é do `designer`.
|
||||
- **Portar anotações do artefato** ("NEEDS ASSET", "Caminho removido") — comentário de design, não UI.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão dos pins C171/C171-F1/C166 que assumem o embed.** Mitigação: reescrever os testes no mesmo delivery e provar verde antes/depois; o comportamento de seleção/share/corte é preservado (só muda a superfície de origem).
|
||||
- **Conflito de merge com C172/C173 (mesmo arquivo).** Mitigação: rebase após ambos (D8); preservar `youtubeOffsetSeconds` e `buildSpeechExcerptYoutubeUrl` — o ponto continua sendo o contrato do link externo; a remoção do start do embed torna o trecho de C172 no iframe obsoleto de propósito.
|
||||
- **Thumbnail indisponível/edge (imagem 404, rede).** Mitigação: gradiente por baixo via CSS; sem `<img onError>` nem estado de erro.
|
||||
- **Ambiguidade "Abrir no YouTube" (capa + bloco de saída).** Mitigação: `data-slot` distintos (`speech-youtube-facade` / `speech-youtube-exit-link`) e testes por role+slot, nunca `getByText` solto.
|
||||
- **E2E browserless não enxerga a parede real.** Mitigação: o e2e cobre o contrato HTML (sem embed, sem `<iframe>`); o aceite de "não acaba em signin" é validado no staging com a fala 997.
|
||||
- **Perda da transcrição posicionável no quadrante só-YouTube.** Mitigação: label cai para "Transcrição" e frases inertes fora da seleção, exatamente como hoje quando o offset é desconhecido; a seleção (C166) continua ativa.
|
||||
|
||||
## Triage pós-simplify + crítica de design (não reabrir)
|
||||
|
||||
**Registrado:** C179 (#1093) — helper único da URL do thumbnail `hqdefault` (4 módulos; `depends: C172`); OPS124 (#1094) — `pnpm knip` local quebra com o `importMap` gitignored no working tree (pré-existente, não é regressão desta entrega).
|
||||
|
||||
**Defer com gatilho:** duplicação do markup do bloco de saída (`renderYoutubeExit`) — revisitar com uma 3ª variante/superfície de saída ou reuso fora do `SpeechDetailPlayer`; testes unit quase-duplicados (VOD-only vs both-sources) — descartado (legibilidade dos quadrantes).
|
||||
|
||||
**Crítica do `designer` (tier `openai/gpt-5.6-sol`) — não certificada, decisão humana registrada:** ajustes de paridade aplicados (exit primário quando a Câmara deixa o ocioso + download em outline; bloco de saída em linha no desktop e empilhado com CTA full-width no mobile; escala/offsets responsivos da capa; `justify-between` da caixa só no desktop). Divergências materiais **aceitas pelo humano em 2026-09-16**: a transcrição permanece no estado só-YouTube (guardrail C162; a cena B do artefato não cobre a seção) e o aviso C166 "Compartilhar por link exige o vídeo no YouTube" permanece no só-VOD (elemento pré-existente pinado por teste; o artefato não cobre o quadrante).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (nunca a parede; Câmara default quando há VOD; capa com saída externa no ponto; fala 997 validada no staging pelo dono)
|
||||
- [ ] Invariantes AGENTS/engineering-standards respeitados (sem `Consent`/collection/migration; sem URL pública nova; gate do acervo intacto; "edit the owner, don't twin"; knip 0 exports mortos)
|
||||
- [ ] Testes de domínio previstos onde o contrato muda (unit do player reescrito; e2e ajustado/adicionado; nenhum write path novo)
|
||||
- [ ] `pnpm gate:fast` e `pnpm push` verdes; evidência do e2e browserless no PR
|
||||
|
||||
Self-score (decision-quality): **5/5** — (1) as decisões caras (D1–D3, D8) têm opções e rejeitadas explícitas; (2) a abordagem cabe no appetite de ~0,5–1 dia sem schema; (3) rabbit holes nomeados (iframe/detecção/seek externo/auto-resolve); (4) depth check reusa o owner, o precedente de capa e `Button asChild`, sem extrair abstração; (5) o outcome de produto é preservado — a engenharia só troca a porta de entrada, não o aceite.
|
||||
32
docs/plans/escala-dry-pos-speech-thumbnail.md
Normal file
32
docs/plans/escala-dry-pos-speech-thumbnail.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Escala DRY: helper único da URL do thumbnail do YouTube
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-09-16
|
||||
Issue: a registrar — C179
|
||||
Priority: P3
|
||||
Impeccable: A — sem superfície nova (só extração de literal)
|
||||
Appetite: ~1 h eng; em fill-in, sem schema/URL pública
|
||||
Depende de: #1082 (C172) — o dono natural do helper é `src/lib/speechShare.ts`, que C172 edita; destrava sozinha quando o C172 flipar `done`.
|
||||
|
||||
## Intenção
|
||||
|
||||
O literal `https://i.ytimg.com/vi/<id>/hqdefault.jpg` está repetido em 4 módulos — `SpeechDetailPlayer.tsx` (capa C178), `SpeechCutPlayer.tsx`, `SpeechCutResultCard.tsx` e `src/app/(frontend)/corte/[id]/page.tsx`. É o mesmo conhecimento (host + formato + sufixo) copiado 4×, com risco de drift silencioso (ex.: troca de host/CSP). O dono natural é `src/lib/speechShare.ts`, que já expõe o contrato do link externo do YouTube.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Extrair + consumir** — adicionar `youtubeThumbnailUrl(videoId: string): string` em `src/lib/speechShare.ts` e usá-lo nos 4 pontos. Unit do helper (id normal, id vazio/whitespace) e ajuste dos pins que assertam a URL.
|
||||
2. **Gates** — `pnpm gate:fast`; entrega via `work-issue` (sem migration, sem UI nova).
|
||||
|
||||
## Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- A duplicação do literal **não** foi extraída no C178 de propósito: `src/lib/speechShare.ts` está sendo editado pelo C172 em worktree paralelo (rebase/conflict). Esta Issue existe justamente para não perder o achado.
|
||||
|
||||
## Explicitamente fora
|
||||
|
||||
- Unificar imagem de capa/posters além do helper (sem mudança visual).
|
||||
- Migrar o host do thumbnail ou adicionar fallback de imagem (comportamento atual mantido).
|
||||
- Defer do C178: a duplicação do markup do bloco de saída (`renderYoutubeExit`, 2 ramos) só revisita com uma **3ª variante/superfície de saída** ou reuso fora do `SpeechDetailPlayer`.
|
||||
|
||||
## Self-score (decisão)
|
||||
|
||||
4/5 — decisão barata de reverter, dono nomeado, depth check justificado (4 call sites), sem mudar comportamento, gate claro.
|
||||
31
docs/plans/ops-knip-importmap-artifact.md
Normal file
31
docs/plans/ops-knip-importmap-artifact.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# OPS: knip local quebra com o importMap (artefato gitignored) no working tree
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-09-16
|
||||
Issue: a registrar — OPS124
|
||||
Priority: P3
|
||||
Impeccable: A — sem UI
|
||||
Appetite: ~30 min eng; sem schema, sem deploy
|
||||
|
||||
## Intenção
|
||||
|
||||
`pnpm knip` falha localmente com `ERROR: Error loading src/payload.config.ts (This module cannot be imported from a Client Component module. It should only be used from a Server Component.)` quando `src/app/(payload)/admin/importMap.js` existe no working tree — artefato gitignored gerado por `pnpm predev`/build (OPS99), que `knip.json` ainda lista em `entry`. Sem o artefato, o knip completa (com os 2 unresolved imports esperados de `[[...segments]]`). No CI o knip roda antes do build (sem o artefato) e passa — confirmado no run `35099887216`; reproduzido idêntico no worktree `main` (`/home/fsolla/Code/teqo`), então **não é regressão de entrega nenhuma**. Efeito: `pnpm push`/`gate:ci` local quebra depois de rodar dev.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Diagnóstico + fix** — decidir entre remover `src/app/(payload)/admin/importMap.js` do `entry` do `knip.json` (o arquivo é artefato de build; os unresolved imports de `[[...segments]]` apontam para um artefato inexistente) ou `ignore`-á-lo com justificativa. Provar: `pnpm predev` (gera o artefato) → `pnpm knip` verde; e `pnpm knip` verde sem o artefato.
|
||||
2. **Gate** — `pnpm gate:fast` + `pnpm push`; sem tocar no pipeline de CI (o CI já passa).
|
||||
|
||||
## Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- Confirmado que o erro é pré-existente e independente do C178 (mesmo erro em `main` com o artefato presente).
|
||||
|
||||
## Explicitamente fora
|
||||
|
||||
- Mudar a ordem do CI (knip antes do build já é o caso).
|
||||
- Commitar o `importMap.js` (OPS99 proíbe; é artefato de build).
|
||||
- Qualquer outra limpeza de `knip.json`.
|
||||
|
||||
## Self-score (decisão)
|
||||
|
||||
4/5 — fix barato e reversível, causa-raiz nomeada, prova verde clara, sem risco de produção.
|
||||
|
|
@ -9,7 +9,6 @@ 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'
|
||||
|
|
@ -28,14 +27,6 @@ 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.'
|
||||
|
||||
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' }
|
||||
|
|
@ -56,9 +47,9 @@ const toResolutionState = (resolution: SpeechVodResolution): ResolutionState =>
|
|||
|
||||
type SpeechDetailPlayerProps = {
|
||||
speechId: number
|
||||
/** C162 — YouTube default when the session link parses; null falls back to the VOD. */
|
||||
/** C162 — YouTube id for the external exit; without it the exit block does not render. */
|
||||
youtubeVideoId: string | null
|
||||
/** Seconds from the session start to the excerpt; null makes the YouTube seek inert. */
|
||||
/** Seconds from the session start to the excerpt; null drops `t=` from the external links. */
|
||||
youtubeOffsetSeconds: number | null
|
||||
/** Stored VOD + excerpt coordinates: the Câmara may be asked on click. */
|
||||
vodResolvable: boolean
|
||||
|
|
@ -106,12 +97,50 @@ const StatusPanel = ({
|
|||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* C178 — the YouTube is never an embedded entry surface (the anonymous embed can
|
||||
* greet the assessor with a signin wall the app cannot even detect). Without a
|
||||
* Câmara excerpt the speech gets an honest cover instead: a single click opens
|
||||
* the video on YouTube, no iframe on the page.
|
||||
*/
|
||||
const YoutubeFacade = ({ videoId, href }: { videoId: string; href: string }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Abrir no YouTube"
|
||||
data-slot="speech-youtube-facade"
|
||||
className="group relative flex aspect-video w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-black/30 bg-stone-900 outline-none transition-all after:pointer-events-none after:absolute after:inset-0 after:ring-0 after:ring-primary/50 after:ring-inset after:transition-all focus-visible:ring-3 focus-visible:ring-ring/50 hover:after:ring-4 focus-visible:after:ring-4"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-cover bg-center opacity-60"
|
||||
style={{ backgroundImage: `url(https://i.ytimg.com/vi/${videoId}/hqdefault.jpg)` }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-[linear-gradient(135deg,rgb(41_37_36/0.55)_0%,rgb(9_9_11/0.85)_60%,rgb(28_25_23/0.7)_100%)]"
|
||||
/>
|
||||
<span className="absolute top-3 left-3 text-left sm:top-4 sm:left-4">
|
||||
<span className="rounded-md border border-white/20 bg-black/45 px-2 py-1 text-xs text-white/80">
|
||||
Vídeo no YouTube
|
||||
</span>
|
||||
</span>
|
||||
<span className="relative grid size-12 place-items-center rounded-full bg-white/90 text-stone-950 shadow-lg transition-transform group-hover:scale-105 group-focus-visible:scale-105 sm:size-16">
|
||||
<PlayIcon className="ml-0.5 size-5 sm:size-7" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="absolute inset-x-3 bottom-3 text-left text-sm font-medium text-white sm:inset-x-5 sm:bottom-5">
|
||||
Abrir no YouTube
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
|
||||
/**
|
||||
* C178 — one player for the four acervo quadrants: the Câmara excerpt is the
|
||||
* 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).
|
||||
*/
|
||||
export const SpeechDetailPlayer = ({
|
||||
speechId,
|
||||
|
|
@ -127,9 +156,6 @@ export const SpeechDetailPlayer = ({
|
|||
speechSummary,
|
||||
}: SpeechDetailPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
// C171-F1 — transcript point clicked while the embed is active: the target
|
||||
// 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
|
||||
|
|
@ -139,18 +165,9 @@ 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)
|
||||
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
|
||||
youtubeVideoId && youtubeOffsetSeconds !== null
|
||||
? youtubeOffsetSeconds + (initialSeconds ?? 0)
|
||||
: null,
|
||||
)
|
||||
// C171 — one player surface at a time: the YouTube embed until the assessor
|
||||
// asks for the Câmara excerpt, which then plays in place.
|
||||
const [surface, setSurface] = useState<'youtube' | 'vod'>(youtubeVideoId ? 'youtube' : '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,
|
||||
|
|
@ -164,18 +181,15 @@ export const SpeechDetailPlayer = ({
|
|||
const showExcerptShare = selecting && Boolean(youtubeVideoId)
|
||||
const showCutAction = selecting && vodResolvable
|
||||
|
||||
// C171-F1 — the pending transcript point wins over the ?t deep link; the
|
||||
// surface switch is a dep so the already verified file seeks on mount.
|
||||
// The deep link (`?t=`) seeks the already verified file on mount.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const target = pendingSeekRef.current ?? initialSeconds
|
||||
if (target === null || target <= 0) return
|
||||
if (initialSeconds === null || initialSeconds <= 0) return
|
||||
|
||||
const seek = () => {
|
||||
video.currentTime = target
|
||||
pendingSeekRef.current = null
|
||||
video.currentTime = initialSeconds
|
||||
}
|
||||
if (video.readyState >= 1) {
|
||||
seek()
|
||||
|
|
@ -183,7 +197,7 @@ export const SpeechDetailPlayer = ({
|
|||
}
|
||||
video.addEventListener('loadedmetadata', seek, { once: true })
|
||||
return () => video.removeEventListener('loadedmetadata', seek)
|
||||
}, [initialSeconds, playbackUrl, surface])
|
||||
}, [initialSeconds, playbackUrl])
|
||||
|
||||
const requestResolution = async (deliverDownload: boolean) => {
|
||||
// Opened synchronously inside the click gesture so the popup blocker sees
|
||||
|
|
@ -220,22 +234,7 @@ export const SpeechDetailPlayer = ({
|
|||
}
|
||||
}
|
||||
|
||||
// C171 — "Assistir na Câmara": switches the player 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)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -273,21 +272,9 @@ export const SpeechDetailPlayer = ({
|
|||
setSelection(extendRangeToSegment(selection, segments, index, selectionDuration))
|
||||
}
|
||||
|
||||
const seekable = youtubeSurface ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
|
||||
const seekable = 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
|
||||
|
|
@ -360,6 +347,9 @@ export const SpeechDetailPlayer = ({
|
|||
</StatusPanel>
|
||||
)
|
||||
}
|
||||
if (youtubeVideoId && youtubeWatchUrl) {
|
||||
return <YoutubeFacade videoId={youtubeVideoId} href={youtubeWatchUrl} />
|
||||
}
|
||||
return (
|
||||
<StatusPanel
|
||||
title="Vídeo indisponível neste momento."
|
||||
|
|
@ -368,63 +358,48 @@ export const SpeechDetailPlayer = ({
|
|||
)
|
||||
}
|
||||
|
||||
const inlineNotice = youtubeSurface ? (
|
||||
resolution.kind === 'generating' ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{GENERATING_TITLE} 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
|
||||
// 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");
|
||||
// once it leaves that state the external exit takes the primary slot.
|
||||
const exitTakesPrimary =
|
||||
Boolean(youtubeWatchUrl) && (!vodResolvable || resolution.kind !== 'idle')
|
||||
const exitVariant = exitTakesPrimary ? 'default' : 'outline'
|
||||
|
||||
// C171 — the exit block stays reachable with the embed playing: the app
|
||||
// never sees the iframe's internal error, so the way out cannot depend on
|
||||
// detecting the block.
|
||||
const renderYoutubeExit = (): ReactNode => {
|
||||
if (!youtubeWatchUrl) return null
|
||||
|
||||
return (
|
||||
<div data-slot="speech-youtube-exit" className="mt-3 rounded-lg border bg-muted/40 px-4 py-3">
|
||||
<p className="text-xs font-medium text-foreground/90">
|
||||
Se o vídeo não abrir aqui, assista por outro caminho:
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{youtubeSurface && vodResolvable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10"
|
||||
data-slot="speech-youtube-exit-camera"
|
||||
onClick={watchVod}
|
||||
>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Assistir na Câmara
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="outline" className="min-h-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
{vodResolvable ? (
|
||||
<>
|
||||
<p className="text-xs font-medium text-foreground/90">
|
||||
Se o vídeo não abrir aqui, assista por outro caminho:
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="hidden text-xs font-medium text-foreground/90 sm:block">
|
||||
Abrir no YouTube
|
||||
</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"
|
||||
>
|
||||
<YoutubeIcon />
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir no YouTube
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
{vodResolvable ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -481,10 +456,10 @@ export const SpeechDetailPlayer = ({
|
|||
) : null}
|
||||
{vodResolvable ? (
|
||||
<Button
|
||||
// While the excerpt share or the cut action is on screen it is the
|
||||
// primary action; the download steps back to outline instead of
|
||||
// splitting the CTA.
|
||||
variant={showExcerptShare || showCutAction ? 'outline' : 'default'}
|
||||
// While the excerpt share, the cut action or the external exit is
|
||||
// the primary action; the download steps back to outline instead
|
||||
// of splitting the CTA.
|
||||
variant={showExcerptShare || showCutAction || exitTakesPrimary ? 'outline' : 'default'}
|
||||
className="min-h-10"
|
||||
disabled={resolving}
|
||||
aria-busy={resolving || undefined}
|
||||
|
|
@ -514,8 +489,6 @@ export const SpeechDetailPlayer = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{inlineNotice}
|
||||
|
||||
{publishedCut ? <SpeechCutResultCard cut={publishedCut} /> : null}
|
||||
|
||||
{selection ? (
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campa
|
|||
* 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`).
|
||||
* 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).
|
||||
*/
|
||||
|
||||
const YOUTUBE_VIDEO_ID = 'lLhRDkSPw0A'
|
||||
|
|
@ -129,7 +130,7 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(rendered(await advisorHome.text())).not.toContain('href="/campanha/comunicacao"')
|
||||
})
|
||||
|
||||
test('search keeps the download off the card; the both-sources detail defaults to YouTube', async ({
|
||||
test('search keeps the download off the card; the both-sources detail defaults to the Câmara excerpt', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
|
|
@ -158,17 +159,20 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(detail.status()).toBe(200)
|
||||
const detailHtml = rendered(await detail.text())
|
||||
expect(detailHtml).toContain('data-slot="speech-player"')
|
||||
// Default surface is the YouTube embed, positioned by the session offset
|
||||
// (2634) plus the deep-linked transcript second (43).
|
||||
expect(detailHtml).toContain(`www.youtube.com/embed/${YOUTUBE_VIDEO_ID}`)
|
||||
expect(detailHtml).toContain(`start=${EXCERPT_OFFSET_SECONDS + 43}`)
|
||||
// C171 — the exit block is server-rendered with the embed: the Câmara
|
||||
// surface is one click away and the watch URL opens at the deep-linked
|
||||
// point (never a signin dead end).
|
||||
// C178 — the YouTube embed is never the entry: the default surface is the
|
||||
// Câmara excerpt (resolved on click) and no iframe is ever server-rendered.
|
||||
expect(detailHtml).not.toContain('youtube.com/embed')
|
||||
expect(detailHtml).not.toContain('<iframe')
|
||||
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.
|
||||
expect(detailHtml).toContain('Se o vídeo não abrir aqui, assista por outro caminho:')
|
||||
expect(detailHtml).toContain('Assistir na Câmara')
|
||||
expect(detailHtml).toContain('Abrir no YouTube')
|
||||
expect(detailHtml).not.toContain('Assistir na Câmara')
|
||||
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"')
|
||||
expect(detailHtml).toContain('Baixar vídeo (MP4)')
|
||||
expect(detailHtml).toContain('Abrir fonte')
|
||||
|
|
@ -182,6 +186,40 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(detailHtml).not.toContain('vod.camara.leg.br')
|
||||
})
|
||||
|
||||
test('a YouTube-only speech gets a clickable cover, never an embedded iframe', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('soyoutube')
|
||||
const speech = await createSpeech(campaign, {
|
||||
marker,
|
||||
withVideo: false,
|
||||
youtubeUrl: `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`,
|
||||
})
|
||||
|
||||
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 detailHtml = rendered(await detail.text())
|
||||
expect(detailHtml).toContain('data-slot="speech-youtube-facade"')
|
||||
expect(detailHtml).toContain('aria-label="Abrir no YouTube"')
|
||||
// C178 — the cover is the external exit: the click goes to YouTube, and no
|
||||
// iframe/video reaches the HTML.
|
||||
expect(detailHtml).toContain(`watch?v=${YOUTUBE_VIDEO_ID}&t=${EXCERPT_OFFSET_SECONDS}`)
|
||||
expect(detailHtml).toContain('Vídeo no YouTube')
|
||||
expect(detailHtml).toContain('Abrir no YouTube')
|
||||
expect(detailHtml).not.toContain('youtube.com/embed')
|
||||
expect(detailHtml).not.toContain('<iframe')
|
||||
expect(detailHtml).not.toContain('<video')
|
||||
// Without an in-page player the "if the video fails here" copy would lie.
|
||||
expect(detailHtml).not.toContain('Se o vídeo não abrir aqui')
|
||||
expect(detailHtml).not.toContain('Assistir na Câmara')
|
||||
expect(detailHtml).toContain('Abrir fonte')
|
||||
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,
|
||||
|
|
@ -201,7 +239,7 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
// while the selection door stays offered.
|
||||
expect(html).toContain('Selecionar trecho')
|
||||
expect(html).toContain('Compartilhar por link exige o vídeo no YouTube')
|
||||
// C171 — no YouTube id means no exit block: there is no embed to escape.
|
||||
// C178 — no YouTube id means no cover and no exit block.
|
||||
expect(html).not.toContain('Abrir no YouTube')
|
||||
expect(html).not.toContain('Se o vídeo não abrir aqui')
|
||||
expect(html).not.toContain('<video')
|
||||
|
|
@ -273,7 +311,7 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
// 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')
|
||||
// C171 — without a YouTube id there is no embed and no exit block.
|
||||
// C178 — without a YouTube id there is no cover and no exit block.
|
||||
expect(html).not.toContain('Abrir no YouTube')
|
||||
expect(html).not.toContain('Se o vídeo não abrir aqui')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { stubMatchMedia } from '../helpers/matchMedia'
|
|||
|
||||
const PLAYBACK_URL = 'https://vod.camara.leg.br/trecho.mp4'
|
||||
const DOWNLOAD_URL = 'https://vod.camara.leg.br/trecho-download.mp4'
|
||||
const YOUTUBE_ID = 'lLhRDkSPw0A'
|
||||
const YOUTUBE_WATCH_URL = 'https://www.youtube.com/watch?v=lLhRDkSPw0A'
|
||||
|
||||
const SEGMENTS: readonly SpeechDetailSegmentViewModel[] = [
|
||||
{
|
||||
|
|
@ -66,7 +68,12 @@ const renderPlayer = (props: Partial<Parameters<typeof SpeechDetailPlayer>[0]> =
|
|||
|
||||
const videoElement = () => document.querySelector('video')
|
||||
const iframeElement = () => document.querySelector('iframe')
|
||||
const facadeElement = () => document.querySelector('[data-slot="speech-youtube-facade"]')
|
||||
const exitLink = () => document.querySelector('[data-slot="speech-youtube-exit-link"]')
|
||||
const playerRoot = () => document.querySelector('[data-slot="speech-player"]')
|
||||
const watchExcerptButton = () => screen.getByRole('button', { name: /assistir o trecho/i })
|
||||
const segmentButton = (seconds: number) =>
|
||||
document.querySelector(`button[data-start-seconds="${seconds}"]`) as HTMLButtonElement
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom does not implement media playback; the seek contract is what matters.
|
||||
|
|
@ -95,7 +102,7 @@ afterEach(() => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
||||
describe('SpeechDetailPlayer — VOD 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)
|
||||
|
|
@ -105,13 +112,13 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
|||
expect(screen.getByText(/O trecho deste vídeo é gerado pela Câmara/)).toBeDefined()
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()).toBeNull()
|
||||
// C171 — without a YouTube id there is no embed to escape from.
|
||||
// C178 — without a YouTube id there is no cover and no exit.
|
||||
expect(screen.queryByText(/Se o vídeo não abrir aqui/)).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /abrir no youtube/i })).toBeNull()
|
||||
expect(exitLink()).toBeNull()
|
||||
// The invariant: rendering the detail never calls the Câmara.
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
fireEvent.click(watchExcerptButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playerRoot()?.getAttribute('aria-busy')).toBe('true')
|
||||
|
|
@ -129,7 +136,7 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
|||
})
|
||||
expect(playerRoot()?.getAttribute('aria-busy')).toBeNull()
|
||||
|
||||
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(videoElement()?.currentTime).toBe(43)
|
||||
})
|
||||
|
||||
|
|
@ -137,7 +144,7 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
|||
respondWith(indisponivel)
|
||||
renderPlayer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
fireEvent.click(watchExcerptButton())
|
||||
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
|
||||
|
||||
respondWith(pronto(PLAYBACK_URL, null))
|
||||
|
|
@ -154,7 +161,7 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
|||
respondWith(gerando)
|
||||
renderPlayer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
fireEvent.click(watchExcerptButton())
|
||||
|
||||
expect(await screen.findByText(/A Câmara está gerando o trecho deste vídeo/)).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /tentar novamente/i })).toBeDefined()
|
||||
|
|
@ -165,7 +172,7 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
|||
respondWith(pronto(null, DOWNLOAD_URL))
|
||||
renderPlayer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
fireEvent.click(watchExcerptButton())
|
||||
|
||||
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()
|
||||
|
|
@ -173,44 +180,130 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
||||
it('embeds the session at the offset and seeks the transcript by reloading start', async () => {
|
||||
describe('SpeechDetailPlayer — C178 the YouTube embed is never the entry', () => {
|
||||
it('defaults to the Câmara excerpt when the speech has both sources', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeVideoId: YOUTUBE_ID,
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: 100,
|
||||
})
|
||||
|
||||
expect(iframeElement()).toBeNull()
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube.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')
|
||||
})
|
||||
expect(screen.getByText(/O trecho deste vídeo é gerado pela Câmara/)).toBeDefined()
|
||||
// The old surface switch is gone: the Câmara is already the default.
|
||||
expect(screen.queryByRole('button', { name: /assistir na câmara/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('embeds the session at the excerpt offset even without a ?t deep link', () => {
|
||||
it('renders a clickable cover — never an iframe — when only YouTube exists', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeVideoId: YOUTUBE_ID,
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: null,
|
||||
vodResolvable: false,
|
||||
})
|
||||
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
)
|
||||
expect(iframeElement()).toBeNull()
|
||||
expect(videoElement()).toBeNull()
|
||||
const facade = facadeElement() as HTMLAnchorElement
|
||||
expect(facade).not.toBeNull()
|
||||
expect(facade.getAttribute('href')).toBe(`${YOUTUBE_WATCH_URL}&t=2634`)
|
||||
expect(facade.getAttribute('target')).toBe('_blank')
|
||||
expect(facade.getAttribute('rel')).toBe('noreferrer')
|
||||
expect(screen.getByText('Vídeo no YouTube')).toBeDefined()
|
||||
// The Câmara panel would promise a surface this speech does not have.
|
||||
expect(screen.queryByText(/gerado pela Câmara dos Deputados/)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the transcript inert when the session offset is unknown', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
|
||||
it('drops t from the cover link when the session offset is unknown', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: YOUTUBE_ID,
|
||||
youtubeOffsetSeconds: null,
|
||||
vodResolvable: false,
|
||||
})
|
||||
|
||||
expect(facadeElement()?.getAttribute('href')).toBe(YOUTUBE_WATCH_URL)
|
||||
})
|
||||
|
||||
it('leaves the transcript inert when there is no in-page surface', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: YOUTUBE_ID,
|
||||
youtubeOffsetSeconds: 2634,
|
||||
vodResolvable: false,
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/clique para posicionar/i)).toBeNull()
|
||||
const segment = document.querySelector('button[data-start-seconds="43"]') as HTMLButtonElement
|
||||
expect(segment.disabled).toBe(true)
|
||||
expect(segmentButton(43).disabled).toBe(true)
|
||||
// The selection mode still enables the phrases (C166).
|
||||
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
|
||||
expect(segmentButton(43).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('repeats the external exit in a labelled box below the cover', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: YOUTUBE_ID,
|
||||
youtubeOffsetSeconds: 2634,
|
||||
vodResolvable: false,
|
||||
})
|
||||
|
||||
expect(document.querySelectorAll('[data-slot="speech-youtube-exit-link"]').length).toBe(1)
|
||||
const link = exitLink() as HTMLAnchorElement
|
||||
expect(link.getAttribute('href')).toBe(`${YOUTUBE_WATCH_URL}&t=2634`)
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
expect(link.getAttribute('rel')).toBe('noreferrer')
|
||||
// Without a player on the page the "if the video fails here" copy would lie.
|
||||
expect(screen.queryByText('Se o vídeo não abrir aqui, assista por outro caminho:')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — C178 the exit is always one click away', () => {
|
||||
it('keeps the exit visible with the Câmara panel, pointed at the deep link', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: YOUTUBE_ID,
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: 100,
|
||||
})
|
||||
|
||||
expect(screen.getByText('Se o vídeo não abrir aqui, assista por outro caminho:')).toBeDefined()
|
||||
expect(
|
||||
screen.getByText('O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.'),
|
||||
).toBeDefined()
|
||||
expect(exitLink()?.getAttribute('href')).toBe(`${YOUTUBE_WATCH_URL}&t=2734`)
|
||||
})
|
||||
|
||||
it('keeps the exit visible while the Câmara resolves and when it fails', async () => {
|
||||
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
fetchMock.mockReturnValue(pending.promise)
|
||||
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
fireEvent.click(watchExcerptButton())
|
||||
|
||||
expect(screen.getByText(/Resolvendo o trecho na Câmara/)).toBeDefined()
|
||||
expect(exitLink()).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ ok: true, json: async () => indisponivel })
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
|
||||
expect(exitLink()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('follows the phrase being played on the Câmara surface', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderPlayer({
|
||||
youtubeVideoId: YOUTUBE_ID,
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: 100,
|
||||
})
|
||||
|
||||
fireEvent.click(watchExcerptButton())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
|
||||
const video = videoElement()!
|
||||
video.currentTime = 43
|
||||
fireEvent.timeUpdate(video)
|
||||
|
||||
expect(exitLink()?.getAttribute('href')).toBe(`${YOUTUBE_WATCH_URL}&t=2677`)
|
||||
})
|
||||
|
||||
it('resolves the MP4 on download and opens the verified URL in a pre-opened tab', async () => {
|
||||
|
|
@ -219,11 +312,7 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
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,
|
||||
})
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
|
||||
expect(openSpy).toHaveBeenCalledWith('', '_blank')
|
||||
|
|
@ -239,7 +328,8 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
expect(tab.location.href).toBe(DOWNLOAD_URL)
|
||||
})
|
||||
expect(tab.close).not.toHaveBeenCalled()
|
||||
expect(iframeElement()).not.toBeNull()
|
||||
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
|
||||
expect(iframeElement()).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the tab and shows the honest notice when the resolution fails', async () => {
|
||||
|
|
@ -247,7 +337,7 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
const tab = { location: { href: '' }, close: vi.fn() }
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
||||
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
|
||||
|
||||
|
|
@ -255,200 +345,11 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
expect(tab.close).toHaveBeenCalled()
|
||||
})
|
||||
expect(await screen.findByText('Não foi possível resolver o trecho.')).toBeDefined()
|
||||
expect(iframeElement()).not.toBeNull()
|
||||
expect(exitLink()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — C171 exits out of the YouTube embed', () => {
|
||||
const cameraButton = () => screen.getByRole('button', { name: /assistir na câmara/i })
|
||||
const youtubeLink = () => screen.getByRole('link', { name: /abrir no youtube/i })
|
||||
|
||||
it('offers both exits, visible with the embed playing, linking the watch URL at the point', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: 100,
|
||||
})
|
||||
|
||||
expect(screen.getByText('Se o vídeo não abrir aqui, assista por outro caminho:')).toBeDefined()
|
||||
expect(
|
||||
screen.getByText('O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.'),
|
||||
).toBeDefined()
|
||||
expect(cameraButton()).toBeDefined()
|
||||
expect(youtubeLink().getAttribute('href')).toBe(
|
||||
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2734',
|
||||
)
|
||||
|
||||
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
|
||||
|
||||
expect(youtubeLink().getAttribute('href')).toBe(
|
||||
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops t from the exit link when the session offset is unknown', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
|
||||
|
||||
expect(youtubeLink().getAttribute('href')).toBe('https://www.youtube.com/watch?v=lLhRDkSPw0A')
|
||||
// The Câmara surface does not depend on the session offset.
|
||||
expect(cameraButton()).toBeDefined()
|
||||
})
|
||||
|
||||
it('offers only the YouTube exit when the Câmara has no stored excerpt', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeOffsetSeconds: 2634,
|
||||
vodResolvable: false,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: /assistir na câmara/i })).toBeNull()
|
||||
expect(youtubeLink()).toBeDefined()
|
||||
// The Câmara support line would promise a surface this speech does not have.
|
||||
expect(
|
||||
screen.queryByText('O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('plays the Câmara excerpt in place of the embed on a single click', async () => {
|
||||
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
fetchMock.mockReturnValue(pending.promise)
|
||||
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: 100,
|
||||
})
|
||||
|
||||
fireEvent.click(cameraButton())
|
||||
|
||||
expect(iframeElement()).toBeNull()
|
||||
expect(screen.getByText(/Resolvendo o trecho na Câmara/)).toBeDefined()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ speechId: 42 })
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
|
||||
})
|
||||
// The transcript now seeks the Câmara surface, and the YouTube exit stays.
|
||||
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
|
||||
expect(videoElement()?.currentTime).toBe(43)
|
||||
expect(youtubeLink()).toBeDefined()
|
||||
expect(screen.queryByRole('button', { name: /assistir na câmara/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('switches to the already verified file without posting again', async () => {
|
||||
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
fetchMock.mockReturnValue(pending.promise)
|
||||
const tab = { location: { href: '' }, close: vi.fn() }
|
||||
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 }))
|
||||
await act(async () => {
|
||||
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
|
||||
})
|
||||
await waitFor(() => expect(tab.location.href).toBe(DOWNLOAD_URL))
|
||||
|
||||
fireEvent.click(cameraButton())
|
||||
|
||||
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not ask the Câmara again while a resolution is already in flight', async () => {
|
||||
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
fetchMock.mockReturnValue(pending.promise)
|
||||
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 }))
|
||||
fireEvent.click(cameraButton())
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
|
||||
})
|
||||
await waitFor(() => expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('switches to the Câmara surface without a second post while the Câmara generates', async () => {
|
||||
respondWith(gerando)
|
||||
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 }))
|
||||
expect(await screen.findByText(/A Câmara está gerando o trecho deste vídeo/)).toBeDefined()
|
||||
|
||||
fireEvent.click(cameraButton())
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(iframeElement()).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /tentar novamente/i })).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the YouTube exit and the honest state when the Câmara fails', async () => {
|
||||
respondWith(indisponivel)
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
fireEvent.click(cameraButton())
|
||||
|
||||
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /tentar novamente/i })).toBeDefined()
|
||||
expect(youtubeLink()).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps share and cut offered while the Câmara surface is active', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
fireEvent.click(cameraButton())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
|
||||
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
|
||||
|
||||
expect(screen.getByRole('button', { name: /^compartilhar$/i })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /cortar vídeo/i })).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — C171-F1 keeps the transcript point across the surface switch', () => {
|
||||
const cameraButton = () => screen.getByRole('button', { name: /assistir na câmara/i })
|
||||
const downloadButton = () => screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i })
|
||||
const segmentButton = (seconds: number) =>
|
||||
document.querySelector(`button[data-start-seconds="${seconds}"]`) as HTMLButtonElement
|
||||
|
||||
const renderYoutubePlayer = (overrides: Partial<Parameters<typeof SpeechDetailPlayer>[0]> = {}) =>
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634, ...overrides })
|
||||
|
||||
const verifyDownloadFirst = async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() }
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
fireEvent.click(downloadButton())
|
||||
await waitFor(() => expect(tab.location.href).toBe(DOWNLOAD_URL))
|
||||
}
|
||||
|
||||
const switchToCamera = async () => {
|
||||
fireEvent.click(cameraButton())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
}
|
||||
|
||||
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;
|
||||
// re-dispatching inside waitFor closes that window deterministically.
|
||||
|
|
@ -459,74 +360,47 @@ describe('SpeechDetailPlayer — C171-F1 keeps the transcript point across the s
|
|||
})
|
||||
}
|
||||
|
||||
it('keeps the transcript point clicked on the embed when switching to the Câmara', async () => {
|
||||
it('seeks the deep-linked point on the VOD-only surface', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderYoutubePlayer({ initialSeconds: 100 })
|
||||
renderPlayer({ initialSeconds: 100 })
|
||||
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(iframeElement()?.getAttribute('src')).toContain('start=2677')
|
||||
|
||||
await switchToCamera()
|
||||
await applyLoadedMetadata(43)
|
||||
})
|
||||
|
||||
it('seeks the clicked point on the Câmara surface even without a deep link', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderYoutubePlayer({ initialSeconds: null })
|
||||
|
||||
fireEvent.click(segmentButton(43))
|
||||
await switchToCamera()
|
||||
await applyLoadedMetadata(43)
|
||||
})
|
||||
|
||||
it('seeks the deep link when the file was verified before switching to the Câmara', async () => {
|
||||
renderYoutubePlayer({ initialSeconds: 100 })
|
||||
await verifyDownloadFirst()
|
||||
|
||||
await switchToCamera()
|
||||
fireEvent.click(watchExcerptButton())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
await applyLoadedMetadata(100)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves the fresh Câmara video at zero when there is no click and no deep link', async () => {
|
||||
renderYoutubePlayer({ initialSeconds: null })
|
||||
await verifyDownloadFirst()
|
||||
it('seeks the deep-linked point on the both-sources surface', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634, initialSeconds: 100 })
|
||||
|
||||
await switchToCamera()
|
||||
fireEvent.click(watchExcerptButton())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
await applyLoadedMetadata(100)
|
||||
})
|
||||
|
||||
it('leaves the fresh Câmara video at zero without a deep link', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634, initialSeconds: null })
|
||||
|
||||
fireEvent.click(watchExcerptButton())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
await applyLoadedMetadata(0)
|
||||
})
|
||||
|
||||
it('applies the pending transcript point after the Câmara file resolves on retry', async () => {
|
||||
respondWith(gerando)
|
||||
renderYoutubePlayer({ initialSeconds: 100 })
|
||||
|
||||
fireEvent.click(segmentButton(43))
|
||||
fireEvent.click(cameraButton())
|
||||
expect(await screen.findByRole('button', { name: /tentar novamente/i })).toBeDefined()
|
||||
expect(videoElement()).toBeNull()
|
||||
|
||||
it('does not re-seek as playback advances after the deep link was applied', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
fireEvent.click(screen.getByRole('button', { name: /tentar novamente/i }))
|
||||
renderPlayer({ initialSeconds: 100 })
|
||||
|
||||
fireEvent.click(watchExcerptButton())
|
||||
await waitFor(() => expect(videoElement()).not.toBeNull())
|
||||
|
||||
await applyLoadedMetadata(43)
|
||||
})
|
||||
|
||||
it('does not re-seek as playback advances after the pending point was applied', async () => {
|
||||
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
|
||||
renderYoutubePlayer({ initialSeconds: 100 })
|
||||
|
||||
fireEvent.click(segmentButton(43))
|
||||
await switchToCamera()
|
||||
await applyLoadedMetadata(100)
|
||||
|
||||
const video = videoElement()!
|
||||
await applyLoadedMetadata(43)
|
||||
|
||||
video.currentTime = 77
|
||||
video.currentTime = 177
|
||||
fireEvent.timeUpdate(video)
|
||||
fireEvent.loadedMetadata(video)
|
||||
|
||||
expect(video.currentTime).toBe(77)
|
||||
expect(video.currentTime).toBe(177)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -539,22 +413,20 @@ describe('SpeechDetailPlayer — no-video quadrant', () => {
|
|||
expect(screen.queryByRole('button', { name: /baixar vídeo/i })).toBeNull()
|
||||
expect(screen.getByRole('link', { name: /abrir fonte/i })).toBeDefined()
|
||||
expect(screen.queryByText(/Se o vídeo não abrir aqui/)).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /abrir no youtube/i })).toBeNull()
|
||||
expect(exitLink()).toBeNull()
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
||||
const segmentButton = (seconds: number) =>
|
||||
document.querySelector(`button[data-start-seconds="${seconds}"]`) as HTMLButtonElement
|
||||
const startSlider = () => screen.getByRole('slider', { name: 'Início do trecho' })
|
||||
const endSlider = () => screen.getByRole('slider', { name: 'Fim do trecho' })
|
||||
const toggleSelection = () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
|
||||
|
||||
it('turns phrase clicks into magnet selection while the mode is on, keeping C162 outside it', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
it('turns phrase clicks into magnet selection while the mode is on', () => {
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('0')
|
||||
|
|
@ -564,23 +436,19 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
expect(startSlider().getAttribute('aria-valuenow')).toBe('0')
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
|
||||
expect(endSlider().getAttribute('aria-valuemax')).toBe('300')
|
||||
// C162 contract: the selection mode does not seek the embed.
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
)
|
||||
|
||||
// Clicking a phrase inside the range re-selects it alone.
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('43')
|
||||
|
||||
// Outside the mode the transcript click seeks exactly as before.
|
||||
// Outside the mode there is no in-page surface yet, so the phrase is inert.
|
||||
toggleSelection()
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(iframeElement()?.getAttribute('src')).toContain('start=2677')
|
||||
expect(segmentButton(43).disabled).toBe(true)
|
||||
expect(videoElement()).toBeNull()
|
||||
})
|
||||
|
||||
it('adjusts the edges by keyboard with the 5 s minimum and no upper cap', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(segmentButton(43))
|
||||
|
|
@ -616,7 +484,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
it('enables the phrases in selection mode and drops t when the offset is unknown', async () => {
|
||||
const writeText = vi.fn(async (_text: string) => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: null })
|
||||
|
||||
expect(segmentButton(43).disabled).toBe(true)
|
||||
toggleSelection()
|
||||
|
|
@ -629,14 +497,14 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: 'Copiar link' }))
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalled())
|
||||
const copied = String(writeText.mock.calls[0]?.[0])
|
||||
expect(copied).toContain('https://www.youtube.com/watch?v=lLhRDkSPw0A')
|
||||
expect(copied).toContain(YOUTUBE_WATCH_URL)
|
||||
expect(copied).not.toContain('&t=')
|
||||
})
|
||||
|
||||
it('copies the message with the interval and links WhatsApp with the same text', async () => {
|
||||
const writeText = vi.fn(async () => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(segmentButton(43))
|
||||
|
|
@ -648,7 +516,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: 'Copiar link' }))
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
'Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:00 a 00:50 https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2634',
|
||||
`Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:00 a 00:50 ${YOUTUBE_WATCH_URL}&t=2634`,
|
||||
),
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: 'Link copiado' })).toBeDefined()
|
||||
|
|
@ -659,7 +527,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
throw new Error('denied')
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
|
|
@ -670,7 +538,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
|
||||
it('opens the share options as a bottom sheet on coarse pointers', async () => {
|
||||
stubMatchMedia(true)
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
|
|
@ -682,7 +550,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
})
|
||||
|
||||
it('drags a handle with the phrase-boundary magnet', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
toggleSelection()
|
||||
|
||||
const track = document.querySelector('[data-slot="speech-excerpt-track"]') as HTMLDivElement
|
||||
|
|
@ -726,7 +594,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
it('resets the copy feedback after its window', async () => {
|
||||
const writeText = vi.fn(async () => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
|
|
|
|||
Loading…
Reference in a new issue