- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
8.2 KiB
TypeScript
210 lines
8.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
DEFAULT_CONFIG,
|
|
DEFAULT_CROP,
|
|
ID_PATTERN,
|
|
TONE_COLOR,
|
|
parseConfig,
|
|
serializeConfig,
|
|
type CardConfig,
|
|
} from './config';
|
|
|
|
const custom: CardConfig = {
|
|
v: 1,
|
|
template: 'classic',
|
|
logo: 'abc123',
|
|
details: 'dark',
|
|
brand: 'aircards-text',
|
|
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',
|
|
logo: null,
|
|
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('eine neue Karte hat kein Logo unten rechts', () => {
|
|
expect(DEFAULT_CONFIG.logo).toBeNull();
|
|
});
|
|
|
|
it('ID_PATTERN: Appwrite-IDs mit höchstens 36 Zeichen und ohne Sonderzeichen am Anfang', () => {
|
|
for (const ok of ['abc123', 'A', '0', 'a.b_c-d', 'x'.repeat(36)]) expect(ID_PATTERN.test(ok), ok).toBe(true);
|
|
for (const bad of ['', 'kaputt id', '-abc', '_abc', '.abc', 'ä', 'a/b', 'x'.repeat(37)]) expect(ID_PATTERN.test(bad), bad).toBe(false);
|
|
});
|
|
|
|
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('eine alte Konfiguration mit network und Apple-Logo: network entfällt, Logo oben links fällt auf den Standard', () => {
|
|
const raw = '{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}';
|
|
expect(parseConfig(raw)).toEqual({
|
|
v: 1,
|
|
template: 'classic',
|
|
logo: null,
|
|
details: 'dark',
|
|
brand: 'aircards-symbol',
|
|
crop: { x: 0, y: 0, zoom: 1 },
|
|
});
|
|
// andere unbekannte Marken ebenso
|
|
expect(parseConfig('{"brand":"visa"}').brand).toBe('aircards-symbol');
|
|
});
|
|
|
|
it('logo: eine gültige ID bleibt, alles andere ergibt null', () => {
|
|
expect(parseConfig('{"logo":"abc123"}').logo).toBe('abc123');
|
|
expect(parseConfig(JSON.stringify({ logo: 'x'.repeat(36) })).logo).toBe('x'.repeat(36));
|
|
for (const bad of ['kaputt id', '', '-abc', 'x'.repeat(37), 42, true, null, ['abc'], { id: 'abc' }]) {
|
|
expect(parseConfig(JSON.stringify({ logo: bad })).logo, JSON.stringify(bad)).toBeNull();
|
|
}
|
|
expect(parseConfig('{}').logo).toBeNull();
|
|
});
|
|
|
|
it('das gewählte Logo bleibt nur dem Feld logo zugeordnet: ein altes network wird nie zum Logo', () => {
|
|
expect(parseConfig('{"network":"abc123"}').logo).toBeNull();
|
|
});
|
|
|
|
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, template: '__proto__', details: 'constructor', brand: 'toString' });
|
|
expect(parseConfig(raw)).toEqual({
|
|
...custom,
|
|
template: DEFAULT_CONFIG.template,
|
|
details: DEFAULT_CONFIG.details,
|
|
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', 'logo', 'template', 'v']);
|
|
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
|
|
});
|
|
|
|
it('serializeConfig schreibt nur bekannte Felder', () => {
|
|
const s = serializeConfig({ ...custom, evil: 1, network: 'jcb' } as CardConfig);
|
|
expect(s).not.toContain('evil');
|
|
expect(s).not.toContain('network');
|
|
expect(JSON.parse(s)).toEqual(custom);
|
|
});
|
|
|
|
it('serializeConfig schreibt genau {v, template, logo, details, brand, crop} in dieser Reihenfolge', () => {
|
|
expect(Object.keys(JSON.parse(serializeConfig(custom)))).toEqual(['v', 'template', 'logo', 'details', 'brand', 'crop']);
|
|
expect(serializeConfig(DEFAULT_CONFIG)).toBe(
|
|
'{"v":1,"template":"signature","logo":null,"details":"light","brand":"aircards-symbol","crop":{"x":0,"y":0,"zoom":1}}',
|
|
);
|
|
});
|
|
|
|
it('serializeConfig bleibt unter 2000 Zeichen', () => {
|
|
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
|
|
const worst: CardConfig = {
|
|
...custom,
|
|
logo: 'x'.repeat(36),
|
|
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);
|
|
});
|
|
});
|