test(card): loadMarkImage mit Stubs testen, Verweise prüfen, Amex-Umriss freistellen

loadMarkImage: Cache je (id, tone), eingefärbter Blob mit eigener Größe,
Freigabe der Blob-URL bei load und error, erneuter Versuch nach Fehler.
Neuer Test: jedes url(#...) zeigt auf eine id derselben SVG-Datei.
amex-outline: Rahmen nach außen verschoben, berührt "A" und "S" nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:47:04 +00:00
1 parent eb6b4ac59d
commit a610df31dc
2 files changed
+135 -4

No files matched your search

+134 -3
View File
@@ -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 = /^<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);
// 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');
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);
});
});