teqo-backup/tests/unit/cardRender.unit.spec.ts
Francisco Solla 8e0366d3ba feat(S14): cards personalizados — ajustes de fidelidade e experiência
A home abre o mesmo compositor do /cards ali mesmo (dialog no desktop,
drawer no mobile) e perde o CTA: os três modelos viram botões e nada
navega. A seção sobe para imediatamente antes da captura de novidades.
O nome no card e no tile alinha à esquerda com o SOU (x=213) via leftX
+ drawCardName extraído; o tile passa a desenhar SEU NOME com o próprio
pipeline real (canvas transparente sobre a base). Modal sem o vão
interno (p-0 sm:p-0) e ajuda legível no tema campaign-site
(--muted-foreground). Remove a variante link e o cardModelHref sem call
site. Unit atualizado (+ drawCardName) e e2e com alinhamento por pixel;
sem migration, sem PII em rede.
2026-09-12 21:01:15 -03:00

163 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { NAME_CARD_SLOT, getCardModel, type CardRect } from '@/lib/cardModels'
import { fitCardName } from '@/lib/cardNameFit'
import { centerCardPhotoTransform, type CardPhotoSize } from '@/lib/cardPhotoTransform'
import {
createCardMeasure,
drawCardName,
renderNameCard,
renderPhotoCard,
type CardDrawContext,
} from '@/lib/cardRender'
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
type TextCall = { text: string; x: number; y: number }
const fontSizeFrom = (font: string): number => {
const match = /(\d+(?:\.\d+)?)px/.exec(font)
return match ? Number(match[1]) : 0
}
const createFakeContext = () => {
const drawCalls: DrawCall[] = []
const textCalls: TextCall[] = []
const ctx: CardDrawContext = {
font: '',
fillStyle: '',
textAlign: 'start',
textBaseline: 'alphabetic',
drawImage: (image, dx, dy, dw, dh) => {
drawCalls.push({ image, dx, dy, dw, dh })
},
fillText: (text, x, y) => {
textCalls.push({ text, x, y })
},
measureText: (text) => {
const size = fontSizeFrom(ctx.font)
return {
width: text.length * size * 0.6,
actualBoundingBoxAscent: size * 0.72,
actualBoundingBoxDescent: size * 0.2,
}
},
}
return { ctx, drawCalls, textCalls }
}
const nameModel = getCardModel('eu-sou-solla')!
const squareModel = getCardModel('perfil-quadrado')!
describe('renderNameCard', () => {
it('draws the master base first and then the name on the left border', () => {
const { ctx, drawCalls, textCalls } = createFakeContext()
const image = { id: 'base' } as unknown as CanvasImageSource
const fit = renderNameCard(ctx, nameModel, {
image,
name: 'João',
fontFamily: 'Brexter',
measure: createCardMeasure(ctx, 'Brexter'),
})
expect(fit.ok).toBe(true)
expect(drawCalls).toEqual([{ image, dx: 0, dy: 0, dw: 1080, dh: 1440 }])
expect(textCalls).toHaveLength(1)
expect(textCalls[0]!.text).toBe('JOÃO')
expect(textCalls[0]!.x).toBe(NAME_CARD_SLOT.leftX)
expect(textCalls[0]!.y).toBeCloseTo(NAME_CARD_SLOT.capTop + NAME_CARD_SLOT.capHeight, 5)
expect(ctx.fillStyle).toBe(NAME_CARD_SLOT.fill)
expect(ctx.textAlign).toBe('left')
})
it('wraps long names into two lines with baselines inside the free band', () => {
const { ctx, textCalls } = createFakeContext()
const fit = renderNameCard(ctx, nameModel, {
image: { id: 'base' } as unknown as CanvasImageSource,
name: 'Jorge Solla da Bahia',
fontFamily: 'Brexter',
measure: createCardMeasure(ctx, 'Brexter'),
})
expect(fit.ok).toBe(true)
expect(textCalls.map((call) => call.text)).toEqual(['JORGE SOLLA', 'DA BAHIA'])
expect(textCalls[0]!.y).toBeLessThan(textCalls[1]!.y)
expect(textCalls[1]!.y).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom)
})
it('draws the base but no text when the name cannot fit (never cuts silently)', () => {
const { ctx, drawCalls, textCalls } = createFakeContext()
const fit = renderNameCard(ctx, nameModel, {
image: { id: 'base' } as unknown as CanvasImageSource,
name: 'Bartolomeucostajunior',
fontFamily: 'Brexter',
measure: createCardMeasure(ctx, 'Brexter'),
})
expect(fit).toEqual({ ok: false, reason: 'too-long' })
expect(drawCalls).toHaveLength(1)
expect(textCalls).toHaveLength(0)
})
})
describe('drawCardName', () => {
it('draws only the fitted name (no base) on the left border', () => {
const { ctx, drawCalls, textCalls } = createFakeContext()
const fit = fitCardName('João', createCardMeasure(ctx, 'Brexter'))
expect(fit.ok).toBe(true)
if (!fit.ok) return
drawCardName(ctx, { fit, fontFamily: 'Brexter' })
expect(drawCalls).toHaveLength(0)
expect(textCalls).toEqual([
{ text: 'JOÃO', x: NAME_CARD_SLOT.leftX, y: NAME_CARD_SLOT.capTop + fit.capHeight },
])
expect(ctx.textAlign).toBe('left')
expect(ctx.fillStyle).toBe(NAME_CARD_SLOT.fill)
})
it('left-aligns both wrapped lines inside the free band', () => {
const { ctx, textCalls } = createFakeContext()
const fit = fitCardName('Jorge Solla da Bahia', createCardMeasure(ctx, 'Brexter'))
expect(fit.ok).toBe(true)
if (!fit.ok) return
drawCardName(ctx, { fit, fontFamily: 'Brexter' })
expect(textCalls.map((call) => call.text)).toEqual(['JORGE SOLLA', 'DA BAHIA'])
expect(textCalls.every((call) => call.x === NAME_CARD_SLOT.leftX)).toBe(true)
expect(textCalls[0]!.y).toBeLessThan(textCalls[1]!.y)
expect(textCalls[1]!.y).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom)
})
})
describe('renderPhotoCard', () => {
it('draws the photo full-bleed under the official overlay', () => {
const { ctx, drawCalls } = createFakeContext()
const photo = { id: 'photo' } as unknown as CanvasImageSource
const frame = { id: 'frame' } as unknown as CanvasImageSource
const size: CardPhotoSize = { width: 2000, height: 1000 }
const window: CardRect = squareModel.photoWindow!
const transform = centerCardPhotoTransform(size, window)
const clamped = renderPhotoCard(ctx, {
photo,
frame,
photoSize: size,
frameSize: { width: squareModel.width, height: squareModel.height },
window,
transform,
})
expect(clamped).toEqual(transform)
expect(drawCalls).toHaveLength(2)
expect(drawCalls[0]!.image).toBe(photo)
expect(drawCalls[1]).toEqual({ image: frame, dx: 0, dy: 0, dw: 1000, dh: 1000 })
expect(drawCalls[0]!.dx).toBeLessThanOrEqual(window.x)
expect(drawCalls[0]!.dy).toBeLessThanOrEqual(window.y)
expect(drawCalls[0]!.dx + drawCalls[0]!.dw).toBeGreaterThanOrEqual(window.x + window.width)
expect(drawCalls[0]!.dy + drawCalls[0]!.dh).toBeGreaterThanOrEqual(window.y + window.height)
})
})