diff --git a/.agents/rules/codebase-map.mdc b/.agents/rules/codebase-map.mdc index 2fbc4007..cd96e027 100644 --- a/.agents/rules/codebase-map.mdc +++ b/.agents/rules/codebase-map.mdc @@ -22,7 +22,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se - `src/components/campaign//` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos). - `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`/`cardCutout.ts`/`cardPhotoHarmonyCanvas.ts` (S17). Geometria/catálogo/tom puros em `src/lib/card{Models,NameFit,PhotoTransform,Render,Harmony}.ts`. - `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona). -- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`). +- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`) e do roster estático das 53 dobradinhas (`stateDeputyCatalog`, S30 — snapshot em `tests/fixtures/state-deputy-catalog.snapshot.json`; artes derivadas por `scripts/build-state-deputy-card-assets.mjs`). - `src/migrations/` — história congelada; nunca editar retroativamente. - `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`). diff --git a/docs/changelog/2026-09-23-s30-card-time-do-estadual.md b/docs/changelog/2026-09-23-s30-card-time-do-estadual.md new file mode 100644 index 00000000..a3c9b0c5 --- /dev/null +++ b/docs/changelog/2026-09-23-s30-card-time-do-estadual.md @@ -0,0 +1 @@ +**S30 — Card "Time do estadual": quinto modelo do funil público, com as 53 dobradinhas (2026-09-23):** o estúdio de cards (`#cards` da home + `/cards` + `?model=time-do-estadual`) ganha o quinto modelo no MESMO composer: o visitante escolhe um dos 53 estaduais, digita como é chamado e envia a foto de busto; o recorte roda no aparelho (esteira do S15 inteira: envio, progresso/retry, harmonização, pan/zoom) e o card final é `TIME DE ` com a arte oficial do escolhido, na ordem medida FOTOS do estadual → recorte na janela `{286,439,592,577}` → BASE do estadual → banners (`TEAM_CARD_LABEL` + banner azul dinâmico do S16). Catálogo estático derivado dos lockups da arte (`src/lib/stateDeputyCatalog.ts`: slug, nome de exibição, número de 5 dígitos e os dois caminhos de asset; 53 entradas imutáveis com snapshot `tests/fixtures/state-deputy-catalog.snapshot.json` + sha256 e busca por nome sem acento ou pela pasta entregue), com os 106 derivativos WebP otimizados (~6,8MB, q82) commitados em `public/cards/estaduais/-{fotos,base}.webp` — re-deriváveis por `pnpm build:state-deputy-card-assets -- --from ` (sharp, falha fechado em par ausente/duplicado/dimensão fora de 1080×1440) — e o jpeg exato aprovado pelo humano copiado byte-a-byte para o tile/placeholder (`/cards/modelo-time-de-voce-com-estadual.jpeg`). Seletor inline portado do gate (trigger com nome+número+"Trocar", painel aberto por padrão sem escolha, busca, lista rolável `role="listbox"`, ↑↓/Enter, seleção por linha inteira com check); pré-escolha mostra a arte de exemplo, pós-escolha sem foto desenha a silhueta do visitante (`#001a42`, geometria do design normalizada na janela) e trocar de estadual não perde o nome nem o recorte (par de arte em estado próprio, sem flicker; harmonia congelada na referência do recorte). Galeria: 5 tiles numa fileira no desktop (`lg:grid-cols-5`), 5 dots no mobile seguindo o scroll e dica "Deslize para ver os cinco modelos"; o badge `NOVO` passa para o modelo novo. Shell do dialog endurecido (`flex flex-col` sobre o `grid` do kit) para o corpo rolar e o rodapé ficar pinado com o seletor aberto. Sem migration, sem collection/CMS, sem Consent, sem segundo cadastro: roster e artes são estáticos, 100% no aparelho e nenhum byte da foto sai. Verificação: unit (catálogo 53/snapshot/sha256/dimensões dos 106 arquivos via sharp, silhueta/`subject` no recorder falso, pins do 5º modelo), `pnpm gate:fast` e e2e do `frontend` (35 verdes: seleção por busca/teclado, fluxo completo com download real do PNG, troca preservando nome/recorte, dots mobile). diff --git a/docs/plans/cards-estadual-dobradinha-impl.md b/docs/plans/cards-estadual-dobradinha-impl.md new file mode 100644 index 00000000..a2d61869 --- /dev/null +++ b/docs/plans/cards-estadual-dobradinha-impl.md @@ -0,0 +1,184 @@ +# Impl: Card personalizado com o seu estadual (dobradinha) + +Status: aprovado (modo --auto) +Atualizado em: 2026-09-23 +Issue: #1271 +Intenção: docs/plans/cards-estadual-dobradinha.md +Appetite restante: herdado — ~1–1,5 dia eng (3 fases somam ≤1,3 dia; sem migration; o único peso novo é o commit dos ~8MB de derivativos) + +## Leitura da intenção + +- **Outcome:** o estúdio de cards ganha o quinto modelo `Time do estadual`: o visitante escolhe um dos 53 estaduais, digita como é chamado e envia a foto de busto; o recorte roda no aparelho e o card final é o `TIME DE ` com a arte oficial do estadual (FOTOS + visitante na janela + BASE + banners), baixável em PNG — sem conta e sem nenhum byte da foto saindo do aparelho. +- **O que NÃO negociar:** + - 100% client-side: sem upload, sem persistência, sem analytics de PII; nome/foto nunca saem do aparelho; os quatro modelos existentes (e suas artes) intocados além do badge. + - Um único editor/fluxo: quinto item do catálogo no MESMO `CardsStudio`/`CardComposer`; `/cards` e `?model=time-do-estadual` continuam. + - Fluxo de foto do S15 inteiro (envio, recorte no aparelho com progresso/retry, harmonização, arrasto/zoom); nenhum controle novo nasce aqui; geometria literal do S15: janela `{286,439,592,577}`. + - Lista dos 53 navegável por toque e teclado no celular; trocar de estadual não perde o nome nem a foto já recortada. + - Nome em uma linha com `fitCardName`/banner azul do S16; fora do cap mínimo → pedir nome mais curto, nunca corte silencioso. + - Catálogo derivado da arte (nome completo + número de urna), imutável e commitado; nenhum schema/migration/CMS; nenhuma leitura de `stateDeputy`/`Contact`. +- **O que reavaliar (hipóteses da intenção):** + - **“Catálogo estático no padrão do `municipalityCatalog`”**: confirmado — `src/lib/stateDeputyCatalog.ts` client-safe + snapshot + sha256; os números saem dos lockups da arte (a base não tem número 2026). + - **“O composer ramifica por kind”**: para não mexer no discriminante dos 4 modelos, a ramificação nova é uma flag (`stateDeputyPicker`) na 5ª entrada `kind:'team'` — registro na Decisão 1. + - **“A arte de exemplo por estadual segue a receita”**: o tile e o placeholder pré-escolha usam o arquivo exato aprovado pelo humano (jpeg), copiado byte-a-byte; só a arte escolhida compõe o card. + +## Abordagem recomendada + +```mermaid +flowchart LR + H["home #cards / rota /cards"] --> S["CardsStudio — ilha única"] + S --> G["CardModelGallery — 5 tiles + dots (mobile)"] + G -->|"tile Time do estadual"| C["CardComposer — editor único"] + C --> SEL["StateDeputySelect — painel inline dos 53"] + SEL --> CAT["stateDeputyCatalog — tabela imutável + snapshot"] + CAT --> AST["public/cards/estaduais/-{fotos,base}.webp"] + C -->|"estadual, sem foto"| SIL["renderTeamCard — subject silhouette"] + C -->|"foto recortada"| RT["renderTeamCard — subject photo"] + AST --> RT + AST --> SIL + SIL --> PV["prévia canvas"] + RT --> PV + PV --> DL["Baixar PNG no aparelho"] + SRC["build-state-deputy-card-assets.mjs --from "] -.->|"deriva e commita"| AST +``` + +**Opções consideradas:** A) estender o estúdio existente com o quinto modelo + catálogo estático dos 53 + seletor inline no mesmo composer | B) rota/editor novo por estadual | C) roster em CMS/migration com assets dinâmicos. +**Recomendação:** A — o outcome é um modelo no estúdio existente; catálogo estático + derivativos commitados mantêm 100% no aparelho, dispensam schema e reusam a esteira do S15 (recorte, harmonização, banners, download); cabe no appetite. +**Rejeitadas:** B porque é o anti-goal "segundo editor"/rota por estadual e duplicaria shells/estado; C porque schema/migration está fora do escopo, a base não tem número de 2026 e o catálogo deve ser derivado da arte. + +### Decisões de engenharia + +1. **Quinto modelo — flag na entrada `kind:'team'`, sem novo `kind`.** + Opções: A) 5ª entrada `time-do-estadual` (`kind:'team'`) + flag nova `stateDeputyPicker: true` ramificando o composer | B) novo valor de `kind` (ex. `'team-state'`) | C) 53 entradas no `CARD_MODELS` (uma por estadual) | D) editor/render dedicado por id. + Recomendação: A — o fluxo é idêntico ao time (mesma janela, esteira de recorte, harmonização e banners); a flag marca só o eixo novo (seletor + arte variável) e deixa `isCardModelId`/`?model=` cobrirem o id de graça. `assetSrc`/`overlaySrc` default = par do JULIO (`/cards/estaduais/julio-fotos.webp` + `julio-base.webp`), `previewSrc` = jpeg aprovado; `photoWindow` idêntico ao S15; `badge: 'NOVO'` move de `time-de-voce` para o novo (design cena 1). + Rejeitadas: B porque ramifica o discriminante usado pelos 4 modelos para algo que a flag expressa; C porque o estadual viraria "modelo" (galeria/pins/URL explodem) e contraria o anti-goal; D porque duplica composer/render. + +2. **Catálogo e assets — módulo puro + derivativos WebP commitados + script re-derivável.** + Opções: A) `src/lib/stateDeputyCatalog.ts` (client-safe, sem imports de `utilities/`) com `stateDeputyCatalog`/`getStateDeputyCard`/`filterStateDeputyCards`, snapshot `tests/fixtures/state-deputy-catalog.snapshot.json` (padrão `municipalityCatalog`), 106 WebP em `public/cards/estaduais/-{fotos,base}.webp` gerados por `scripts/build-state-deputy-card-assets.mjs --from ` | B) collection Payload/migration | C) commitar os PNG originais (~569MB) | D) importar do diretório de origem no build/runtime. + Recomendação: A — lib pura como o resto de `src/lib`; q≈82 (medido: FOTOS≈95KB + BASE≈51KB por estadual → ~8MB no total) porque o canvas carrega a URL crua (fora do otimizador do `next/image`) e o deploy não pode depender de diretório externo (escada de cache #3: artefato commitado via script re-executável); o script pareia `FOTOS*`/`BASE*` case-insensitive por pasta, aplica `slugify` no nome e falha se faltar par ou se a dimensão não for 1080×1440. + Rejeitadas: B (fora de escopo; sem número 2026 na base); C (peso no repo/imagem sem ganho); D (deploy refém de pasta local e fora da escada de cache). + +3. **Seletor — componente novo com painel inline do design; Base UI Combobox rejeitado.** + Opções: A) `src/components/cards/StateDeputySelect.tsx` portando a cena 3 (trigger h-11 com nome+número+"Trocar" ou placeholder, painel inline aberto por default sem escolha, busca, lista rolável `max-h-52` com `role="listbox"`/opções, teclado ↑↓ Enter, seleção por linha inteira com check) | B) `src/components/ui/combobox.tsx` (Base UI, já no bundle público) | C) ` { + setQuery(event.target.value) + setActiveIndex(0) + listRef.current?.scrollTo({ top: 0 }) + }} + onKeyDown={handleSearchKeyDown} + className="min-w-0 flex-1 bg-transparent text-sm text-(--campaign-ink) outline-none placeholder:text-(--campaign-muted)" + /> + +

+ {stateDeputyCatalog.length} estaduais · use ↑ ↓ e Enter para escolher +

+ + +
+ {results.map((card, index) => { + const isSelected = selected?.slug === card.slug + const isActive = index === activeIndex + + return ( + + ) + })} +
+ + {results.length === 0 ? ( +

+ Nenhum estadual encontrado. +

+ ) : null} + + ) : null} + + ) +} diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts index f89aa251..d0f43847 100644 --- a/src/lib/cardModels.ts +++ b/src/lib/cardModels.ts @@ -16,6 +16,7 @@ const CARD_MODEL_IDS = [ 'perfil-quadrado', 'perfil-retangular', 'time-de-voce', + 'time-do-estadual', ] as const export type CardModelId = (typeof CARD_MODEL_IDS)[number] @@ -41,6 +42,12 @@ export type CardModel = { overlaySrc?: string /** Team model: the filled example shown before a cutout is ready. */ previewSrc?: string + /** + * S30 — team model whose art is picked per state deputy: the composer swaps + * `assetSrc`/`overlaySrc` for the selected catalog entry (the defaults are the + * illustrative JULIO pair) and gates the flow on the selection. + */ + stateDeputyPicker?: boolean /** Optional gallery badge (e.g. `NOVO`). */ badge?: string } @@ -79,6 +86,22 @@ export const CARD_MODELS: readonly CardModel[] = [ assetSrc: '/cards/team-card-base.png', overlaySrc: '/cards/team-card-front.png', previewSrc: '/cards/team-card-example.jpg', + width: 1080, + height: 1440, + photoWindow: { x: 286, y: 439, width: 592, height: 577 }, + }, + { + id: 'time-do-estadual', + kind: 'team', + label: 'Time do estadual', + // S30 — the defaults are the illustrative pair (JULIO, the deputy of the + // approved example art); the composer swaps them for the selected entry of + // `stateDeputyCatalog`. `previewSrc` is the exact file approved by the + // human for the gallery tile and the pre-selection placeholder. + assetSrc: '/cards/estaduais/julio-fotos.webp', + overlaySrc: '/cards/estaduais/julio-base.webp', + previewSrc: '/cards/modelo-time-de-voce-com-estadual.jpeg', + stateDeputyPicker: true, badge: 'NOVO', width: 1080, height: 1440, diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts index 9fe7e9a4..1e955779 100644 --- a/src/lib/cardRender.ts +++ b/src/lib/cardRender.ts @@ -45,6 +45,19 @@ export type CardDrawContext = { restore(): void translate(x: number, y: number): void rotate(angle: number): void + beginPath(): void + arc(x: number, y: number, radius: number, startAngle: number, endAngle: number): void + ellipse( + x: number, + y: number, + radiusX: number, + radiusY: number, + rotation: number, + startAngle: number, + endAngle: number, + ): void + rect(x: number, y: number, width: number, height: number): void + fill(): void } export const createCardMeasure = ( @@ -205,12 +218,54 @@ export const renderPhotoCard = ( return clampCardPhotoTransform(args.transform, args.photoSize, args.window) } +/** S30 — silhouette tone ported from the design gate's visitor placeholder. */ +export const CARD_VISITOR_SILHOUETTE_FILL = '#001a42' + +/** + * S30 — the pre-photo state of the state-deputy model: the visitor placeholder + * of the design gate drawn over the deputy art (head circle + shoulder dome), + * normalized on the photo window so every art uses the same ruler. The shape + * mirrors the design's CSS (head = 34% of the window width at the top; shoulders + * = full width, 72% of the height with their base 3% past the window bottom, so + * the front overlay covers the seam). The gate's small bottom corner radius + * (10%) is deliberately omitted: it falls entirely behind the front overlay. + */ +export const drawCardVisitorSilhouette = (ctx: CardDrawContext, window: CardRect): void => { + const centerX = window.x + window.width / 2 + const headRadius = window.width * 0.17 + const shoulderHeight = window.height * 0.72 + const shoulderBottom = window.y + window.height * 1.03 + const shoulderTop = shoulderBottom - shoulderHeight + const domeRadiusX = window.width * 0.48 + const domeRadiusY = shoulderHeight * 0.48 + const bodyTop = shoulderTop + domeRadiusY + + ctx.fillStyle = CARD_VISITOR_SILHOUETTE_FILL + + ctx.beginPath() + ctx.arc(centerX, window.y + headRadius, headRadius, 0, Math.PI * 2) + ctx.fill() + + ctx.beginPath() + ctx.rect(window.x, bodyTop, window.width, shoulderBottom - bodyTop) + ctx.ellipse(centerX, bodyTop, domeRadiusX, domeRadiusY, 0, Math.PI, 0) + ctx.fill() +} + +/** S30 — the foreground subject of a team composition. */ +export type TeamCardSubject = + | { + kind: 'photo' + photo: CanvasImageSource + photoSize: CardPhotoSize + transform: CardPhotoTransform + } + | { kind: 'silhouette' } + export type TeamCardRenderArgs = { base: CanvasImageSource overlay: CanvasImageSource - photo: CanvasImageSource - photoSize: CardPhotoSize - transform: CardPhotoTransform + subject: TeamCardSubject window: CardRect name: string fontFamily: string @@ -220,12 +275,14 @@ export type TeamCardRenderArgs = { export type TeamCardRenderResult = { fit: CardNameFit - transform: CardPhotoTransform + /** The clamped photo transform; `null` for the silhouette subject. */ + transform: CardPhotoTransform | null } /** - * S15 — the team card composition, in the measured order: master base → cutout - * photo (framed by the transform) → master front overlay → `TIME DE` banner → + * S15/S30 — the team card composition, in the measured order: master base → + * foreground subject (the framed cutout photo, or the visitor silhouette of the + * state-deputy pre-photo state) → master front overlay → `TIME DE` banner → * blue name banner (only when the name fits; never cut silently). The two * banners are drawn by the renderer because neither master carries them. */ @@ -236,8 +293,14 @@ export const renderTeamCard = ( ): TeamCardRenderResult => { ctx.drawImage(args.base, 0, 0, model.width, model.height) - const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window) - ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height) + let transform: CardPhotoTransform | null = null + if (args.subject.kind === 'photo') { + const rect = cardPhotoDrawRect(args.subject.transform, args.subject.photoSize, args.window) + ctx.drawImage(args.subject.photo, rect.x, rect.y, rect.width, rect.height) + transform = clampCardPhotoTransform(args.subject.transform, args.subject.photoSize, args.window) + } else { + drawCardVisitorSilhouette(ctx, args.window) + } ctx.drawImage(args.overlay, 0, 0, model.width, model.height) @@ -255,8 +318,5 @@ export const renderTeamCard = ( const fit = fitCardName(args.name, args.measure, slot) if (fit.ok) drawCardName(ctx, { fit, fontFamily: args.fontFamily, slot }) - return { - fit, - transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window), - } + return { fit, transform } } diff --git a/src/lib/stateDeputyCatalog.ts b/src/lib/stateDeputyCatalog.ts new file mode 100644 index 00000000..1c478a96 --- /dev/null +++ b/src/lib/stateDeputyCatalog.ts @@ -0,0 +1,122 @@ +/** + * Static catalog of the 53 state-deputy cards ("dobradinhas") of the campaign + * site (S30) — the roster behind the `time-do-estadual` card model. The table + * is derived from the official artwork delivered by the campaign: the display + * name and the ballot number come from the lockup baked into each front art + * (the campaign database has no 2026 ballot number), and the slugs come from + * the delivered folder names. + * + * It is committed as an immutable table: the slug and the two asset paths are + * the public contract of the model, and the selector shows the name + number. + * The originals never enter the repo; `public/cards/estaduais/-{fotos,base}.webp` + * are the optimized derivatives produced (and re-producible) by + * `scripts/build-state-deputy-card-assets.mjs --from `. + * + * Stability is guarded by tests/fixtures/state-deputy-catalog.snapshot.json. + */ + +export interface StateDeputyCatalogEntry { + /** Canonical immutable slug — asset path segment and selector identity. */ + readonly slug: string + /** Display name from the lockup, e.g. "Julio Pinheiro". */ + readonly name: string + /** Five-digit ballot number from the lockup, digits only (e.g. "13999"). */ + readonly ballotNumber: string + /** Background art (group + the deputy) drawn under the visitor's cutout. */ + readonly photosSrc: string + /** Front overlay art (band with the deputy's lockup) drawn above it. */ + readonly baseSrc: string +} + +const ESTADUAL_ASSETS_DIR = '/cards/estaduais' + +const entry = (slug: string, name: string, ballotNumber: string): StateDeputyCatalogEntry => ({ + slug, + name, + ballotNumber, + photosSrc: `${ESTADUAL_ASSETS_DIR}/${slug}-fotos.webp`, + baseSrc: `${ESTADUAL_ASSETS_DIR}/${slug}-base.webp`, +}) + +/** Alphabetical by delivered folder name (stable order, never a ranking). */ +export const stateDeputyCatalog: readonly StateDeputyCatalogEntry[] = [ + entry('adriana', 'Coletivo de Enfermagem', '13763'), + entry('andre', 'André Fidalgo', '13131'), + entry('andrea', 'Andréa Castro', '55670'), + entry('angelo', 'Angelo Almeida', '13100'), + entry('arthur', 'Artur Barachisio Lisbôa', '55255'), + entry('bobo', 'Bobô', '65888'), + entry('carol', 'Carol da Pitanguinha', '13110'), + entry('cicero', 'Cícero Monteiro', '70133'), + entry('denise', 'Denise Menezes', '55111'), + entry('elane', 'Dra. Elaine', '55100'), + entry('euclides', 'Euclides Fernandes', '13123'), + entry('fabiola', 'Dra. Fabíola Mansur', '43123'), + entry('fatima', 'Fátima Nunes', '13567'), + entry('felipe', 'Felipe Duarte', '70111'), + entry('geane', 'Geane Vasconcelos', '12120'), + entry('hilton', 'Hilton', '50150'), + entry('ivana', 'Ivana Bastos', '55555'), + entry('jaco', 'Jacó', '13130'), + entry('jamille', 'Jamile da Saúde', '45456'), + entry('josafa', 'Josafá Marinho', '12222'), + entry('juliete', 'Juliete Barreto', '65100'), + entry('julio', 'Julio Pinheiro', '13999'), + entry('junior', 'Júnior Muniz', '13000'), + entry('kleber', 'Kleber Rosa', '50500'), + entry('leninha', 'Lenínha Valente', '13111'), + entry('leo', 'Leo de Neco', '70700'), + entry('ludmilla', 'Ludmilla', '55444'), + entry('magno', 'Magno', '18000'), + entry('marcos', 'Dr. Marcos Adriano', '12999'), + entry('marlene', 'Marlene do Sindicato', '18111'), + entry('mestre', 'Mestre Reginaldo', '18273'), + entry('neusa', 'Neusa Cadore', '13690'), + entry('niltinho', 'Niltinho', '55999'), + entry('osni', 'Osni', '13013'), + entry('pablo', 'Pablo Barrozo', '70900'), + entry('patrick', 'Patrick Lopes', '70123'), + entry('pinheiro', 'Pinheiro', '13700'), + entry('radiovaldo', 'Radiovaldo', '13050'), + entry('roberto', 'Roberto Carlos', '43333'), + entry('robinson', 'Robinson', '13500'), + entry('rogerio', 'Rogério Andrade', '15444'), + entry('rosemberg', 'Rosemberg', '13444'), + entry('rosival', 'Rosival Leite', '13333'), + entry('rowenna', 'Rowenna', '13456'), + entry('silva', 'Silva Neto', '70444'), + entry('silvio', 'Silvio Dias', '13113'), + entry('thiago', 'Thiago Gilleno', '10000'), + entry('vilma', 'Vilma Reis', '13033'), + entry('vitor', 'Vitor Azevedo', '70333'), + entry('welligton', 'Wellington Oliveira', '40888'), + entry('wenceslau', 'Wenceslau', '65111'), + entry('yulo', 'Yulo', '13234'), + entry('ze-raimundo', 'Zé Raimundo', '13222'), +] + +const catalogBySlug = new Map(stateDeputyCatalog.map((card) => [card.slug, card])) + +export const getStateDeputyCard = (slug: string): StateDeputyCatalogEntry | undefined => + catalogBySlug.get(slug) + +const normalize = (value: string): string => + value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + +/** + * Accent/case-insensitive search by display name; an empty query keeps all. + * The delivered folder name (the slug) is also accepted as a keyword, so + * `adriana` finds the entry whose art shows "Coletivo de Enfermagem". + */ +export const filterStateDeputyCards = (query: string): readonly StateDeputyCatalogEntry[] => { + const needle = normalize(query) + if (!needle) return stateDeputyCatalog + + return stateDeputyCatalog.filter( + (card) => normalize(card.name).includes(needle) || normalize(card.slug).includes(needle), + ) +} diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 8648f8f9..27713365 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1783,14 +1783,21 @@ test.describe('Cards personalizados (S14)', () => { await expect( section.getByRole('heading', { name: 'Mostre que você está com Solla' }), ).toBeVisible() - // S14/S15 — the CTA is gone: the four models are the only trigger. + // S14/S15/S30 — the CTA is gone: the five models are the only trigger. await expect(section.getByRole('link', { name: 'Criar meu card' })).toHaveCount(0) - for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular', 'time-de-voce']) { + for (const model of [ + 'eu-sou-solla', + 'perfil-quadrado', + 'perfil-retangular', + 'time-de-voce', + 'time-do-estadual', + ]) { await expect(section.locator(`[data-card-model-tile="${model}"]`).first()).toBeAttached() } - await expect(section.locator('[data-card-model-tile="time-de-voce"]').first()).toContainText( - 'NOVO', - ) + // S30 — the `NOVO` badge marks the newest model (design gate scene 1). + await expect( + section.locator('[data-card-model-tile="time-do-estadual"]').first(), + ).toContainText('NOVO') // S14 — the name tile reproduces the real result: `SEU NOME` drawn by the // composer pipeline, left-aligned with the `SOU` border (x≈213 at 1080). @@ -2192,3 +2199,156 @@ test.describe('Cards personalizados (S15 — enquadramento automático)', () => ).toBe(true) }) }) + +/** + * S30 — the state-deputy model reuses the S15 cutout stub; the new surfaces are + * the picker (search + ↑↓/Enter), the visitor silhouette that marks the photo + * slot and the art swap that must keep the typed name and the processed photo. + */ +test.describe('Cards personalizados (S30 — Time do estadual)', () => { + /** `jul` matches Juliete and Julio; ↓ moves the active option to Julio. */ + const pickJulioByKeyboard = async (dialog: Locator) => { + const search = dialog.getByRole('combobox', { name: 'Buscar estadual' }) + await search.fill('jul') + await search.press('ArrowDown') + await search.press('Enter') + } + + /** + * Window probes at 1080×1440: x=582 is the photo window center (286 + 592/2), + * y=540 the silhouette head center (439 + 0.17·592) and y=727 the window + * center where the stub cutout lands. + */ + const windowCenterPixel = (canvas: Locator, x: number, y: number) => + canvas.evaluate( + (element, point) => { + const node = element as HTMLCanvasElement + return [...node.getContext('2d')!.getImageData(point.x, point.y, 1, 1).data] + }, + { x, y }, + ) + + test('picks a deputy, draws the silhouette, cuts the photo and downloads the PNG', async ({ + page, + }) => { + await setCutoutStub(page, 'ok') + await page.goto('/cards?model=time-do-estadual') + + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('heading', { name: 'Escolha sua dobradinha' })).toBeVisible() + await expect(dialog.getByText('Escolha seu estadual da dobradinha.')).toBeVisible() + // Pre-selection: the approved example, the notice and no photo box yet. + await expect(dialog.getByText('A silhueta marca o lugar da sua foto.')).toBeVisible() + await expect(dialog.getByText('Escolha um estadual para continuar.')).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Escolher foto' })).toHaveCount(0) + const primary = dialog.getByRole('button', { name: 'Criar meu card' }) + await expect(primary).toBeDisabled() + + await pickJulioByKeyboard(dialog) + + const trigger = dialog.getByRole('button', { name: /Seu estadual/ }) + await expect(trigger).toContainText('Julio Pinheiro') + await expect(trigger).toContainText('13999') + await expect(dialog.getByRole('button', { name: 'Escolher foto' })).toBeVisible() + + // The visitor silhouette (#001a42) marks the photo window head. + const canvas = dialog.locator('canvas') + await expect.poll(() => windowCenterPixel(canvas, 582, 540)).toEqual([0, 26, 66, 255]) + + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') + await expect(primary).toBeDisabled() + await uploadBustPhoto(dialog) + await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() + await expect(primary).toBeEnabled() + + await primary.click() + await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() + await expect(dialog.getByText('Julio Pinheiro · 13999')).toBeVisible() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + dialog.getByRole('button', { name: 'Baixar meu card' }).click(), + ]) + expect(download.suggestedFilename()).toBe('card-jorge-solla-time-do-estadual.png') + expect( + readFileSync((await download.path())!) + .subarray(0, 8) + .equals(PNG_SIGNATURE), + ).toBe(true) + }) + + test('switching the deputy keeps the typed name and the processed photo', async ({ page }) => { + await setCutoutStub(page, 'ok') + await page.goto('/cards?model=time-do-estadual') + + const dialog = page.getByRole('dialog') + await pickJulioByKeyboard(dialog) + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') + await uploadBustPhoto(dialog) + await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() + + // The cutout landed: harmony OFF samples the stub fixture at the window center. + const canvas = dialog.locator('canvas') + await dialog.getByRole('switch', { name: 'Harmonizar cores' }).click() + await expect.poll(() => windowCenterPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) + + // Swap the deputy through the trigger; the new one confirms on it. + await dialog.getByRole('button', { name: /Seu estadual/ }).click() + const search = dialog.getByRole('combobox', { name: 'Buscar estadual' }) + await search.fill('adriana') + await search.press('Enter') + const trigger = dialog.getByRole('button', { name: /Seu estadual/ }) + await expect(trigger).toContainText('Coletivo de Enfermagem') + await expect(trigger).toContainText('13763') + + // Name and cutout survive the swap: still ready, CTA enabled, photo pixel intact. + await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled() + await expect.poll(() => windowCenterPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) + }) + + test('a name that cannot fit fails closed on the estadual model', async ({ page }) => { + await page.goto('/cards?model=time-do-estadual') + + const dialog = page.getByRole('dialog') + await pickJulioByKeyboard(dialog) + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente') + + await expect(dialog.getByRole('heading', { name: 'Encurte o nome' })).toBeVisible() + await expect(dialog.getByRole('alert')).toContainText('nome mais curto') + await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled() + }) + + test('the picker shows the empty state when nothing matches', async ({ page }) => { + await page.goto('/cards?model=time-do-estadual') + + const dialog = page.getByRole('dialog') + await dialog.getByRole('combobox', { name: 'Buscar estadual' }).fill('sem-resultado') + + await expect(dialog.getByText('Nenhum estadual encontrado.')).toBeVisible() + }) + + test('mobile gallery exposes the five dots and the five-model hint', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/cards') + + const firstDot = page.getByRole('button', { name: 'Ir para o modelo 1 de 5' }) + const fifthDot = page.getByRole('button', { name: 'Ir para o modelo 5 de 5' }) + await expect(fifthDot).toBeVisible() + await expect(page.getByText('Deslize para ver os cinco modelos')).toBeVisible() + await expect(firstDot).toHaveAttribute('aria-current', 'true') + + // Clicking the last dot scrolls the track and keeps the active dot on it + // (the programmatic scroll lock must not bounce the index back mid-animation). + await fifthDot.click() + await expect(fifthDot).toHaveAttribute('aria-current', 'true') + await expect(firstDot).not.toHaveAttribute('aria-current', 'true') + await expect + .poll(() => + page + .locator('ul[aria-label="Modelos de card (mobile)"]') + .evaluate((element) => element.scrollLeft), + ) + .toBeGreaterThan(0) + }) +}) diff --git a/tests/fixtures/state-deputy-catalog.snapshot.json b/tests/fixtures/state-deputy-catalog.snapshot.json new file mode 100644 index 00000000..3caf0fca --- /dev/null +++ b/tests/fixtures/state-deputy-catalog.snapshot.json @@ -0,0 +1,379 @@ +{ + "description": "Frozen snapshot of the derived state-deputy card catalog (S30) — slug, display name, ballot number and asset paths are the public contract of the time-do-estadual model.", + "generatedFrom": "src/lib/stateDeputyCatalog.ts", + "stateDeputyCount": 53, + "identitySha256": "dd94a308d507fbad5b2e31fa83080aa829fb918a48c2ce8f13fe08df985c5694", + "entries": [ + { + "slug": "adriana", + "name": "Coletivo de Enfermagem", + "ballotNumber": "13763", + "photosSrc": "/cards/estaduais/adriana-fotos.webp", + "baseSrc": "/cards/estaduais/adriana-base.webp" + }, + { + "slug": "andre", + "name": "André Fidalgo", + "ballotNumber": "13131", + "photosSrc": "/cards/estaduais/andre-fotos.webp", + "baseSrc": "/cards/estaduais/andre-base.webp" + }, + { + "slug": "andrea", + "name": "Andréa Castro", + "ballotNumber": "55670", + "photosSrc": "/cards/estaduais/andrea-fotos.webp", + "baseSrc": "/cards/estaduais/andrea-base.webp" + }, + { + "slug": "angelo", + "name": "Angelo Almeida", + "ballotNumber": "13100", + "photosSrc": "/cards/estaduais/angelo-fotos.webp", + "baseSrc": "/cards/estaduais/angelo-base.webp" + }, + { + "slug": "arthur", + "name": "Artur Barachisio Lisbôa", + "ballotNumber": "55255", + "photosSrc": "/cards/estaduais/arthur-fotos.webp", + "baseSrc": "/cards/estaduais/arthur-base.webp" + }, + { + "slug": "bobo", + "name": "Bobô", + "ballotNumber": "65888", + "photosSrc": "/cards/estaduais/bobo-fotos.webp", + "baseSrc": "/cards/estaduais/bobo-base.webp" + }, + { + "slug": "carol", + "name": "Carol da Pitanguinha", + "ballotNumber": "13110", + "photosSrc": "/cards/estaduais/carol-fotos.webp", + "baseSrc": "/cards/estaduais/carol-base.webp" + }, + { + "slug": "cicero", + "name": "Cícero Monteiro", + "ballotNumber": "70133", + "photosSrc": "/cards/estaduais/cicero-fotos.webp", + "baseSrc": "/cards/estaduais/cicero-base.webp" + }, + { + "slug": "denise", + "name": "Denise Menezes", + "ballotNumber": "55111", + "photosSrc": "/cards/estaduais/denise-fotos.webp", + "baseSrc": "/cards/estaduais/denise-base.webp" + }, + { + "slug": "elane", + "name": "Dra. Elaine", + "ballotNumber": "55100", + "photosSrc": "/cards/estaduais/elane-fotos.webp", + "baseSrc": "/cards/estaduais/elane-base.webp" + }, + { + "slug": "euclides", + "name": "Euclides Fernandes", + "ballotNumber": "13123", + "photosSrc": "/cards/estaduais/euclides-fotos.webp", + "baseSrc": "/cards/estaduais/euclides-base.webp" + }, + { + "slug": "fabiola", + "name": "Dra. Fabíola Mansur", + "ballotNumber": "43123", + "photosSrc": "/cards/estaduais/fabiola-fotos.webp", + "baseSrc": "/cards/estaduais/fabiola-base.webp" + }, + { + "slug": "fatima", + "name": "Fátima Nunes", + "ballotNumber": "13567", + "photosSrc": "/cards/estaduais/fatima-fotos.webp", + "baseSrc": "/cards/estaduais/fatima-base.webp" + }, + { + "slug": "felipe", + "name": "Felipe Duarte", + "ballotNumber": "70111", + "photosSrc": "/cards/estaduais/felipe-fotos.webp", + "baseSrc": "/cards/estaduais/felipe-base.webp" + }, + { + "slug": "geane", + "name": "Geane Vasconcelos", + "ballotNumber": "12120", + "photosSrc": "/cards/estaduais/geane-fotos.webp", + "baseSrc": "/cards/estaduais/geane-base.webp" + }, + { + "slug": "hilton", + "name": "Hilton", + "ballotNumber": "50150", + "photosSrc": "/cards/estaduais/hilton-fotos.webp", + "baseSrc": "/cards/estaduais/hilton-base.webp" + }, + { + "slug": "ivana", + "name": "Ivana Bastos", + "ballotNumber": "55555", + "photosSrc": "/cards/estaduais/ivana-fotos.webp", + "baseSrc": "/cards/estaduais/ivana-base.webp" + }, + { + "slug": "jaco", + "name": "Jacó", + "ballotNumber": "13130", + "photosSrc": "/cards/estaduais/jaco-fotos.webp", + "baseSrc": "/cards/estaduais/jaco-base.webp" + }, + { + "slug": "jamille", + "name": "Jamile da Saúde", + "ballotNumber": "45456", + "photosSrc": "/cards/estaduais/jamille-fotos.webp", + "baseSrc": "/cards/estaduais/jamille-base.webp" + }, + { + "slug": "josafa", + "name": "Josafá Marinho", + "ballotNumber": "12222", + "photosSrc": "/cards/estaduais/josafa-fotos.webp", + "baseSrc": "/cards/estaduais/josafa-base.webp" + }, + { + "slug": "juliete", + "name": "Juliete Barreto", + "ballotNumber": "65100", + "photosSrc": "/cards/estaduais/juliete-fotos.webp", + "baseSrc": "/cards/estaduais/juliete-base.webp" + }, + { + "slug": "julio", + "name": "Julio Pinheiro", + "ballotNumber": "13999", + "photosSrc": "/cards/estaduais/julio-fotos.webp", + "baseSrc": "/cards/estaduais/julio-base.webp" + }, + { + "slug": "junior", + "name": "Júnior Muniz", + "ballotNumber": "13000", + "photosSrc": "/cards/estaduais/junior-fotos.webp", + "baseSrc": "/cards/estaduais/junior-base.webp" + }, + { + "slug": "kleber", + "name": "Kleber Rosa", + "ballotNumber": "50500", + "photosSrc": "/cards/estaduais/kleber-fotos.webp", + "baseSrc": "/cards/estaduais/kleber-base.webp" + }, + { + "slug": "leninha", + "name": "Lenínha Valente", + "ballotNumber": "13111", + "photosSrc": "/cards/estaduais/leninha-fotos.webp", + "baseSrc": "/cards/estaduais/leninha-base.webp" + }, + { + "slug": "leo", + "name": "Leo de Neco", + "ballotNumber": "70700", + "photosSrc": "/cards/estaduais/leo-fotos.webp", + "baseSrc": "/cards/estaduais/leo-base.webp" + }, + { + "slug": "ludmilla", + "name": "Ludmilla", + "ballotNumber": "55444", + "photosSrc": "/cards/estaduais/ludmilla-fotos.webp", + "baseSrc": "/cards/estaduais/ludmilla-base.webp" + }, + { + "slug": "magno", + "name": "Magno", + "ballotNumber": "18000", + "photosSrc": "/cards/estaduais/magno-fotos.webp", + "baseSrc": "/cards/estaduais/magno-base.webp" + }, + { + "slug": "marcos", + "name": "Dr. Marcos Adriano", + "ballotNumber": "12999", + "photosSrc": "/cards/estaduais/marcos-fotos.webp", + "baseSrc": "/cards/estaduais/marcos-base.webp" + }, + { + "slug": "marlene", + "name": "Marlene do Sindicato", + "ballotNumber": "18111", + "photosSrc": "/cards/estaduais/marlene-fotos.webp", + "baseSrc": "/cards/estaduais/marlene-base.webp" + }, + { + "slug": "mestre", + "name": "Mestre Reginaldo", + "ballotNumber": "18273", + "photosSrc": "/cards/estaduais/mestre-fotos.webp", + "baseSrc": "/cards/estaduais/mestre-base.webp" + }, + { + "slug": "neusa", + "name": "Neusa Cadore", + "ballotNumber": "13690", + "photosSrc": "/cards/estaduais/neusa-fotos.webp", + "baseSrc": "/cards/estaduais/neusa-base.webp" + }, + { + "slug": "niltinho", + "name": "Niltinho", + "ballotNumber": "55999", + "photosSrc": "/cards/estaduais/niltinho-fotos.webp", + "baseSrc": "/cards/estaduais/niltinho-base.webp" + }, + { + "slug": "osni", + "name": "Osni", + "ballotNumber": "13013", + "photosSrc": "/cards/estaduais/osni-fotos.webp", + "baseSrc": "/cards/estaduais/osni-base.webp" + }, + { + "slug": "pablo", + "name": "Pablo Barrozo", + "ballotNumber": "70900", + "photosSrc": "/cards/estaduais/pablo-fotos.webp", + "baseSrc": "/cards/estaduais/pablo-base.webp" + }, + { + "slug": "patrick", + "name": "Patrick Lopes", + "ballotNumber": "70123", + "photosSrc": "/cards/estaduais/patrick-fotos.webp", + "baseSrc": "/cards/estaduais/patrick-base.webp" + }, + { + "slug": "pinheiro", + "name": "Pinheiro", + "ballotNumber": "13700", + "photosSrc": "/cards/estaduais/pinheiro-fotos.webp", + "baseSrc": "/cards/estaduais/pinheiro-base.webp" + }, + { + "slug": "radiovaldo", + "name": "Radiovaldo", + "ballotNumber": "13050", + "photosSrc": "/cards/estaduais/radiovaldo-fotos.webp", + "baseSrc": "/cards/estaduais/radiovaldo-base.webp" + }, + { + "slug": "roberto", + "name": "Roberto Carlos", + "ballotNumber": "43333", + "photosSrc": "/cards/estaduais/roberto-fotos.webp", + "baseSrc": "/cards/estaduais/roberto-base.webp" + }, + { + "slug": "robinson", + "name": "Robinson", + "ballotNumber": "13500", + "photosSrc": "/cards/estaduais/robinson-fotos.webp", + "baseSrc": "/cards/estaduais/robinson-base.webp" + }, + { + "slug": "rogerio", + "name": "Rogério Andrade", + "ballotNumber": "15444", + "photosSrc": "/cards/estaduais/rogerio-fotos.webp", + "baseSrc": "/cards/estaduais/rogerio-base.webp" + }, + { + "slug": "rosemberg", + "name": "Rosemberg", + "ballotNumber": "13444", + "photosSrc": "/cards/estaduais/rosemberg-fotos.webp", + "baseSrc": "/cards/estaduais/rosemberg-base.webp" + }, + { + "slug": "rosival", + "name": "Rosival Leite", + "ballotNumber": "13333", + "photosSrc": "/cards/estaduais/rosival-fotos.webp", + "baseSrc": "/cards/estaduais/rosival-base.webp" + }, + { + "slug": "rowenna", + "name": "Rowenna", + "ballotNumber": "13456", + "photosSrc": "/cards/estaduais/rowenna-fotos.webp", + "baseSrc": "/cards/estaduais/rowenna-base.webp" + }, + { + "slug": "silva", + "name": "Silva Neto", + "ballotNumber": "70444", + "photosSrc": "/cards/estaduais/silva-fotos.webp", + "baseSrc": "/cards/estaduais/silva-base.webp" + }, + { + "slug": "silvio", + "name": "Silvio Dias", + "ballotNumber": "13113", + "photosSrc": "/cards/estaduais/silvio-fotos.webp", + "baseSrc": "/cards/estaduais/silvio-base.webp" + }, + { + "slug": "thiago", + "name": "Thiago Gilleno", + "ballotNumber": "10000", + "photosSrc": "/cards/estaduais/thiago-fotos.webp", + "baseSrc": "/cards/estaduais/thiago-base.webp" + }, + { + "slug": "vilma", + "name": "Vilma Reis", + "ballotNumber": "13033", + "photosSrc": "/cards/estaduais/vilma-fotos.webp", + "baseSrc": "/cards/estaduais/vilma-base.webp" + }, + { + "slug": "vitor", + "name": "Vitor Azevedo", + "ballotNumber": "70333", + "photosSrc": "/cards/estaduais/vitor-fotos.webp", + "baseSrc": "/cards/estaduais/vitor-base.webp" + }, + { + "slug": "welligton", + "name": "Wellington Oliveira", + "ballotNumber": "40888", + "photosSrc": "/cards/estaduais/welligton-fotos.webp", + "baseSrc": "/cards/estaduais/welligton-base.webp" + }, + { + "slug": "wenceslau", + "name": "Wenceslau", + "ballotNumber": "65111", + "photosSrc": "/cards/estaduais/wenceslau-fotos.webp", + "baseSrc": "/cards/estaduais/wenceslau-base.webp" + }, + { + "slug": "yulo", + "name": "Yulo", + "ballotNumber": "13234", + "photosSrc": "/cards/estaduais/yulo-fotos.webp", + "baseSrc": "/cards/estaduais/yulo-base.webp" + }, + { + "slug": "ze-raimundo", + "name": "Zé Raimundo", + "ballotNumber": "13222", + "photosSrc": "/cards/estaduais/ze-raimundo-fotos.webp", + "baseSrc": "/cards/estaduais/ze-raimundo-base.webp" + } + ] +} diff --git a/tests/unit/cardModels.unit.spec.ts b/tests/unit/cardModels.unit.spec.ts index 458dd83d..04495f05 100644 --- a/tests/unit/cardModels.unit.spec.ts +++ b/tests/unit/cardModels.unit.spec.ts @@ -11,12 +11,13 @@ import { } from '@/lib/cardModels' describe('card model catalog (S13/S15)', () => { - it('exposes the four shipped models with unique ids', () => { + it('exposes the five shipped models with unique ids', () => { expect(CARD_MODELS.map((model) => model.id)).toEqual([ 'eu-sou-solla', 'perfil-quadrado', 'perfil-retangular', 'time-de-voce', + 'time-do-estadual', ]) expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length) }) @@ -74,7 +75,6 @@ describe('card model catalog (S13/S15)', () => { assetSrc: '/cards/team-card-base.png', overlaySrc: '/cards/team-card-front.png', previewSrc: '/cards/team-card-example.jpg', - badge: 'NOVO', width: 1080, height: 1440, photoWindow: { x: 286, y: 439, width: 592, height: 577 }, @@ -82,6 +82,25 @@ describe('card model catalog (S13/S15)', () => { expect(getCardModel('eu-sou-solla')?.overlaySrc).toBeUndefined() }) + it('pins the S30 state-deputy model: same team geometry, picker flag and the approved example', () => { + expect(getCardModel('time-do-estadual')).toMatchObject({ + kind: 'team', + label: 'Time do estadual', + assetSrc: '/cards/estaduais/julio-fotos.webp', + overlaySrc: '/cards/estaduais/julio-base.webp', + previewSrc: '/cards/modelo-time-de-voce-com-estadual.jpeg', + stateDeputyPicker: true, + badge: 'NOVO', + width: 1080, + height: 1440, + photoWindow: { x: 286, y: 439, width: 592, height: 577 }, + }) + // The `NOVO` badge marks the newest model only (design gate scene 1). + expect(getCardModel('time-de-voce')?.badge).toBeUndefined() + expect(getCardModel('time-do-estadual')?.stateDeputyPicker).toBe(true) + expect(getCardModel('time-de-voce')?.stateDeputyPicker).toBeUndefined() + }) + it('guards the team banners and name slot measured from the example card', () => { expect(TEAM_CARD_LABEL).toMatchObject({ text: 'TIME DE', @@ -137,6 +156,7 @@ describe('card model catalog (S13/S15)', () => { expect(isCardModelId('perfil-quadrado')).toBe(true) expect(isCardModelId('perfil-retangular')).toBe(true) expect(isCardModelId('time-de-voce')).toBe(true) + expect(isCardModelId('time-do-estadual')).toBe(true) expect(isCardModelId('modelo-inventado')).toBe(false) expect(isCardModelId(['perfil-quadrado'])).toBe(false) expect(isCardModelId(undefined)).toBe(false) diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts index 803f9c14..4b5bd752 100644 --- a/tests/unit/cardRender.unit.spec.ts +++ b/tests/unit/cardRender.unit.spec.ts @@ -15,8 +15,10 @@ import { type CardPhotoSize, } from '@/lib/cardPhotoTransform' import { + CARD_VISITOR_SILHOUETTE_FILL, createCardMeasure, drawCardName, + drawCardVisitorSilhouette, renderNameCard, renderPhotoCard, renderTeamCard, @@ -74,6 +76,21 @@ const createFakeContext = () => { rotate: (angle) => { ops.push({ op: 'rotate', args: [angle] }) }, + beginPath: () => { + ops.push({ op: 'beginPath', args: [] }) + }, + arc: (x, y, radius, startAngle, endAngle) => { + ops.push({ op: 'arc', args: [x, y, radius, startAngle, endAngle] }) + }, + ellipse: (x, y, radiusX, radiusY, rotation, startAngle, endAngle) => { + ops.push({ op: 'ellipse', args: [x, y, radiusX, radiusY, rotation, startAngle, endAngle] }) + }, + rect: (x, y, width, height) => { + ops.push({ op: 'rect', args: [x, y, width, height] }) + }, + fill: () => { + ops.push({ op: 'fill', args: [] }) + }, } return { ctx, drawCalls, textCalls, rectCalls, ops } @@ -184,9 +201,7 @@ describe('renderTeamCard', () => { const result = renderTeamCard(fake.ctx, teamModel, { base, overlay, - photo, - photoSize, - transform, + subject: { kind: 'photo', photo, photoSize, transform }, window, name, fontFamily: 'Brexter', @@ -244,6 +259,82 @@ describe('renderTeamCard', () => { expect(rectCalls).toHaveLength(1) expect(textCalls.map((call) => call.text)).toEqual(['TIME DE']) }) + + it('returns the clamped photo transform for the photo subject', () => { + const { result, photo } = render('Maria') + + expect(result.transform).toEqual(transform) + + // A transform outside the cover floor comes back clamped, never as sent. + const fake = createFakeContext() + const outOfBounds = { zoom: 0.5, offsetX: 10_000, offsetY: -10_000 } + const clamped = renderTeamCard(fake.ctx, teamModel, { + base: { id: 'base' } as unknown as CanvasImageSource, + overlay: { id: 'overlay' } as unknown as CanvasImageSource, + subject: { kind: 'photo', photo, photoSize, transform: outOfBounds }, + window, + name: 'Maria', + fontFamily: 'Brexter', + measure: createCardMeasure(fake.ctx, 'Brexter'), + }) + + expect(clamped.transform).not.toEqual(outOfBounds) + expect(clamped.transform?.zoom).toBe(1) + }) + + it('draws the visitor silhouette instead of the photo when no cutout is ready (S30)', () => { + const fake = createFakeContext() + const base = { id: 'base' } as unknown as CanvasImageSource + const overlay = { id: 'overlay' } as unknown as CanvasImageSource + const result = renderTeamCard(fake.ctx, teamModel, { + base, + overlay, + subject: { kind: 'silhouette' }, + window, + name: 'Maria', + fontFamily: 'Brexter', + measure: createCardMeasure(fake.ctx, 'Brexter'), + }) + + expect(result.transform).toBeNull() + expect(result.fit).toMatchObject({ ok: true, lines: ['MARIA'] }) + expect(fake.drawCalls.map((call) => call.image)).toEqual([base, overlay]) + // Head circle + shoulder dome (rect + upper-half ellipse), both filled. + expect(fake.ops.filter((op) => op.op === 'fill')).toHaveLength(2) + }) +}) + +describe('drawCardVisitorSilhouette (S30)', () => { + const window = teamModel.photoWindow! + + it('normalizes the head and shoulders on the photo window', () => { + const { ctx, ops } = createFakeContext() + + drawCardVisitorSilhouette(ctx, window) + + const shoulderHeight = window.height * 0.72 + const shoulderBottom = window.y + window.height * 1.03 + const bodyTop = shoulderBottom - shoulderHeight + shoulderHeight * 0.48 + const rect = ops.find((op) => op.op === 'rect')! + const ellipse = ops.find((op) => op.op === 'ellipse')! + + expect(rect.args).toEqual([window.x, bodyTop, window.width, shoulderBottom - bodyTop]) + expect(ellipse.args).toEqual([ + window.x + window.width / 2, + bodyTop, + window.width * 0.48, + shoulderHeight * 0.48, + 0, + Math.PI, + 0, + ]) + // The shoulders never grow wider than the window they sit in. + expect(window.x + window.width / 2 - ellipse.args[2]!).toBeGreaterThanOrEqual(window.x) + expect(window.x + window.width / 2 + ellipse.args[2]!).toBeLessThanOrEqual( + window.x + window.width, + ) + expect(ctx.fillStyle).toBe(CARD_VISITOR_SILHOUETTE_FILL) + }) }) describe('resolveCardBannerWidth (S16)', () => { diff --git a/tests/unit/stateDeputyCatalog.unit.spec.ts b/tests/unit/stateDeputyCatalog.unit.spec.ts new file mode 100644 index 00000000..d1d2d3f8 --- /dev/null +++ b/tests/unit/stateDeputyCatalog.unit.spec.ts @@ -0,0 +1,129 @@ +// @vitest-environment node + +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' + +import { + filterStateDeputyCards, + getStateDeputyCard, + stateDeputyCatalog, +} from '@/lib/stateDeputyCatalog' + +type Snapshot = { + stateDeputyCount: number + identitySha256: string + entries: Array<{ + slug: string + name: string + ballotNumber: string + photosSrc: string + baseSrc: string + }> +} + +const snapshot = JSON.parse( + readFileSync(new URL('../fixtures/state-deputy-catalog.snapshot.json', import.meta.url), 'utf8'), +) as Snapshot + +const publicFile = (src: string) => fileURLToPath(new URL(`../../public${src}`, import.meta.url)) + +const APPROVED_EXAMPLE_SRC = fileURLToPath( + new URL( + '../../docs/plans/cards-estadual-dobradinha-ui-design-assets/modelo-time-de-voce-com-estadual.jpeg', + import.meta.url, + ), +) + +describe('State-deputy card catalog (S30 — 53 dobradinhas)', () => { + it('has the 53 delivered deputies with unique slugs, names and ballot numbers', () => { + expect(stateDeputyCatalog).toHaveLength(53) + expect(new Set(stateDeputyCatalog.map((card) => card.slug)).size).toBe(53) + expect(new Set(stateDeputyCatalog.map((card) => card.name)).size).toBe(53) + expect(new Set(stateDeputyCatalog.map((card) => card.ballotNumber)).size).toBe(53) + + for (const card of stateDeputyCatalog) { + expect(card.slug, card.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/) + expect(card.name.trim().length, card.slug).toBeGreaterThan(0) + expect(card.ballotNumber, card.slug).toMatch(/^\d{5}$/) + } + }) + + it('keeps both asset paths under /cards/estaduais and derived from the slug', () => { + for (const card of stateDeputyCatalog) { + expect(card.photosSrc, card.slug).toBe(`/cards/estaduais/${card.slug}-fotos.webp`) + expect(card.baseSrc, card.slug).toBe(`/cards/estaduais/${card.slug}-base.webp`) + } + }) + + it('ships the 106 optimized derivatives committed in public/cards/estaduais', async () => { + for (const card of stateDeputyCatalog) { + for (const src of [card.photosSrc, card.baseSrc]) { + const metadata = await sharp(publicFile(src)).metadata() + expect(metadata.format, src).toBe('webp') + expect(`${metadata.width}x${metadata.height}`, src).toBe('1080x1440') + } + + const base = await sharp(publicFile(card.baseSrc)).metadata() + expect(base.hasAlpha, card.baseSrc).toBe(true) + } + }) + + it('uses the exact approved example file for the gallery tile', () => { + const approved = readFileSync(APPROVED_EXAMPLE_SRC) + const shipped = readFileSync(publicFile('/cards/modelo-time-de-voce-com-estadual.jpeg')) + + expect(createHash('sha256').update(shipped).digest('hex')).toBe( + createHash('sha256').update(approved).digest('hex'), + ) + }) + + it('matches the frozen identity snapshot (slug/name/number/assets are the public contract)', () => { + expect(snapshot.stateDeputyCount).toBe(53) + + const derived = stateDeputyCatalog.map(({ slug, name, ballotNumber, photosSrc, baseSrc }) => ({ + slug, + name, + ballotNumber, + photosSrc, + baseSrc, + })) + expect(derived).toEqual(snapshot.entries) + + const rows = derived + .map( + (card) => + `P\t${card.slug}\t${card.name}\t${card.ballotNumber}\t${card.photosSrc}\t${card.baseSrc}\n`, + ) + .join('') + expect(createHash('sha256').update(rows).digest('hex')).toBe(snapshot.identitySha256) + }) + + it('looks up by slug', () => { + expect(getStateDeputyCard('julio')?.name).toBe('Julio Pinheiro') + expect(getStateDeputyCard('ze-raimundo')?.ballotNumber).toBe('13222') + expect(getStateDeputyCard('julio-pinheiro')).toBeUndefined() + }) + + it('filters by display name ignoring case and accents', () => { + expect(filterStateDeputyCards('')).toHaveLength(53) + expect(filterStateDeputyCards(' ')).toHaveLength(53) + expect(filterStateDeputyCards('JULIO').map((card) => card.slug)).toEqual(['julio']) + expect(filterStateDeputyCards('josefa').map((card) => card.slug)).toEqual([]) + expect(filterStateDeputyCards('josafa').map((card) => card.slug)).toEqual(['josafa']) + expect(filterStateDeputyCards('fatima').map((card) => card.slug)).toEqual(['fatima']) + expect(filterStateDeputyCards('dra.').map((card) => card.slug)).toEqual(['elane', 'fabiola']) + expect(filterStateDeputyCards('sem-resultado')).toEqual([]) + }) + + it('accepts the delivered folder name (slug) as a search keyword', () => { + expect(filterStateDeputyCards('adriana').map((card) => card.name)).toEqual([ + 'Coletivo de Enfermagem', + ]) + expect(filterStateDeputyCards('ze-raimundo').map((card) => card.slug)).toEqual(['ze-raimundo']) + expect(filterStateDeputyCards('jamille').map((card) => card.name)).toEqual(['Jamile da Saúde']) + }) +})