feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- 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>
This commit is contained in:
1 parent
3051b3b3f9
commit
0be5abd954
44 files changed
+1401
-366
No files matched your search
+48
-109
@@ -1,62 +1,39 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { NETWORK_IDS } from './config';
|
||||
import favicon from '../../public/favicon.svg?raw';
|
||||
import {
|
||||
BRAND_MARKS,
|
||||
NETWORK_MARKS,
|
||||
getBrandMark,
|
||||
getNetworkMark,
|
||||
loadMarkImage,
|
||||
svgAspect,
|
||||
tintSvg,
|
||||
} from './marks';
|
||||
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg } from './marks';
|
||||
|
||||
const NETWORK_LABELS = [
|
||||
'Automatisch', 'Blau', 'Marineblau', 'Metallisches Silber',
|
||||
'Farbe', 'Metallisches Silber', 'Wortmarke', 'Silber + Text', 'Klassisch',
|
||||
'Farbe', 'Metallisches Silber', 'Weißer Rahmen', 'Englisch',
|
||||
'Blau', 'Umriss', 'Select',
|
||||
'JCB', 'Discover',
|
||||
];
|
||||
const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
|
||||
|
||||
const ALL = [...NETWORK_MARKS, ...BRAND_MARKS];
|
||||
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple'];
|
||||
|
||||
describe('Registers', () => {
|
||||
it('NETWORK_MARKS folgt NETWORK_IDS', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.id)).toEqual([...NETWORK_IDS]);
|
||||
});
|
||||
|
||||
it('Labels stehen im Wortlaut der Vorlage in Reihenfolge', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.label)).toEqual(NETWORK_LABELS);
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text', 'Apple']);
|
||||
});
|
||||
|
||||
it('BRAND_MARKS: Logo-Gruppe, feste Reihenfolge', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(['aircards-symbol', 'aircards-text', 'apple']);
|
||||
describe('Register', () => {
|
||||
it('BRAND_MARKS: nur die zwei AirCard-IOS-Marken, in fester Reihenfolge, Logo-Gruppe', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(BRAND_IDS);
|
||||
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
|
||||
});
|
||||
|
||||
it('Gruppen der Netzwerke', () => {
|
||||
const groups = NETWORK_MARKS.map((m) => m.group);
|
||||
expect(groups).toEqual([
|
||||
'Visa', 'Visa', 'Visa', 'Visa',
|
||||
'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard',
|
||||
'UnionPay', 'UnionPay', 'UnionPay', 'UnionPay',
|
||||
'American Express', 'American Express', 'American Express',
|
||||
'JCB', 'Discover',
|
||||
]);
|
||||
it('Labels stehen im Wortlaut der Vorlage', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']);
|
||||
});
|
||||
|
||||
it('getNetworkMark / getBrandMark finden den Eintrag', () => {
|
||||
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id);
|
||||
expect(getBrandMark('apple').id).toBe('apple');
|
||||
it('getBrandMark findet den Eintrag', () => {
|
||||
for (const id of BRAND_IDS) expect(getBrandMark(id as 'aircards-symbol').id).toBe(id);
|
||||
expect(getBrandMark('aircards-text').label).toBe('AirCard-IOS · Text');
|
||||
});
|
||||
|
||||
it('keine Netzwerk- und keine Apple-Marken mehr im Register', () => {
|
||||
const registry = BRAND_MARKS.map((m) => `${m.id} ${m.group} ${m.label}`).join('\n');
|
||||
expect(registry).not.toMatch(/visa|mastercard|unionpay|american express|jcb|discover|apple/i);
|
||||
});
|
||||
|
||||
it('assets/marks enthält nur noch die zwei AirCard-IOS-Dateien (keine fremden Marken-Logos)', () => {
|
||||
const files = readdirSync(join(import.meta.dirname, '../assets/marks')).sort();
|
||||
expect(files).toEqual(['aircards-symbol.svg', 'aircards-text.svg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG-Inhalt', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
expect(m.svg.trimStart().startsWith('<svg')).toBe(true);
|
||||
expect(m.svg).toMatch(/viewBox="[^"]+"/);
|
||||
expect(m.svg).not.toContain('<image');
|
||||
@@ -67,7 +44,7 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
const root = /^<svg\b[^>]*>/.exec(m.svg.trimStart())![0];
|
||||
expect(root, m.id).toContain('xmlns="http://www.w3.org/2000/svg"');
|
||||
expect(root, m.id).not.toMatch(/\s(width|height)=/);
|
||||
@@ -84,24 +61,15 @@ describe('SVG-Inhalt', () => {
|
||||
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
|
||||
});
|
||||
|
||||
it('svgAspect: Visa ist ein breites Wortlogo', () => {
|
||||
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1);
|
||||
});
|
||||
|
||||
it('Einfärbbar genau bei den fünf Tint-Marken', () => {
|
||||
const tinted = ALL.filter((m) => m.tint).map((m) => m.id).sort();
|
||||
expect(tinted).toEqual([...TINT_IDS].sort());
|
||||
});
|
||||
|
||||
it('Tint-Marken enthalten currentColor, alle anderen nicht', () => {
|
||||
for (const m of ALL) {
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(m.tint);
|
||||
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
|
||||
for (const m of BRAND_MARKS) {
|
||||
expect(m.tint, m.id).toBe(true);
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg ersetzt jedes currentColor', () => {
|
||||
for (const id of TINT_IDS) {
|
||||
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
|
||||
for (const m of BRAND_MARKS) {
|
||||
const dark = tintSvg(m.svg, 'dark');
|
||||
expect(dark).toContain('#111111');
|
||||
expect(dark).not.toContain('currentColor');
|
||||
@@ -111,14 +79,14 @@ describe('SVG-Inhalt', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg lässt Marken ohne currentColor unverändert', () => {
|
||||
const m = getNetworkMark('jcb');
|
||||
expect(tintSvg(m.svg, 'dark')).toBe(m.svg);
|
||||
it('tintSvg lässt ein SVG ohne currentColor unverändert', () => {
|
||||
const svg = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
|
||||
expect(tintSvg(svg, 'dark')).toBe(svg);
|
||||
});
|
||||
|
||||
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
|
||||
const id = hit[1];
|
||||
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false);
|
||||
@@ -129,36 +97,15 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
describe('Verweise innerhalb der Dateien', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
const defined = new Set([...m.svg.matchAll(/\bid="([^"]+)"/g)].map((h) => h[1]));
|
||||
const refs = [...m.svg.matchAll(/url\(\s*['"]?#([^)'"\s]+)/g)].map((h) => h[1]);
|
||||
for (const ref of refs) expect(defined.has(ref), `${ref} fehlt in ${m.id}`).toBe(true);
|
||||
});
|
||||
|
||||
it('die Silber-/Verlaufs-Marken verwenden überhaupt Verweise', () => {
|
||||
for (const id of ['visa-silver', 'mc-silver', 'mc-silver-text', 'up-silver', 'jcb', 'discover']) {
|
||||
expect(getNetworkMark(id as never).svg, id).toMatch(/url\(#/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxH', () => {
|
||||
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
|
||||
|
||||
it('109 für Visa, Mastercard-Wortmarken, UnionPay English, Discover', () => {
|
||||
for (const id of ['visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', 'mc-wordmark', 'mc-silver-text', 'up-english', 'discover']) {
|
||||
expect(h(id), id).toBe(109);
|
||||
}
|
||||
});
|
||||
|
||||
it('125 für Symbol-Logos', () => {
|
||||
for (const id of ['mc-color', 'mc-silver', 'mc-classic', 'up-color', 'up-silver', 'up-white-frame', 'amex-blue', 'amex-outline', 'jcb']) {
|
||||
expect(h(id), id).toBe(125);
|
||||
}
|
||||
});
|
||||
|
||||
it('90 für Amex Select, 100 für alle Marken', () => {
|
||||
expect(h('amex-select')).toBe(90);
|
||||
it('100 für beide Marken', () => {
|
||||
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -290,11 +237,11 @@ describe('loadMarkImage', () => {
|
||||
|
||||
it('gleiches (id, tone) liefert dieselbe Promise und lädt nur einmal, anderer Ton eine andere', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
const a = m.loadMarkImage(visa, 'dark');
|
||||
const b = m.loadMarkImage(visa, 'dark');
|
||||
const c = m.loadMarkImage(visa, 'light');
|
||||
const d = m.loadMarkImage(m.getNetworkMark('jcb'), 'dark');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
const a = m.loadMarkImage(symbol, 'dark');
|
||||
const b = m.loadMarkImage(symbol, 'dark');
|
||||
const c = m.loadMarkImage(symbol, 'light');
|
||||
const d = m.loadMarkImage(m.getBrandMark('aircards-text'), 'dark');
|
||||
expect(b).toBe(a);
|
||||
expect(c).not.toBe(a);
|
||||
expect(d).not.toBe(a);
|
||||
@@ -302,15 +249,15 @@ describe('loadMarkImage', () => {
|
||||
expect(imgs[0]).toBeInstanceOf(FakeImage);
|
||||
expect(blobs).toHaveLength(3);
|
||||
// nach dem Laden bleibt der Eintrag im Cache
|
||||
expect(m.loadMarkImage(visa, 'dark')).toBe(a);
|
||||
expect(m.loadMarkImage(symbol, 'dark')).toBe(a);
|
||||
expect(blobs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
await m.loadMarkImage(visa, 'dark');
|
||||
await m.loadMarkImage(visa, 'light');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
await m.loadMarkImage(symbol, 'dark');
|
||||
await m.loadMarkImage(symbol, 'light');
|
||||
const [dark, light] = await Promise.all(blobs.map((b) => b.text()));
|
||||
expect(blobs[0].type).toBe('image/svg+xml');
|
||||
expect(dark).toContain('#111111');
|
||||
@@ -320,33 +267,25 @@ describe('loadMarkImage', () => {
|
||||
const root = /^<svg\b[^>]*>/.exec(dark)![0];
|
||||
const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]);
|
||||
const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]);
|
||||
expect(h).toBe(visa.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(visa.svg), 1);
|
||||
expect(h).toBe(symbol.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(symbol.svg), 1);
|
||||
// Bild bekommt die Blob-URL
|
||||
expect(env.images[0].src).toBe('blob:fake/1');
|
||||
expect(env.images[1].src).toBe('blob:fake/2');
|
||||
});
|
||||
|
||||
it('Marken ohne currentColor bleiben im Blob unverändert (bis auf die Größe)', async () => {
|
||||
const m = await fresh();
|
||||
const jcb = m.getNetworkMark('jcb');
|
||||
await m.loadMarkImage(jcb, 'dark');
|
||||
const text = await blobs[0].text();
|
||||
expect(text.replace(/^<svg width="[\d.]+" height="[\d.]+"/, '<svg')).toBe(jcb.svg);
|
||||
});
|
||||
|
||||
it('gibt die Blob-URL nach dem Laden frei', async () => {
|
||||
const m = await fresh();
|
||||
await m.loadMarkImage(m.getNetworkMark('visa-blue'), 'light');
|
||||
await m.loadMarkImage(m.getBrandMark('aircards-text'), 'light');
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
});
|
||||
|
||||
it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => {
|
||||
const m = await fresh();
|
||||
const def = m.getNetworkMark('visa-auto');
|
||||
const def = m.getBrandMark('aircards-symbol');
|
||||
env.mode = 'error';
|
||||
const first = m.loadMarkImage(def, 'dark');
|
||||
await expect(first).rejects.toThrow(/visa-auto/);
|
||||
await expect(first).rejects.toThrow(/aircards-symbol/);
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
|
||||
env.mode = 'load';
|
||||
|
||||
Reference in new issue
Block a user