mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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.
163 lines
5.8 KiB
TypeScript
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)
|
|
})
|
|
})
|