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 = /^]*>/.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); + // 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(/^ { + const m = await fresh(); + await m.loadMarkImage(m.getNetworkMark('visa-blue'), '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'); + env.mode = 'error'; + const first = m.loadMarkImage(def, 'dark'); + await expect(first).rejects.toThrow(/visa-auto/); + expect(revoked).toEqual(['blob:fake/1']); + + env.mode = 'load'; + const second = m.loadMarkImage(def, 'dark'); + expect(second).not.toBe(first); + await expect(second).resolves.toBeInstanceOf(FakeImage); + expect(blobs).toHaveLength(2); + expect(revoked).toEqual(['blob:fake/1', 'blob:fake/2']); + // der erfolgreiche Versuch bleibt zwischengespeichert + expect(m.loadMarkImage(def, 'dark')).toBe(second); }); });