mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(C171): player do acervo toca ou oferece a saída em um clique — nunca beco de signin (Closes #1047)
This commit is contained in:
parent
2e389365a3
commit
cb0e902eee
6 changed files with 474 additions and 9 deletions
1
docs/changelog/2026-09-15-c171.md
Normal file
1
docs/changelog/2026-09-15-c171.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C171 — Acervo: o player do YouTube nunca termina num beco de signin (2026-09-15):** o detalhe de uma fala com vídeo no YouTube parava no "faça login para confirmar que você não é um robô" — clicar em signin abria outra janela (onde a pessoa já estava logada) e nada mudava na página. Agora o embed padrão passa a usar `www.youtube.com/embed/<id>` (mesmos `playsinline=1&rel=0&start=`, atacando o bot-check de embeds anônimos do `youtube-nocookie`; a área é interna e autenticada) e, **sempre visível** logo abaixo do player, há saída em um clique: **"Assistir na Câmara"** troca a superfície ativa e resolve o trecho sob demanda no próprio `SpeechDetailPlayer` (reuso integral do C162 — sem segundo player, sem novo POST quando o arquivo já foi verificado, com os estados honestos de resolvendo/gerando/falha) e **"Abrir no YouTube"** abre `watch?v=<id>&t=<segundos>` no ponto (reuso do `buildSpeechExcerptYoutubeUrl` do C166; `t=` omitido quando o offset é desconhecido). O bloco não depende de detectar o bloqueio — o app nunca vê o erro interno do iframe — então a saída está ao alcance também no caminho feliz (hierarquia secundária, apoio só quando a fala tem VOD). A transcrição passa a posicionar a **superfície ativa** (iframe ou `<video>`), o link de saída acompanha o último ponto clicado, e share/corte/download/seleção (C166/C167) seguem intactos nos quatro quadrantes; sem VOD sobra só o link (o apoio some para não prometer uma superfície inexistente). O embed do site público continua `youtube-nocookie` (LGPD); gate do acervo e crédito "Fonte: Câmara dos Deputados · CC BY 4.0" intocados; sem migration, `Consent`, schema ou escrita. Testes: unit RTL do player (saídas nos quadrantes, 1 POST no switch, href no ponto, transcrição na superfície da Câmara, falha mantendo a saída) e e2e browserless do acervo estendido (host novo, bloco de saída no HTML e ausência dele em só-VOD/sem-vídeo). Follow-up registrado: **C171-F1 (#1052)** — a troca para a Câmara perde o ponto da transcrição (clique sobrescrito e deep link ignorado no arquivo já verificado).
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# C171-F1 — Player: a troca para a Câmara perde o ponto da transcrição
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-09-15
|
||||
Issue: (registrada na triage do simplify do C171)
|
||||
Pai: #1047 (C171) — `depends: [1047]`
|
||||
Priority: P2
|
||||
Kind: defect
|
||||
Appetite: ~0,25 dia eng (client-only; sem schema, sem `Consent`, sem escrita, sem rota)
|
||||
Responsável: —
|
||||
|
||||
> Débito registrado na triage do simplify/impeccable do C171
|
||||
> ([c171-player-youtube-sem-signin-impl.md](c171-player-youtube-sem-signin-impl.md)).
|
||||
|
||||
## Contexto
|
||||
|
||||
O C171 introduziu a superfície ativa (`surface: 'youtube' | 'vod'`) e o "Assistir na Câmara" no
|
||||
`SpeechDetailPlayer`. O efeito de `initialSeconds` existe desde o C162 e posiciona o `<video>`
|
||||
quando o `playbackUrl` passa a existir — dependência `[initialSeconds, playbackUrl]`.
|
||||
|
||||
Dois furos no mesmo dono (efeito keyed em `playbackUrl`, não na montagem do `<video>`):
|
||||
|
||||
1. **Ponto clicado sobrescrito.** Clique na frase com o embed ativo grava o ponto em `activeStart`
|
||||
(e recarrega `start=` do iframe). Ao trocar para a Câmara, o `<video>` nasce e o efeito
|
||||
posiciona em `initialSeconds` (deep link `?t=`) — ou, sem deep link, deixa em 0 — ignorando o
|
||||
último clique; highlight e `t=` da saída ficam dessincronizados até o primeiro `timeupdate`.
|
||||
2. **Deep link perdido quando o arquivo já estava verificado.** Se a pessoa baixa o MP4 primeiro
|
||||
(o `playbackUrl` resolve com a superfície YouTube ativa), o efeito roda sem `<video>` montado
|
||||
e retorna cedo; ao trocar para a Câmara as deps não mudam e o efeito **não** re-roda — o
|
||||
`<video>` fica em 0 mesmo com `initialSeconds > 0` (regressão do contrato C162).
|
||||
|
||||
## Evidência
|
||||
|
||||
- `src/components/campaign/speech/SpeechDetailPlayer.tsx:164-177` — efeito de `initialSeconds`
|
||||
com deps `[initialSeconds, playbackUrl]` e sem considerar `activeStart`/a montagem do `<video>`.
|
||||
- `:222-233` — `seekTo` grava `activeStart` no ramo YouTube; `:216-220` — `watchVod` só troca a
|
||||
superfície quando o arquivo já está verificado (sem novo POST).
|
||||
- `tests/unit/speechDetailPlayer.unit.spec.tsx` — "plays the Câmara excerpt in place of the embed"
|
||||
clica a transcrição **depois** de a Câmara resolver (`initialSeconds: 100`); "switches to the
|
||||
already verified file" usa `initialSeconds: null` — nenhum dos dois cobre os casos acima.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Clique explícito na transcrição feito com a superfície YouTube ativa vence o `initialSeconds`
|
||||
quando o `<video>` da Câmara monta: `video.currentTime` = último ponto clicado (excerpt-relative).
|
||||
- Sem clique explícito, o contrato C162/C171 permanece: `initialSeconds` no arquivo recém-resolvido
|
||||
**e** no já verificado (download primeiro); sem deep link, 0.
|
||||
- Nenhum seek em loop: `timeupdate` não re-dispara o efeito; sem mudança de copy/layout/quadrantes
|
||||
sem YouTube, do POST/rota da Câmara e do `t=` da saída (que segue o ponto efetivo).
|
||||
|
||||
## Decisão de engenharia
|
||||
|
||||
Opções: A) precedência de alvo (`pendingSeekRef` gravado por `seekTo` no ramo YouTube, consumido
|
||||
quando o `<video>` monta) + deps do efeito incluindo a transição de superfície | B) incluir
|
||||
`activeStart` direto nas deps do efeito | C) remontar o player na troca de superfície.
|
||||
|
||||
**Recomendação: A** — cobre os dois furos (clique vence o deep link; deep link vale mesmo no
|
||||
caminho já verificado) sem re-disparo a cada `timeupdate`; o ref é do dono (clique), não do estado
|
||||
de reprodução.
|
||||
|
||||
**Rejeitadas:** B porque `activeStart` muda a cada `timeupdate` e o efeito re-seekaria em loop;
|
||||
C porque descarta a resolução já verificada e força novo fetch (regressão do "sem novo POST").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fix (~0,15 dia).** `seekTo` (ramo YouTube) grava o alvo no ref; efeito do `<video>` usa
|
||||
`ref.current ?? initialSeconds` e passa a rodar na transição para a superfície da Câmara; ref
|
||||
limpo após o seek.
|
||||
- Testes (RTL, `tests/unit/speechDetailPlayer.unit.spec.tsx`): clique na frase 43 com embed
|
||||
ativo → "Assistir na Câmara" → `video.currentTime === 43` (com `initialSeconds: 100` e sem
|
||||
deep link); arquivo já verificado (download primeiro) + `initialSeconds > 0` sem clique →
|
||||
`video.currentTime === initialSeconds`; pins C162 existentes sem clique inalterados.
|
||||
- Verificação: `pnpm test:unit`, `pnpm typecheck`.
|
||||
2. **Gates (~0,1 dia).** `pnpm gate:fast`; e2e browserless do acervo inalterado (a interação é
|
||||
dona do jsdom); changelog no fechamento.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- IFrame API/`postMessage` para retomar posição do embed do YouTube — segue cortado (C162/C171).
|
||||
- Persistir o ponto entre reloads; tocar `speechVodResolver.ts`/rota `resolver-vod`/`actions/speech.ts`
|
||||
(dono: C169/#1045).
|
||||
- Mudanças de layout/copy do bloco de saída (o débito cosmético S3 do triage tem gatilho próprio
|
||||
no plano-pai).
|
||||
|
||||
## Riscos
|
||||
|
||||
- **Ref obsoleto de um clique antigo:** o alvo só é gravado no ramo YouTube e só é lido quando o
|
||||
`<video>` monta; teste cobre POST novo e arquivo já verificado.
|
||||
- **Compat C162:** nenhum pin sem clique muda — rodar a suíte do player inteira.
|
||||
122
docs/plans/c171-player-youtube-sem-signin-impl.md
Normal file
122
docs/plans/c171-player-youtube-sem-signin-impl.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Impl: Acervo: o player do YouTube nunca termina num beco de signin — toca ou oferece a saída em um clique
|
||||
|
||||
Status: executado
|
||||
Atualizado em: 2026-09-15
|
||||
Issue: #1047
|
||||
Intenção: docs/plans/c171-player-youtube-sem-signin.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng; sem corte — sem schema, sem `Consent`, sem server novo, sem arquivo novo de player)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** abrir uma fala com YouTube nunca termina num beco de signin — o embed toca quando o YouTube deixa e, quando não deixa, a página oferece em um clique uma superfície que toca nesta página (o trecho da Câmara sob demanda, quando elegível) e/ou "Abrir no YouTube" no ponto exato; transcrição, seleção, share, corte e download seguem intactos.
|
||||
- **O que NÃO negociar:** saída independente de detecção do bloqueio (o app não vê o erro do iframe) — sempre visível; `t=` só com offset conhecido (nunca inventar); transcrição posiciona a **superfície ativa**; sem segundo player/editor; sem `Consent`/collection/migration; gate do acervo (`communicator`/`coordinator`/`candidate`; `advisor`/`leader` negados) e crédito "Fonte: Câmara dos Deputados · CC BY 4.0" intocados; URLs públicas intocadas; `excerptTMs` verbatim (C169).
|
||||
- **O que reavaliar:** a hipótese de que a troca de host (`youtube-nocookie.com` → `www.youtube.com`, mesmos params) é o remédio barato para o bot-check de embed anônimo — é a recomendação C da intenção, assumida e validada no gate; a intenção sugere `src/lib/speechShare.ts` como área provável — não muda: o dono do link `watch?v=&t=` já existe e é reusado; e a fixture e2e é reavaliada na decisão 7 (nenhum browser e2e visita o detalhe hoje).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D["Detalhe da fala (RSC)<br/>youtubeVideoId · offset · vodResolvable"] --> Q{"youtubeVideoId?"}
|
||||
Q -- "não" --> V["Quadrante só VOD / nenhum<br/>ramos C162 inalterados"]
|
||||
Q -- "sim" --> Y["superfície ativa youtube<br/>iframe www.youtube.com/embed<br/>start = offset + t"]
|
||||
Y --> X["saídas em 1 clique, sempre visíveis<br/>'Se o vídeo não abrir aqui…'"]
|
||||
X -- "Assistir na Câmara" --> S["surface = vod<br/>POST resolver-vod (C162)"]
|
||||
S --> P["resolvendo / gerando / estado honesto"]
|
||||
P -- "PRONTO verificado" --> M["video nativo no trecho exato<br/>na própria página"]
|
||||
P -- "falha" --> E["estado honesto + Tentar novamente<br/>'Abrir no YouTube' permanece"]
|
||||
X -- "Abrir no YouTube" --> W["watch?v=ID (t=…) em aba nova<br/>sem t= quando offset desconhecido"]
|
||||
Y --> T["clique na transcrição<br/>posiciona a superfície ativa"]
|
||||
M --> T
|
||||
M --> Z["seleção · share · cortar · baixar<br/>C166/C167 intactos"]
|
||||
Y --> Z
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) manter `youtube-nocookie.com` e entregar só as saídas | B) trocar o host para `https://www.youtube.com/embed/<id>` (mesmos params) **e** entregar as saídas + estado de superfície — recomendação C da intenção | C) trocar o host e transformar o YouTube numa superfície própria (página/modal do player).
|
||||
**Recomendação:** B — a troca de host é uma constante e ataca de frente as hipóteses (a)/(b) de bot-check/embed anônimo ("toca sem login"), enquanto as saídas cobrem o que o YouTube bloquear de fato (idade/embed desabilitado); a área é interna e autenticada, então o custo LGPD é menor que no site público — **assumido — validar no gate**.
|
||||
**Rejeitadas:** A porque mantém o defeito provável intocado quando o remédio custa uma linha, e aposta tudo na saída; C porque é o rabbit hole "segundo player/superfície" que a intenção corta.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Host do embed.** Opções: A) manter `youtube-nocookie.com`, só saídas | B) `www.youtube.com` (mesmos `playsinline=1`, `rel=0`, `start` só >0) **e** saídas | C) host novo + superfície própria. **Recomendação:** B. **Rejeitadas:** A (não ataca a hipótese mais provável); C (anti-goal). _(assumido — validar no gate)_
|
||||
2. **Superfície ativa.** Opções: A) `surface: 'youtube' | 'vod'` no `SpeechDetailPlayer`; iframe só com `surface === 'youtube'`; ramos VOD existentes byte a byte | B) iframe fixo + `<video>` "embaixo" (dois players) | C) `?surface=vod` na URL com reload. **Recomendação:** A — um dono do vídeo, uma superfície por vez, resolução C162 reusada. **Rejeitadas:** B (dois players e transcrição ambígua); C (estado volátil em URL interna, reload perde a resolução já verificada).
|
||||
3. **Saídas sempre visíveis.** Opções: A) sempre que há `youtubeVideoId`, bloco secundário abaixo do media | B) só no bloqueio (exige detecção) | C) escondidas no menu de ações. **Recomendação:** A — aceite explícito: o app não vê o erro do iframe; B é o beco de hoje. "Assistir na Câmara" só quando `surface === 'youtube' && vodResolvable`; "Abrir no YouTube" sempre com id. **Rejeitadas:** B (detecção fora de escopo); C (esconderia a saída). _(assumido)_
|
||||
4. **"Abrir no YouTube".** Opções: A) aba nova com `buildSpeechExcerptYoutubeUrl(youtubeVideoId, youtubeOffsetSeconds, activeStart ?? initialSeconds ?? 0)` | B) trocar o embed na mesma página | C) link autenticado do acervo. **Recomendação:** A — reusa o dono do `watch?v=&t=` (C166), `t=` omitido com offset nulo, sem helper/twin novo (knip preservado); "no ponto" = último ponto da transcrição/deep link (`activeStart ?? initialSeconds`), senão o início da fala. **Rejeitadas:** B (já é efeito da troca de host, não é botão); C (vaza URL interna). _(assumido)_
|
||||
5. **Transcrição na superfície ativa.** Opções: A) `seekTo`/`seekable`/label/`disabled` ramificando em `surface` | B) manter ramificando em `youtubeVideoId` | C) desabilitar no VOD. **Recomendação:** A — B faria o clique tentar o iframe desmontado (regressão do aceite "posiciona na superfície ativa"); C é regressão C162. **Rejeitadas:** B e C.
|
||||
6. **Detecção do bloqueio.** Opções: A) nenhuma (sem IFrame API/`onError`/polling) | B) IFrame API + heurística. **Recomendação:** A — fora de escopo explícito; a saída não depende de detecção. **Rejeitada:** B.
|
||||
7. **Fixture e2e.** Opções: A) não tocar `tests/e2e/fixtures/e2eTest.ts` | B) estender o abort para `www.youtube.com/embed/**`. **Recomendação:** A — as duas famílias do acervo são browserless e nenhum browser e2e visita o detalhe; tocar no harness classifica o PR como high-risk (curated e2e) sem cobertura nova. **Rejeitada:** B nesta fatia. **Gatilho de revisitação:** o primeiro browser e2e que carregar o detalhe adiciona o abort do host novo (ou a rota vira regex dos dois hosts).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (dono único, encaixe cirúrgico): `buildYoutubeSrc` (:29-35) troca só o host para `https://www.youtube.com/embed/<id>` (params/ordem intocados); `title`, `allow`, `allowFullScreen`, `loading="lazy"` do iframe (:250-257) **byte a byte** — nada de `sandbox` novo; estado `surface` iniciado em `'youtube'` quando há id, `'vod'` caso contrário; `renderMedia` (:247-258) devolve o iframe só com `surface === 'youtube' && youtubeVideoId` e mantém os ramos VOD/`StatusPanel` como estão; `watchVod()` = `setSurface('vod')` + `requestResolution(false)` apenas quando não há `playbackUrl` e não há request em voo (`resolving`/`generating`) — já resolvido, troca sem novo POST; `seekTo` (:201-212) ramifica em `surface === 'youtube'` (offset nulo → inerte), senão `video.currentTime` + play; `seekable` (:245) vira `surface === 'youtube' ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)` (label :471-476 e `disabled` :492 seguem de graça); `inlineNotice` (:340-355) gate em `surface === 'youtube' && youtubeVideoId` (no VOD o painel já conta o estado; o aviso do download em curso no caminho "ambos" segue aparecendo com o iframe ativo); bloco de saída local ao arquivo (mesmo padrão do `StatusPanel`/`renderMedia`, **sem arquivo/componente novo**), logo abaixo do media, `data-slot="speech-youtube-exit"`: título "Se o vídeo não abrir aqui, assista por outro caminho:", apoio "O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.", "Assistir na Câmara" (`Button variant="outline"`, `PlayIcon`) quando `surface === 'youtube' && vodResolvable`, e "Abrir no YouTube" (`Button asChild variant="outline"`, `<a target="_blank" rel="noreferrer">`, `YoutubeIcon` de `src/components/socialIcons.tsx:33-45`) sempre que `youtubeVideoId`, href = `buildSpeechExcerptYoutubeUrl(...)` (decisão 4). Intocados: download (:405-429), share/cut (:384-404), `SpeechExcerptControls`, `SpeechCutDialog`, `publishedCut`, `aria-busy`, aviso sem-YouTube do C166 (:457-467).
|
||||
- **`src/lib/speechShare.ts`** (sem mudança): `buildSpeechExcerptYoutubeUrl` (:29-44) é o dono do `watch?v=&t=` e já é unit-testado/consumido por `SpeechExcerptShare.tsx:18,53-60` — reuso direto no player, sem alias/segundo helper.
|
||||
- **`src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx` / `speechViewModels.ts` / rotas / actions**: nenhuma mudança — props atuais (`youtubeVideoId`, `youtubeOffsetSeconds`, `vodResolvable`) bastam; nada de schema/VM nova; **não tocar** `speechVodResolver.ts`, rota `resolver-vod`, `actions/speech.ts` (dono: C169/#1045).
|
||||
- **Migration:** sem migration — nenhum campo/collection/`payload-types` novo (`push: false` intocado).
|
||||
- **Access / Consent:** nenhum `Consent` novo; gate `canReadSpeechCatalog` do detalhe/rota intocado; nenhuma escrita nova; `advisor`/`leader` seguem negados.
|
||||
- **UI:** Impeccable B — shape → craft → critique → polish; reusa `Button`, `YoutubeIcon`, o padrão de aviso `rounded-lg border bg-muted/40 px-4 py-3` (mesma família do aviso C166) e o tema `data-theme="campaign"`; sem shell novo, sem `--pt-red`; hierarquia secundária no caminho feliz (o draft decide a ênfase âmbar no gate); identificadores em inglês, copy pt-BR verbatim da intenção.
|
||||
|
||||
### Comportamento por quadrante
|
||||
|
||||
| Quadrante | Superfície inicial | Saídas visíveis | Transcrição | Ações |
|
||||
| ----------------------------------- | --------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- | --------------------------------------- |
|
||||
| YouTube + VOD | iframe `www.youtube.com/embed` com `start=offset+t` | "Assistir na Câmara" + "Abrir no YouTube" | posiciona o iframe (recarrega `start=`) | baixar MP4, share (C166), cortar (C167) |
|
||||
| Só YouTube (`vodResolvable: false`) | iframe idem | só "Abrir no YouTube" (`t=` omitido se offset nulo) | posiciona com offset; inerte sem offset (C162) | share ok; sem baixar/cortar (C162/C167) |
|
||||
| Só VOD (sem id) | capa "Assistir o trecho" → painel → `<video>` | nenhuma (nada a escapar) | posiciona o `<video>` | baixar, cortar, aviso sem-link C166 |
|
||||
| Nenhum | "Vídeo indisponível neste momento." | nenhuma | inerte | "Abrir fonte" apenas |
|
||||
|
||||
**Caminho feliz (embed tocando):** as saídas seguem visíveis em hierarquia secundária — o app não vê o erro interno, então não há estado "bloqueado"; **após "Assistir na Câmara"**: o VOD vira a superfície, sobra "Abrir no YouTube" (a Câmara já é o player) e falha da Câmara mantém o link (nunca beco).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
**Não se aplica como dado:** o estado do embed/saída é **feedback de ação** (o que fazer agora), não dado de acervo — mesma decisão do C166/C162. Formas rejeitadas: contador/telemetria de bloqueio, classificação da causa do signin, badge de status no card/detalhe, mostrar o erro interno do iframe (o app não o vê).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer (~0,25 dia):** host swap + `surface` + `watchVod` + bloco de saída no `SpeechDetailPlayer`; os 3 pins de host do unit atualizados (183, 201, 293) e casos novos verdes. Verificação: `pnpm test:unit`, `pnpm typecheck`.
|
||||
2. **UI (~0,25–0,5 dia):** shape → craft → critique → polish do bloco (desktop/mobile ~390px, flex-wrap, `min-h-10`, a11y do `data-slot="speech-youtube-exit"`, copy verbatim); RTL completo dos quadrantes. Verificação: `pnpm test:unit`, `pnpm lint`, `pnpm format:check`.
|
||||
3. **Gates (~0,25 dia):** e2e browserless do acervo; `pnpm gate:fast`; `pnpm push`; CI roda `campaignSpeechAcervo`+`campaignSpeechCut` pelo manifesto (`src/components/campaign/speech` → :300-314); changelog `docs/changelog/2026-09-15-c171-youtube-sem-beco.md` no fechamento.
|
||||
|
||||
## Plano de testes
|
||||
|
||||
- **Unit RTL** (`tests/unit/speechDetailPlayer.unit.spec.tsx`, estender): trocar **apenas o literal do host** nos pins :183, :201, :293 (params e comportamento intactos); YouTube+VOD mostra título + "Assistir na Câmara" + "Abrir no YouTube" com href `watch?v=<id>&t=<offset+initial>`; `vodResolvable: false` mostra só o link; offset `null` → href sem `&t=`; clique "Assistir na Câmara" → `fetch` 1× `{ speechId: 42 }`, painel "Resolvendo o trecho na Câmara…", iframe desmontado, depois `<video src>` e clique em `button[data-start-seconds="43"]` → `video.currentTime === 43` (e `seekable`/label voltam); resolução já pronta (via download) → troca sem novo POST; falha → painel honesto + "Tentar novamente" **com "Abrir no YouTube" presente**; share/cut/seleção seguem disponíveis na superfície da Câmara; os blocos C162/C166/C167 existentes continuam verdes.
|
||||
- **E2E browserless** (`tests/e2e/campaignSpeechAcervo.e2e.spec.ts`): pin :163 → `www.youtube.com/embed/${YOUTUBE_VIDEO_ID}`; no detalhe "ambos" (deep link `?t=43`) somar `Se o vídeo não abrir aqui`, `Assistir na Câmara`, `Abrir no YouTube` e `t=2677` (2634+43, offset verbatim), mantendo `not.toContain('<video')` e `not.toContain('vod.camara.leg.br')`; só-VOD (:178-201) e nenhum (:249-266) negam `Abrir no YouTube` e `Se o vídeo não abrir aqui`; rota `resolver-vod` (:268-322) intocada. Sem browser e2e novo — a interação é dona do jsdom RTL.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- IFrame API/`enablejsapi`/`postMessage`/`onError`/polling para detectar bloqueio — corte da intenção; a saída não depende de detecção.
|
||||
- Proxy/oEmbed/cache server-side do embed, baixar/mirror do vídeo, login/SSO no YouTube, cookies de terceiros — fora.
|
||||
- Segundo player/superfície própria, preview do trecho no embed, voltar o host para `nocookie` — fora.
|
||||
- Mexer no embed do site público (`CampaignStorySection`/`frontend.e2e.spec.ts:508-514`) — permanece `youtube-nocookie`.
|
||||
- Fixture e2e (decisão 7), CSP/middleware/`headers()`, `Consent`/collection/migration/escrita, redesenho do detalhe/lista além do encaixe — fora.
|
||||
|
||||
### Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Extração de `renderYoutubeExit()`; testes de guard do `watchVod` (resolving/generating → 1 POST); pins negativos dos quadrantes só-VOD/nenhum; `youtubeSurface && youtubeVideoId` mantido só no `renderMedia` (narrowing); apoio "O trecho da Câmara toca nesta página…" condicional a `vodResolvable`. Tudo já no diff desta entrega.
|
||||
|
||||
### Adiado com gatilho / Explicitamente fora (triage pós-simplify, 2026-09-15)
|
||||
|
||||
- **Ponto da transcrição perdido na troca para a Câmara** (S2, estrutural): o efeito de `initialSeconds` (:164-177) sobrescreve o último clique (`activeStart`) e, no arquivo já verificado (download primeiro), nem roda quando o `<video>` monta — a fatia vira a Issue **[C171-F1 (#1052)](https://github.com/fsolla/teqo/issues/1052)** (`depends: [1047]`, plano `docs/plans/c171-f1-ponto-da-transcricao-na-troca-de-superficie.md`). Não implementar nesta entrega.
|
||||
- **`watchVod` re-POSTA com `resolved` + `playbackUrl` nulo** (S1): intencional — o clique explícito em "Assistir na Câmara" é o retry do probe de playback; alinhar o guard a `resolved` só mostraria o painel honesto sem nova tentativa. Descartado (sem código).
|
||||
- **`YoutubeIcon` sem `data-icon="inline-start"` no "Abrir no YouTube"** (S3, cosmético ~2px): gatilho = próximo toque no bloco `speech-youtube-exit` — usar `data-icon="inline-start"` (precedente `SpeechCutShareActions.tsx:47`).
|
||||
- **`surface` sem união discriminada `{kind:'youtube';videoId:string} | {kind:'vod'}`** (S4): 1 consumidor; o `&& youtubeVideoId` do `renderMedia` é só narrowing. Gatilho = 3º consumidor do estado de superfície ou extração do iframe para componente próprio.
|
||||
- **Renomes `surface`→`playerSurface`, `youtubeWatchUrl`→`youtubeExitUrl`, `watchVod`→`showVodSurface`** (S5): pureza de nomes sem consumidor externo — descartado (score 1).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Pins do host trocado:** 3 no unit (`:183`, `:201`, `:293`) + 1 no e2e (`:163`) — trocar só o literal e rodar os pins a cada mudança; nenhuma asserção de comportamento muda.
|
||||
- **A troca de host não resolver (idade/embed desabilitado):** as saídas cobrem o que o YouTube ainda bloquear; sem prometer aviso automático; `t=` nunca inventado.
|
||||
- **Postura LGPD:** premissa "área interna autenticada" registrada como _assumido — validar no gate_; o site público fica `nocookie`; reversão é uma constante em `buildYoutubeSrc`.
|
||||
- **iframe `sandbox`/`allow`:** não adicionar `sandbox` (quebraria o player); `allow`, `allowFullScreen`, `loading` e `title` intocados — fora do diff.
|
||||
- **C169 concorrente no mesmo domínio:** não tocar resolver/rota/action; dependência só suave (resolver corrigido torna o botão "Assistir na Câmara" mais confiável).
|
||||
- **Duplo POST/"Assistir na Câmara" repetido:** guard de `resolving`/`generating`/`playbackUrl` no `watchVod` + teste de 1 chamada.
|
||||
- **Regressão C162/C166/C167:** ramos VOD, download, share, cut e seleção intocados; pins rodam a cada fase.
|
||||
- **Hermeticidade e2e:** nenhum browser e2e carrega o detalhe (as duas famílias são HTTP); gatilho de revisitação na decisão 7 se um browser spec passar a carregar.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção coberto: 4 quadrantes + caminho feliz; saída em 1 clique sempre visível; "Assistir na Câmara" toca o trecho na própria página e a transcrição posiciona a superfície ativa; `watch?v=&t=` no ponto e sem `t=` quando o offset é desconhecido; crédito CC BY e share/cut/download intactos.
|
||||
- [x] Invariantes AGENTS/engineering-standards: sem migration/`Consent`/escrita; sem twin (`buildSpeechExcerptYoutubeUrl` reusado; nenhum helper novo); `src/lib` sem import novo; gate do acervo intocado; copy pt-BR / identificadores em inglês; nenhum comentário supérfluo.
|
||||
- [x] Testes de domínio previstos (unit jsdom + e2e browserless) onde o contrato do player muda; `pnpm gate:fast` limpo e manifest de e2e sem entrada nova.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
5/5 — (1) todas as decisões caras (host, superfície, saídas, link, fixture) têm opções + rejeitadas explícitas; (2) cabe no appetite herdado porque corta detecção/IFrame API/superfície nova e não cria arquivo; (3) rabbit holes nomeados com corte; (4) depth check: reusa `SpeechDetailPlayer`, `StatusPanel`, `Button`, `YoutubeIcon` e `buildSpeechExcerptYoutubeUrl` — nada de shell/twin paralelo; (5) o outcome permanece intacto (toca ou saída em um clique, nunca beco de signin), com a troca de host sinalizada como assumida ao gate humano.
|
||||
|
|
@ -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'
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
MIN_EXCERPT_SECONDS,
|
||||
type ExcerptRange,
|
||||
} from '@/lib/speechExcerptSelection'
|
||||
import { buildSpeechExcerptYoutubeUrl } from '@/lib/speechShare'
|
||||
import type { SpeechVodResolution } from '@/lib/speechVod'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
|
@ -31,7 +33,7 @@ const buildYoutubeSrc = (videoId: string, startSeconds: number | null): string =
|
|||
if (startSeconds !== null && startSeconds > 0) {
|
||||
params.set('start', String(Math.floor(startSeconds)))
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`
|
||||
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`
|
||||
}
|
||||
|
||||
type ResolutionState =
|
||||
|
|
@ -139,9 +141,20 @@ export const SpeechDetailPlayer = ({
|
|||
? 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,
|
||||
youtubeOffsetSeconds,
|
||||
activeStart ?? initialSeconds ?? 0,
|
||||
)
|
||||
: null
|
||||
const selectionDuration = durationSeconds ?? segments.at(-1)?.endSeconds ?? null
|
||||
const selectionAvailable = selectionDuration !== null && selectionDuration >= MIN_EXCERPT_SECONDS
|
||||
const selecting = selection !== null
|
||||
|
|
@ -198,8 +211,16 @@ 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 (youtubeVideoId) {
|
||||
if (youtubeSurface) {
|
||||
if (youtubeOffsetSeconds === null) return
|
||||
setYoutubeStart(youtubeOffsetSeconds + seconds)
|
||||
setActiveStart(seconds)
|
||||
|
|
@ -242,10 +263,10 @@ export const SpeechDetailPlayer = ({
|
|||
setSelection(extendRangeToSegment(selection, segments, index, selectionDuration))
|
||||
}
|
||||
|
||||
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
|
||||
const seekable = youtubeSurface ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
|
||||
|
||||
const renderMedia = (): ReactNode => {
|
||||
if (youtubeVideoId) {
|
||||
if (youtubeSurface && youtubeVideoId) {
|
||||
return (
|
||||
<iframe
|
||||
src={buildYoutubeSrc(youtubeVideoId, youtubeStart)}
|
||||
|
|
@ -337,7 +358,7 @@ export const SpeechDetailPlayer = ({
|
|||
)
|
||||
}
|
||||
|
||||
const inlineNotice = youtubeVideoId ? (
|
||||
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.
|
||||
|
|
@ -354,10 +375,56 @@ export const SpeechDetailPlayer = ({
|
|||
) : null
|
||||
) : null
|
||||
|
||||
// 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">
|
||||
<a
|
||||
href={youtubeWatchUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-slot="speech-youtube-exit-link"
|
||||
>
|
||||
<YoutubeIcon />
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="speech-player" aria-busy={resolving || undefined}>
|
||||
{renderMedia()}
|
||||
|
||||
{renderYoutubeExit()}
|
||||
|
||||
{selection && selectionDuration !== null ? (
|
||||
<SpeechExcerptControls
|
||||
segments={segments}
|
||||
|
|
|
|||
|
|
@ -160,8 +160,15 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
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(`youtube-nocookie.com/embed/${YOUTUBE_VIDEO_ID}`)
|
||||
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).
|
||||
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).toContain(`watch?v=${YOUTUBE_VIDEO_ID}&t=${EXCERPT_OFFSET_SECONDS + 43}`)
|
||||
expect(detailHtml).toContain('data-start-seconds="0"')
|
||||
expect(detailHtml).toContain('Baixar vídeo (MP4)')
|
||||
expect(detailHtml).toContain('Abrir fonte')
|
||||
|
|
@ -194,6 +201,9 @@ 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.
|
||||
expect(html).not.toContain('Abrir no YouTube')
|
||||
expect(html).not.toContain('Se o vídeo não abrir aqui')
|
||||
expect(html).not.toContain('<video')
|
||||
expect(html).not.toContain('<iframe')
|
||||
// The stored link is an eligibility signal, never a rendered URL.
|
||||
|
|
@ -263,6 +273,9 @@ 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.
|
||||
expect(html).not.toContain('Abrir no YouTube')
|
||||
expect(html).not.toContain('Se o vídeo não abrir aqui')
|
||||
})
|
||||
|
||||
test.describe('POST /campanha/comunicacao/acervo/resolver-vod (C162)', () => {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ 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.
|
||||
expect(screen.queryByText(/Se o vídeo não abrir aqui/)).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /abrir no youtube/i })).toBeNull()
|
||||
// The invariant: rendering the detail never calls the Câmara.
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
|
|
@ -180,7 +183,7 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2734',
|
||||
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2734',
|
||||
)
|
||||
|
||||
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
|
||||
|
|
@ -198,7 +201,7 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
})
|
||||
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -256,6 +259,174 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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 — no-video quadrant', () => {
|
||||
it('renders the unavailable block without retry or download', () => {
|
||||
renderPlayer({ vodResolvable: false, sourceUrl: 'https://imagem.camara.leg.br/diario.pdf' })
|
||||
|
|
@ -264,6 +435,8 @@ describe('SpeechDetailPlayer — no-video quadrant', () => {
|
|||
expect(screen.queryByRole('button', { name: /tentar novamente/i })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /baixar vídeo/i })).toBeNull()
|
||||
expect(screen.getByRole('link', { name: /abrir fonte/i })).toBeDefined()
|
||||
expect(screen.queryByText(/Se o vídeo não abrir aqui/)).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /abrir no youtube/i })).toBeNull()
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()).toBeNull()
|
||||
})
|
||||
|
|
@ -290,7 +463,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
|||
expect(endSlider().getAttribute('aria-valuemax')).toBe('180')
|
||||
// C162 contract: the selection mode does not seek the embed.
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
)
|
||||
|
||||
// Clicking a phrase inside the range re-selects it alone.
|
||||
|
|
|
|||
Loading…
Reference in a new issue