From 5f64321cc5797afdd9e53c106ebe6bb42abe91b1 Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Wed, 16 Sep 2026 19:42:35 -0300
Subject: [PATCH] =?UTF-8?q?fix(C177):=20"Abrir=20fonte"=20do=20acervo=20s?=
=?UTF-8?q?=C3=B3=20com=20fonte=20oficial=20(Closes=20#1087)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/changelog/2026-09-16-c177.md | 1 +
...o-abrir-fonte-so-com-fonte-oficial-impl.md | 118 ++++++++++++++++++
.../(app)/comunicacao/acervo/[id]/page.tsx | 4 +-
.../campaign/speech/SpeechDetailPlayer.tsx | 27 ++--
.../campaign/speech/SpeechResultCard.tsx | 16 ++-
src/utilities/speech/speechViewModels.ts | 11 +-
tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 45 ++++++-
tests/unit/speechDetailPlayer.unit.spec.tsx | 43 ++++++-
tests/unit/speechViewModels.unit.spec.ts | 10 +-
9 files changed, 247 insertions(+), 28 deletions(-)
create mode 100644 docs/changelog/2026-09-16-c177.md
create mode 100644 docs/plans/acervo-abrir-fonte-so-com-fonte-oficial-impl.md
diff --git a/docs/changelog/2026-09-16-c177.md b/docs/changelog/2026-09-16-c177.md
new file mode 100644
index 00000000..e54fd0af
--- /dev/null
+++ b/docs/changelog/2026-09-16-c177.md
@@ -0,0 +1 @@
+**C177 — "Abrir fonte" do acervo só quando há fonte oficial (2026-09-16):** o botão de fonte do acervo de falas (`/campanha/comunicacao/acervo`) derivava `sourceUrl: officialTextUrl ?? youtubeUrl` e, sem o Diário oficial, abria o vídeo cru no YouTube sem o ponto — atalho pior e duplicado do "Abrir no YouTube" timestampado (C154/C178). O fallback morre: o `SpeechListItemViewModel` expõe `officialTextUrl` (nunca `youtubeUrl`) e o `SpeechDetailPlayer` só renderiza o botão quando `officialTextUrl` existe, agora rotulado **"Abrir Diário Oficial"** — lista e detalhe com o mesmo critério e o mesmo rótulo; o `page.tsx` deixa de derivar. O aviso "sem YouTube" passa a prometer só as saídas realmente renderizadas (MP4 e/ou Diário). C160 preservado (PDF direto quando há Diário); "Abrir no YouTube" intocado; sem schema/migration/Consent/VOD/URL pública. Testes: unit do view model (caso negativo YouTube-only → `officialTextUrl` nulo) e do player (rótulo, ausência e a frase do aviso nos quatro casos) + e2e de fala sem fonte na lista e no detalhe. Issue #1087.
diff --git a/docs/plans/acervo-abrir-fonte-so-com-fonte-oficial-impl.md b/docs/plans/acervo-abrir-fonte-so-com-fonte-oficial-impl.md
new file mode 100644
index 00000000..4226cc69
--- /dev/null
+++ b/docs/plans/acervo-abrir-fonte-so-com-fonte-oficial-impl.md
@@ -0,0 +1,118 @@
+# Impl: Acervo: "Abrir fonte" só quando existe a fonte oficial (fim do atalho duplicado)
+
+Status: aprovado
+Atualizado em: 2026-09-16
+Issue: #1087
+Intenção: docs/plans/acervo-abrir-fonte-so-com-fonte-oficial.md
+Appetite restante: herdado (~0,25–0,5 dia eng; dois sítios de render)
+
+## Leitura da intenção
+
+- **Outcome:** o botão de fonte do acervo passa a significar uma coisa só — a fonte oficial. Ele só renderiza quando `officialTextUrl` é não-nulo; sem Diário, some. Nenhum botão rotulado como fonte aponta para vídeo sem timestamp, na lista e no detalhe.
+- **O que NÃO negociar:** remover o fallback `?? youtubeUrl` nos dois pontos; "Abrir no YouTube" (com `t=` no ponto) intocado; C160 preservado (PDF oficial continua destino quando existe); sem schema/Consent/migration; sem tocar em VOD/MP4/import/CC BY; URL pública do acervo intocada; lista e detalhe com o mesmo critério e o mesmo rótulo.
+- **O que reavaliar:** só o nome do símbolo derivado (`sourceUrl` deixa de dizer a verdade) e a cópia do aviso de "sem YouTube" (`SpeechDetailPlayer.tsx:528-531`), que promete "abrir a fonte oficial" mesmo quando ela não existe. Nada de produto muda; é honestidade de tipo e de texto.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ A[speech.officialTextUrl] -->|não-nulo| B["botão: Abrir Diário Oficial"]
+ A -->|nulo| C[sem botão de fonte]
+ D[speech.youtubeUrl] --> E["Abrir no YouTube (com t=)"]
+```
+
+**Opções consideradas:** A) manter `sourceUrl` só mudando a derivação; B) renomear o derivado para `officialTextUrl` e alinhar a prop do player, removendo o fallback; C) remover o botão de vez (pedido literal do usuário).
+
+**Recomendação:** B — endurece a condição, preserva o acesso ao PDF oficial (C160) e faz o tipo dizer a verdade. Uma regra só, compartilhada por lista e detalhe.
+
+**Rejeitadas:** A — o nome `sourceUrl` continua prometendo "fonte ou vídeo" e mantém o conceito ambíguo; a limpeza de tipo é o ponto. C — decisão de gate já tomada contra: perderia o atalho de um clique ao Diário e esvaziaria o C160 (registrado na intenção, 2026-09-16).
+
+### Componentes / mudanças
+
+- **`toSpeechListItemViewModel`** (`src/utilities/speech/speechViewModels.ts:67,242`): renomear `SpeechListItemViewModel.sourceUrl` → `officialTextUrl` e derivar `speech.officialTextUrl ?? null` (o `?? youtubeUrl` morre). O loader já traz `officialTextUrl: true` (`speechPageData.ts:44`), então nenhum loader muda.
+- **`SpeechResultCard`** (`src/components/campaign/speech/SpeechResultCard.tsx:109-114,221-226`): consumir `speech.officialTextUrl` nos dois renders (quadrante "fala de origem" e card normal); rótulo `Abrir Diário Oficial`.
+- **`SpeechDetailPlayer`** (`src/components/campaign/speech/SpeechDetailPlayer.tsx:61,155,498-505`): renomear a prop `sourceUrl` → `officialTextUrl`; rótulo `Abrir Diário Oficial`; ajuste da cópia em `:528-531` (ver D4).
+- **`SpeechDetailPage`** (`src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx:69,121`): apagar `const sourceUrl = view.officialTextUrl ?? view.youtubeUrl` e passar `officialTextUrl={view.officialTextUrl}` direto (o detail VM já expõe `officialTextUrl`).
+- **Migration:** sem migration.
+- **Access / Consent:** N/A.
+- **UI:** sem nova superfície; um rótulo muda e um render some quando o dado falta. Nenhum campo/tela novos.
+
+### Dados → forma (se aplicável)
+
+N/A — a fatia não apresenta dado novo: é um botão existente cuja condição de existência passa a ser "há fonte oficial?".
+
+### Decisões de engenharia (D1–D4)
+
+**D1 — semântica do símbolo.**
+
+- **Opções:** A) renomear `sourceUrl` → `officialTextUrl` no VM da lista e na prop do player; B) manter `sourceUrl` só trocando a derivação para `officialTextUrl ?? null`; C) criar nome neutro novo (`officialSourceUrl`).
+- **Recomendação:** A. `SpeechDetailViewModel` já tem `officialTextUrl` e o record Payload também; o nome passa a ser o mesmo conceito nos três lugares e lista/detalhe deixam de divergir. Impactos: `SpeechResultCard` usa `speech.officialTextUrl`; o teste unit do VM vira `row.officialTextUrl`; a prop do player e o `page.tsx` deixam de derivar.
+- **Rejeitadas:** B — mantém a mentira semântica (o tipo diz "fonte" e já foi "fonte ou vídeo"); C — diverge do nome do campo do record sem ganho.
+
+**D2 — rótulo.**
+
+- **Opções:** A) `Abrir Diário Oficial`; B) `Ver Diário Oficial`; C) `Abrir PDF oficial`.
+- **Recomendação:** A (produto, 2026-09-16). Nomeia o registro oficial e mantém o verbo do botão atual; mesmo rótulo na lista e no detalhe (assumido).
+- **Rejeitadas:** B — perde o verbo de ação já estabelecido; C — "PDF" promete formato, e a fonte oficial é o Diário, não necessariamente o PDF.
+
+**D3 — testes.**
+
+- **Opções:** A) unit do VM (rename + caso negativo) + unit do player (rename/label/ausência) + e2e estendido, sem unit novo do `SpeechResultCard`; B) idem A mais um unit novo do `SpeechResultCard`; C) só e2e.
+- **Recomendação:** A. O e2e server-renderizado é o dono da superfície da lista (`campaignSpeechAcervo.e2e.spec.ts`), então um unit do card seria redundante; o unit do VM guarda a derivação pura e o unit do player guarda o render do detalhe. Trabalho: `tests/unit/speechViewModels.unit.spec.ts:30-38` renomeia e ganha o caso `youtubeUrl`-only → `officialTextUrl` nulo; `tests/unit/speechDetailPlayer.unit.spec.tsx:61,442,447` renomeia a prop, atualiza o rótulo e adiciona assert negativo com `officialTextUrl={null}`; e2e estende `createSpeech` com `officialTextUrl?: string | null` (default no literal atual `https://camara.leg.br/discurso`, que hoje é fixo e não permite criar fala sem fonte) e adiciona "fala sem fonte oficial não mostra botão de fonte nem na lista nem no detalhe, mesmo com `youtubeUrl`", atualizando os três `toContain('Abrir fonte')` (`:181,:222,:318`) para o rótulo novo e somando um assert positivo na lista do teste de duas fontes.
+- **Rejeitadas:** B — teste redundante sobre uma superfície já coberta ponta a ponta; C — sem unit, a derivação pura (o `?? youtubeUrl` removido) fica sem guarda barato.
+
+**D4 — cópia do estado sem YouTube (`SpeechDetailPlayer.tsx:528-531`).**
+
+- **Opções:** A) deixar como está; B) tornar as cláusulas condicionais ao que de fato renderiza (`vodResolvable` → MP4; `officialTextUrl` → Diário); C) remover o aviso.
+- **Recomendação:** B, mínimo. Hoje o texto diz "Você ainda pode baixar o MP4 ou abrir a fonte oficial" mesmo quando um dos botões não existe; depois da renomeação o "fonte oficial" passa a ser promessa falsa quando `officialTextUrl` é nulo. Montar a frase só com as saídas presentes (mantendo a nota de que a seleção de trecho continua) elimina a mentira sem redesenho; o caso novo de e2e (sem fonte + sem YouTube) cobre isso.
+- **Rejeitadas:** A — mantém texto que promete botão inexistente; C — joga fora a informação de limite do link (C166).
+
+## Fases verificáveis
+
+1. **Semântica/dados:** renomear `sourceUrl` → `officialTextUrl` no `SpeechListItemViewModel` com derivação `speech.officialTextUrl ?? null`; ajustar `SpeechResultCard` (dois renders) e o `page.tsx` (remover a derivação, passar a prop); atualizar `tests/unit/speechViewModels.unit.spec.ts`. Verde: unit de viewModels.
+2. **Detalhe/rótulo:** renomear a prop do `SpeechDetailPlayer` para `officialTextUrl`, trocar o rótulo para `Abrir Diário Oficial` nos três sítios, ajustar a cópia de `:528-531` (D4); atualizar `tests/unit/speechDetailPlayer.unit.spec.tsx`. Verde: unit do player.
+3. **Ponta a ponta:** estender `createSpeech` com `officialTextUrl?: string | null`; atualizar os três asserts de rótulo e adicionar o caso "sem fonte oficial não mostra botão (lista e detalhe), mesmo com YouTube". Gates — `pnpm gate:fast`; push via `pnpm push`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Backfill ou resolução de Diários ausentes (é do C160); aqui não se cria fonte onde não há.
+- Tocar em VOD/MP4, trechos, transcrições, import, crédito CC BY ou na URL pública do acervo.
+- Novo campo de fonte, wrapper de componente, abstração de "link externo" ou redesenho do card/player.
+- Renomear o `sourceUrl` homônimo de `src/utilities/speech/speechCutJob.ts:223` (URL de playback do job) ou os `sourceUrl` do domínio `cityReport*` — não compartilham tipo.
+- Mexer no "Abrir no YouTube" (`youtubeWatchUrl`, exit block, facade).
+
+## Riscos e mitigação
+
+- **Lista e detalhe divergirem:** a condição fica idêntica (`officialTextUrl` não-nulo) e o e2e novo cobre as duas superfícies; a prop única `officialTextUrl` elimina a derivação local do `page.tsx`.
+- **Asserts de e2e presos ao rótulo antigo:** os três `toContain('Abrir fonte')` são atualizados na mesma fase; o helper `createSpeech` passa a aceitar `officialTextUrl` para o caso negativo sem quebrar os existentes (default preservado).
+- **Regressão do C160 (perder o PDF):** o unit do VM com as duas fontes continua assertando o destino do Diário; o e2e de duas fontes segue esperando o rótulo novo, agora só com `officialTextUrl`.
+- **Cópia mentirosa remanescente:** o D4 condiciona as cláusulas ao que renderiza; o caso de e2e sem fonte exercita o caminho.
+- **Seleção/filtros/segmentos do acervo inalterados:** nenhum loader, `where` ou `select` muda.
+
+## Aceite de engenharia
+
+- [ ] `SpeechListItemViewModel` expõe `officialTextUrl` (sem `?? youtubeUrl`) e o `SpeechDetailPlayer` recebe a prop `officialTextUrl`.
+- [ ] O botão de fonte só renderiza com `officialTextUrl` não-nulo, na lista e no detalhe; sem ele, some — `youtubeUrl` nunca é destino.
+- [ ] Rótulo `Abrir Diário Oficial` nos dois sítios; "Abrir no YouTube" (com `t=`) intocado e ainda o caminho de vídeo.
+- [ ] `page.tsx` deixa de derivar `sourceUrl` e passa `view.officialTextUrl` direto.
+- [ ] Cópia de `SpeechDetailPlayer.tsx:528-531` não promete botão inexistente.
+- [ ] Unit de viewModels (rename + caso `youtubeUrl`-only → `officialTextUrl` nulo) e unit do player (prop/rótulo/assert de ausência) verdes; e2e cobre a lista e o detalhe sem fonte e não regride os casos existentes.
+- [ ] Sem migration, sem Consent/Access, sem tocar em VOD/MP4/import/CC BY/URL pública; `pnpm gate:fast` verde e push via `pnpm push`.
+
+## Já resolvido no simplify/critique (não reabrir)
+
+- **S1 — `SpeechDetailViewModel.youtubeUrl` morto:** só era lido pelo fallback removido no `page.tsx`; campo e atribuição removidos (`speechViewModels.ts`).
+- **S2 — termo divergente:** o aviso "sem YouTube" dizia "abrir a fonte oficial" e o botão "Abrir Diário Oficial"; o aviso passou a dizer "abrir o Diário Oficial".
+- **S3 — branches do `remainingExits` sem cobertura:** novo unit `promises only the exits it renders in the no-YouTube notice (C177)` cobre os quatro casos (nenhum/só MP4/só Diário/ambos).
+
+## Explicitamente fora
+
+- **S4 — type predicate `(exit): exit is string`:** o predicado é inferível no TS moderno, mas a anotação explícita é escolha válida (nit, score 1) — descartado.
+- **S5 — `remainingExits` calculado a cada render:** array de 2 itens lido num branch; memo seria YAGNI (score 1) — descartado.
+- **S6 — literal `Abrir Diário Oficial` em 3 sítios:** constante compartilhada/wrapper foi barrada pelo plano; sem drift real. **Gatilho:** um 4º sítio de fonte oficial no acervo justifica a constante.
+- **S7 — assert negativo do e2e indireto:** o invariante "nunca o fallback YouTube" já é pinado direto e barato no unit do view model (`officialTextUrl` nulo com `youtubeUrl` presente) — descartado.
+- Backfill/VOD/novo campo/wrapper/redesenho seguem fora (ver Rabbit holes).
+
+## Self-score (decision-quality)
+
+5/5 — (1) o outcome é um invariante verificável (nenhum botão de fonte leva a vídeo sem ponto) e o e2e novo o prova nas duas superfícies; (2) a recomendação resolve a ambiguidade de tipo em vez de só remendar a derivação; (3) D1–D4 têm opções, recomendação e rejeitadas explícitas, com o impacto de cada rename listado; (4) o corte de escopo é duro (nada de backfill/VOD/novo campo) e o único ponto fora do botão (D4) é justificado como honestidade da mesma cópia; (5) os testes evitam redundância (sem unit novo do card) e o helper de e2e é estendido de forma compatível.
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
index 2c0c25a5..f7eac65e 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
@@ -66,8 +66,6 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
loadSpeechCutsForSpeech(payload, user, speechId),
])
- const sourceUrl = view.officialTextUrl ?? view.youtubeUrl
-
return (
exit !== null)
// The deep link (`?t=`) seeks the already verified file on mount.
useEffect(() => {
@@ -495,11 +507,11 @@ export const SpeechDetailPlayer = ({
Baixar vídeo (MP4)
) : null}
- {sourceUrl ? (
+ {officialTextUrl ? (
) : null}
@@ -526,8 +538,9 @@ export const SpeechDetailPlayer = ({
Compartilhar por link exige o vídeo no YouTube
- Esta fala não tem vídeo no YouTube — não há link externo que abra no ponto. Você ainda
- pode baixar o MP4 ou abrir a fonte oficial, e a seleção de trecho continua disponível.
+ Esta fala não tem vídeo no YouTube — não há link externo que abra no ponto. A seleção de
+ trecho continua disponível
+ {remainingExits.length ? `, e você ainda pode ${remainingExits.join(' ou ')}.` : '.'}
) : null}
diff --git a/src/components/campaign/speech/SpeechResultCard.tsx b/src/components/campaign/speech/SpeechResultCard.tsx
index 18ad2ed0..98de302e 100644
--- a/src/components/campaign/speech/SpeechResultCard.tsx
+++ b/src/components/campaign/speech/SpeechResultCard.tsx
@@ -106,10 +106,14 @@ const SpeechActions = ({
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
- {speech.sourceUrl ? (
-
+ {speech.officialTextUrl ? (
+
- Abrir fonte
+ Abrir Diário Oficial
) : null}
@@ -218,10 +222,10 @@ export const SpeechResultCard = ({
{watchLabel}
- {speech.sourceUrl ? (
-
+ {speech.officialTextUrl ? (
+
- Abrir fonte
+ Abrir Diário Oficial
) : null}
diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts
index 4d5ace4a..f2a8a0cd 100644
--- a/src/utilities/speech/speechViewModels.ts
+++ b/src/utilities/speech/speechViewModels.ts
@@ -67,7 +67,12 @@ export type SpeechListItemViewModel = {
/** YouTube cover of the session link (C175); null when the speech has none. */
thumbnailUrl: string | null
watchHref: string
- sourceUrl: string | null
+ /**
+ * C177 — the official source (Diário) link; null when the speech has no
+ * official text. Never falls back to `youtubeUrl`: a source button that
+ * opened a video without the point is the duplicate atalho this item killed.
+ */
+ officialTextUrl: string | null
/** C174 — the cuts already made from this speech, newest first. */
cuts: SpeechCutViewModel[]
/**
@@ -98,7 +103,6 @@ export type SpeechDetailViewModel = {
summary: string | null
officialTranscript: string | null
officialTextUrl: string | null
- youtubeUrl: string | null
keywords: string[]
topics: { value: SpeechTopic; label: string }[]
scopes: { value: SpeechScope; label: string }[]
@@ -245,7 +249,7 @@ export const toSpeechListItemViewModel = ({
municipalities: municipalityViewModels(speech, municipalityLabels),
thumbnailUrl: youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl)),
watchHref: buildWatchHref(speech.id, matchedSegment, q),
- sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null,
+ officialTextUrl: speech.officialTextUrl ?? null,
cuts: [...cuts],
matchedTextSearch: speechMatchesText(speech, query),
}
@@ -296,7 +300,6 @@ export const toSpeechDetailViewModel = ({
summary: speech.summary ?? null,
officialTranscript: speech.officialTranscript ?? null,
officialTextUrl: speech.officialTextUrl ?? null,
- youtubeUrl: speech.youtubeUrl ?? null,
keywords: speech.keywords ?? [],
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
index 8599f4c8..67fd095e 100644
--- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
+++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
@@ -35,6 +35,8 @@ const createSpeech = async (
withVideo?: boolean
youtubeUrl?: string | null
eventStartAt?: string
+ /** C177 — set to null to create a speech without the official text. */
+ officialTextUrl?: string | null
},
) => {
const marker = input.marker
@@ -55,7 +57,10 @@ const createSpeech = async (
// Mirrors what `upsertSpeechBundle` derives from the segments (C154).
searchText: `a retomada da saude ${marker} na bahia`,
presidingOfficer: 'Pompeo de Mattos',
- officialTextUrl: 'https://camara.leg.br/discurso',
+ officialTextUrl:
+ input.officialTextUrl === undefined
+ ? 'https://camara.leg.br/discurso'
+ : input.officialTextUrl,
eventId: 67091,
audioId: 558641,
excerptTMs: EXCERPT_TMS,
@@ -157,6 +162,8 @@ test.describe('communication vertical (C154/C162)', () => {
// never reaches any list HTML.
expect(resultsHtml).not.toContain('Baixar')
expect(resultsHtml).not.toContain('vod.camara.leg.br')
+ // C177 — the source button names the official record (the card carries it too).
+ expect(resultsHtml).toContain('Abrir Diário Oficial')
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}?t=43&q=${marker}`)
expect(detail.status()).toBe(200)
@@ -178,7 +185,7 @@ test.describe('communication vertical (C154/C162)', () => {
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')
+ expect(detailHtml).toContain('Abrir Diário Oficial')
expect(detailHtml).toContain('Fonte: Câmara dos Deputados')
// C166 — the excerpt picker's door is server-rendered; its link limit is
// not in this quadrant (the share control itself only appears in the mode,
@@ -219,10 +226,40 @@ test.describe('communication vertical (C154/C162)', () => {
// 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).toContain('Abrir Diário Oficial')
expect(detailHtml).not.toContain('vod.camara.leg.br')
})
+ test('a speech without the official text hides the source button in list and detail (C177)', async ({
+ campaign,
+ campaignRequest,
+ }) => {
+ const marker = campaign.fixtures.value('semfonte')
+ const speech = await createSpeech(campaign, {
+ marker,
+ officialTextUrl: null,
+ 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 results = await request.get(`/campanha/comunicacao/acervo?q=${marker}`)
+ expect(results.status()).toBe(200)
+ const resultsHtml = rendered(await results.text())
+ expect(resultsHtml).toContain(marker)
+ // C177 — no official text means no source button, and the YouTube URL is
+ // never smuggled into a "source" link; the card CTA still opens the speech.
+ expect(resultsHtml).not.toContain('Abrir Diário Oficial')
+
+ const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
+ expect(detail.status()).toBe(200)
+ const detailHtml = rendered(await detail.text())
+ expect(detailHtml).toContain('Abrir no YouTube')
+ expect(detailHtml).not.toContain('Abrir Diário Oficial')
+ expect(detailHtml).not.toContain('Abrir fonte')
+ })
+
test('a VOD-only speech offers the on-click resolution instead of a player', async ({
campaign,
campaignRequest,
@@ -315,7 +352,7 @@ test.describe('communication vertical (C154/C162)', () => {
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
const html = rendered(await detail.text())
expect(html).toContain('Vídeo indisponível neste momento')
- expect(html).toContain('Abrir fonte')
+ expect(html).toContain('Abrir Diário Oficial')
// 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')
diff --git a/tests/unit/speechDetailPlayer.unit.spec.tsx b/tests/unit/speechDetailPlayer.unit.spec.tsx
index d52b2846..595b4c42 100644
--- a/tests/unit/speechDetailPlayer.unit.spec.tsx
+++ b/tests/unit/speechDetailPlayer.unit.spec.tsx
@@ -58,7 +58,7 @@ const renderPlayer = (props: Partial[0]> =
vodResolvable
segments={SEGMENTS}
initialSeconds={null}
- sourceUrl={null}
+ officialTextUrl={null}
durationSeconds={300}
speechType="BREVES COMUNICAÇÕES"
speechDateLabel="11/08/2026"
@@ -439,17 +439,54 @@ describe('SpeechDetailPlayer — C174 excerpt request from outside the player',
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' })
+ renderPlayer({
+ vodResolvable: false,
+ officialTextUrl: 'https://imagem.camara.leg.br/diario.pdf',
+ })
expect(screen.getByText('Vídeo indisponível neste momento.')).toBeDefined()
expect(screen.queryByRole('button', { name: /tentar novamente/i })).toBeNull()
expect(screen.queryByRole('button', { name: /baixar vídeo/i })).toBeNull()
- expect(screen.getByRole('link', { name: /abrir fonte/i })).toBeDefined()
+ expect(screen.getByRole('link', { name: /abrir diário oficial/i })).toBeDefined()
expect(screen.queryByText(/Se o vídeo não abrir aqui/)).toBeNull()
expect(exitLink()).toBeNull()
expect(videoElement()).toBeNull()
expect(iframeElement()).toBeNull()
})
+
+ it('drops the source button without the official text (C177)', () => {
+ renderPlayer({ vodResolvable: false, officialTextUrl: null })
+
+ expect(screen.queryByRole('link', { name: /abrir diário oficial/i })).toBeNull()
+ expect(screen.queryByText(/abrir o Diário Oficial/)).toBeNull()
+ })
+
+ it('promises only the exits it renders in the no-YouTube notice (C177)', () => {
+ renderPlayer({ youtubeVideoId: null, vodResolvable: false, officialTextUrl: null })
+ expect(screen.getByText(/A seleção de trecho continua disponível\.$/)).toBeDefined()
+
+ cleanup()
+ renderPlayer({ youtubeVideoId: null, vodResolvable: true, officialTextUrl: null })
+ expect(screen.getByText(/e você ainda pode baixar o MP4\.$/)).toBeDefined()
+
+ cleanup()
+ renderPlayer({
+ youtubeVideoId: null,
+ vodResolvable: false,
+ officialTextUrl: 'https://imagem.camara.leg.br/diario.pdf',
+ })
+ expect(screen.getByText(/e você ainda pode abrir o Diário Oficial\.$/)).toBeDefined()
+
+ cleanup()
+ renderPlayer({
+ youtubeVideoId: null,
+ vodResolvable: true,
+ officialTextUrl: 'https://imagem.camara.leg.br/diario.pdf',
+ })
+ expect(
+ screen.getByText(/e você ainda pode baixar o MP4 ou abrir o Diário Oficial\.$/),
+ ).toBeDefined()
+ })
})
describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
diff --git a/tests/unit/speechViewModels.unit.spec.ts b/tests/unit/speechViewModels.unit.spec.ts
index 1ccf4cb1..50ea6f97 100644
--- a/tests/unit/speechViewModels.unit.spec.ts
+++ b/tests/unit/speechViewModels.unit.spec.ts
@@ -34,6 +34,14 @@ describe('toSpeechListItemViewModel thumbnail (C175)', () => {
})
expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
- expect(row.sourceUrl).toBe('https://camara.leg.br/discurso')
+ expect(row.officialTextUrl).toBe('https://camara.leg.br/discurso')
+ })
+
+ it('has no source link without the official text, never the YouTube fallback (C177)', () => {
+ expect(viewModel().officialTextUrl).toBeNull()
+ expect(viewModel({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' }).officialTextUrl).toBeNull()
+ expect(viewModel({ officialTextUrl: 'https://camara.leg.br/discurso' }).officialTextUrl).toBe(
+ 'https://camara.leg.br/discurso',
+ )
})
})