diff --git a/web/src/assets/marks/amex-outline.svg b/web/src/assets/marks/amex-outline.svg
index 236dd40..a569042 100644
--- a/web/src/assets/marks/amex-outline.svg
+++ b/web/src/assets/marks/amex-outline.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/web/src/card/marks.test.ts b/web/src/card/marks.test.ts
index 651af51..d680ce3 100644
--- a/web/src/card/marks.test.ts
+++ b/web/src/card/marks.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { NETWORK_IDS } from './config';
import favicon from '../../public/favicon.svg?raw';
import {
@@ -128,6 +128,20 @@ 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) => {
+ 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;
@@ -159,7 +173,124 @@ describe('Favicon', () => {
});
describe('loadMarkImage', () => {
- it('ist eine Funktion', () => {
- expect(typeof loadMarkImage).toBe('function');
+ type Mode = 'load' | 'error';
+
+ /** Ersatz für `Image`: feuert onload/onerror beim Setzen von `src` (je nach `mode`). */
+ const env = { mode: 'load' as Mode, images: [] as FakeImage[] };
+ class FakeImage {
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ #src = '';
+ get src() {
+ return this.#src;
+ }
+ set src(value: string) {
+ this.#src = value;
+ env.images.push(this);
+ queueMicrotask(() => (env.mode === 'load' ? this.onload?.() : this.onerror?.()));
+ }
+ }
+
+ const blobs: Blob[] = [];
+ const revoked: string[] = [];
+ const realCreate = URL.createObjectURL;
+ const realRevoke = URL.revokeObjectURL;
+
+ beforeEach(() => {
+ env.mode = 'load';
+ env.images.length = 0;
+ blobs.length = 0;
+ revoked.length = 0;
+ URL.createObjectURL = vi.fn((blob: Blob) => {
+ blobs.push(blob);
+ return `blob:fake/${blobs.length}`;
+ });
+ URL.revokeObjectURL = vi.fn((url: string) => {
+ revoked.push(url);
+ });
+ vi.stubGlobal('Image', FakeImage);
+ });
+
+ afterEach(() => {
+ URL.createObjectURL = realCreate;
+ URL.revokeObjectURL = realRevoke;
+ vi.unstubAllGlobals();
+ });
+
+ /** Frisches Modul je Test, damit der Bild-Cache leer startet. */
+ async function fresh() {
+ vi.resetModules();
+ return import('./marks');
+ }
+
+ 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');
+ expect(b).toBe(a);
+ expect(c).not.toBe(a);
+ expect(d).not.toBe(a);
+ const imgs = await Promise.all([a, c, d]);
+ 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(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 [dark, light] = await Promise.all(blobs.map((b) => b.text()));
+ expect(blobs[0].type).toBe('image/svg+xml');
+ expect(dark).toContain('#111111');
+ expect(dark).not.toContain('currentColor');
+ expect(light).toContain('#FFFFFF');
+ expect(light).not.toContain('#111111');
+ const root = /^