From 1a4ed0d595bc93630af6566a52f72aa929be812c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:29:20 +0000 Subject: [PATCH] feat(card): Konfiguration, Geometrie und Bildausschnitt Co-Authored-By: Claude Opus 5.5 --- web/src/card/config.test.ts | 182 +++++++++++++++++++++++++++++ web/src/card/config.ts | 116 ++++++++++++++++++ web/src/card/crop.test.ts | 197 +++++++++++++++++++++++++++++++ web/src/card/crop.ts | 60 ++++++++++ web/src/card/geometry.test.ts | 214 ++++++++++++++++++++++++++++++++++ web/src/card/geometry.ts | 89 ++++++++++++++ 6 files changed, 858 insertions(+) create mode 100644 web/src/card/config.test.ts create mode 100644 web/src/card/config.ts create mode 100644 web/src/card/crop.test.ts create mode 100644 web/src/card/crop.ts create mode 100644 web/src/card/geometry.test.ts create mode 100644 web/src/card/geometry.ts diff --git a/web/src/card/config.test.ts b/web/src/card/config.test.ts new file mode 100644 index 0000000..07ebd07 --- /dev/null +++ b/web/src/card/config.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_CONFIG, + DEFAULT_CROP, + NETWORK_IDS, + TONE_COLOR, + parseConfig, + serializeConfig, + type CardConfig, +} from './config'; + +const custom: CardConfig = { + v: 1, + template: 'classic', + network: 'jcb', + details: 'dark', + brand: 'apple', + crop: { x: -120.5, y: 33, zoom: 2.5 }, +}; + +describe('Standardwerte', () => { + it('DEFAULT_CONFIG hat die vereinbarten Werte', () => { + expect(DEFAULT_CONFIG).toEqual({ + v: 1, + template: 'signature', + network: 'visa-auto', + details: 'light', + brand: 'aircards-symbol', + crop: { x: 0, y: 0, zoom: 1 }, + }); + expect(DEFAULT_CROP).toEqual({ x: 0, y: 0, zoom: 1 }); + expect(DEFAULT_CONFIG.crop).toEqual(DEFAULT_CROP); + }); + + it('DEFAULT_CONFIG.crop ist ein eigenes Objekt, nicht DEFAULT_CROP', () => { + expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP); + }); + + it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => { + expect(NETWORK_IDS.length).toBe(18); + expect([...NETWORK_IDS]).toEqual([ + 'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', + 'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic', + 'up-color', 'up-silver', 'up-white-frame', 'up-english', + 'amex-blue', 'amex-outline', 'amex-select', + 'jcb', 'discover', + ]); + expect(new Set(NETWORK_IDS).size).toBe(18); + }); + + it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => { + expect(TONE_COLOR).toEqual({ light: '#FFFFFF', dark: '#111111' }); + }); +}); + +describe('parseConfig / serializeConfig', () => { + it('Rundlauf ergibt dieselbe Konfiguration', () => { + expect(parseConfig(serializeConfig(custom))).toEqual(custom); + expect(parseConfig(serializeConfig(DEFAULT_CONFIG))).toEqual(DEFAULT_CONFIG); + }); + + it('null, undefined, leer und kaputtes JSON ergeben die Standardkonfiguration', () => { + expect(parseConfig(null)).toEqual(DEFAULT_CONFIG); + expect(parseConfig(undefined)).toEqual(DEFAULT_CONFIG); + expect(parseConfig('')).toEqual(DEFAULT_CONFIG); + expect(parseConfig('kaputt{')).toEqual(DEFAULT_CONFIG); + }); + + it('JSON, das kein Objekt ist, ergibt die Standardkonfiguration', () => { + for (const raw of ['null', '42', '"signature"', '[]', 'true']) { + expect(parseConfig(raw)).toEqual(DEFAULT_CONFIG); + } + }); + + it('unbekanntes Netzwerk setzt nur dieses Feld auf den Standard', () => { + const raw = JSON.stringify({ ...custom, network: 'paypal' }); + expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network }); + }); + + it('jedes Feld wird einzeln validiert', () => { + const raw = JSON.stringify({ ...custom, template: 'foo', details: 'blue', brand: 5 }); + expect(parseConfig(raw)).toEqual({ + ...custom, + template: DEFAULT_CONFIG.template, + details: DEFAULT_CONFIG.details, + brand: DEFAULT_CONFIG.brand, + }); + }); + + it('Namen aus Object.prototype gelten nicht als gültige Werte', () => { + const raw = JSON.stringify({ ...custom, network: 'constructor', template: '__proto__', brand: 'toString' }); + expect(parseConfig(raw)).toEqual({ + ...custom, + network: DEFAULT_CONFIG.network, + template: DEFAULT_CONFIG.template, + brand: DEFAULT_CONFIG.brand, + }); + }); + + it('fehlende Felder bekommen den Standard', () => { + expect(parseConfig('{"template":"image"}')).toEqual({ ...DEFAULT_CONFIG, template: 'image' }); + expect(parseConfig('{}')).toEqual(DEFAULT_CONFIG); + }); + + it('zoom wird auf 1 bis 4 begrenzt', () => { + expect(parseConfig(JSON.stringify({ ...custom, crop: { x: 0, y: 0, zoom: 99 } })).crop.zoom).toBe(4); + expect(parseConfig(JSON.stringify({ ...custom, crop: { x: 0, y: 0, zoom: 0.2 } })).crop.zoom).toBe(1); + expect(parseConfig(JSON.stringify({ ...custom, crop: { x: 0, y: 0, zoom: -3 } })).crop.zoom).toBe(1); + }); + + it('nicht endliche oder falsch typisierte Zahlen fallen auf den Standard des Feldes zurück', () => { + // NaN und Infinity werden von JSON.stringify zu null + const nan = serializeConfig({ ...custom, crop: { x: NaN, y: Infinity, zoom: NaN } }); + expect(parseConfig(nan).crop).toEqual({ x: 0, y: 0, zoom: 1 }); + const text = parseConfig('{"crop":{"x":"12","y":"NaN","zoom":"3"}}'); + expect(text.crop).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + + it('crop.x NaN setzt nur x zurück', () => { + const raw = serializeConfig({ ...custom, crop: { x: NaN, y: 33, zoom: 2.5 } }); + expect(parseConfig(raw).crop).toEqual({ x: 0, y: 33, zoom: 2.5 }); + }); + + it('fehlender oder kaputter crop ergibt den Standard-Ausschnitt', () => { + expect(parseConfig('{"crop":"abc"}').crop).toEqual(DEFAULT_CROP); + expect(parseConfig('{"crop":null}').crop).toEqual(DEFAULT_CROP); + expect(parseConfig('{"crop":[1,2,3]}').crop).toEqual(DEFAULT_CROP); + expect(parseConfig('{"template":"minimal"}').crop).toEqual(DEFAULT_CROP); + }); + + it('x und y werden nicht begrenzt (das macht clampCrop mit dem Bild)', () => { + const c = parseConfig(JSON.stringify({ ...custom, crop: { x: -9999, y: 5000, zoom: 1 } })); + expect(c.crop).toEqual({ x: -9999, y: 5000, zoom: 1 }); + }); + + it('unbekannte Felder werden verworfen', () => { + const raw = JSON.stringify({ ...custom, evil: '