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

+1 -1
View File
@@ -1 +1 @@
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-width="36" d="M18 18h961.517v961.517H18z"/><path fill="currentColor" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg> <svg viewBox="-66 -67.24 1132.01 1132.01" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-width="36" d="M-48-49.24h1096v1096H-48z"/><path fill="currentColor" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

+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 { NETWORK_IDS } from './config';
import favicon from '../../public/favicon.svg?raw'; import favicon from '../../public/favicon.svg?raw';
import { 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', () => { describe('maxH', () => {
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH; const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
@@ -159,7 +173,124 @@ describe('Favicon', () => {
}); });
describe('loadMarkImage', () => { describe('loadMarkImage', () => {
it('ist eine Funktion', () => { type Mode = 'load' | 'error';
expect(typeof loadMarkImage).toBe('function');
/** 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);
}); });
}); });