diff --git a/docs/plans/escala-dry-pos-c216-impl.md b/docs/plans/escala-dry-pos-c216-impl.md index 1bbbc041..f0d6507f 100644 --- a/docs/plans/escala-dry-pos-c216-impl.md +++ b/docs/plans/escala-dry-pos-c216-impl.md @@ -63,7 +63,7 @@ Ajustes de rota que acompanham D1 (comportamento idêntico, mesmos 404s): **D3 (F3) — `SpeechExcerpt` com `className` por site e `quoted`; chips por grupos.** `Decisão: SpeechExcerpt({ excerpt, className, quoted? }) em novo src/components/campaign/speech/SpeechExcerpt.tsx: null sem parts; '… '/' …' conforme truncatedStart/End; com quoted envolve em “ … ”. className fica por call site (Câmara 'text-sm leading-relaxed text-foreground/90'; web 'mt-2 text-sm leading-6 text-foreground/90'; provenance 'mt-1 text-sm leading-6 text-foreground/90'). SpeechResultChips({ groups: { key, items, max, variant?, className? }[] }) em novo SpeechResultChips.tsx: renderiza até max por grupo, soma o +N de todos os grupos e usa Badge (secondary com className 'font-normal'; +N outline com 'font-normal text-muted-foreground'); container e guard de vazio continuam no call site. Câmara passa 3 grupos (topics/3, scopes/2, keywords/3 outline muted — keywords mapeadas de string para { value, label }); web passa 2.` `Por quê: são 3 sites do MESMO truncamento (os dois cards + o provenance C192, no mesmo arquivo do card) e 2 sites de chips com caps 3/2 (+ 3 keywords na Câmara); o +N somando grupos é a única forma de manter a semântica da Câmara sem duplicar a soma. className explícito evita mudança de pixel (Impeccable A) e é honesto — as classes divergem de propósito.` -`Rejeitadas: unificar className/line-height (muda pixel sem design review; o e2e não pina line-height, mas a intenção é A); incluir RecordingResultCard.tsx:57-62 (terceiro excerpt é de OUTRO card — fora do gatilho "3º card de fala"); API de chips por props fixas (não permitiria 3 grupos × 2); exportar os tipos dos grupos (knip types: error sem consumidor externo — os call sites usam literais contextuais).` +`Rejeitadas: unificar className/line-height (muda pixel sem design review; o e2e não pina line-height, mas a intenção é A); incluir RecordingResultCard.tsx:57-62 (terceiro excerpt é de OUTRO card — fora do gatilho "3º card de fala"); API de chips por props fixas (não permitiria 3 grupos × 2); exportar os tipos dos grupos — na execução o tipo `SpeechChipGroup` saiu e os 2 call sites anotam `SpeechChipGroup[]` (knip verde); a hipótese do literal contextual não se confirmou).` ### Componentes / mudanças @@ -76,7 +76,7 @@ Ajustes de rota que acompanham D1 (comportamento idêntico, mesmos 404s): - **Migration:** sem migration (nenhum schema/collection/global tocado). - **Access / Consent:** inalterados — o helper reusa `getCampaignUser` + `canReadCommunicationCatalog` fail-closed; nenhum Consent novo, nenhum ID hardcoded, nenhum PII novo. - **UI:** Impeccable A — sem superfície visual nova; F2/F3 são refactor byte-idêntico (classes preservadas por call site). -- **Testes:** novo `tests/unit/speechResultCards.unit.spec.ts` com `renderToStaticMarkup` (padrão de `campaignComponents.unit.spec.ts`): pin do excerpt (ellipses, `mark`, `quoted`, vazio) e do `+N` somando grupos (Câmara 3+2+3 com caps → +3; web 2 grupos → +1). +- **Testes:** novo `tests/unit/speechResultCards.unit.spec.ts` com `renderToStaticMarkup` (padrão de `campaignComponents.unit.spec.ts`): pin do excerpt (ellipses, `mark`, `quoted`, vazio) e do `+N` somando grupos (Câmara 4+3+4 com caps → +3; web 4+3 → +2). ## Fases verificáveis @@ -85,12 +85,43 @@ Ajustes de rota que acompanham D1 (comportamento idêntico, mesmos 404s): 3. **F3 (excerpt/chips) — unit + cards.** Cria os 2 componentes, migra os 3 sites de excerpt e os 2 de chips, adiciona o unit novo. Verificação: `pnpm test:unit` + e2e `campaignSpeechAcervo` (listas Câmara/web/gravações). 4. **Gates — `pnpm gate:fast`; `pnpm push`** (o CI do PR roda a cascata completa, incl. os e2e da vertical). Commits pequenos por fase, F1 primeiro. +## Adiado com gatilho (triage pós-revisão, 2026-09-24) + +Nada abaixo atingiu o piso de registro (expensive_lock ≥4). Aplicados na sessão (não +reabrir): S1 (JSDoc do isServable), S4 (números dos chips no impl plan), Q1 +(subject→itemNoun), Q4 (AcervoResultsControls), Q5 (keywords normalizadas no call site), +Q8 (changelog), Q10 (unit com o caso sem +N). + +- **F1-residual — `select` não chaveado à collection (`privateMediaGate.ts:45`).** + Chave errada/omitida compila e um predicado pode ler campo não selecionado; o efeito é + 404 silencioso (fail-closed). O mapped type por `DataFromCollectionSlug` **não é viável + sem cast** (probe 2026-09-24): `SelectFromCollectionSlug` não é exportado por `payload`; + `Partial, true>>` não assigna ao `select` do + `findByID` (`undefined` vs `SelectIncludeType | true`); intersectar com `SelectType` + compila, mas o index signature anula o excess-property check. Caminho barato quando + doer: `satisfies Partial'>, true>>` no + literal de cada call site (pega chave errada; omissão continua passando). + **Gatilho:** a 6ª rota usar o gate (poster/C217) ou o primeiro 404 rastreado a `select` + divergente do predicado. +- **F1-residual — `notFound` silencioso em 6 cópias + `Number(id)` em 5 rotas.** + Extrair `privateMediaNotFound()` no dono (`privateMediaResponse.ts`, onde a 6ª cópia já + mora — o `NextResponse` das rotas é assignável a `Response`) e o gate aceitar + `id: string | number`, validando no dono; `pieceIdFrom` do POST fica (a schema precisa + do número). A rota mantém o controle (`if (!found) return privateMediaNotFound()`). + **Gatilho:** o mesmo acima, ou qualquer mudança no shape do 404 (status/header). +- **Harness de router duplicado nos unit specs** (`campaignComponents.unit.spec.ts:99-120` + e `speechResultCards.unit.spec.ts:17-27`). Consolidar `mockAppRouter`/`renderWithAppRouter` + em `tests/helpers/`. O gatilho de `escala-dry-pos-b33.md:106` (4º spec de router) já + vencera antes deste F3 (17 specs mockam `next/navigation`); a conversão do `vi.mock` de + `campaignListFilterNavigation.unit.spec.ts` entra no mesmo pagamento. + **Gatilho:** o próximo lote que tocar unit specs de componente. + ## Rabbit holes / Não escopo (engenharia) - Rota `poster` (`acervo/[id]/poster/route.ts`) e demais rotas de mídia fora das 5 — o gate delas não está neste lote. - POST de upload/anexo (`conteudos/[id]/arquivo` POST, `conteudos/enviar`, `gravacoes/enviar`) — auth própria por envelope de erro (401/403), não é o 404 silencioso do gate de leitura. - Central de Conteúdos público (`(frontend)/conteudos/[slug]/midia`) — contrato público, outro gate. -- `RecordingResultCard.tsx:57-62` (terceiro excerpt, de outro card) — o gatilho do F3 é um 3º card de FALA. +- `RecordingResultCard.tsx:57-62` (excerpt de outro card — o gatilho do F3 era um 3º card de FALA, não este). **Gatilho:** a próxima edição do card (C217/design) troca o bloco pelo `SpeechExcerpt` com className próprio (`mt-2 text-sm leading-5 text-foreground/90`, bytes idênticos); conferir o caso `parts` vazio (hoje o site emite as elipses; o componente devolve null). - `speechCoverage`/Sollinha/C217. - Unificar `speechHasActiveFilters` × `recordingHasActiveFilters` (shapes `phases` × `people`) — defer com gatilho no impl do C216. - Centralizar fixtures de bytes MP4/MP3 dos int specs (2 sites) — defer. diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx index d172d148..a78e195d 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx @@ -34,12 +34,14 @@ import { buildRecordingFiltersKey, buildRecordingListHref, recordingHasActiveFilters, + type RecordingListState, } from '@/utilities/recordings/recordingListUrl' import { loadRecordingsPageData } from '@/utilities/recordings/recordingPageData' import { buildSpeechFiltersKey, buildSpeechListHref, speechHasActiveFilters, + type SpeechListState, } from '@/utilities/speech/speechListUrl' import { loadSpeechAcervoPageData, @@ -101,14 +103,14 @@ const AcervoResultsHeading = ({ themeActive, themeUnavailable, idleTitle, - subject, + itemNoun, controls, }: { themeMode: boolean themeActive: boolean themeUnavailable: boolean idleTitle: string - subject: 'fala' | 'gravação' + itemNoun: 'fala' | 'gravação' controls: ReactNode }) => (
@@ -125,7 +127,7 @@ const AcervoResultsHeading = ({ {themeUnavailable ? 'Comportamento atual do acervo.' : themeActive - ? `Confira o indício em cada ${subject} antes de abrir.` + ? `Confira o indício em cada ${itemNoun} antes de abrir.` : 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}

) : null} @@ -134,6 +136,25 @@ const AcervoResultsHeading = ({
) +/** + * The retry + ordering wrapper of the two sources that offer the sort control; + * the Câmara hands the retry button bare to the heading instead. + */ +const AcervoResultsControls = ({ + state, + themeUnavailable, + hint, +}: { + state: RecordingListState | SpeechListState + themeUnavailable: boolean + hint: string +}) => ( +
+ {themeUnavailable ? : null} + +
+) + const RecordingsSource = ({ data, }: { @@ -164,15 +185,13 @@ const RecordingsSource = ({ themeActive={themeActive} themeUnavailable={data.themeUnavailable} idleTitle="Gravações encontradas" - subject="gravação" + itemNoun="gravação" controls={ -
- {data.themeUnavailable ? : null} - -
+ } /> @@ -275,15 +294,13 @@ const WebSpeechesSource = ({ themeActive={themeActive} themeUnavailable={data.themeUnavailable} idleTitle="Resultados encontrados" - subject="fala" + itemNoun="fala" controls={ -
- {data.themeUnavailable ? : null} - -
+ } /> @@ -371,7 +388,7 @@ const CamaraSource = ({ data }: { data: Awaited : null} /> ) : null} diff --git a/src/components/campaign/speech/SpeechResultCard.tsx b/src/components/campaign/speech/SpeechResultCard.tsx index b42f9a5d..1ca498f6 100644 --- a/src/components/campaign/speech/SpeechResultCard.tsx +++ b/src/components/campaign/speech/SpeechResultCard.tsx @@ -206,7 +206,7 @@ export const SpeechResultCard = ({ { key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS }, { key: 'keywords', - items: speech.keywords, + items: speech.keywords.map((keyword) => ({ value: keyword, label: keyword })), max: MAX_KEYWORD_CHIPS, variant: 'outline', className: 'font-normal text-muted-foreground', diff --git a/src/components/campaign/speech/SpeechResultChips.tsx b/src/components/campaign/speech/SpeechResultChips.tsx index b3545e98..4657456a 100644 --- a/src/components/campaign/speech/SpeechResultChips.tsx +++ b/src/components/campaign/speech/SpeechResultChips.tsx @@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/Badge' */ export type SpeechChipGroup = { key: string - items: readonly { value: string; label: string }[] | readonly string[] + items: readonly { value: string; label: string }[] max: number variant?: 'secondary' | 'outline' className?: string @@ -27,18 +27,15 @@ export const SpeechResultChips = ({ groups }: { groups: readonly SpeechChipGroup return ( <> {groups.flatMap((group) => - group.items.slice(0, group.max).map((item) => { - const chip = typeof item === 'string' ? { value: item, label: item } : item - return ( - - {chip.label} - - ) - }), + group.items.slice(0, group.max).map((chip) => ( + + {chip.label} + + )), )} {hidden > 0 ? ( diff --git a/src/utilities/privateMedia/privateMediaGate.ts b/src/utilities/privateMedia/privateMediaGate.ts index 01a7d419..fad56406 100644 --- a/src/utilities/privateMedia/privateMediaGate.ts +++ b/src/utilities/privateMedia/privateMediaGate.ts @@ -20,8 +20,9 @@ type PrivateMediaCollectionSlug = 'contentPiece' | 'recording' | 'reel' | 'speec * for EVERY denial (the route maps it to its silent 404, so the response never * leaks which artifacts exist). The caller injects only what is its own: the * collection, the optional `select`, the eligibility predicate (`isServable`, - * mandatory so a new route cannot silently skip its kill switch) and the media - * field picker (`artifactOf`). + * mandatory so every new route has to declare its own eligibility — explicitly + * `() => true` when the domain has no kill switch) and the media field picker + * (`artifactOf`). * * The HTTP contract (range, disposition, 404) stays in the route — this helper * never builds a response. diff --git a/tests/unit/speechResultCards.unit.spec.ts b/tests/unit/speechResultCards.unit.spec.ts index 2f9c6e51..099d1e78 100644 --- a/tests/unit/speechResultCards.unit.spec.ts +++ b/tests/unit/speechResultCards.unit.spec.ts @@ -112,6 +112,7 @@ describe('speech result cards excerpt', () => { ) expect(html).not.toContain('um trecho sobre') + expect(html).not.toContain('text-sm leading-relaxed') }) it('wraps the theme provenance excerpt in quotes', () => { @@ -122,6 +123,8 @@ describe('speech result cards excerpt', () => { expect(html).toContain('Por que apareceu') expect(html).toContain('“') expect(html).toContain('”') + // The provenance replaces the common excerpt — they never coexist (C192). + expect(html).not.toContain('text-sm leading-relaxed') }) it('keeps the web card own excerpt classes', () => { @@ -147,7 +150,25 @@ describe('speech result cards chips', () => { it('sums only the groups the web card has', () => { const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() })) + expect(html).toContain('Saúde') + expect(html).toContain('Bahia') + expect(html).not.toContain('Esporte') + expect(html).not.toContain('Internacional') expect(html).toContain('+2') expect(html).not.toContain('sus') }) + + it('omits the +N badge when every chip fits', () => { + const html = renderWithAppRouter( + createElement(SpeechResultCard, { + speech: speech({ + topics: [{ value: 'saude', label: 'Saúde' }], + scopes: [{ value: 'bahia', label: 'Bahia' }], + keywords: [], + }), + }), + ) + + expect(html).not.toMatch(/\+\d/) + }) })