teqo-backup/tests/unit/cardRender.unit.spec.ts
Francisco Solla 43f2e4592c fix(cards): recorte no celular (WASM/WebGL) e volta do enquadramento S15
O recorte do card 'Time de você' falhava em navegadores endurecidos (IronFox/Tor
desligam WebAssembly e WebGL por padrão) com a copy errada de 'tente outra foto';
o motor MediaPipe exige ambos (o grafo pede kGpuService mesmo com delegate CPU).
Agora o adapter sonda WASM/WebGL antes do download e devolve 'unsupported' com
mensagem acionável; a entrada de inferência é capada em 1600 e a foto passa pelo
loadCardPhoto (foto de 61MP que falhava agora conclui).

O enquadramento automático volta ao S15 (bbox cobre a janela): o S18 mirava a
caixa do rosto em 103px e deixava o busto inteiro pequeno na janela. Saem o
detector de rosto (asset removido), frameCardPhotoOnFace, a referência 103px, o
piso 0.2 e a âncora S20 (inerte sob o cover). S16/S17 intactos.
2026-09-20 17:07:01 -03:00

337 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest'
import {
NAME_CARD_SLOT,
TEAM_CARD_LABEL,
TEAM_CARD_NAME_BANNER,
TEAM_CARD_NAME_SLOT,
getCardModel,
type CardRect,
} from '@/lib/cardModels'
import { fitCardName } from '@/lib/cardNameFit'
import {
centerCardPhotoTransform,
frameCardPhotoOnBbox,
type CardPhotoSize,
} from '@/lib/cardPhotoTransform'
import {
createCardMeasure,
drawCardName,
renderNameCard,
renderPhotoCard,
renderTeamCard,
resolveCardBannerWidth,
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 }
type RectCall = { x: number; y: number; width: number; height: number }
type OpCall = { op: string; args: 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 rectCalls: RectCall[] = []
const ops: OpCall[] = []
const ctx: CardDrawContext = {
font: '',
fillStyle: '',
textAlign: 'start',
textBaseline: 'alphabetic',
drawImage: (image, dx, dy, dw, dh) => {
drawCalls.push({ image, dx, dy, dw, dh })
},
fillRect: (x, y, width, height) => {
rectCalls.push({ x, y, width, height })
},
fillText: (text, x, y) => {
textCalls.push({ text, x, y })
},
measureText: (text) => {
const size = fontSizeFrom(ctx.font)
return {
width: text.length * size * 0.84,
actualBoundingBoxAscent: size * 0.72,
actualBoundingBoxDescent: size * 0.2,
}
},
save: () => {
ops.push({ op: 'save', args: [] })
},
restore: () => {
ops.push({ op: 'restore', args: [] })
},
translate: (x, y) => {
ops.push({ op: 'translate', args: [x, y] })
},
rotate: (angle) => {
ops.push({ op: 'rotate', args: [angle] })
},
}
return { ctx, drawCalls, textCalls, rectCalls, ops }
}
const nameModel = getCardModel('eu-sou-solla')!
const squareModel = getCardModel('perfil-quadrado')!
const teamModel = getCardModel('time-de-voce')!
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('renderTeamCard', () => {
const window = teamModel.photoWindow!
const photoSize: CardPhotoSize = { width: 400, height: 600 }
const transform = frameCardPhotoOnBbox(photoSize, window, {
x: 80,
y: 40,
width: 240,
height: 420,
})!
const render = (name: string) => {
const fake = createFakeContext()
const base = { id: 'base' } as unknown as CanvasImageSource
const overlay = { id: 'overlay' } as unknown as CanvasImageSource
const photo = { id: 'photo' } as unknown as CanvasImageSource
const result = renderTeamCard(fake.ctx, teamModel, {
base,
overlay,
photo,
photoSize,
transform,
window,
name,
fontFamily: 'Brexter',
measure: createCardMeasure(fake.ctx, 'Brexter'),
})
return { ...fake, result, base, overlay, photo }
}
it('draws base → framed photo → overlay, then the label and the name banner', () => {
const { drawCalls, textCalls, rectCalls, ops, result, base, overlay, photo } = render('Maria')
expect(result.fit).toMatchObject({ ok: true, lines: ['MARIA'] })
expect(drawCalls.map((call) => call.image)).toEqual([base, photo, overlay])
expect(drawCalls[0]).toEqual({ image: base, dx: 0, dy: 0, dw: 1080, dh: 1440 })
expect(drawCalls[2]).toEqual({ image: overlay, dx: 0, dy: 0, dw: 1080, dh: 1440 })
// Both banners are drawn as rotated rectangles: two save/restore pairs.
expect(rectCalls).toHaveLength(2)
expect(rectCalls[0]).toMatchObject({
width: TEAM_CARD_LABEL.width,
height: TEAM_CARD_LABEL.height,
})
// S16 — `MARIA` at the ideal cap (130/0.72) with the 0.84em fake measures
// ≈758.33 of ink; the banner grows by the model's 39px padding: ≈797.33.
expect(rectCalls[1]).toMatchObject({
y: -TEAM_CARD_NAME_BANNER.height / 2,
height: TEAM_CARD_NAME_BANNER.height,
})
expect(rectCalls[1]!.width).toBeCloseTo(797.33, 1)
expect(rectCalls[1]!.x).toBeCloseTo(-797.33 / 2, 1)
expect(ops.filter((op) => op.op === 'save')).toHaveLength(2)
expect(ops.filter((op) => op.op === 'restore')).toHaveLength(2)
expect(ops.map((op) => op.op)).toEqual([
'save',
'translate',
'rotate',
'restore',
'save',
'translate',
'rotate',
'restore',
])
expect(ops[1]!.args).toEqual([TEAM_CARD_LABEL.centerX, TEAM_CARD_LABEL.centerY])
expect(ops[5]!.args).toEqual([TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY])
expect(textCalls.map((call) => call.text)).toEqual(['TIME DE', 'MARIA'])
expect(textCalls.every((call) => call.x === 0)).toBe(true)
})
it('draws the label but no name banner when the name cannot fit (never cuts silently)', () => {
const { textCalls, rectCalls, result } = render('Bartolomeucostajunior')
expect(result.fit).toEqual({ ok: false, reason: 'too-long' })
expect(rectCalls).toHaveLength(1)
expect(textCalls.map((call) => call.text)).toEqual(['TIME DE'])
})
})
describe('resolveCardBannerWidth (S16)', () => {
const slot = TEAM_CARD_NAME_SLOT
const maxInkWidth = slot.maxInkWidth
it('pins the design table pairs (ink = width − 39: reference 509 → ceiling 1000)', () => {
const designPairs: [ink: number, width: number][] = [
[470, 509],
[527, 566],
[807, 846],
[925, 964],
[951, 990],
[952, 991],
[961, 1000],
]
for (const [ink, width] of designPairs) {
expect(resolveCardBannerWidth(slot, ink)).toBe(width)
}
})
it('keeps the reference width for short names and clamps past the ceiling', () => {
expect(resolveCardBannerWidth(slot, 0)).toBe(slot.banner.width)
expect(resolveCardBannerWidth(slot, 200)).toBe(slot.banner.width)
expect(resolveCardBannerWidth(slot, 2000)).toBe(slot.banner.maxWidth)
})
it('never grows a fixed banner (the red TIME DE label)', () => {
const labelSlot = { banner: TEAM_CARD_LABEL, maxInkWidth }
expect(resolveCardBannerWidth(labelSlot, 0)).toBe(TEAM_CARD_LABEL.width)
expect(resolveCardBannerWidth(labelSlot, 5000)).toBe(TEAM_CARD_LABEL.width)
})
})
describe('drawCardName (centered team slot)', () => {
it('draws the blue banner with the centered name through the same banner helper', () => {
const { ctx, textCalls, rectCalls, ops } = createFakeContext()
const fit = fitCardName('Maria', createCardMeasure(ctx, 'Brexter'), TEAM_CARD_NAME_SLOT)
expect(fit.ok).toBe(true)
if (!fit.ok) return
drawCardName(ctx, { fit, fontFamily: 'Brexter', slot: TEAM_CARD_NAME_SLOT })
// S16 — dynamic width resolved from the fitted ink (same ≈797.33 as above).
expect(rectCalls).toHaveLength(1)
expect(rectCalls[0]).toMatchObject({
y: -TEAM_CARD_NAME_BANNER.height / 2,
height: TEAM_CARD_NAME_BANNER.height,
})
expect(rectCalls[0]!.width).toBeCloseTo(797.33, 1)
expect(rectCalls[0]!.x).toBeCloseTo(-797.33 / 2, 1)
expect(ops).toEqual([
{ op: 'save', args: [] },
{ op: 'translate', args: [TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY] },
{ op: 'rotate', args: [(TEAM_CARD_NAME_BANNER.rotationDeg * Math.PI) / 180] },
{ op: 'restore', args: [] },
])
expect(textCalls).toEqual([{ text: 'MARIA', x: 0, y: fit.capHeight / 2 }])
expect(ctx.fillStyle).toBe('#ffffff')
})
})
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)
})
})