From f5ec847f1fb72e9c17168dff8483d0c38bfbf8b5 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 18:29:29 -0300 Subject: [PATCH] refactor(C216-FOLLOWUP-DRY): excerpt e chips compartilhados nos cards de fala (F3) (Related #1325) --- .../campaign/speech/SpeechExcerpt.tsx | 32 ++++ .../campaign/speech/SpeechResultCard.tsx | 78 +++------ .../campaign/speech/SpeechResultChips.tsx | 50 ++++++ .../campaign/speech/WebSpeechResultCard.tsx | 39 ++--- tests/unit/speechResultCards.unit.spec.ts | 153 ++++++++++++++++++ 5 files changed, 272 insertions(+), 80 deletions(-) create mode 100644 src/components/campaign/speech/SpeechExcerpt.tsx create mode 100644 src/components/campaign/speech/SpeechResultChips.tsx create mode 100644 tests/unit/speechResultCards.unit.spec.ts diff --git a/src/components/campaign/speech/SpeechExcerpt.tsx b/src/components/campaign/speech/SpeechExcerpt.tsx new file mode 100644 index 00000000..fb777823 --- /dev/null +++ b/src/components/campaign/speech/SpeechExcerpt.tsx @@ -0,0 +1,32 @@ +import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts' +import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight' + +/** + * C216-FOLLOWUP-DRY — the truncated excerpt shared by the speech cards and the + * C192 provenance block: a leading `… ` and a trailing ` …` around the + * highlighted parts, nothing when there is no excerpt. `quoted` wraps the + * provenance excerpt in typographic quotes; `className` keeps each site's own + * line-height/margin (the sites differ on purpose). + */ +export const SpeechExcerpt = ({ + excerpt, + className, + quoted = false, +}: { + excerpt: SpeechHighlightedExcerpt + className: string + quoted?: boolean +}) => { + if (excerpt.parts.length === 0) return null + + const prefix = excerpt.truncatedStart ? '… ' : '' + const suffix = excerpt.truncatedEnd ? ' …' : '' + + return ( +

+ {quoted ? `“${prefix}` : prefix} + + {quoted ? `${suffix}”` : suffix} +

+ ) +} diff --git a/src/components/campaign/speech/SpeechResultCard.tsx b/src/components/campaign/speech/SpeechResultCard.tsx index 956df7b8..b42f9a5d 100644 --- a/src/components/campaign/speech/SpeechResultCard.tsx +++ b/src/components/campaign/speech/SpeechResultCard.tsx @@ -2,10 +2,13 @@ import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react' import Link from 'next/link' import type { ReactNode } from 'react' -import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts' +import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt' import { SpeechNestedCuts } from '@/components/campaign/speech/SpeechNestedCuts' +import { + SpeechResultChips, + type SpeechChipGroup, +} from '@/components/campaign/speech/SpeechResultChips' import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail' -import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight' import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels' @@ -20,18 +23,6 @@ const MAX_KEYWORD_CHIPS = 3 const MetaSeparator = () => -const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => { - if (excerpt.parts.length === 0) return null - - return ( -

- {excerpt.truncatedStart ? '… ' : null} - - {excerpt.truncatedEnd ? ' …' : null} -

- ) -} - /** * C192 — the theme result provenance: the real excerpt that matched the theme, * with the expanded term highlighted. It replaces the common excerpt (never @@ -45,11 +36,11 @@ const SpeechThemeProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt

Por que apareceu

-

- {`“${excerpt.truncatedStart ? '… ' : ''}`} - - {`${excerpt.truncatedEnd ? ' …' : ''}”`} -

+ ) } @@ -210,14 +201,17 @@ export const SpeechResultCard = ({ ) } - const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS) - const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS) - const visibleKeywords = speech.keywords.slice(0, MAX_KEYWORD_CHIPS) - const hiddenChips = - speech.topics.length - - visibleTopics.length + - (speech.scopes.length - visibleScopes.length) + - (speech.keywords.length - visibleKeywords.length) + const chipGroups: SpeechChipGroup[] = [ + { key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS }, + { key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS }, + { + key: 'keywords', + items: speech.keywords, + max: MAX_KEYWORD_CHIPS, + variant: 'outline', + className: 'font-normal text-muted-foreground', + }, + ] const isTheme = speech.themeMatchTerm !== null // C192 — a theme card only offers the trecho seek when the speech really // contains the literal query (a stopword-only segment hit must not claim it). @@ -228,32 +222,12 @@ export const SpeechResultCard = ({ const bodyNode = isTheme ? ( ) : ( - - ) - const chips = ( - <> - {visibleTopics.map((topic) => ( - - {topic.label} - - ))} - {visibleScopes.map((scope) => ( - - {scope.label} - - ))} - {visibleKeywords.map((keyword) => ( - - {keyword} - - ))} - {hiddenChips > 0 ? ( - - +{hiddenChips} - - ) : null} - + ) + const chips = const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length) return ( diff --git a/src/components/campaign/speech/SpeechResultChips.tsx b/src/components/campaign/speech/SpeechResultChips.tsx new file mode 100644 index 00000000..b3545e98 --- /dev/null +++ b/src/components/campaign/speech/SpeechResultChips.tsx @@ -0,0 +1,50 @@ +import { Badge } from '@/components/ui/Badge' + +/** + * One chip group: `max` bounds how many of its items the card shows; the hidden + * ones are summed with the other groups' into a single `+N`. The muted group is + * the keyword vocabulary only the Câmara card has. + */ +export type SpeechChipGroup = { + key: string + items: readonly { value: string; label: string }[] | readonly string[] + max: number + variant?: 'secondary' | 'outline' + className?: string +} + +/** + * C216-FOLLOWUP-DRY — the chips line shared by the speech cards (C154/C216): + * the leading few per group plus one `+N` badge summing every hidden item. The + * container and the empty guard stay with the caller. + */ +export const SpeechResultChips = ({ groups }: { groups: readonly SpeechChipGroup[] }) => { + const hidden = groups.reduce( + (total, group) => total + Math.max(0, group.items.length - group.max), + 0, + ) + + 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} + + ) + }), + )} + {hidden > 0 ? ( + + +{hidden} + + ) : null} + + ) +} diff --git a/src/components/campaign/speech/WebSpeechResultCard.tsx b/src/components/campaign/speech/WebSpeechResultCard.tsx index 06505b9e..fa5738f6 100644 --- a/src/components/campaign/speech/WebSpeechResultCard.tsx +++ b/src/components/campaign/speech/WebSpeechResultCard.tsx @@ -1,10 +1,13 @@ import { PlayIcon, VideoIcon } from 'lucide-react' import Link from 'next/link' -import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts' +import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt' +import { + SpeechResultChips, + type SpeechChipGroup, +} from '@/components/campaign/speech/SpeechResultChips' import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail' import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill' -import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import type { WebSpeechListItemViewModel } from '@/utilities/speech/speechViewModels' @@ -21,10 +24,10 @@ const MAX_SCOPE_CHIPS = 2 export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewModel }) => { const { excerpt, thumbnailUrl, watchHref } = speech const metaLine = [speech.dateLabel, speech.durationLabel].filter(Boolean).join(' · ') - const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS) - const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS) - const hiddenChips = - speech.topics.length - visibleTopics.length + (speech.scopes.length - visibleScopes.length) + const chipGroups: SpeechChipGroup[] = [ + { key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS }, + { key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS }, + ] const hasChips = Boolean(speech.topics.length || speech.scopes.length) return ( @@ -65,31 +68,11 @@ export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewM

{speech.title}

- {excerpt.parts.length > 0 ? ( -

- {excerpt.truncatedStart ? '… ' : null} - - {excerpt.truncatedEnd ? ' …' : null} -

- ) : null} + {hasChips ? (
- {visibleTopics.map((topic) => ( - - {topic.label} - - ))} - {visibleScopes.map((scope) => ( - - {scope.label} - - ))} - {hiddenChips > 0 ? ( - - +{hiddenChips} - - ) : null} +
) : null} diff --git a/tests/unit/speechResultCards.unit.spec.ts b/tests/unit/speechResultCards.unit.spec.ts new file mode 100644 index 00000000..2f9c6e51 --- /dev/null +++ b/tests/unit/speechResultCards.unit.spec.ts @@ -0,0 +1,153 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime' + +import { SpeechResultCard } from '@/components/campaign/speech/SpeechResultCard' +import { WebSpeechResultCard } from '@/components/campaign/speech/WebSpeechResultCard' +import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight' +import type { + SpeechListItemViewModel, + WebSpeechListItemViewModel, +} from '@/utilities/speech/speechViewModels' + +import { stub } from '../helpers/stub' + +const mockAppRouter = stub[0]['value']>({ + push: () => {}, + replace: () => {}, + refresh: () => {}, + forward: () => {}, + back: () => {}, + prefetch: () => Promise.resolve(), +}) + +const renderWithAppRouter = (element: Parameters[0]) => + renderToStaticMarkup(createElement(AppRouterContext.Provider, { value: mockAppRouter }, element)) + +const excerpt = (overrides: Partial = {}): SpeechHighlightedExcerpt => ({ + parts: [ + { text: 'um trecho sobre ', highlighted: false }, + { text: 'saúde', highlighted: true }, + ], + truncatedStart: true, + truncatedEnd: true, + ...overrides, +}) + +const speech = (overrides: Partial = {}): SpeechListItemViewModel => ({ + id: 1, + speechAtLabel: '11/08/2026', + type: 'Discurso', + durationLabel: '2min', + presidingOfficer: null, + excerpt: excerpt(), + matchKind: 'segment', + topics: [ + { value: 'saude', label: 'Saúde' }, + { value: 'educacao', label: 'Educação' }, + { value: 'cultura', label: 'Cultura' }, + { value: 'esporte', label: 'Esporte' }, + ], + scopes: [ + { value: 'bahia', label: 'Bahia' }, + { value: 'brasil', label: 'Brasil' }, + { value: 'internacional', label: 'Internacional' }, + ], + keywords: ['sus', 'vacina', 'atenção básica', 'orçamento'], + municipalities: [], + thumbnailUrl: null, + watchHref: '/campanha/comunicacao/acervo/1', + officialTextUrl: null, + cuts: [], + matchedTextSearch: true, + themeMatchTerm: null, + ...overrides, +}) + +const webSpeech = ( + overrides: Partial = {}, +): WebSpeechListItemViewModel => ({ + id: 9, + title: 'Fala na internet', + platform: { value: 'youtube', label: 'YouTube' }, + dateLabel: '12/09/2026', + durationLabel: '5min', + excerpt: excerpt(), + topics: [ + { value: 'saude', label: 'Saúde' }, + { value: 'educacao', label: 'Educação' }, + { value: 'cultura', label: 'Cultura' }, + { value: 'esporte', label: 'Esporte' }, + ], + scopes: [ + { value: 'bahia', label: 'Bahia' }, + { value: 'brasil', label: 'Brasil' }, + { value: 'internacional', label: 'Internacional' }, + ], + thumbnailUrl: null, + watchHref: '/campanha/comunicacao/acervo/internet/9', + ...overrides, +}) + +describe('speech result cards excerpt', () => { + it('renders the truncated excerpt with the highlighted part', () => { + const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() })) + + expect(html).toContain('text-sm leading-relaxed text-foreground/90') + expect(html).toContain('… ') + expect(html).toContain(' …') + expect(html).toContain(' { + const html = renderWithAppRouter( + createElement(SpeechResultCard, { + speech: speech({ + excerpt: { parts: [], truncatedStart: false, truncatedEnd: false }, + }), + }), + ) + + expect(html).not.toContain('um trecho sobre') + }) + + it('wraps the theme provenance excerpt in quotes', () => { + const html = renderWithAppRouter( + createElement(SpeechResultCard, { speech: speech({ themeMatchTerm: 'saúde' }) }), + ) + + expect(html).toContain('Por que apareceu') + expect(html).toContain('“') + expect(html).toContain('”') + }) + + it('keeps the web card own excerpt classes', () => { + const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() })) + + expect(html).toContain('mt-2 text-sm leading-6 text-foreground/90') + }) +}) + +describe('speech result cards chips', () => { + it('caps each group and sums every hidden chip on the Câmara card', () => { + const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() })) + + expect(html).toContain('Saúde') + expect(html).toContain('Bahia') + expect(html).toContain('sus') + expect(html).not.toContain('Esporte') + expect(html).not.toContain('Internacional') + expect(html).not.toContain('orçamento') + expect(html).toContain('+3') + }) + + it('sums only the groups the web card has', () => { + const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() })) + + expect(html).toContain('+2') + expect(html).not.toContain('sus') + }) +})