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

587 lines
21 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 { COLINHA_ESTADUAL_LABEL, COLINHA_ROW_LAYOUT } from '@/lib/cardColinha'
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 {
CARD_VISITOR_SILHOUETTE_FILL,
createCardMeasure,
drawCardName,
drawCardVisitorSilhouette,
renderColinhaCard,
renderNameCard,
renderPhotoCard,
renderTeamCard,
resolveCardBannerWidth,
type CardDrawContext,
} from '@/lib/cardRender'
import { getStateDeputyCard, type StateDeputyCatalogEntry } from '@/lib/stateDeputyCatalog'
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
type TextCall = { text: string; x: number; y: number }
type TextState = { align: CanvasTextAlign; fill: string; font: string; letterSpacing: string }
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 textStates: TextState[] = []
const rectCalls: RectCall[] = []
const ops: OpCall[] = []
const ctx: CardDrawContext = {
font: '',
letterSpacing: '',
globalAlpha: 1,
fillStyle: '',
textAlign: 'start',
textBaseline: 'alphabetic',
imageSmoothingEnabled: false,
imageSmoothingQuality: 'low',
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 })
textStates.push({
align: ctx.textAlign,
fill: typeof ctx.fillStyle === 'string' ? ctx.fillStyle : '',
font: ctx.font,
letterSpacing: ctx.letterSpacing,
})
},
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] })
},
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] })
},
roundRect: (x, y, width, height, radii) => {
ops.push({
op: 'roundRect',
args: [x, y, width, height, typeof radii === 'number' ? radii : -1],
})
},
clip: () => {
ops.push({ op: 'clip', args: [] })
},
createLinearGradient: (x0, y0, x1, y1) => {
ops.push({ op: 'createLinearGradient', args: [x0, y0, x1, y1] })
return {
addColorStop: (offset: number) => {
ops.push({ op: 'addColorStop', args: [offset] })
},
}
},
fill: () => {
ops.push({ op: 'fill', args: [] })
},
}
return { ctx, drawCalls, textCalls, textStates, 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,
subject: { kind: 'photo', 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'])
})
it('returns the photo transform with the zoom clamped and the free position kept (S33)', () => {
const { result, photo } = render('Maria')
expect(result.transform).toEqual(transform)
// S33 — `time-de-voce` is `free`: the zoom still clamps to [1, 4] but the
// position comes back exactly as sent; a re-clamp here would snap the photo
// back on the next paint (drawing and return share the model's policy).
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).toEqual({ zoom: 1, offsetX: 10_000, offsetY: -10_000 })
// The drawing follows the same free policy (second drawImage is the photo).
expect(fake.drawCalls[1]).toMatchObject({ dx: 10_000, dy: -10_000 })
})
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)', () => {
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)
})
it('still clamps the visitor adjustment on the bounded photo models (S13/S14)', () => {
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 clamped = renderPhotoCard(ctx, {
photo,
frame,
photoSize: size,
frameSize: { width: squareModel.width, height: squareModel.height },
window,
transform: { zoom: 1, offsetX: 9999, offsetY: 9999 },
})
expect(clamped).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
expect(drawCalls[0]).toEqual({ image: photo, dx: 0, dy: 0, dw: 1480, dh: 740 })
})
})
describe('renderColinhaCard (S34)', () => {
const colinhaModel = getCardModel('minha-colinha')!
const art = { id: 'art' } as unknown as CanvasImageSource
const deputy = getStateDeputyCard('julio')!
const render = (chosen: StateDeputyCatalogEntry | null) => {
const fake = createFakeContext()
renderColinhaCard(fake.ctx, colinhaModel, {
image: art,
deputy: chosen,
fontFamily: 'Brexter',
measure: createCardMeasure(fake.ctx, 'Brexter', 900),
})
return fake
}
// The fake face is conservative (0.84em per char): JULIO PINHEIRO shrinks
// from 36.72 to floor(36.72 × 195.83 / 431.83) = 16 to fit the mask band.
const nameFontSize = 16
const baseline = COLINHA_ROW_LAYOUT.copy.top + nameFontSize * 0.72
const nameWidth = 'JULIO PINHEIRO'.length * nameFontSize * 0.84
it('draws the approved art whole at 1080×1920 with high smoothing', () => {
const { ctx, drawCalls, ops } = render(deputy)
expect(drawCalls).toEqual([{ image: art, dx: 0, dy: 0, dw: 1080, dh: 1920 }])
expect(ctx.imageSmoothingEnabled).toBe(true)
expect(ctx.imageSmoothingQuality).toBe('high')
// None of the S31 composition survives: no clip/save/gradient/roundRect.
expect(ops).toHaveLength(0)
})
it('leaves the art exactly as delivered before a pick (no text, no rect)', () => {
const { drawCalls, rectCalls, textCalls, ops } = render(null)
expect(drawCalls).toEqual([{ image: art, dx: 0, dy: 0, dw: 1080, dh: 1920 }])
expect(rectCalls).toHaveLength(0)
expect(textCalls).toHaveLength(0)
expect(ops).toHaveLength(0)
})
it('fills the estadual line: mask, label at the left, name at the right and five digits', () => {
const { ctx, rectCalls, textCalls, textStates } = render(deputy)
expect(rectCalls).toEqual([
{
x: COLINHA_ROW_LAYOUT.mask.x,
y: COLINHA_ROW_LAYOUT.mask.y,
width: COLINHA_ROW_LAYOUT.mask.width,
height: COLINHA_ROW_LAYOUT.mask.height,
},
])
expect(textCalls.map((call) => call.text)).toEqual([
COLINHA_ESTADUAL_LABEL,
'JULIO PINHEIRO',
'1',
'3',
'9',
'9',
'9',
])
const label = textCalls[0]!
expect(label.x).toBeCloseTo(
COLINHA_ROW_LAYOUT.copy.right - nameWidth - COLINHA_ROW_LAYOUT.gap,
5,
)
expect(label.y).toBeCloseTo(baseline, 5)
expect(textStates[0]).toMatchObject({
align: 'right',
fill: COLINHA_ROW_LAYOUT.office.color,
font: `400 ${COLINHA_ROW_LAYOUT.office.fontSize}px Brexter`,
letterSpacing: '0px',
})
const name = textCalls[1]!
expect(name.x).toBeCloseTo(COLINHA_ROW_LAYOUT.copy.right, 5)
expect(name.y).toBeCloseTo(baseline, 5)
expect(textStates[1]).toMatchObject({
align: 'right',
fill: COLINHA_ROW_LAYOUT.name.color,
font: `900 ${nameFontSize}px Brexter`,
letterSpacing: `${COLINHA_ROW_LAYOUT.name.letterSpacingEm * nameFontSize}px`,
})
const { digit } = COLINHA_ROW_LAYOUT
const centerY = digit.top + digit.height / 2
textCalls.slice(2).forEach((call, index) => {
expect(call.x).toBeCloseTo(digit.left + digit.width / 2 + index * digit.step, 5)
expect(call.y).toBeCloseTo(centerY, 5)
expect(textStates[2 + index]).toMatchObject({
align: 'center',
fill: digit.color,
font: `900 ${digit.fontSize}px Arial Black, Arial, Helvetica, sans-serif`,
})
})
// The tracking set for the name never leaks to the next paint.
expect(ctx.letterSpacing).toBe('0px')
expect(ctx.globalAlpha).toBe(1)
})
})