From ddcc05d09a9bd3d13b85f516fc0d3c1008a1cc01 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 1 Oct 2026 18:50:26 -0300 Subject: [PATCH] =?UTF-8?q?feat(S45):=20cards=20verticais=20no=20cat=C3=A1?= =?UTF-8?q?logo,=20na=20home=20e=20na=20p=C3=A1gina=20da=20pe=C3=A7a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conteudos/(catalog)/loading.tsx | 25 +++--- .../conteudos/ContentCatalogCardItem.tsx | 13 +-- src/components/conteudos/ContentPieceCard.tsx | 80 +++++++++++++------ .../conteudos/ContentPieceCatalog.tsx | 46 +++++++---- .../conteudos/ContentPieceDetail.tsx | 13 ++- .../conteudos/ContentPieceHomeBoard.tsx | 4 +- .../conteudos/ContentPieceHomeCard.tsx | 12 ++- .../conteudos/ContentPieceMedia.tsx | 25 ++++-- .../conteudos/contentPieceClasses.ts | 2 +- src/lib/contentPieceCatalog.ts | 38 +++++++++ tests/e2e/frontendConteudos.e2e.spec.ts | 78 ++++++++++++++++-- tests/unit/contentPieceCatalog.unit.spec.ts | 23 ++++++ .../unit/contentPieceHomeBoard.unit.spec.tsx | 41 ++++++++++ tests/unit/contentPieceMedia.unit.spec.tsx | 4 + 14 files changed, 326 insertions(+), 78 deletions(-) diff --git a/src/app/(frontend)/conteudos/(catalog)/loading.tsx b/src/app/(frontend)/conteudos/(catalog)/loading.tsx index d12ae731..c8ec0516 100644 --- a/src/app/(frontend)/conteudos/(catalog)/loading.tsx +++ b/src/app/(frontend)/conteudos/(catalog)/loading.tsx @@ -1,23 +1,28 @@ /** * S27 — the loading state of the Central (artefato: cena 05): the skeleton * shape of the board, so a slow connection sees the page it is about to get. + * S45 — the vertical board shape (4 columns from `lg`; the dominant 9:16 video + * slot, an approximation of the mixed board) with the media edge-to-edge like + * the real card. */ export default function ConteudosLoading() { return (
-
-
- {[0, 1, 2].map((index) => ( +
+
+ {[0, 1, 2, 3].map((index) => (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
))} diff --git a/src/components/conteudos/ContentCatalogCardItem.tsx b/src/components/conteudos/ContentCatalogCardItem.tsx index 49eb8092..e0f6d42c 100644 --- a/src/components/conteudos/ContentCatalogCardItem.tsx +++ b/src/components/conteudos/ContentCatalogCardItem.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { sendCardOpeningEvent } from '@/lib/contentEvents' import { contentPieceTypeLabels } from '@/lib/contentPiece' -import type { CardCatalogItem } from '@/lib/contentPieceCatalog' +import { CONTENT_PIECE_ASPECT_CLASS, type CardCatalogItem } from '@/lib/contentPieceCatalog' import { cn } from '@/lib/utils' import { @@ -15,7 +15,7 @@ import { /** * S38 — the art of a model item (artefato: cenas 01–03): the model's real file - * cropped from the top (the banners and the name line live there), or the + * whole in the 3:4 vertical slot (S45 — the art is never cropped), or the * neutral dashed placeholder of the photo models, whose master is only the * frame with a transparent photo window. The `NOVO` badge of the newest model * sits on the art. @@ -23,7 +23,8 @@ import { const CardItemArt = ({ item }: { item: CardCatalogItem }) => ( ( src={item.art.src} alt="" fill - sizes="(min-width: 1024px) 360px, (min-width: 640px) 45vw, 128px" - className="object-cover object-top" + sizes="(min-width: 1024px) 260px, (min-width: 640px) 45vw, 112px" + className="object-contain" /> )} @@ -72,7 +73,7 @@ export const ContentCatalogCardItem = ({ item }: { item: CardCatalogItem }) => ( onClick={() => sendCardOpeningEvent(item.modelId)} className={cn( CONTENT_PIECE_CARD, - 'grid grid-cols-[128px_1fr] text-black no-underline sm:block', + 'grid grid-cols-[112px_minmax(0,1fr)] text-black no-underline sm:block', item.badge ? 'border-2 border-[#e4102f]' : null, CONTENT_PIECE_FOCUS, )} diff --git a/src/components/conteudos/ContentPieceCard.tsx b/src/components/conteudos/ContentPieceCard.tsx index ce33aeb6..e64556fb 100644 --- a/src/components/conteudos/ContentPieceCard.tsx +++ b/src/components/conteudos/ContentPieceCard.tsx @@ -1,6 +1,11 @@ import Link from 'next/link' -import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog' +import { + CONTENT_PIECE_ASPECT_CLASS, + contentPieceBoardBucket, + contentPieceMediaKind, + type ContentPiecePublicItem, +} from '@/lib/contentPieceCatalog' import { cn } from '@/lib/utils' import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions' @@ -84,8 +89,9 @@ const CardThemeEvidence = ({ item }: { item: ContentPiecePublicItem }) => { } /** - * S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video/audio/card - * and link pieces take the full card with the asset on top; a photo/text is the + * S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video takes the + * full card with the 9:16 frame on top, audio/link take the wide row (the + * media column at the left, no framing to preserve), and a photo/text is the * compact horizontal row. Actions work from the card itself — download never * needs the piece page. */ @@ -97,23 +103,25 @@ export const ContentPieceCard = ({ onShare, themeMode, }: ContentPieceCardProps) => { - const kind = contentPieceMediaKind(item) - const isCompact = kind === 'image' || kind === 'text' || kind === 'other' + const bucket = contentPieceBoardBucket(contentPieceMediaKind(item)) - if (isCompact) { + if (bucket === 'compact') { return (
+ {/* S45 — a fixed 96×120 (4:5) box: the compact row is a flex child, and + `aspect-ratio` on a flex item is the WebKit #265243 trap; the fixed + box keeps the whole photo, never a crop. */}
@@ -121,14 +129,49 @@ export const ContentPieceCard = ({
- - + +
) } + const body = ( +
+ + + + +
+ + +
+ + Ver detalhes da peça → + +
+ ) + + if (bucket === 'wide') { + return ( +
+ + {body} +
+ ) + } + return (
-
- - - - -
- - -
- - Ver detalhes da peça → - -
+ {body}
) } diff --git a/src/components/conteudos/ContentPieceCatalog.tsx b/src/components/conteudos/ContentPieceCatalog.tsx index 04a386e1..5f23ee85 100644 --- a/src/components/conteudos/ContentPieceCatalog.tsx +++ b/src/components/conteudos/ContentPieceCatalog.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { + contentPieceBoardBucket, contentPieceMediaKind, interleaveCatalogItems, isCardCatalogItem, @@ -16,14 +17,15 @@ import { ContentPieceCard } from './ContentPieceCard' import { ContentPieceShareSheet } from './ContentPieceShareSheet' /** - * S27 — the catalogue board (artefato: cena 01): full cards for video, audio - * and link pieces, compact rows for photo/text, one piece plays at a time - * (switching unmounts the previous element) and the share sheet is owned here, - * so every card opens the same vote message. + * S27 — the catalogue board (artefato: cena 01): the vertical board (video and + * the S38 models) runs four columns from `lg`, audio/link keep a wide two-column + * grid (no framing to preserve) and photo/text keep the compact scan row. One + * piece plays at a time (switching unmounts the previous element) and the share + * sheet is owned here, so every card opens the same vote message. * - * S38 — the six card models are items of the same board: they take the full-card - * bucket and the whole item is one link to the studio (`/cards?model=`); the - * old single invite tile is gone and no section groups them. + * S38 — the six card models are items of the same board: they take the vertical + * board bucket and the whole item is one link to the studio (`/cards?model=`); + * the old single invite tile is gone and no section groups them. */ export const ContentPieceCatalog = ({ items, @@ -36,7 +38,8 @@ export const ContentPieceCatalog = ({ const [playingId, setPlayingId] = useState(null) const [sharingItem, setSharingItem] = useState(null) - const largePieces: ContentPiecePublicItem[] = [] + const videoPieces: ContentPiecePublicItem[] = [] + const widePieces: ContentPiecePublicItem[] = [] const compactItems: ContentPiecePublicItem[] = [] const cards: CardCatalogItem[] = [] for (const item of items) { @@ -44,13 +47,14 @@ export const ContentPieceCatalog = ({ cards.push(item) continue } - const kind = contentPieceMediaKind(item) - if (kind === null || kind === 'video' || kind === 'audio') largePieces.push(item) + const bucket = contentPieceBoardBucket(contentPieceMediaKind(item)) + if (bucket === 'vertical') videoPieces.push(item) + else if (bucket === 'wide') widePieces.push(item) else compactItems.push(item) } - // S38 — the models alternate with the full-card pieces (never a block of - // their own); the compact rows keep the S27 board below. - const largeItems = interleaveCatalogItems(largePieces, cards) + // S38 — the models alternate with the video pieces (never a block of their + // own); the wide and compact rows keep the S27 board below. + const verticalItems = interleaveCatalogItems(videoPieces, cards) const cardProps = (item: ContentPiecePublicItem) => ({ item, @@ -63,9 +67,9 @@ export const ContentPieceCatalog = ({ return ( <> - {largeItems.length > 0 ? ( -
- {largeItems.map((item) => + {verticalItems.length > 0 ? ( +
+ {verticalItems.map((item) => isCardCatalogItem(item) ? ( ) : ( @@ -75,8 +79,16 @@ export const ContentPieceCatalog = ({
) : null} + {widePieces.length > 0 ? ( +
+ {widePieces.map((item) => ( + + ))} +
+ ) : null} + {compactItems.length > 0 ? ( -
+
{compactItems.map((item) => ( ))} diff --git a/src/components/conteudos/ContentPieceDetail.tsx b/src/components/conteudos/ContentPieceDetail.tsx index 733efada..610f9442 100644 --- a/src/components/conteudos/ContentPieceDetail.tsx +++ b/src/components/conteudos/ContentPieceDetail.tsx @@ -3,7 +3,11 @@ import { useEffect, useRef, useState } from 'react' import { sendContentPieceEvent } from '@/lib/contentEvents' -import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog' +import { + contentPieceAspectClass, + contentPieceMediaKind, + type ContentPiecePublicItem, +} from '@/lib/contentPieceCatalog' import { cn } from '@/lib/utils' import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions' @@ -50,14 +54,17 @@ export const ContentPieceDetail = ({ item }: { item: ContentPiecePublicItem }) = return ( <> -
+ {/* S45 — the piece page keeps the card's promise: the 360px media column + (artefato cena 05) and the frame of the type — Reel 9:16, photo 4:5, + wide kinds 16:9 — with the media whole, never cropped. */} +
setPlaying((current) => !current)} onEnded={() => setPlaying(false)} variant="detail" - className="aspect-video" + className={contentPieceAspectClass(contentPieceMediaKind(item)) ?? 'aspect-video'} />
diff --git a/src/components/conteudos/ContentPieceHomeBoard.tsx b/src/components/conteudos/ContentPieceHomeBoard.tsx index 92f013fb..84cde858 100644 --- a/src/components/conteudos/ContentPieceHomeBoard.tsx +++ b/src/components/conteudos/ContentPieceHomeBoard.tsx @@ -241,7 +241,9 @@ export const ContentPieceHomeBoard = ({
{selection.map(({ item, match }) => ( diff --git a/src/components/conteudos/ContentPieceHomeCard.tsx b/src/components/conteudos/ContentPieceHomeCard.tsx index e14bf24c..bca98450 100644 --- a/src/components/conteudos/ContentPieceHomeCard.tsx +++ b/src/components/conteudos/ContentPieceHomeCard.tsx @@ -1,6 +1,10 @@ import Link from 'next/link' -import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog' +import { + buildContentPieceCatalogHref, + contentPieceAspectClass, + contentPieceMediaKind, +} from '@/lib/contentPieceCatalog' import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection' import { slugify } from '@/lib/slug' import { cn } from '@/lib/utils' @@ -67,6 +71,10 @@ export const ContentPieceHomeCard = ({ }) => { const typeHref = item.isLink ? null : buildContentPieceCatalogHref({ tipo: item.type }) const matchFacetHref = matchHref(item, match) + // S45 — the thumb keeps the form of the type (photo 4:5, Reel 9:16); the + // wide kinds (audio/link/text) keep a short fixed column on the phone and the + // wide block from `sm` up, where the card becomes a full block. + const aspectClass = contentPieceAspectClass(contentPieceMediaKind(item)) return (
diff --git a/src/components/conteudos/ContentPieceMedia.tsx b/src/components/conteudos/ContentPieceMedia.tsx index 05861ad0..2cccbb04 100644 --- a/src/components/conteudos/ContentPieceMedia.tsx +++ b/src/components/conteudos/ContentPieceMedia.tsx @@ -50,7 +50,12 @@ const PlayButton = ({ aria-label={label} className={cn( 'absolute grid place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none', - compact ? 'size-11 sm:size-16' : 'size-14 sm:size-16', + // C243 — public light-surface focus: the yellow signal plus the dark blue + // outer separator, so the ring separates from the yellow button itself. + 'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)]', + // S45 — the approved play (artefato cena 07): 56px on the card, 44px on + // the compact thumb (44 → 56 from `sm`, where that card becomes a block). + compact ? 'size-11 sm:size-14' : 'size-14', )} > @@ -141,6 +146,7 @@ export const ContentPieceMedia = ({ if (kind === null) { return (
{item.originLabel} @@ -180,14 +188,17 @@ export const ContentPieceMedia = ({ if (kind === 'image') { return ( -
+
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */} {item.title}
) @@ -196,6 +207,7 @@ export const ContentPieceMedia = ({ if (kind === 'text') { return (
@@ -304,6 +318,7 @@ export const ContentPieceMedia = ({ return (
{ + if (kind === 'video' || kind === 'image') return CONTENT_PIECE_ASPECT_CLASS[kind] + return undefined +} + +export type ContentPieceBoardBucket = 'vertical' | 'wide' | 'compact' + +/** + * S45 — the bucket of a piece on the board (artefato cena 01): video and the + * S38 models share the vertical board, audio/link keep the wide row and + * image/text/other keep the compact scan row. One rule for the card and the + * catalogue grids. + */ +export const contentPieceBoardBucket = ( + kind: ContentPieceMediaKind | null, +): ContentPieceBoardBucket => { + if (kind === 'video') return 'vertical' + if (kind === 'audio' || kind === null) return 'wide' + return 'compact' +} + const DOWNLOAD_PREFIX = 'jorge-solla-1313' const DOWNLOAD_FALLBACK_EXTENSION = 'bin' diff --git a/tests/e2e/frontendConteudos.e2e.spec.ts b/tests/e2e/frontendConteudos.e2e.spec.ts index b5113f76..3a53ed44 100644 --- a/tests/e2e/frontendConteudos.e2e.spec.ts +++ b/tests/e2e/frontendConteudos.e2e.spec.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' import { join } from 'node:path' import type { APIRequestContext, Page } from '@playwright/test' @@ -46,9 +47,15 @@ const createdLeadershipIds: number[] = [] const uniqueMarker = () => randomUUID().slice(0, 8) -const mediaFor = (type: 'video' | 'foto' | 'texto') => { +const mediaFor = (type: 'video' | 'foto' | 'audio' | 'texto') => { if (type === 'video') return { mimetype: 'video/mp4', buffer: TEST_MP4, extension: 'mp4' } if (type === 'foto') return { mimetype: 'image/png', buffer: TEST_PNG, extension: 'png' } + if (type === 'audio') + return { + mimetype: 'audio/mpeg', + buffer: readFileSync(join(process.cwd(), 'tests/fixtures/jingle-tone.mp3')), + extension: 'mp3', + } return { mimetype: 'text/plain', buffer: Buffer.from('Peça voto pra Solla 1313.'), @@ -92,7 +99,8 @@ const createPiece = async ( publicFigures?: string[] }, ): Promise<{ id: number; slug: string }> => { - const mediaKind = data.type === 'video' || data.type === 'foto' ? data.type : 'texto' + const mediaKind = + data.type === 'video' || data.type === 'foto' || data.type === 'audio' ? data.type : 'texto' const mediaSpec = mediaFor(mediaKind) const media = data.withMedia === false @@ -279,8 +287,10 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { const headers = await adminHeaders(request, BASE_URL) const videoTitle = `Fim da escala ${uniqueMarker()}` const photoTitle = `Solla no SUS ${uniqueMarker()}` + const audioTitle = `Áudio da Central ${uniqueMarker()}` const video = await createPiece(request, headers, { title: videoTitle, type: 'video' }) const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' }) + const audio = await createPiece(request, headers, { title: audioTitle, type: 'audio' }) const media = mediaRequests(page) const frames = frameRequests(page) @@ -293,12 +303,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { page.getByRole('heading', { name: 'Uma mensagem sua pode conquistar mais um voto.' }), ).toBeVisible() await expect(page.getByText('Central de Conteúdos').first()).toBeVisible() - await expect(page.locator('article[data-content-piece]')).toHaveCount(2) + await expect(page.locator('article[data-content-piece]')).toHaveCount(3) // S38 — the six models are items of the same board: the card item and the // pieces share one grid (no section of their own, no invite tile). await expect(page.locator('[data-card-model]')).toHaveCount(6) - // The video piece shares the full-card grid with the models (the photo is a - // compact row below): the models are items of the board, not a section. + // S45 — the video shares the vertical board with the models (the photo is a + // compact row below and the audio keeps the wide row): the models are items + // of the board, not a section. const board = page.locator('[data-card-model]').first().locator('xpath=..') await expect(board.locator('article[data-content-piece]')).toHaveCount(1) await expect(board.locator('[data-card-model]')).toHaveCount(6) @@ -331,8 +342,29 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { .toBeGreaterThan(0) await expect(page.getByRole('button', { name: `Reproduzir ${videoTitle}` })).toBeVisible() + // S45 — every piece appears in the form of its type: the Reel keeps the + // 9:16 slot (the still whole, `contain`), the photo keeps the 96×120 (4:5) + // compact thumb, the audio keeps the wide row and the model art keeps 3:4. + const mediaSlot = (slug: string) => + page.locator(`article[data-content-piece="${slug}"] [data-content-piece-media]`) + const videoSlot = await mediaSlot(video.slug).boundingBox() + expect(videoSlot!.height / videoSlot!.width).toBeCloseTo(16 / 9, 2) + await expect(still).toHaveCSS('object-fit', 'contain') + const photoSlot = await mediaSlot(photo.slug).boundingBox() + expect(photoSlot!.width).toBeCloseTo(96, 0) + expect(photoSlot!.height).toBeCloseTo(120, 0) + await expect(mediaSlot(photo.slug).locator('img')).toHaveCSS('object-fit', 'contain') + const audioSlot = await mediaSlot(audio.slug).boundingBox() + expect(audioSlot!.width).toBeCloseTo(190, 0) + expect(audioSlot!.height).toBeGreaterThanOrEqual(156) + const modelSlot = await page + .locator('[data-card-model="time-de-voce"] > span') + .first() + .boundingBox() + expect(modelSlot!.height / modelSlot!.width).toBeCloseTo(4 / 3, 2) + // Play mounts only the tapped piece and fetches only its media. The still - // leaves the slot: the player takes the same 16:9 box (cena 03). + // leaves the slot: the player takes the same 9:16 box (cena 06). await page.getByRole('button', { name: `Reproduzir ${videoTitle}` }).click() await expect(page.locator('video')).toHaveCount(1) await expect.poll(() => media.filter((url) => url.includes(video.slug)).length).toBe(1) @@ -368,6 +400,17 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await sheet.getByRole('button', { name: 'Fechar', exact: true }).click() await expect(sheet).toHaveCount(0) + + // S45 — the vertical board never overflows the phone (cena 02). + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/conteudos') + await waitForSettledPage(page) + await expect(page.locator('article[data-content-piece]')).toHaveCount(3) + const overflow = await page.evaluate(() => { + const container = document.querySelector('[data-theme="campaign-site"]') + return container ? container.scrollWidth - container.clientWidth : 0 + }) + expect(overflow).toBeLessThanOrEqual(1) }) test('filters by facet and term and shows the honest no-results state', async ({ @@ -378,8 +421,9 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await unpublishEveryPiece(request, headers) const marker = uniqueMarker() const videoTitle = `Debate na rádio ${marker}` + const photoTitle = `Card do giro ${marker}` const video = await createPiece(request, headers, { title: videoTitle, type: 'video' }) - await createPiece(request, headers, { title: `Card do giro ${marker}`, type: 'foto' }) + const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' }) await page.goto(`/conteudos?tipo=video`) await waitForSettledPage(page) @@ -410,6 +454,19 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await expect(page).toHaveURL(new RegExp(`/conteudos/${video.slug}$`)) await expect(page.getByRole('heading', { name: videoTitle })).toBeVisible() await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible() + + // S45 — the piece page keeps the card's promise: the 9:16 stage in the + // 360px media column (cena 05), never wider than the text. + const stage = await page.locator('[data-content-piece-media]').boundingBox() + expect(stage!.width).toBeCloseTo(360, 0) + expect(stage!.height / stage!.width).toBeCloseTo(16 / 9, 2) + + // The photo keeps the 4:5 stage on its own page. + await page.goto(`/conteudos/${photo.slug}`) + await expect(page.getByRole('heading', { name: photoTitle })).toBeVisible() + const photoStage = await page.locator('[data-content-piece-media]').boundingBox() + expect(photoStage!.width).toBeCloseTo(360, 0) + expect(photoStage!.height / photoStage!.width).toBeCloseTo(5 / 4, 2) }) test('lists the card models by nickname, filters by Tipo Card and routes to the studio (S38)', async ({ @@ -756,6 +813,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await expect(section.getByText('Mais recente', { exact: true })).toHaveCount(2) await expect(section.getByRole('link', { name: 'Mais recente' })).toHaveCount(0) + // S45 — the 124px phone thumb keeps the form of the type: photo 4:5, Reel 9:16. + const photoBox = await photoCard.locator('[data-content-piece-media]').boundingBox() + expect(photoBox!.width).toBeCloseTo(124, 0) + expect(photoBox!.height / photoBox!.width).toBeCloseTo(5 / 4, 2) + const videoBox = await videoCard.locator('[data-content-piece-media]').boundingBox() + expect(videoBox!.height / videoBox!.width).toBeCloseTo(16 / 9, 2) + // S42 — the card shares through the same S27 sheet, without leaving the home. await section.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click() const homeSheet = page.getByRole('dialog', { name: 'Compartilhar peça' }) diff --git a/tests/unit/contentPieceCatalog.unit.spec.ts b/tests/unit/contentPieceCatalog.unit.spec.ts index 3d180581..d9958878 100644 --- a/tests/unit/contentPieceCatalog.unit.spec.ts +++ b/tests/unit/contentPieceCatalog.unit.spec.ts @@ -5,7 +5,10 @@ import { describe, expect, it } from 'vitest' import { buildContentPieceCatalogHref, cardCatalogItems, + CONTENT_PIECE_ASPECT_CLASS, contentCatalogItems, + contentPieceAspectClass, + contentPieceBoardBucket, contentPieceCatalogActiveFilters, contentPieceCatalogFacets, contentPieceDownloadFilename, @@ -525,6 +528,26 @@ describe('content piece public view model', () => { 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', diff --git a/tests/unit/contentPieceHomeBoard.unit.spec.tsx b/tests/unit/contentPieceHomeBoard.unit.spec.tsx index e4b97682..664d67a8 100644 --- a/tests/unit/contentPieceHomeBoard.unit.spec.tsx +++ b/tests/unit/contentPieceHomeBoard.unit.spec.tsx @@ -256,6 +256,47 @@ describe('ContentPieceHomeBoard', () => { await waitFor(() => expect(document.querySelector('video')).toBeNull()) }) + it('keeps the frame of the type on the sample card (S45)', async () => { + readPermission.mockResolvedValue('denied') + const photo = item(1) + const video = item(2, { + title: 'Vídeo da home', + type: 'video', + typeLabel: 'Vídeo', + file: { + id: 102, + path: '/conteudos/peca-2/midia', + mimeType: 'video/mp4', + downloadFilename: 'peca-2.mp4', + }, + }) + const audio = item(3, { + title: 'Áudio da home', + type: 'audio', + typeLabel: 'Áudio', + file: { + id: 103, + path: '/conteudos/peca-3/midia', + mimeType: 'audio/mpeg', + downloadFilename: 'peca-3.mp3', + }, + }) + + renderBoard([photo, video, audio]) + await waitFor(() => expect(readPermission).toHaveBeenCalled()) + + const slot = (slug: string) => + document.querySelector( + `article[data-content-piece="${slug}"] [data-content-piece-media]`, + ) + + // Photo keeps 4:5 and the Reel keeps 9:16; the wide kinds stay short on the + // phone and take the wide block from `sm` up. + expect(slot('peca-1')?.className).toContain('aspect-[4/5]') + expect(slot('peca-2')?.className).toContain('aspect-[9/16]') + expect(slot('peca-3')?.className).toContain('max-sm:h-[156px]') + }) + it('keeps the recent selection when the position fails, without an error or a retry', async () => { readPermission.mockResolvedValue('granted') readPosition.mockResolvedValue({ ok: false, reason: 'timeout' }) diff --git a/tests/unit/contentPieceMedia.unit.spec.tsx b/tests/unit/contentPieceMedia.unit.spec.tsx index 0889b64b..f88f0810 100644 --- a/tests/unit/contentPieceMedia.unit.spec.tsx +++ b/tests/unit/contentPieceMedia.unit.spec.tsx @@ -72,6 +72,8 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => { renderSlot({}) expect(frameImage()).not.toBeNull() + // S45 — the still shows whole in the slot, never cropped. + expect(frameImage()?.className).toContain('object-contain') expect(document.querySelector('video')).toBeNull() expect(document.querySelector('audio')).toBeNull() // S27: the element that would fetch the file only exists after the tap. @@ -246,5 +248,7 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => { expect(document.querySelectorAll('img')).toHaveLength(1) expect(document.querySelector('img')?.getAttribute('src')).toBe('/conteudos/peca-7/midia') + // S45 — the photo shows whole in its 4:5 slot, never cropped. + expect(document.querySelector('img')?.className).toContain('object-contain') }) })