feat(S26): título completo e crédito do artista no card do jingle (Closes #1240)

This commit is contained in:
Francisco Solla 2026-09-21 12:32:26 -03:00
parent 162dc8d017
commit 8a5e271f6d
7 changed files with 288 additions and 5 deletions

View file

@ -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 `<p>` 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).

View file

@ -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. <artista>)` 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<br/>title verbatim"] --> R["jingleReads.getPublishedJingleItems()"]
R --> VM["JingleViewModel.title<br/>(completo, intocado)"]
VM --> CARD["JingleCards (dono único do card)<br/>home h4 + /jingles"]
VM --> ARIA["aria-labels play/pausa, progresso e download<br/>+ fallback de coverAlt"]
CARD --> SPLIT["splitJingleTitle<br/>(pura, src/lib/jingle.ts)"]
SPLIT --> BASE["heading: base"]
SPLIT --> CR["p.credit: 'feat. <artista>' | 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: `<p>` 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; `<p>` 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: `<p>` 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 <completo>`, `Progresso do jingle <completo>`, `Baixar <completo> 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 `<p>` 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`; `<p>` 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).

View file

@ -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 = ({
<div className="mx-auto grid max-w-6xl gap-5 sm:gap-6 md:grid-cols-3">
{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 = ({
</div>
<div className="p-4 md:p-5">
<div className="flex items-center gap-4">
<div className="flex items-start gap-4">
<button
type="button"
data-play
@ -188,9 +189,14 @@ export const JingleCards = ({
{playing ? <PauseIcon /> : <PlayIcon />}
</button>
<div className="min-w-0 flex-1">
<Heading className="m-0 truncate border-b-0 pb-0 text-left font-[family-name:var(--font-exo2)] text-2xl leading-none font-black tracking-[-0.02em] md:text-[26px]">
{jingle.title}
<Heading className="m-0 border-b-0 pb-0 text-left font-[family-name:var(--font-exo2)] text-2xl leading-[1.02] font-black tracking-[-0.02em] wrap-anywhere">
{base}
</Heading>
{credit ? (
<p className="mt-1.5 text-[13px] leading-[1.25] font-bold text-(--campaign-muted)">
{credit}
</p>
) : null}
{playing ? (
<p className="mt-2 flex items-center gap-2 text-sm font-bold text-(--pt-red)">
<span className="h-2 w-2 rounded-full bg-(--pt-red)" />

View file

@ -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).

View file

@ -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()
})
})

View file

@ -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' }

View file

@ -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<HTMLElement>('[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(<JinglePlayer jingles={[FEAT_JINGLE]} />)
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(<JinglePlayer jingles={[FEAT_JINGLE]} />)
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(<JinglePlayer jingles={JINGLES} />)
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')
}
})
})