From 8a5e271f6dbf173ba6166bf9add1df4b1e60dec6 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 21 Sep 2026 12:32:26 -0300 Subject: [PATCH] =?UTF-8?q?feat(S26):=20t=C3=ADtulo=20completo=20e=20cr?= =?UTF-8?q?=C3=A9dito=20do=20artista=20no=20card=20do=20jingle=20(Closes?= =?UTF-8?q?=20#1240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-21-s26.md | 1 + .../jingle-titulo-credito-artista-impl.md | 134 ++++++++++++++++++ src/components/jingles/JingleCards.tsx | 14 +- src/lib/jingle.ts | 20 +++ tests/e2e/frontendJingles.e2e.spec.ts | 49 +++++++ tests/unit/jingle.unit.spec.ts | 27 +++- tests/unit/jinglePlayer.unit.spec.tsx | 48 +++++++ 7 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/2026-09-21-s26.md create mode 100644 docs/plans/jingle-titulo-credito-artista-impl.md diff --git a/docs/changelog/2026-09-21-s26.md b/docs/changelog/2026-09-21-s26.md new file mode 100644 index 00000000..532abc87 --- /dev/null +++ b/docs/changelog/2026-09-21-s26.md @@ -0,0 +1 @@ +**S26 — Card do jingle: título completo e crédito do artista (2026-09-21):** o crédito de quem fez a música deixa de virar reticências. Os três jingles oficiais chegam em produção como `Jorge Solla 1313 (feat. Felipe Forrozeiro)` (e Nagib Barroso, e É O MT), mas o card impunha `truncate` de uma linha e o nome do parceiro sumia (`Jorge Solla 1313 (fe…`). A correção é só de exibição — nenhum título publicado foi reescrito: uma função pura nova em `src/lib/jingle.ts` (`splitJingleTitle`) extrai o segmento final `(feat. …)` do título (case-insensitive no rótulo, artista com o casing preservado, sem crédito vazio/base vazia; sem o segmento o título volta verbatim) e o `JingleCards` — dono único do card na home e em `/jingles`, sem twin — porta classe-a-classe o design aprovado (`docs/plans/jingle-titulo-credito-artista-ui-design.html`): o heading perde `truncate` e ganha `leading-[1.02]` + `wrap-anywhere` (24px em todos os viewports, sem o bump `md:text-[26px]`), o bloco vira `items-start` e o crédito ganha `

` própria (13px, peso 700, `--campaign-muted`) sempre visível parado e tocando. O `JingleViewModel` ficou intacto: o título completo segue alimentando os aria-labels de play/pausa, progresso e download e o fallback do `coverAlt`; capa, áudio, slug, ordem, published, filename do download, grid e o player da rádio (S25) não mudaram — e jingle sem crédito (`Axé`) não reserva linha vazia, só deixa de cortar. Testes: unit da função pura (3 títulos reais + bordas de segmento duplicado/`FEAT.`/espaços/base vazia), 3 unit RTL novos no card (base sem truncamento + crédito parado e tocando, aria-labels com o título completo e fallback sem linha) e e2e novo no `frontendJingles` (crédito e título-base em `/jingles` e na home, crédito ao tocar, aria completo, `download` inalterado e prova de não-clipping com `whiteSpace`/`scrollWidth` medidos). Verificação: `pnpm gate:fast` verde (4227 unit), e2e local do `frontendJingles` 5/5 verdes, e crítica final do `designer` (tier `openai/gpt-5.6-sol`) **paridade confirmada** contra o app renderizado a 390/1280 (parado, tocando, título longo e fallback sem crédito). diff --git a/docs/plans/jingle-titulo-credito-artista-impl.md b/docs/plans/jingle-titulo-credito-artista-impl.md new file mode 100644 index 00000000..a8ad6d3d --- /dev/null +++ b/docs/plans/jingle-titulo-credito-artista-impl.md @@ -0,0 +1,134 @@ +# Impl: Card de jingle — título completo e crédito "(feat. …)" visível (S26) + +Status: aprovado (modo --auto) +Atualizado em: 2026-09-21 +Issue: #1240 +Intenção: docs/plans/jingle-titulo-credito-artista.md +Appetite restante: herdado (~0,5 dia eng; um outcome — título-base integral em até 2 linhas + crédito do artista sempre visível; sem ajuste de escopo, cortes em Rabbit holes) + +## Leitura da intenção + +- **Outcome:** no card de jingle (home e `/jingles`) o título-base para de truncar e o segmento final `(feat. )` vira uma linha própria, em corpo menor, visível parado e tocando; o dado é intocado (títulos, capas, áudio, slug, ordem, published e o nome do download) e o mesmo card serve as duas superfícies. Jingle sem `(feat. …)` (ex.: `Axé`) mantém o layout de hoje, apenas sem corte. Aria-labels seguem com o título completo. +- **O que NÃO negociar:** extração **apenas na exibição** (nada de schema/migration/reedição de título); crédito nunca truncado — nem no estado tocando, nem na home, nem em `/jingles`; aria-labels de play/pausa, progresso e download com o título COMPLETO; capa, botão amarelo, estado, progresso, download, filename truncado, grid, raios/sombras e o player da rádio (S25) intocados; metadata/OG de `/jingles` e a home cacheada (`unstable_cache` tag `jingles`) intocados; sem PII/Consent; editar o dono, sem twin. +- **O que reavaliar (hipóteses da "Direção no codebase"):** + - Derivar no `JingleViewModel` **não** é o caminho barato: `tests/unit/jingle.unit.spec.ts:36-51` pina o objeto exato com `toEqual` e `jinglePlayer.unit.spec.tsx:7-24` / `jingleHomeSection.unit.spec.tsx` montam literais typechecked — campos novos quebrariam os três sem ganho (D1). A função pura em `src/lib/jingle.ts` é a casa (módulo client-safe, dono dos derivados de exibição como `jingleDownloadFilename`). + - O heading tem um bump `md:text-[26px]` (`JingleCards.tsx:191`) que o design derruba — fica `text-2xl` (24px) em todos os viewports, com `wrap-anywhere` no lugar do `truncate` e `leading-[1.02]` no lugar do `leading-none` (D3). + - O mesmo card já é único para home (`headingLevel="h4"`, `JingleHomeSection.tsx:81`) e `/jingles` (`JinglePlayer.tsx:11-13`) — corrigir no dono cobre as duas superfícies sem branching. + - Os títulos do e2e atual (`Axé oficial`, `Forró oficial`, `Pagodão oficial`) não têm `(feat. …)`, então nada quebra — mas o aceite exige cobertura e2e própria com crédito (D4). + +## Abordagem recomendada + +```mermaid +flowchart LR + J["collection jingle
title verbatim"] --> R["jingleReads.getPublishedJingleItems()"] + R --> VM["JingleViewModel.title
(completo, intocado)"] + VM --> CARD["JingleCards (dono único do card)
home h4 + /jingles"] + VM --> ARIA["aria-labels play/pausa, progresso e download
+ fallback de coverAlt"] + CARD --> SPLIT["splitJingleTitle
(pura, src/lib/jingle.ts)"] + SPLIT --> BASE["heading: base"] + SPLIT --> CR["p.credit: 'feat. ' | null"] +``` + +**Opções consideradas:** A) derivar no `JingleViewModel` | B) função pura nova em `src/lib/jingle.ts` chamada pelo card no render | C) parsing inline no card. + +**Recomendação:** B — a extração é regra de exibição e mora no módulo que já é dono dos derivados de display (`jingleDownloadFilename`), client-safe, testável sem render, sem tocar no contrato serializado nem nos literais typechecked; `jingle.title` completo continua alimentando aria-labels (`JingleCards.tsx:184,208,237`) e o fallback de `coverAlt` (`src/lib/jingle.ts:72`). + +**Rejeitadas:** A) quebra `toEqual` exato e os literais dos três specs, transforma a leitura server em contrato de apresentação e ainda exigiria carregar o título completo no VM para aria/alt (dois campos, mais churn, zero ganho); C) enterra a regra no JSX, não é unit-testável isolada e duplicaria a leitura do título para os aria-labels. + +### Decisões de engenharia + +**D1. Onde quebrar título e crédito (caro: fronteira de módulo/contrato).** + +- _Opções:_ A) campos `titleBase`/`artistCredit` no `JingleViewModel`, derivados em `toJingleViewModel` | B) função pura nova em `src/lib/jingle.ts`, chamada pelo card no render, shape do VM intocado | C) parsing inline no card. +- _Recomendação:_ B — `src/lib/jingle.ts` é o dono existente dos derivados de exibição e é client-safe (só importa `@/lib/slug`); o VM (e a leitura `jingleReads.ts:57-72`) fica intocado, então `tests/unit/jingle.unit.spec.ts:43-50` e os literais de `jinglePlayer`/`jingleHomeSection` seguem verdes; o card continua dono único do markup nas duas superfícies. +- _Rejeitadas:_ A porque o VM viraria contrato de apresentação e quebraria os três testes pinados, além de manter dois campos para o mesmo dado; C porque não há teste possível sem render e a regra ficaria duplicada entre heading e aria-labels. + +**D2. Contrato e regra da função pura (caro: semântica de exibição).** + +- _Opções:_ A) retorna `{ base, artist }` e o card monta `feat. ${artist}` | B) retorna `{ base, credit }` com `credit` pronto para exibição (`'feat. É O MT'`), sem parênteses | C) retorna o segmento literal `'(feat. É O MT)'` e o card remove os parênteses. +- _Recomendação:_ B — o unit pina exatamente a linha exibida, o card não concatena copy e o design (linha própria, sem parênteses) é atendido sem transformação no JSX. Regra: extrai **apenas o segmento final**, com reconhecimento case-insensitive de `feat.` (rótulo normalizado para `feat. `, casing do artista preservado), exigindo artista e base não vazios — senão não divide; sem segmento, `{ base: title, credit: null }` (título verbatim, sem linha reservada). +- _Rejeitadas:_ A porque espalha a copy no JSX e o unit não pina a string final; C porque o recorte com parênteses é conflito de responsabilidade e mantém a transformação no card. Case-sensitive estrito foi considerado e rejeitado: custa zero reconhecer `(Feat. …)` e o dado publicado não se reescreve; heurísticas de idioma (`part.`, `com`) e normalização de acento ficam de fora — sem sinal no dado real. + +**D3. Port das classes (fill-in: design é a fonte).** + +- Heading: remove `truncate`, `leading-none` → `leading-[1.02]`, ganha `wrap-anywhere`, remove `md:text-[26px]` (fica `text-2xl` = 24px em todos os viewports), mantém `tracking-[-0.02em]` e o resto. Bloco `JingleCards.tsx:179`: `items-center` → `items-start`. Crédito: `

` própria (`mt-1.5 text-[13px] leading-[1.25] font-bold text-(--campaign-muted)`) entre heading e a linha de estado. `wrap-anywhere` (Tailwind 4.1.18) porque o fail-safe exige quebrar palavra longa dentro do card; `line-clamp-*` está proibido pelo design. + +**D4. Estratégia de testes (custo de spec).** + +- _Opções:_ A) misturar um título com `feat.` nos fixtures do primeiro teste do `frontendJingles` | B) teste novo focado dentro do mesmo spec serial | C) novo spec e2e (+ entrada no manifest). +- _Recomendação:_ B — o spec serial já é o dono das linhas de jingle (publica via REST, `createJingle` em `frontendJingles.e2e.spec.ts:70-90`) e o manifest já mapeia `src/lib/jingle` e `src/components/jingles` para `frontendJingles`; o teste novo publica 1 jingle com `(feat. …)` e pina `/jingles` + home sem reindexar asserções de contagem/índice existentes (`toHaveCount(2)`, `cardStates(['stopped','playing'])`, downloads). +- _Rejeitadas:_ A porque muda counts e índices já pinados e mistura escopos; C porque paga entrada nova no manifest e cria um segundo dono das linhas de jingle. Unit do card usa fixture própria (sem tocar no `JINGLES` compartilhado, para não recontar `getAllByText('Pronto para tocar')`). + +### Componentes / mudanças + +- **`splitJingleTitle`** (`src/lib/jingle.ts`, novo export puro): regex ancorada no fim `/\s*\(\s*feat\.\s+([^()]+?)\s*\)\s*$/i`; `base = title.slice(0, match.index).trim()`, `artist = match[1].trim()`; se match e ambos não vazios → `{ base, credit: 'feat. ' + artist }`; senão → `{ base: title, credit: null }`. Sem import novo (client-safe como o resto do módulo); retorno inferido (sem tipo exportado sem consumidor — knip). +- **`JingleCards`** (`src/components/jingles/JingleCards.tsx`, dono único do card): no `map`, `const { base, credit } = splitJingleTitle(jingle.title)`; heading (`:191-193`) renderiza `{base}` com as classes de D3; `

` de crédito logo após o heading, só quando `credit`; bloco `:179` vira `items-start`; estados `:194-201`, aria-labels `:184/:208/:237`, download `:233-241` e filename `:242-244` intocados. +- **Sem mudanças:** `JinglePlayer.tsx`, `JingleHomeSection.tsx` (`headingLevel="h4"`), `jingleReads.ts` e a URL pública — o card compartilhado resolve as duas superfícies. +- **Testes:** `tests/unit/jingle.unit.spec.ts` (describe novo de `splitJingleTitle`); `tests/unit/jinglePlayer.unit.spec.tsx` (describe novo com fixture própria de `(feat. …)`, parado e tocando, aria completo, sem `truncate`); `tests/e2e/frontendJingles.e2e.spec.ts` (teste novo no describe serial). +- **`docs/changelog/2026-09-21-s26.md`:** entrada curta de entrega. +- **Migration:** nenhuma (nenhum schema; sem `generate:types`). +- **Access / Consent:** N/A — leitura pública, sem formulário, sem PII e sem chave de Consent. +- **UI:** Impeccable B — port classe-a-classe do artefato aprovado (`docs/plans/jingle-titulo-credito-artista-ui-design.html`, cenas 01/02/03) sobre o card existente; crítica final do designer (tier `openai/gpt-5.6-sol`). + +### Port do design aprovado (spec das classes) + +- Título-base: Exo 2, 24px, peso 900, line-height 1.02, letter-spacing -0.02em, `overflow-wrap: anywhere`; SEM `truncate`; até 2 linhas nos títulos reais (o estresse passa de 2 e nada corta — crédito continua inteiro). O design mostra desktop e mobile em 24px (sem bump `md:text-[26px]`). A cena 03 usa 22px apenas como recurso de cena — não é alvo do port. +- Crédito: `

` própria sob o heading, `feat. Nome do artista` SEM parênteses, 13px (mt-1.5/6px), peso 700, cor `--campaign-muted` (#6c615e). Nunca truncado. +- Layout do bloco: `flex items-start gap-4`; heading, crédito e linha de estado empilhados em `min-w-0 flex-1`. Sem crédito: nenhuma linha vazia reservada. +- Aria-labels preservam o título COMPLETO: `Tocar/Pausar jingle `, `Progresso do jingle `, `Baixar em MP3`. +- Capa, botão amarelo, estado, progresso, download, raios/sombras, grid e filename truncado: intocados. + +### Dados → forma (se aplicável) + +N/A — a intenção declara "Vou apresentar dados? Não"; a fatia é exibição de título já publicado, sem métrica, agregação, PII ou pergunta de data-presentation. + +## Fases verificáveis + +1. **Fase 1 — pura + unit (tracer, ~1 h):** `splitJingleTitle` em `src/lib/jingle.ts` + describe no `tests/unit/jingle.unit.spec.ts` (3 títulos reais — `Jorge Solla 1313 (feat. Felipe Forrozeiro)`, `(feat. Nagib Barroso)`, `(feat. É O MT)` — e bordas: `Axé`/`Forró`, `(ao vivo)`, `(feat. )`, só-crédito, `(FEAT. X)`, espaços, segmento final duplicado). Checkpoint: `pnpm vitest run tests/unit/jingle.unit.spec.ts` verde; nenhuma mudança visível. +2. **Fase 2 — port do card (~1,5 h):** classes/DOM em `JingleCards.tsx` (D3) + describe novo em `tests/unit/jinglePlayer.unit.spec.tsx` (fixture própria: parado → heading base, crédito e "Pronto para tocar"; tocando → crédito permanece + "Em reprodução"; aria de play/pausa, progresso e download com título completo; sem `(feat. …)` → nenhum crédito). Checkpoint: `pnpm gate:fast` verde e conferência visual 390/1280 contra as cenas 01/02/03. +3. **Fase 3 — e2e (~1 h):** teste novo no `frontendJingles.e2e.spec.ts` (serial): 1 jingle com `(feat. …)`, `/jingles` com heading base visível, crédito visível parado e tocando, aria completo nos três controles, `download` inalterado; home (`/?e2e=`) com o mesmo card. Checkpoint: `pnpm test:e2e -- tests/e2e/frontendJingles.e2e.spec.ts` verde. +4. **Fase 4 — fechamento (~0,5 h):** crítica final do designer contra o artefato (390/1280); `pnpm gate:fast`; `docs/changelog/2026-09-21-s26.md`; push via `pnpm push` (CI roda o restante). Checkpoint: PR com `Closes #1240`. + +## Rabbit holes / Não escopo (engenharia) + +- Campo/relação de artista, migration e curadoria dos títulos — a intenção já corta; revisitável só com gatilho de filtrar/ordenar por artista. +- "Limpar" o `JingleViewModel`/`coverAlt`/aria com o título-base: o título completo é contrato acessível e do `alt`; nada de reescrever o dado. +- Qualquer `truncate`/`line-clamp-*` como fail-safe no heading ou no crédito (o design proíbe; a cena 03 é o pin visual). +- Redesenho da seção, grid (`:143`), capas, player da rádio (`RadioEmbed`/S25), embed, metadata/OG de `/jingles` e o `truncate` do filename (`:242`). +- Splitter genérico para outros conteúdos (posts/tags) sem 3º call site; normalização de acento/idioma (`part.`, `com`), parênteses aninhados e múltiplos `feat.` num mesmo segmento. +- Analytics/telemetria de play/download, estados novos, animação. +- Novo spec e2e ou entrada no manifest (prefixos atuais já cobrem), snapshots de DOM. + +## Riscos e mitigação + +- **Accessible name do heading muda (base no lugar do completo):** deliberado pelo design; os controles seguem com o título completo — unit pina os quatro nomes e o e2e novo pina heading base; os títulos dos testes atuais não têm `(feat. …)`, então nada quebra. +- **Corte silencioso voltar:** o port remove `truncate` e não introduz clamp; unit do card pina a ausência de `truncate` no heading e a presença do crédito nos dois estados; e2e mede overflow 390 (asserção existente na home) e o visual é conferido contra as cenas. +- **Regex over/under-match:** unit cobre os 3 títulos reais (`É O MT` incluso) e as bordas; a função nunca devolve crédito vazio nem base vazia (fallback = título verbatim). +- **Crédito sumir no estado tocando:** a `

` fica fora do branch de estado; unit pina crédito + "Em reprodução" juntos. +- **Regressão nos testes pinados do VM:** D1 mantém o shape; rodar a suíte unit inteira (nada de `toEqual`/literais muda). +- **Altura/ritmo do card com título de 2 linhas:** `items-start` mantém o botão no topo e o bloco cresce no fluxo; cena 03 prova que nada sobrepõe/estoura; grid `md:grid-cols-3` segue. +- **Fonte menor no desktop (26→24px):** design fixa 24px; conferir a cena 01 e que nenhum seletor/teste dependa de `md:text-[26px]`. +- **Home cacheada:** mudança só de exibição; nenhuma tag/revalidação tocada; e2e da home navega com `/?e2e=`. +- **A11y do crédito:** é texto real (heading + crédito anunciados), sem `aria-hidden` e sem duplicar o crédito no heading. + +## Aceite de engenharia + +- [ ] `splitJingleTitle` puro em `src/lib/jingle.ts`, client-safe e sem import novo; contrato `{ base, credit }` com crédito pronto para exibição (`feat. …`) e nunca crédito vazio/base vazia. +- [ ] Card único (`JingleCards`) usa a função; heading sem `truncate`, com `leading-[1.02]`/`wrap-anywhere` e sem `md:text-[26px]`; bloco `items-start`; `

` de crédito só quando há crédito. +- [ ] Aria-labels de play/pausa, progresso e download continuam com `jingle.title` completo; fallback de `coverAlt` inalterado. +- [ ] Nada muda em schema/migration/Consent; títulos, capas, áudio, slug, ordem, published e download filename intocados; grid, player da rádio e metadata de `/jingles` intocados. +- [ ] Testes: unit puro (3 títulos reais + bordas), unit do card (parado e tocando + aria completo), e2e com `(feat. …)` (crédito visível, aria completo, download inalterado) nas duas superfícies. +- [ ] `pnpm gate:fast` verde; e2e do `frontendJingles` verde; CI verde. +- [ ] Changelog `docs/changelog/2026-09-21-s26.md` e PR com `Closes #1240`. + +## Self-score decision-quality (gate ≥4) + +| Critério | Nota | Justificativa | +| ----------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1. Decisões caras têm rejeitadas | 5 | D1 (onde derivar: VM × pura × inline), D2 (contrato/regra do split, incl. case-sensitivity) e D4 (estratégia de teste: fixture × teste novo × spec novo) com opções, recomendação e rejeitadas justificadas; D3 é fill-in do design e está registrado. | +| 2. Abordagem cabe no appetite | 5 | ~0,5 dia: uma função pura no dono (`src/lib/jingle.ts`), classes/DOM no dono do card e três arquivos de teste estendidos; sem schema/migration/rota/componente/collection novo, sem novo spec e sem entrada no manifest. | +| 3. Rabbit holes nomeados | 5 | Campo/schema de artista, curadoria de títulos, clamp/truncate de fail-safe, redesign da seção/player da rádio, splitter genérico, novo spec/manifest, snapshots e telemetria explicitamente cortados. | +| 4. Depth check (reusa donos/shells) | 5 | Nenhum módulo/componente novo: a regra entra no módulo puro existente, o card segue dono único do markup (home + `/jingles`), a leitura/cache não muda e os testes estendem os donos; sem twin. | +| 5. Intenção (aceite) preservada | 4 | Todo o aceite mapeado; desvio visual deliberado documentado — o design derruba o bump `md:text-[26px]` (24px em todos os viewports) — e o crédito sem parênteses já era recomendação assumida no gate; nenhum afeta o outcome de produto. | + +Média: **4,8** (≥4). diff --git a/src/components/jingles/JingleCards.tsx b/src/components/jingles/JingleCards.tsx index 8ae487a5..27528b36 100644 --- a/src/components/jingles/JingleCards.tsx +++ b/src/components/jingles/JingleCards.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import { useRef, useState } from 'react' -import type { JingleViewModel } from '@/lib/jingle' +import { splitJingleTitle, type JingleViewModel } from '@/lib/jingle' import { formatSpeechClock } from '@/lib/speechClock' import { cn } from '@/lib/utils' @@ -143,6 +143,7 @@ export const JingleCards = ({

{jingles.map((jingle) => { const playing = activeId === jingle.id + const { base, credit } = splitJingleTitle(jingle.title) const state = playback[jingle.id] ?? EMPTY_PLAYBACK const percent = state.durationSeconds && state.durationSeconds > 0 @@ -176,7 +177,7 @@ export const JingleCards = ({
-
+
- - {jingle.title} + + {base} + {credit ? ( +

+ {credit} +

+ ) : null} {playing ? (

diff --git a/src/lib/jingle.ts b/src/lib/jingle.ts index 3d840077..14d52834 100644 --- a/src/lib/jingle.ts +++ b/src/lib/jingle.ts @@ -50,6 +50,26 @@ export const jingleDownloadFilename = ( return `${DOWNLOAD_PREFIX}-${base}.${safeExtension}` } +const FEAT_CREDIT_PATTERN = /\s*\(\s*feat\.\s+([^()]+?)\s*\)\s*$/i + +/** + * S26 — display-only split of the featured artist. Published titles carry the + * guest as a trailing `(feat. …)` segment; the card shows the base on its own + * heading and the credit on a second line. The stored title is never rewritten: + * the full string keeps feeding the aria-labels and the cover alt, and a title + * without the segment falls back to itself with no credit line. + */ +export const splitJingleTitle = (title: string) => { + const match = title.match(FEAT_CREDIT_PATTERN) + if (!match) return { base: title, credit: null } + + const base = title.slice(0, match.index).trim() + const artist = match[1].trim() + if (!base || !artist) return { base: title, credit: null } + + return { base, credit: `feat. ${artist}` } +} + /** * A card only exists when both media are readable: an unpopulated/missing * cover or audio fails closed (the jingle is skipped, never rendered broken). diff --git a/tests/e2e/frontendJingles.e2e.spec.ts b/tests/e2e/frontendJingles.e2e.spec.ts index c48052b6..3af435f5 100644 --- a/tests/e2e/frontendJingles.e2e.spec.ts +++ b/tests/e2e/frontendJingles.e2e.spec.ts @@ -308,4 +308,53 @@ test.describe('Frontend jingles (S21)', () => { await expect(seeAll).toBeVisible() await expect(seeAll).toHaveAttribute('href', '/jingles') }) + + // S26 — the artist credit never disappears: its own line under the base + // title, untruncated, stopped and playing, on /jingles and the home; the + // full title (credit included) keeps feeding the aria-labels. + test('shows the featured artist credit without truncating the title', async ({ + page, + request, + }) => { + const headers = await adminHeaders(request, BASE_URL) + await unpublishEveryJingle(request, headers) + const slug = uniqueSlug() + const fullTitle = 'Jorge Solla 1313 (feat. Felipe Forrozeiro)' + await createJingle(request, headers, { title: fullTitle, slug }) + + await page.goto('/jingles') + const card = page.locator('article[data-jingle]') + await expect(card).toHaveCount(1) + const heading = card.getByRole('heading', { name: 'Jorge Solla 1313', exact: true }) + await expect(heading).toBeVisible() + // Visibility alone does not catch CSS clipping: the heading must wrap + // (never `nowrap`) and its text must fit the box without hidden overflow. + const headingLayout = await heading.evaluate((el) => ({ + whiteSpace: getComputedStyle(el).whiteSpace, + overflows: el.scrollWidth > el.clientWidth + 1, + })) + expect(headingLayout.whiteSpace).not.toBe('nowrap') + expect(headingLayout.overflows).toBe(false) + await expect(card.getByText('feat. Felipe Forrozeiro')).toBeVisible() + await expect(page.getByRole('button', { name: `Tocar jingle ${fullTitle}` })).toBeVisible() + await expect(page.getByRole('link', { name: `Baixar ${fullTitle} em MP3` })).toHaveAttribute( + 'download', + `jorge-solla-1313-${slug}.mp3`, + ) + await expect(page.getByText(`jorge-solla-1313-${slug}.mp3`)).toBeVisible() + + // Playing keeps the credit on the card. + await page.getByRole('button', { name: `Tocar jingle ${fullTitle}` }).click() + await expect(card.getByText('Em reprodução')).toBeVisible() + await expect(card.getByText('feat. Felipe Forrozeiro')).toBeVisible() + + // The home reuses the same card and the same credit. + await page.goto(`/?e2e=${Date.now()}`) + const homeCard = page.locator('[data-home-section="sound"] article[data-jingle]') + await expect(homeCard).toHaveCount(1) + await expect( + homeCard.getByRole('heading', { name: 'Jorge Solla 1313', exact: true }), + ).toBeVisible() + await expect(homeCard.getByText('feat. Felipe Forrozeiro')).toBeVisible() + }) }) diff --git a/tests/unit/jingle.unit.spec.ts b/tests/unit/jingle.unit.spec.ts index 084db5ea..7aa36002 100644 --- a/tests/unit/jingle.unit.spec.ts +++ b/tests/unit/jingle.unit.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { jingleDownloadFilename, toJingleViewModel } from '@/lib/jingle' +import { jingleDownloadFilename, splitJingleTitle, toJingleViewModel } from '@/lib/jingle' describe('jingleDownloadFilename', () => { it.each([ @@ -24,6 +24,31 @@ describe('jingleDownloadFilename', () => { }) }) +describe('splitJingleTitle', () => { + it.each([ + ['Jorge Solla 1313 (feat. Felipe Forrozeiro)', 'Jorge Solla 1313', 'feat. Felipe Forrozeiro'], + ['Jorge Solla 1313 (feat. Nagib Barroso)', 'Jorge Solla 1313', 'feat. Nagib Barroso'], + ['Jorge Solla 1313 (feat. É O MT)', 'Jorge Solla 1313', 'feat. É O MT'], + ['Jorge Solla 1313 (FEAT. É O MT)', 'Jorge Solla 1313', 'feat. É O MT'], + ['Jorge Solla 1313 ( feat. É O MT )', 'Jorge Solla 1313', 'feat. É O MT'], + ['Jorge Solla 1313 (feat. Felipe Forrozeiro) ', 'Jorge Solla 1313', 'feat. Felipe Forrozeiro'], + ['Jorge Solla 1313 (feat. A) (feat. B)', 'Jorge Solla 1313 (feat. A)', 'feat. B'], + ])('splits %s', (title, base, credit) => { + expect(splitJingleTitle(title)).toEqual({ base, credit }) + }) + + it.each([ + 'Axé', + 'Forró', + 'Jorge Solla 1313 (ao vivo)', + 'Jorge Solla 1313 (feat. )', + '(feat. Felipe Forrozeiro)', + 'Jorge Solla 1313 (feat. X) ao vivo', + ])('keeps %s verbatim without a credit line', (title) => { + expect(splitJingleTitle(title)).toEqual({ base: title, credit: null }) + }) +}) + describe('toJingleViewModel', () => { const jingle = { id: 7, title: 'Axé', slug: 'axe' } diff --git a/tests/unit/jinglePlayer.unit.spec.tsx b/tests/unit/jinglePlayer.unit.spec.tsx index f2da77e1..ae8d6967 100644 --- a/tests/unit/jinglePlayer.unit.spec.tsx +++ b/tests/unit/jinglePlayer.unit.spec.tsx @@ -26,6 +26,17 @@ const JINGLES: readonly JingleViewModel[] = [ const playMock = vi.fn(() => Promise.resolve()) const pauseMock = vi.fn() +// S26 — own fixture: the specs above count the "Pronto para tocar" texts of +// the shared array, so the credited title stays out of `JINGLES`. +const FEAT_JINGLE: JingleViewModel = { + id: 9, + title: 'Jorge Solla 1313 (feat. Felipe Forrozeiro)', + coverUrl: '/api/media/file/capa-forro.jpg', + coverAlt: 'Capa do jingle Jorge Solla 1313 (feat. Felipe Forrozeiro) de Jorge Solla 1313', + audioUrl: '/api/media/file/forro.mp3', + downloadFilename: 'jorge-solla-1313-forro.mp3', +} + const cards = () => Array.from(document.querySelectorAll('[data-jingle]')) const cardState = (index: number) => cards()[index]?.getAttribute('data-state') const playButton = (index: number) => @@ -171,3 +182,40 @@ describe('JinglePlayer', () => { expect(screen.getAllByText(/Tempo —:—/)).toHaveLength(2) }) }) + +describe('JinglePlayer — título e crédito do artista (S26)', () => { + it('shows the base title untruncated and the credit on its own line, stopped and playing', () => { + render() + + const heading = screen.getByRole('heading', { level: 2, name: 'Jorge Solla 1313' }) + expect(heading.className).not.toContain('truncate') + expect(heading.className).toContain('wrap-anywhere') + expect(screen.getByText('feat. Felipe Forrozeiro')).toBeDefined() + expect(screen.getByText('Pronto para tocar')).toBeDefined() + + fireEvent.click(playButton(0)) + + expect(cardState(0)).toBe('playing') + expect(screen.getByText('Em reprodução')).toBeDefined() + expect(screen.getByText('feat. Felipe Forrozeiro')).toBeDefined() + }) + + it('keeps the full title in the play, progress and download labels', () => { + render() + + const fullTitle = 'Jorge Solla 1313 (feat. Felipe Forrozeiro)' + expect(playButton(0).getAttribute('aria-label')).toBe(`Tocar jingle ${fullTitle}`) + expect(document.querySelector(`[aria-label="Progresso do jingle ${fullTitle}"]`)).not.toBeNull() + expect(screen.getByRole('link', { name: `Baixar ${fullTitle} em MP3` })).toBeDefined() + }) + + it('reserves no credit line and still drops the truncation without a feat. segment', () => { + render() + + expect(screen.queryByText(/^feat\./)).toBeNull() + for (const heading of screen.getAllByRole('heading', { level: 2 })) { + expect(heading.className).not.toContain('truncate') + expect(heading.className).toContain('wrap-anywhere') + } + }) +})