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) }) })