feat(card): Konfiguration, Geometrie und Bildausschnitt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:29:20 +00:00
1 parent 05a735993b
commit 1a4ed0d595
6 files changed
+858

No files matched your search

+182
View File
@@ -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: '<script>', crop: { ...custom.crop, extra: 1 } });
const c = parseConfig(raw);
expect(c).toEqual(custom);
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'network', 'template', 'v']);
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
});
it('serializeConfig schreibt nur bekannte Felder', () => {
const s = serializeConfig({ ...custom, evil: 1 } as CardConfig);
expect(s).not.toContain('evil');
expect(JSON.parse(s)).toEqual(custom);
});
it('serializeConfig bleibt unter 2000 Zeichen', () => {
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
const worst: CardConfig = {
...custom,
network: 'mc-silver-text',
brand: 'aircards-symbol',
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
};
expect(serializeConfig(worst).length).toBeLessThan(2000);
});
it('parseConfig liefert immer neue Objekte, die Standardwerte lassen sich nicht verändern', () => {
const a = parseConfig(null);
const b = parseConfig(null);
expect(a).not.toBe(b);
expect(a).not.toBe(DEFAULT_CONFIG);
expect(a.crop).not.toBe(b.crop);
expect(a.crop).not.toBe(DEFAULT_CONFIG.crop);
expect(a.crop).not.toBe(DEFAULT_CROP);
a.crop.x = 500;
a.template = 'image';
expect(parseConfig(null)).toEqual(DEFAULT_CONFIG);
expect(DEFAULT_CONFIG.crop.x).toBe(0);
expect(DEFAULT_CONFIG.template).toBe('signature');
expect(DEFAULT_CROP).toEqual({ x: 0, y: 0, zoom: 1 });
const c = parseConfig('{"template":"image"}');
c.crop.zoom = 3;
expect(DEFAULT_CROP.zoom).toBe(1);
expect(parseConfig('{}').crop.zoom).toBe(1);
});
});