// @vitest-environment node import { describe, expect, it } from 'vitest' import { buildContentPieceCatalogHref, cardCatalogItems, CONTENT_PIECE_ASPECT_CLASS, contentCatalogItems, contentPieceAspectClass, contentPieceBoardBucket, contentPieceCatalogActiveFilters, contentPieceCatalogFacets, contentPieceDownloadFilename, contentPieceFramePath, contentPieceMediaKind, contentPieceMediaPath, contentPiecePublicPath, contentPiecePublicPeople, contentPieceTextExcerpt, contentPieceThemeMatch, contentPieceThemeTerms, filterContentPieceCatalogItems, interleaveCatalogItems, isCardCatalogItem, parseContentPieceCatalogParams, toContentPiecePublicItem, type CardCatalogItem, type ContentCatalogItem, type ContentPiecePublicItem, type ContentPiecePublicSource, } from '@/lib/contentPieceCatalog' const source = (patch: Partial = {}): ContentPiecePublicSource => ({ id: 1, slug: 'fim-da-escala-6x1', title: 'Fim da escala 6x1 é saúde', type: 'video', status: 'publicado', origin: 'arquivo', topics: ['economia-trabalho'], cityLabel: 'Salvador', region: 'Metropolitano de Salvador', description: 'Solla explica a jornada.', transcript: 'A redução da jornada é saúde.', durationSeconds: 134, searchText: 'fim da escala 6x1 e saude salvador', media: { id: 7, filename: 'Fim da Escala.MP4' }, ...patch, }) const item = (patch: Partial = {}): ContentPiecePublicItem => { const resolved = toContentPiecePublicItem(source(patch)) if (!resolved) throw new Error('fixture should be public') return resolved } const pieceRows = (rows: readonly ContentCatalogItem[]): ContentPiecePublicItem[] => rows.filter((row): row is ContentPiecePublicItem => !isCardCatalogItem(row)) const cardRows = (rows: readonly ContentCatalogItem[]): CardCatalogItem[] => rows.filter((row): row is CardCatalogItem => isCardCatalogItem(row)) describe('content piece catalog params', () => { it('parses one valid value per facet and drops unknown values', () => { expect( parseContentPieceCatalogParams({ tipo: ['video', 'foto'], cidade: 'salvador', regiao: 'metropolitano-de-salvador', tema: 'economia-trabalho', instituicao: 'camara-dos-deputados', lideranca: 'maria-silva', q: ' escala ', mode: 'tema', }), ).toEqual({ tipo: 'video', cidade: 'salvador', regiao: 'metropolitano-de-salvador', tema: 'economia-trabalho', instituicao: 'camara-dos-deputados', lideranca: 'maria-silva', q: 'escala', mode: 'tema', }) expect(parseContentPieceCatalogParams({ tipo: 'inexistente', tema: 'nada' })).toEqual({ tipo: null, cidade: null, regiao: null, tema: null, instituicao: null, lideranca: null, q: '', mode: null, }) expect(parseContentPieceCatalogParams({ cidade: '../etc/passwd' }).cidade).toBeNull() // S37 — the people facet is a display-name slug; spaces/accents never pass. expect(parseContentPieceCatalogParams({ lideranca: 'Maria Silva' }).lideranca).toBeNull() expect(parseContentPieceCatalogParams({}).q).toBe('') }) it('parses the theme mode only with a query and drops unknown modes', () => { expect(parseContentPieceCatalogParams({ q: 'escala', mode: 'tema' }).mode).toBe('tema') // `exato` is the default and never serialized; unknown values mean exact too. expect(parseContentPieceCatalogParams({ q: 'escala', mode: 'exato' }).mode).toBeNull() expect(parseContentPieceCatalogParams({ q: 'escala', mode: 'semantico' }).mode).toBeNull() expect(parseContentPieceCatalogParams({ q: ' ', mode: 'tema' }).mode).toBeNull() expect(parseContentPieceCatalogParams({ mode: 'tema' }).mode).toBeNull() }) it('builds the canonical href with a fixed order and no empty params', () => { expect(buildContentPieceCatalogHref({})).toBe('/conteudos') expect(buildContentPieceCatalogHref({ q: ' ' })).toBe('/conteudos') expect( buildContentPieceCatalogHref({ q: 'escala', tema: 'economia-trabalho', tipo: 'video', cidade: 'salvador', }), ).toBe('/conteudos?tipo=video&cidade=salvador&tema=economia-trabalho&q=escala') expect(buildContentPieceCatalogHref({ tipo: 'video', tema: null, q: '' })).toBe( '/conteudos?tipo=video', ) // S37 — the people facet is appended after Instituição, before the query. expect( buildContentPieceCatalogHref({ instituicao: 'camara-dos-deputados', lideranca: 'maria-silva', q: 'escala', }), ).toBe('/conteudos?instituicao=camara-dos-deputados&lideranca=maria-silva&q=escala') expect(buildContentPieceCatalogHref({ q: 'escala', mode: 'tema' })).toBe( '/conteudos?q=escala&mode=tema', ) // A mode without a query has nothing to expand and is not serialized. expect(buildContentPieceCatalogHref({ q: '', mode: 'tema' })).toBe('/conteudos') }) it('lists the active filters with their removal hrefs', () => { const items = [ item(), item({ id: 2, slug: 'card', type: 'card', topics: ['saude'], cityLabel: null }), ] const params = parseContentPieceCatalogParams({ tipo: 'video', cidade: 'salvador', q: 'escala', }) const filters = contentPieceCatalogActiveFilters(params, contentPieceCatalogFacets(items)) expect(filters.map((filter) => `${filter.label}: ${filter.value}`)).toEqual([ 'Tipo: Vídeo', 'Cidade: Salvador', 'Busca: escala', ]) expect(filters[0]?.removeHref).toBe('/conteudos?cidade=salvador&q=escala') expect(filters[2]?.removeHref).toBe('/conteudos?tipo=video&cidade=salvador') }) }) describe('content piece catalog facets', () => { it('derives options from published items only, in enum/alphabetical order', () => { const facets = contentPieceCatalogFacets([ item({ leaderNames: ['Maria Silva'] }), item({ id: 2, slug: 'card-saude', type: 'card', topics: ['saude'], cityLabel: 'Feira de Santana', region: 'Portal do Sertão', institution: 'Câmara dos Deputados', publicFigures: ['Dep. Estadual Exemplo'], }), ]) expect(facets.tipo).toEqual([ { value: 'video', label: 'Vídeo' }, { value: 'card', label: 'Card' }, ]) expect(facets.tema.map((option) => option.label)).toEqual(['Saúde', 'Economia e Trabalho']) expect(facets.cidade.map((option) => option.label)).toEqual(['Feira de Santana', 'Salvador']) expect(facets.regiao.map((option) => option.label)).toEqual([ 'Metropolitano de Salvador', 'Portal do Sertão', ]) expect(facets.instituicao).toEqual([ { value: 'camara-dos-deputados', label: 'Câmara dos Deputados' }, ]) // S37 — one facet unions the leader snapshot and the curated figures, // alphabetical by the display name. expect(facets.lideranca).toEqual([ { value: 'dep-estadual-exemplo', label: 'Dep. Estadual Exemplo' }, { value: 'maria-silva', label: 'Maria Silva' }, ]) }) it('collapses homonyms and spelling variants into one people option (S37)', () => { const facets = contentPieceCatalogFacets([ item({ leaderNames: ['Dra. Elaine'] }), item({ id: 2, slug: 'outra', publicFigures: ['Dra Elaine'] }), ]) expect(facets.lideranca).toEqual([{ value: 'dra-elaine', label: 'Dra Elaine' }]) // No piece carries people: the facet renders nothing. expect(contentPieceCatalogFacets([item()]).lideranca).toEqual([]) }) }) describe('content piece catalog filtering', () => { const items = [ item(), item({ id: 2, slug: 'card-feira', title: 'Card do giro', type: 'card', topics: ['saude'], cityLabel: 'Feira de Santana', searchText: 'card do giro saude feira de santana', }), item({ id: 3, slug: 'link-yt', origin: 'youtube', media: null, sourceUrl: 'https://youtu.be/X', searchText: 'video no youtube', }), ] const idsFor = (raw: Record) => pieceRows(filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw))).map( (row) => row.id, ) it('combines facets and the accent-insensitive term', () => { expect(idsFor({})).toEqual([1, 2, 3]) expect(idsFor({ tipo: 'video' })).toEqual([1, 3]) expect(idsFor({ tema: 'saude' })).toEqual([2]) expect(idsFor({ cidade: 'feira-de-santana' })).toEqual([2]) expect(idsFor({ q: 'ESCALA' })).toEqual([1]) expect(idsFor({ q: 'saúde' })).toEqual([1, 2]) expect(idsFor({ tipo: 'video', cidade: 'feira-de-santana' })).toEqual([]) expect(idsFor({ tipo: 'video', q: 'escala' })).toEqual([1]) }) it('filters by the people facet union and combines with the other facets (S37)', () => { const people = [ item({ leaderNames: ['Maria Silva'] }), item({ id: 2, slug: 'card-feira', type: 'card', cityLabel: 'Feira de Santana', publicFigures: ['Dep. Estadual Exemplo'], }), ] const ids = (raw: Record) => pieceRows(filterContentPieceCatalogItems(people, parseContentPieceCatalogParams(raw))).map( (row) => row.id, ) expect(ids({ lideranca: 'maria-silva' })).toEqual([1]) expect(ids({ lideranca: 'dep-estadual-exemplo' })).toEqual([2]) expect(ids({ lideranca: 'maria-silva', tipo: 'card' })).toEqual([]) expect(ids({ lideranca: 'ninguem' })).toEqual([]) }) it('ORs the literal query with the expanded theme terms, facets still AND', () => { const idsForTerms = (raw: Record, terms: string[]) => pieceRows( filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw), terms), ).map((row) => row.id) // No terms is byte-identical to the literal search. expect(idsForTerms({ q: 'escala' }, [])).toEqual([1]) // The expanded term surfaces a piece the query alone would not. expect(idsForTerms({ q: 'giro' }, ['card'])).toEqual([2]) expect(idsForTerms({ q: 'zzz' }, ['card', 'giro'])).toEqual([2]) // Facets keep narrowing the OR result. expect(idsForTerms({ q: 'zzz', tipo: 'video' }, ['card'])).toEqual([]) expect(idsForTerms({ q: 'zzz' }, ['ESCALA'])).toEqual([1]) }) it('mixes the card items into the same facet and order (S38)', () => { const rows = filterContentPieceCatalogItems( [...items, ...cardCatalogItems()], parseContentPieceCatalogParams({ tipo: 'card' }), ) expect(rows.map((row) => (isCardCatalogItem(row) ? row.modelId : row.slug))).toEqual([ 'card-feira', ...cardCatalogItems().map((card) => card.modelId), ]) expect(contentPieceCatalogFacets([...items, ...cardCatalogItems()]).tipo).toEqual([ { value: 'video', label: 'Vídeo' }, { value: 'card', label: 'Card' }, ]) }) }) describe('card models as catalogue items (S38)', () => { const cards = cardCatalogItems() const modelIdsFor = (raw: Record) => cardRows(filterContentPieceCatalogItems(cards, parseContentPieceCatalogParams(raw))).map( (card) => card.modelId, ) it('builds one item per model with the studio deep link and the model copy', () => { expect(cards.map((card) => card.modelId)).toEqual([ 'eu-sou-solla', 'perfil-quadrado', 'perfil-retangular', 'time-de-voce', 'time-do-estadual', 'minha-colinha', ]) expect(cards.every((card) => card.href === `/cards?model=${card.modelId}`)).toBe(true) expect(cards.find((card) => card.modelId === 'minha-colinha')?.badge).toBe('NOVO') expect(cards.every((card) => card.badge === null || card.modelId === 'minha-colinha')).toBe( true, ) }) it('carries the real art, or the neutral placeholder of the photo models', () => { const artOf = (modelId: string) => cards.find((card) => card.modelId === modelId)?.art expect(artOf('time-de-voce')).toEqual({ kind: 'image', src: '/cards/team-card-example.jpg' }) expect(artOf('eu-sou-solla')).toEqual({ kind: 'image', src: '/cards/name-card-base.jpg' }) expect(artOf('perfil-quadrado')).toEqual({ kind: 'placeholder', shape: 'square' }) expect(artOf('perfil-retangular')).toEqual({ kind: 'placeholder', shape: 'portrait' }) }) it('finds the models by nickname, by name and by description', () => { expect(modelIdsFor({ q: 'santinho' })).toEqual(['time-de-voce', 'minha-colinha']) expect(modelIdsFor({ q: 'foto de perfil' })).toEqual(['perfil-quadrado', 'perfil-retangular']) expect(modelIdsFor({ q: 'colinha' })).toEqual(['minha-colinha']) expect(modelIdsFor({ q: 'estadual' })).toEqual(['time-do-estadual']) expect(modelIdsFor({ q: 'Moldura quadrada' })).toEqual(['perfil-quadrado']) expect(modelIdsFor({ q: 'cola de votação' })).toEqual(['minha-colinha']) }) it('matches the Tipo facet only as card and never a geographic/theme facet', () => { expect(modelIdsFor({ tipo: 'card' })).toHaveLength(6) expect(modelIdsFor({ tipo: 'video' })).toEqual([]) expect(modelIdsFor({ cidade: 'salvador' })).toEqual([]) expect(modelIdsFor({ regiao: 'metropolitano-de-salvador' })).toEqual([]) expect(modelIdsFor({ instituicao: 'camara-dos-deputados' })).toEqual([]) expect(modelIdsFor({ tema: 'saude' })).toEqual([]) }) it('feeds only the Tipo facet, never the geographic ones', () => { const facets = contentPieceCatalogFacets(cards) expect(facets.tipo).toEqual([{ value: 'card', label: 'Card' }]) expect(facets.cidade).toEqual([]) expect(facets.regiao).toEqual([]) expect(facets.instituicao).toEqual([]) expect(facets.tema).toEqual([]) }) it('appends the cards only to a non-empty Central', () => { expect(contentCatalogItems([], 0, cards)).toEqual([]) expect(contentCatalogItems([item()], 1, cards)).toHaveLength(7) expect(contentCatalogItems([item()], 1, cards).map((row) => isCardCatalogItem(row))).toEqual([ false, true, true, true, true, true, true, ]) }) it('alternates pieces and models, never a block of cards', () => { const label = (row: ContentCatalogItem) => (isCardCatalogItem(row) ? row.modelId : row.id) const first = item({ id: 1 }) const second = item({ id: 2, slug: 'outra-peca' }) expect(interleaveCatalogItems([first, second], cards.slice(0, 2)).map(label)).toEqual([ 1, 'eu-sou-solla', 2, 'perfil-quadrado', ]) // The remainder of the longer side keeps its own committed order. expect(interleaveCatalogItems([first], cards.slice(0, 2)).map(label)).toEqual([ 1, 'eu-sou-solla', 'perfil-quadrado', ]) expect(interleaveCatalogItems([first, second], []).map(label)).toEqual([1, 2]) }) }) describe('content piece theme terms and provenance (S28)', () => { it('drops empty terms, duplicates and the literal query itself', () => { expect(contentPieceThemeTerms('escala', ['', ' ', 'Escala', 'fim da escala 6x1'])).toEqual([ 'fim da escala 6x1', ]) expect(contentPieceThemeTerms('escala', ['FIM DA ESCALA', 'fim da escala'])).toEqual([ 'FIM DA ESCALA', ]) }) it('quotes the transcript window that carries the term', () => { const match = contentPieceThemeMatch(source({ searchText: 'a reducao da jornada e saude' }), [ 'redução da jornada', ]) expect(match?.term).toBe('redução da jornada') expect(match?.evidence?.quoted).toBe(true) expect(match?.evidence?.source).toBe('transcript') expect(match?.evidence?.parts.some((part) => part.highlighted)).toBe(true) }) it('falls back to the description window when the transcript does not carry it', () => { const match = contentPieceThemeMatch( source({ transcript: null, description: 'Solla explica a jornada de trabalho.', searchText: 'jornada de trabalho', }), ['jornada de trabalho'], ) expect(match?.evidence?.source).toBe('description') expect(match?.evidence?.quoted).toBe(true) }) it('shows a real passage without highlight when the match came from elsewhere', () => { const match = contentPieceThemeMatch( source({ searchText: 'camara dos deputados saude', description: 'Solla explica a jornada.' }), ['camara dos deputados'], ) expect(match?.term).toBe('camara dos deputados') expect(match?.evidence?.quoted).toBe(false) expect(match?.evidence?.source).toBe('description') expect(match?.evidence?.parts).toEqual([ { text: 'Solla explica a jornada.', highlighted: false }, ]) }) it('never claims a term the haystack does not carry', () => { expect(contentPieceThemeMatch(source(), ['escola sem partido'])).toBeNull() expect(contentPieceThemeMatch(source(), [])).toBeNull() }) it('annotates the public item only when the theme mode passes terms', () => { const themed = source({ searchText: 'a reducao da jornada e saude' }) const item = toContentPiecePublicItem(themed, { themeTerms: ['redução da jornada'] }) expect(item?.themeMatch?.term).toBe('redução da jornada') const literal = toContentPiecePublicItem(themed) expect(literal?.themeMatch).toBeNull() // The transcript itself never reaches the view model. expect(literal && 'transcript' in literal).toBe(false) }) }) describe('content piece public view model', () => { it('resolves the file piece with paths, duration and metadata line', () => { const view = item() expect(view.isLink).toBe(false) expect(view.typeLabel).toBe('Vídeo') expect(view.durationLabel).toBe('02:14') expect(view.metaLabel).toBe('Economia e Trabalho · Salvador') expect(view.publicPath).toBe('/conteudos/fim-da-escala-6x1') expect(view.file?.path).toBe('/conteudos/fim-da-escala-6x1/midia') expect(view.file?.mimeType).toBeNull() expect(view.file?.downloadFilename).toBe('jorge-solla-1313-fim-da-escala-6x1.mp4') }) it('resolves a link piece with the platform URL and no media', () => { const view = item({ origin: 'instagram', media: null, sourceUrl: 'https://www.instagram.com/reel/ABC/', transcript: null, }) expect(view.isLink).toBe(true) expect(view.originLabel).toBe('Instagram') expect(view.file).toBeNull() }) it('declares a frame only for a video with an archived file (C226)', () => { expect(item().framePath).toBe('/conteudos/fim-da-escala-6x1/frame') // Same decision as the card's own media kind, so a piece can never be asked // for a frame it cannot produce. expect(item({ media: { id: 7, mimeType: 'video/mp4' }, type: 'texto' }).framePath).toBe( '/conteudos/fim-da-escala-6x1/frame', ) // Everything else keeps the neutral slot. expect(item({ media: { id: 7, mimeType: 'audio/mpeg' }, type: 'audio' }).framePath).toBeNull() expect(item({ media: { id: 7, mimeType: 'image/png' }, type: 'foto' }).framePath).toBeNull() expect(item({ media: { id: 7, mimeType: 'text/plain' }, type: 'texto' }).framePath).toBeNull() expect( item({ media: { id: 7, mimeType: 'application/pdf' }, type: 'card' }).framePath, ).toBeNull() // Unknown MIME with a non-video editorial type: the type decides, as everywhere. expect(item({ media: { id: 7 }, type: 'foto' }).framePath).toBeNull() // A link piece never has an archived file, so it never has a frame. expect( item({ media: null, sourceUrl: 'https://www.instagram.com/reel/ABC/', type: 'video' }) .framePath, ).toBeNull() }) it('resolves the media kind from the stored MIME with a type fallback', () => { expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'video/mp4' } }))).toBe('video') expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'audio/mpeg' } }))).toBe('audio') expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'image/webp' } }))).toBe('image') expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'text/plain' } }))).toBe('text') expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'application/pdf' } }))).toBe( 'other', ) // No MIME stored: the editorial type decides. expect(contentPieceMediaKind(item({ media: { id: 7 } }))).toBe('video') expect(contentPieceMediaKind(item({ type: 'card', media: { id: 7 } }))).toBe('image') expect(contentPieceMediaKind(item({ media: null, sourceUrl: 'https://youtu.be/X' }))).toBeNull() }) it('maps the frame of the type, leaving the wide kinds without a class (S45)', () => { expect(contentPieceAspectClass('video')).toBe('aspect-[9/16]') expect(contentPieceAspectClass('image')).toBe('aspect-[4/5]') // The S38 model art is not a media kind: its host reads the table directly. expect(CONTENT_PIECE_ASPECT_CLASS.model).toBe('aspect-[3/4]') // Audio, link, text and other have no framing to preserve. for (const kind of ['audio', 'text', 'other', null] as const) { expect(contentPieceAspectClass(kind)).toBeUndefined() } }) it('buckets the board by type: vertical, wide or compact (S45)', () => { expect(contentPieceBoardBucket('video')).toBe('vertical') expect(contentPieceBoardBucket('audio')).toBe('wide') expect(contentPieceBoardBucket(null)).toBe('wide') for (const kind of ['image', 'text', 'other'] as const) { expect(contentPieceBoardBucket(kind)).toBe('compact') } }) it('leads the metadata line with who appears in the piece (S37)', () => { expect(item({ leaderNames: ['Maria Silva'] }).metaLabel).toBe( 'Maria Silva · Economia e Trabalho · Salvador', ) // A figure takes the slot when there is no leader name. expect(item({ publicFigures: ['Dep. Estadual Exemplo'] }).metaLabel).toBe( 'Dep. Estadual Exemplo · Economia e Trabalho · Salvador', ) // Nobody marked: the previous line stays byte-identical. expect(item().metaLabel).toBe('Economia e Trabalho · Salvador') }) it('unions leaders and figures as people options, deduped by display name (S37)', () => { expect( contentPiecePublicPeople({ leaderNames: ['Maria Silva'], publicFigures: ['Maria Silva', 'Dep. Estadual Exemplo'], }), ).toEqual([ { slug: 'maria-silva', name: 'Maria Silva' }, { slug: 'dep-estadual-exemplo', name: 'Dep. Estadual Exemplo' }, ]) expect(contentPiecePublicPeople({ leaderNames: [], publicFigures: [' '] })).toEqual([]) }) it('fails closed without a slug or without file and link', () => { expect(toContentPiecePublicItem(source({ slug: null }))).toBeNull() expect(toContentPiecePublicItem(source({ status: 'rascunho' }))).toBeNull() expect(toContentPiecePublicItem(source({ media: null }))).toBeNull() expect(toContentPiecePublicItem(source({ media: null, sourceUrl: null }))).toBeNull() expect( toContentPiecePublicItem(source({ media: null, sourceUrl: 'https://youtu.be/X' }))?.isLink, ).toBe(true) }) it('keeps the text excerpt short and the download name legible', () => { expect(contentPieceTextExcerpt('\n “Saúde é direito.” \nSegunda linha')).toBe( '“Saúde é direito.”', ) expect(contentPieceTextExcerpt('')).toBeNull() expect(contentPieceTextExcerpt('a'.repeat(200))?.endsWith('…')).toBe(true) expect(contentPieceDownloadFilename('card-feira', 'Card Final.PNG')).toBe( 'jorge-solla-1313-card-feira.png', ) expect(contentPieceDownloadFilename('sem-extensao', null)).toBe( 'jorge-solla-1313-sem-extensao.bin', ) expect(contentPiecePublicPath('x')).toBe('/conteudos/x') expect(contentPieceMediaPath('x')).toBe('/conteudos/x/midia') expect(contentPieceFramePath('x')).toBe('/conteudos/x/frame') }) })