import { describe, it, expect } from 'vitest'; import { CARD_H, CARD_W, LOGO_MAX_H, MARGIN, PREVIEW_RADIUS, WALLET_KEEP_CLEAR, fitBrand, fitLogo, layoutFor, type Box, } from './geometry'; import type { TemplateId } from './config'; const TEMPLATES: TemplateId[] = ['image', 'minimal', 'classic', 'signature']; const intersects = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; const insideCanvas = (b: Box) => b.x >= 0 && b.y >= 0 && b.x + b.w <= CARD_W && b.y + b.h <= CARD_H; // Feste Worst-Case-Boxen der PLATINUM-Zeile (Text 360 px breit) const PLATINUM_BOX: Partial> = { classic: { x: 1120, y: 77, w: 360, h: 58 }, signature: { x: 60, y: 182, w: 360, h: 55 }, }; const NUMBER_BOX: Box = { x: 157, y: 572, w: 867, h: 78 }; describe('Konstanten', () => { it('Kartenmaße und Rand', () => { expect(CARD_W).toBe(1536); expect(CARD_H).toBe(969); expect(MARGIN).toBe(56); expect(PREVIEW_RADIUS).toBe(57); }); it('WALLET_KEEP_CLEAR liegt unten links', () => { expect(WALLET_KEEP_CLEAR).toEqual({ x: 0, y: 779, w: 620, h: 190 }); expect(WALLET_KEEP_CLEAR.y + WALLET_KEEP_CLEAR.h).toBe(CARD_H); }); }); describe('fitLogo', () => { it('LOGO_MAX_H ist 125', () => { expect(LOGO_MAX_H).toBe(125); }); it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => { expect(fitLogo(2, 125)).toEqual({ x: 1230, y: 788, w: 250, h: 125 }); }); it('ohne maxH gilt LOGO_MAX_H', () => { expect(fitLogo(2)).toEqual(fitLogo(2, LOGO_MAX_H)); expect(fitLogo(1)).toEqual({ x: 1355, y: 788, w: 125, h: 125 }); }); it('sehr breite Logos werden auf 360 begrenzt', () => { const b = fitLogo(10, 125); expect(b.w).toBeCloseTo(360, 1); expect(b.h).toBeCloseTo(36, 1); expect(b.x + b.w).toBeCloseTo(1480, 1); expect(b.y + b.h).toBeCloseTo(913, 1); }); it('hohe Logos werden auf die Höhe begrenzt', () => { const b = fitLogo(0.5, 125); expect(b.h).toBeCloseTo(125, 1); expect(b.w).toBeCloseTo(62.5, 1); expect(b.x + b.w).toBeCloseTo(1480, 1); expect(b.y + b.h).toBeCloseTo(913, 1); }); it('maxH ist einstellbar', () => { const b = fitLogo(1, 100); expect(b.w).toBeCloseTo(100, 1); expect(b.h).toBeCloseTo(100, 1); expect(b.y + b.h).toBeCloseTo(913, 1); }); it('Seitenverhältnis bleibt erhalten', () => { for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) { const b = fitLogo(aspect); expect(b.w / b.h).toBeCloseTo(aspect, 1); expect(b.h).toBeLessThanOrEqual(LOGO_MAX_H + 1e-9); expect(b.w).toBeLessThanOrEqual(360 + 1e-9); } }); }); describe('fitBrand', () => { it('quadratisches Logo: 100 × 100 links oben auf dem Rand', () => { expect(fitBrand(1)).toEqual({ x: 56, y: 56, w: 100, h: 100 }); }); it('breites Logo wird auf 360 × 100 begrenzt', () => { const b = fitBrand(3.6); expect(b.x).toBe(56); expect(b.y).toBe(56); expect(b.w).toBeCloseTo(360, 1); expect(b.h).toBeCloseTo(100, 1); expect(b.x + b.w).toBeCloseTo(416, 1); expect(b.y + b.h).toBeCloseTo(156, 1); }); it('sehr breites Logo ist höchstens 360 breit, Höhe folgt dem Seitenverhältnis', () => { const b = fitBrand(8); expect(b.w).toBeCloseTo(360, 1); expect(b.h).toBeCloseTo(45, 1); }); it('maxH ist einstellbar', () => { const b = fitBrand(1, 60); expect(b.w).toBeCloseTo(60, 1); expect(b.h).toBeCloseTo(60, 1); }); }); describe('layoutFor', () => { it('image: kein Logo, keine Marke, keine optionalen Felder', () => { const l = layoutFor('image'); expect(l.logo).toBe(false); expect(l.brand).toBe(false); expect(Object.keys(l).sort()).toEqual(['brand', 'logo']); }); it('minimal: nur Logo', () => { const l = layoutFor('minimal'); expect(l.logo).toBe(true); expect(l.brand).toBe(false); expect(Object.keys(l).sort()).toEqual(['brand', 'logo']); }); it('classic: alles', () => { const l = layoutFor('classic'); expect(l.logo).toBe(true); expect(l.brand).toBe(true); expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' }); expect(l.chip).toEqual({ x: 157, y: 326, w: 226, h: 205, r: 32, hLines: [383, 429, 474], vLines: [234, 307], padY: [383, 474], }); expect(l.contactless).toEqual({ path: 'M548.75 379.89a98.21 98.21 0 0 1 0 98.21M527.14 390.7a76.61 76.61 0 0 1 0 76.61' + 'M506.52 400.61a55.39 55.98 0 0 1 0 55.98M485.89 411.32a35.36 33.39 0 0 1 0 35.36', lineWidth: 11.8, box: { x: 480, y: 374, w: 88, h: 110 }, }); expect(l.number).toEqual({ startX: 157, pitch: 45.6, baseline: 650, size: 72, bulletY: 624, bulletR: 8, text: '•••• •••• •••• 8888', }); }); it('signature: Logo, Marke und PLATINUM links', () => { const l = layoutFor('signature'); expect(l.logo).toBe(true); expect(l.brand).toBe(true); expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' }); expect(l.chip).toBeUndefined(); expect(l.contactless).toBeUndefined(); expect(l.number).toBeUndefined(); }); it('liefert unabhängige Kopien', () => { const a = layoutFor('classic'); a.chip!.hLines.push(999); a.number!.text = 'x'; const b = layoutFor('classic'); expect(b.chip!.hLines).toEqual([383, 429, 474]); expect(b.number!.text).toBe('•••• •••• •••• 8888'); }); it('Kontaktlinien des Chips liegen im Chip', () => { const c = layoutFor('classic').chip!; for (const y of c.hLines) expect(y > c.y && y < c.y + c.h).toBe(true); for (const x of c.vLines) expect(x > c.x && x < c.x + c.w).toBe(true); expect(c.padY[0]).toBe(c.hLines[0]); expect(c.padY[1]).toBe(c.hLines[c.hLines.length - 1]); }); it('die festen PLATINUM-Boxen passen zu den Layoutwerten', () => { for (const t of ['classic', 'signature'] as const) { const p = layoutFor(t).platinum!; const box = PLATINUM_BOX[t]!; if (p.align === 'right') expect(box.x + box.w).toBe(p.x); else expect(box.x).toBe(p.x); expect(p.baseline > box.y && p.baseline <= box.y + box.h).toBe(true); } }); it('die Nummernzeile passt in die feste Box', () => { const n = layoutFor('classic').number!; expect(n.startX).toBe(NUMBER_BOX.x); expect(n.baseline).toBe(NUMBER_BOX.y + NUMBER_BOX.h); expect(n.startX + n.text.length * n.pitch).toBeLessThanOrEqual(NUMBER_BOX.x + NUMBER_BOX.w); }); }); describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (t) => { const l = layoutFor(t); const boxes: Record = {}; if (l.chip) boxes.chip = { x: l.chip.x, y: l.chip.y, w: l.chip.w, h: l.chip.h }; if (l.contactless) boxes.contactless = l.contactless.box; if (l.number) boxes.number = NUMBER_BOX; if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!; if (l.logo) { boxes.logoSquare = fitLogo(1); boxes.logoWide = fitLogo(10); } if (l.brand) boxes.brand = fitBrand(3.6); it('hat für jedes Element eine Prüfbox', () => { expect(Object.keys(boxes).length).toBe( { image: 0, minimal: 2, classic: 7, signature: 4 }[t], ); }); for (const [name, box] of Object.entries(boxes)) { it(`${name} liegt im Canvas und schneidet WALLET_KEEP_CLEAR nicht`, () => { expect(insideCanvas(box)).toBe(true); expect(intersects(box, WALLET_KEEP_CLEAR)).toBe(false); }); } });