mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
fix(C177): "Abrir fonte" do acervo só com fonte oficial (Closes #1087)
This commit is contained in:
parent
42f013317a
commit
c7fd5c932d
9 changed files with 247 additions and 28 deletions
1
docs/changelog/2026-09-16-c177.md
Normal file
1
docs/changelog/2026-09-16-c177.md
Normal file
|
|
@ -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.
|
||||
118
docs/plans/acervo-abrir-fonte-so-com-fonte-oficial-impl.md
Normal file
118
docs/plans/acervo-abrir-fonte-so-com-fonte-oficial-impl.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -66,8 +66,6 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
|
|||
loadSpeechCutsForSpeech(payload, user, speechId),
|
||||
])
|
||||
|
||||
const sourceUrl = view.officialTextUrl ?? view.youtubeUrl
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<SetCampaignPageChrome
|
||||
|
|
@ -118,7 +116,7 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
|
|||
vodResolvable={view.vodResolvable}
|
||||
segments={view.segments}
|
||||
initialSeconds={initialSeconds}
|
||||
sourceUrl={sourceUrl}
|
||||
officialTextUrl={view.officialTextUrl}
|
||||
durationSeconds={view.durationSeconds}
|
||||
speechType={view.type}
|
||||
speechDateLabel={view.speechDateLabel}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,12 @@ type SpeechDetailPlayerProps = {
|
|||
vodResolvable: boolean
|
||||
segments: readonly SpeechDetailSegmentViewModel[]
|
||||
initialSeconds: number | null
|
||||
sourceUrl: string | null
|
||||
/**
|
||||
* C177 — official source (Diário) link; null drops the source button. Never
|
||||
* fed with the YouTube URL: a source button that opened a video without the
|
||||
* point duplicated (worse) the "Abrir no YouTube" exit.
|
||||
*/
|
||||
officialTextUrl: string | null
|
||||
/** C166 — raw duration for the excerpt picker; null falls back to the last segment end. */
|
||||
durationSeconds: number | null
|
||||
/** C166 — speech type for the share message (`null` reads as "fala"). */
|
||||
|
|
@ -152,7 +157,7 @@ export const SpeechDetailPlayer = ({
|
|||
vodResolvable,
|
||||
segments,
|
||||
initialSeconds,
|
||||
sourceUrl,
|
||||
officialTextUrl,
|
||||
durationSeconds,
|
||||
speechType,
|
||||
speechDateLabel,
|
||||
|
|
@ -183,6 +188,13 @@ export const SpeechDetailPlayer = ({
|
|||
const selecting = selection !== null
|
||||
const showExcerptShare = selecting && Boolean(youtubeVideoId)
|
||||
const showCutAction = selecting && vodResolvable
|
||||
// C177 — the "no YouTube link" notice must not promise a button that is not
|
||||
// rendered: the MP4 needs the Câmara excerpt, the Diário needs the official
|
||||
// text (never the YouTube URL).
|
||||
const remainingExits = [
|
||||
vodResolvable ? 'baixar o MP4' : null,
|
||||
officialTextUrl ? 'abrir o Diário Oficial' : null,
|
||||
].filter((exit): exit is string => exit !== null)
|
||||
|
||||
// The deep link (`?t=`) seeks the already verified file on mount.
|
||||
useEffect(() => {
|
||||
|
|
@ -495,11 +507,11 @@ export const SpeechDetailPlayer = ({
|
|||
Baixar vídeo (MP4)
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
{officialTextUrl ? (
|
||||
<Button asChild variant="outline" className="min-h-10">
|
||||
<a href={sourceUrl} target="_blank" rel="noreferrer">
|
||||
<a href={officialTextUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir fonte
|
||||
Abrir Diário Oficial
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
|
|
@ -526,8 +538,9 @@ export const SpeechDetailPlayer = ({
|
|||
Compartilhar por link exige o vídeo no YouTube
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
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 ')}.` : '.'}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -106,10 +106,14 @@ const SpeechActions = ({
|
|||
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
|
||||
</Link>
|
||||
</Button>
|
||||
{speech.sourceUrl ? (
|
||||
<ActionLink href={speech.sourceUrl} variant="ghost" fullWidthOnMobile={fullWidthOnMobile}>
|
||||
{speech.officialTextUrl ? (
|
||||
<ActionLink
|
||||
href={speech.officialTextUrl}
|
||||
variant="ghost"
|
||||
fullWidthOnMobile={fullWidthOnMobile}
|
||||
>
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir fonte
|
||||
Abrir Diário Oficial
|
||||
</ActionLink>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -218,10 +222,10 @@ export const SpeechResultCard = ({
|
|||
{watchLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
{speech.sourceUrl ? (
|
||||
<ActionLink href={speech.sourceUrl} variant="ghost">
|
||||
{speech.officialTextUrl ? (
|
||||
<ActionLink href={speech.officialTextUrl} variant="ghost">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir fonte
|
||||
Abrir Diário Oficial
|
||||
</ActionLink>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,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[]
|
||||
/**
|
||||
|
|
@ -95,7 +100,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 }[]
|
||||
|
|
@ -239,7 +243,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),
|
||||
}
|
||||
|
|
@ -282,7 +286,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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const renderPlayer = (props: Partial<Parameters<typeof SpeechDetailPlayer>[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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue