import { readdirSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import favicon from '../../public/favicon.svg?raw'; import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg, viewBoxAspect } from './marks'; const BRAND_IDS = ['aircards-symbol', 'aircards-text']; 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('Labels stehen im Wortlaut der Vorlage', () => { expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']); }); 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(BRAND_MARKS.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => { expect(m.svg.trimStart().startsWith(' { for (const m of BRAND_MARKS) { const root = /^]*>/.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)=/); } }); it('svgAspect: ohne brauchbare viewBox 1', () => { expect(svgAspect('')).toBe(1); expect(svgAspect('')).toBe(1); }); it('svgAspect liest die viewBox', () => { expect(svgAspect('')).toBeCloseTo(2, 1); expect(svgAspect('')).toBeCloseTo(3, 1); }); it('viewBoxAspect: Breite durch Höhe aus dem Wert einer viewBox (Leerraum oder Kommas), sonst null', () => { expect(viewBoxAspect('0 0 300 100')).toBeCloseTo(3, 5); expect(viewBoxAspect(' 10,20,200,100 ')).toBeCloseTo(2, 5); expect(viewBoxAspect('0 0 100 100')).toBe(1); for (const unusable of ['', 'none', '0 0 0 10', '0 0 10 0', '0 0 -10 5', '0 0 10', '0 0 1 2 3', '0 0 a b', '0 0 Infinity 5']) { expect(viewBoxAspect(unusable), JSON.stringify(unusable)).toBeNull(); } }); 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 m of BRAND_MARKS) { const dark = tintSvg(m.svg, 'dark'); expect(dark).toContain('#111111'); expect(dark).not.toContain('currentColor'); const light = tintSvg(m.svg, 'light'); expect(light).toContain('#FFFFFF'); expect(light).not.toContain('currentColor'); } }); it('tintSvg lässt ein SVG ohne currentColor unverändert', () => { const svg = ''; expect(tintSvg(svg, 'dark')).toBe(svg); }); it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => { const seen = new Map(); 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); seen.set(id, m.id); } } }); }); describe('Verweise innerhalb der Dateien', () => { 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); }); }); describe('maxH', () => { it('100 für beide Marken', () => { expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true); }); }); describe('AirCard-IOS-Marken', () => { const symbol = getBrandMark('aircards-symbol').svg; const text = getBrandMark('aircards-text').svg; it('Symbol: drei Karten mit Deckkraft .45/.7/1, Wellen als Maske aus der vorderen Karte geschnitten', () => { expect(symbol).toContain('fill-opacity=".45"'); expect(symbol).toContain('fill-opacity=".7"'); expect(symbol).toMatch(/]*\bid="aircards-symbol-[\w-]+"/); expect(symbol).toMatch(/]*\bmask="url\(#aircards-symbol-[\w-]+\)"/); expect(symbol).not.toMatch(/fill-opacity="1"/); }); it('Symbol: die Maske hat keine eigene Region und ihr weißes Feld ist größer als die vordere Karte (kein Haarstrich an der Naht)', () => { const tag = /]*>/.exec(symbol)?.[0] ?? ''; // Ohne x/y/width/height gilt die Standardregion (-10 % / 120 % der Karte); eine Region exakt auf der Kartenkante // würde deren Kantenglättung doppelt anwenden und bei Vergrößerung einen hellen Strich lassen. expect(tag).not.toMatch(/\s(?:x|y|width|height|maskUnits)=/); const white = /]*>\s*]*fill="#fff"[^>]*\bd="M(-?[\d.]+)[ ,](-?[\d.]+)h([\d.]+)v([\d.]+)H/.exec(symbol); expect(white, 'weißes Rechteck am Anfang der Maske').not.toBeNull(); const [x, y, w, h] = white!.slice(1).map(Number); // Die vordere Karte: x 172–852, y 660–1090. expect(x).toBeLessThan(172); expect(y).toBeLessThan(660); expect(x + w).toBeGreaterThan(852); expect(y + h).toBeGreaterThan(1090); }); it('Symbol: enge viewBox um die drei Karten (680 x 730)', () => { expect(svgAspect(symbol)).toBeCloseTo(680 / 730, 3); }); it('Text: nur Pfade, ein breites Wortlogo', () => { expect(text).toContain(', keine Schrift, keine externen Verweise', () => { for (const svg of [symbol, text]) { expect(svg).not.toContain(' { for (const svg of [symbol, text]) { const colours = [...svg.matchAll(/\b(?:fill|stroke)="([^"]+)"/g)].map((h) => h[1].toLowerCase()); for (const c of colours) expect(['currentcolor', 'none', '#fff', '#000'], c).toContain(c); } expect(text).not.toMatch(/#(?:fff|000)/i); }); }); describe('Favicon', () => { it('ist das neue Logo: 1024er Raster, feste Farben, kein currentColor, keine Metadaten', () => { expect(favicon).not.toContain('currentColor'); expect(favicon).toMatch(/viewBox="0 0 1024 1024"/); expect(favicon).toContain('#0e0a24'); expect(favicon).toContain('#8b6cff'); for (const bad of [' { const defined = new Set([...favicon.matchAll(/\bid="([^"]+)"/g)].map((h) => h[1])); const refs = [...favicon.matchAll(/url\(\s*['"]?#([^)'"\s]+)/g)].map((h) => h[1]); expect(refs.length).toBeGreaterThan(0); for (const ref of refs) expect(defined.has(ref), ref).toBe(true); }); }); describe('loadMarkImage', () => { 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 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); 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(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 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'); 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(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('gibt die Blob-URL nach dem Laden frei', async () => { const m = await fresh(); 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.getBrandMark('aircards-symbol'); env.mode = 'error'; const first = m.loadMarkImage(def, 'dark'); await expect(first).rejects.toThrow(/aircards-symbol/); 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); }); });