teqo-backup/tests/unit/cardRender.unit.spec.ts

299 lines
10 KiB
TypeScript

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,
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.6,
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,
})
expect(rectCalls[1]).toMatchObject({
width: TEAM_CARD_NAME_BANNER.width,
height: TEAM_CARD_NAME_BANNER.height,
})
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('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 })
expect(rectCalls).toEqual([
{
x: -TEAM_CARD_NAME_BANNER.width / 2,
y: -TEAM_CARD_NAME_BANNER.height / 2,
width: TEAM_CARD_NAME_BANNER.width,
height: TEAM_CARD_NAME_BANNER.height,
},
])
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)
})
})