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

172 lines
6.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { NAME_CARD_SLOT, TEAM_CARD_NAME_SLOT } from '@/lib/cardModels'
import {
fitCardName,
normalizeCardName,
resolveFontSizeForCapHeight,
type CardMeasureText,
} from '@/lib/cardNameFit'
/** Deterministic fake: 0.84em per char (Brexter-like), 0.72em cap, 0.2em descent. */
const measure: CardMeasureText = (text, fontSize) => ({
width: text.length * fontSize * 0.84,
actualBoundingBoxAscent: fontSize * 0.72,
actualBoundingBoxDescent: fontSize * 0.2,
})
const inkWidth = (text: string, fontSize: number) => measure(text, fontSize).width
const minFontSize = resolveFontSizeForCapHeight(measure, NAME_CARD_SLOT.minCapHeight)
describe('normalizeCardName', () => {
it('trims, collapses spaces and uppercases with the pt-BR locale', () => {
expect(normalizeCardName(' joão conceição ')).toBe('JOÃO CONCEIÇÃO')
})
})
describe('resolveFontSizeForCapHeight', () => {
it('calibrates the font size from the measured cap, not the raw cap value', () => {
expect(resolveFontSizeForCapHeight(measure, 106)).toBeCloseTo(106 / 0.72, 5)
})
it('falls back to the cap value when the probe has no ascent', () => {
const broken: CardMeasureText = () => ({
width: 0,
actualBoundingBoxAscent: 0,
actualBoundingBoxDescent: 0,
})
expect(resolveFontSizeForCapHeight(broken, 106)).toBe(106)
})
})
describe('fitCardName', () => {
it('keeps the master cap height for a short name', () => {
const fit = fitCardName('João', measure)
expect(fit).toMatchObject({ ok: true, lines: ['JOÃO'] })
if (fit.ok) {
expect(fit.capHeight).toBeCloseTo(NAME_CARD_SLOT.capHeight, 5)
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5)
}
})
it('shrinks a long single line to the ink width instead of cutting it', () => {
const fit = fitCardName('Conceição', measure)
expect(fit).toMatchObject({ ok: true, lines: ['CONCEIÇÃO'] })
if (fit.ok) {
expect(fit.fontSize).toBeLessThan(resolveFontSizeForCapHeight(measure, 106))
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize)
}
})
it('wraps into two balanced lines when single-line would fall under the minimum', () => {
const fit = fitCardName('Jorge Solla da Bahia', measure)
expect(fit.ok).toBe(true)
if (fit.ok) {
expect(fit.lines).toEqual(['JORGE SOLLA', 'DA BAHIA'])
expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize)
for (const line of fit.lines) {
expect(inkWidth(line, fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
}
const blockHeight = fit.lineHeight + fit.capHeight
expect(blockHeight).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom - NAME_CARD_SLOT.capTop)
}
})
it('does not split a single word in the middle', () => {
const fit = fitCardName('Bartolomeucostajunior', measure)
expect(fit).toEqual({ ok: false, reason: 'too-long' })
})
it('fails closed for an empty name', () => {
expect(fitCardName(' ', measure)).toEqual({ ok: false, reason: 'empty' })
})
it('fails closed when shrinking cannot reach the ink width (adversarial metrics)', () => {
const rigid: CardMeasureText = (_text, fontSize) => ({
width: 5000,
actualBoundingBoxAscent: fontSize * 0.72,
actualBoundingBoxDescent: fontSize * 0.2,
})
const rigidCase = fitCardName('João', rigid)
expect(rigidCase.ok).toBe(false)
if (!rigidCase.ok) expect(rigidCase.reason).toBe('too-long')
})
it('fails closed when the shrink factor collapses to zero', () => {
const absurd: CardMeasureText = (_text, fontSize) => ({
width: 1_000_000_000,
actualBoundingBoxAscent: fontSize * 0.72,
actualBoundingBoxDescent: fontSize * 0.2,
})
const absurdCase = fitCardName('João', absurd)
expect(absurdCase.ok).toBe(false)
if (!absurdCase.ok) expect(absurdCase.reason).toBe('too-long')
})
it('preserves Portuguese accents in the fitted lines', () => {
const fit = fitCardName('Coração', measure)
expect(fit.ok).toBe(true)
if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO')
})
describe('team slot (single line, centered)', () => {
it('keeps the measured cap for a short name and never wraps', () => {
const fit = fitCardName('Ana', measure, TEAM_CARD_NAME_SLOT)
expect(fit).toMatchObject({ ok: true, lines: ['ANA'] })
if (fit.ok) {
expect(fit.capHeight).toBeCloseTo(TEAM_CARD_NAME_SLOT.capHeight, 5)
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(
TEAM_CARD_NAME_SLOT.maxInkWidth,
)
expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5)
}
})
it('shrinks a name that overflows the ink width down to the readable minimum', () => {
const fit = fitCardName('Guilherme', measure, TEAM_CARD_NAME_SLOT)
expect(fit).toMatchObject({ ok: true, lines: ['GUILHERME'] })
if (fit.ok) {
expect(fit.capHeight).toBeLessThan(TEAM_CARD_NAME_SLOT.capHeight)
expect(fit.fontSize).toBeGreaterThanOrEqual(
resolveFontSizeForCapHeight(measure, TEAM_CARD_NAME_SLOT.minCapHeight),
)
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(
TEAM_CARD_NAME_SLOT.maxInkWidth,
)
}
})
it('fits a long compound name within the new ceiling (S16)', () => {
const fit = fitCardName('Maria da Conceição', measure, TEAM_CARD_NAME_SLOT)
expect(fit).toMatchObject({ ok: true, lines: ['MARIA DA CONCEIÇÃO'] })
if (fit.ok) {
expect(fit.capHeight).toBeGreaterThanOrEqual(TEAM_CARD_NAME_SLOT.minCapHeight)
expect(fit.inkWidth).toBeLessThanOrEqual(TEAM_CARD_NAME_SLOT.maxInkWidth)
expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5)
}
})
it('fails with too-long instead of wrapping below the readable minimum', () => {
expect(fitCardName('Bartolomeucostajunior', measure, TEAM_CARD_NAME_SLOT)).toEqual({
ok: false,
reason: 'too-long',
})
expect(fitCardName('Anticonstitucionalissimamente', measure, TEAM_CARD_NAME_SLOT)).toEqual({
ok: false,
reason: 'too-long',
})
})
})
})