Files
AirCardIOS/web/src/card/marks.test.ts
T
ClaudeandClaude Opus 5.5 56d841b8e2 fix(logos): SVG-Größe aus Breite und Höhe mit ergänzter viewBox, Pixelgrenze vor dem Dekodieren
Entscheidungen des Controllers zu Task 6 (Bedenken 2 bis 4).

- logos/prepare-logo.ts: sizeSvg(svg) ersetzt sizeSvgRoot und liefert { svg, w, h }. Das Seitenverhältnis kommt aus der brauchbaren
  viewBox des Wurzelelements, sonst aus dessen Breite und Höhe (Pixel oder ohne Einheit), sonst ist es quadratisch; Prozent und
  andere Einheiten, 0 und negative Werte zählen als unbrauchbar. Ohne brauchbare viewBox, aber mit Größe, wird viewBox="0 0 <Breite>
  <Höhe>" ergänzt (eine unbrauchbare viewBox wird dabei ersetzt, nie doppelt geschrieben), damit der Inhalt auf das Raster skaliert
  statt in Originalgröße oben links zu stehen. Eine brauchbare viewBox bleibt immer unberührt. Gelesen werden nur die Attribute des
  Wurzelelements (vorher die erste viewBox irgendwo im Text, etwa die eines inneren <symbol>); Anführungszeichen " und ' gehen
  beide. Tokenizer und Längenparser laufen linear (zwei quadratische Entwürfe fielen im Zeittest durch).
- logos/prepare-logo.ts: Raster und SVG werden über decodeImage aus editor/upload.ts geladen: erst die Maße lesen und gegen
  40 Megapixel prüfen, erst dann dekodieren (vorher dekodierte createImageBitmap zuerst). decodeImages Text für „zu groß“ wird zum
  Text des Logos, jeder andere Fehler bleibt „Das Logo konnte nicht gelesen werden.“ Die eigene Ladefunktion für SVG und
  createImageBitmap entfallen. LOGO_SOURCE_MAX_PIXELS ist jetzt MAX_IMAGE_PIXELS (eine Quelle, Wert 40 000 000 per Test festgehalten).
- logos/prepare-logo.ts: SVG_FALLBACK_SIZE entfernt (nichts benutzte es).
- card/marks.ts: neu viewBoxAspect(value) mit den Regeln aus svgAspect; svgAspect ruft es auf (Verhalten unverändert), so stehen die
  viewBox-Regeln nur einmal da.
- Tests: sizeSvg als Tabelle (Größe ohne Einheit, px, hochkant, Prozent, mm/em/pt/auto, nur Breite oder Höhe, 0 und negativ,
  vorhandene viewBox bleibt, unbrauchbare viewBox wird ersetzt, innere Elemente zählen nicht, XML-Kopf, „>“ im Wert, Zeitgrenze),
  prepareLogo mit gestelltem Image: Zahl der Pixel wird vor decode() geprüft (8001 x 5000 und 20 000 x 20 000 ohne decode, genau
  8000 x 5000 mit), SVG nur mit Breite und Höhe wird 2048 x 614 gerastert und bekommt viewBox="0 0 200 60"; marks.test.ts für
  viewBoxAspect. Vitest 47 Dateien, 1478 Tests grün, tsc sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:26:30 +00:00

310 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('<svg')).toBe(true);
expect(m.svg).toMatch(/viewBox="[^"]+"/);
expect(m.svg).not.toContain('<image');
expect(m.svg).not.toContain('<script');
const a = svgAspect(m.svg);
expect(Number.isFinite(a)).toBe(true);
expect(a).toBeGreaterThan(0);
});
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
for (const m of BRAND_MARKS) {
const root = /^<svg\b[^>]*>/.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('<svg></svg>')).toBe(1);
expect(svgAspect('<svg viewBox="0 0 0 10"></svg>')).toBe(1);
});
it('svgAspect liest die viewBox', () => {
expect(svgAspect('<svg viewBox="0 0 200 100"></svg>')).toBeCloseTo(2, 1);
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).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 = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
expect(tintSvg(svg, 'dark')).toBe(svg);
});
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
const seen = new Map<string, string>();
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(/<mask\b[^>]*\bid="aircards-symbol-[\w-]+"/);
expect(symbol).toMatch(/<rect\b[^>]*\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 = /<mask\b[^>]*>/.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 = /<mask\b[^>]*>\s*<path\b[^>]*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('<path');
expect(svgAspect(text)).toBeGreaterThan(6);
});
it('Marken sind rein Pfad/Form: kein <text>, keine Schrift, keine externen Verweise', () => {
for (const svg of [symbol, text]) {
expect(svg).not.toContain('<text');
expect(svg).not.toContain('font-family');
expect(svg).not.toContain('<style');
expect(svg).not.toMatch(/(?:xlink:)?href\s*=/);
expect(svg).not.toMatch(/url\(\s*['"]?(?!#)/);
}
});
it('Marken verwenden als einzige Farbe currentColor (Masken-Weiß/-Schwarz ausgenommen)', () => {
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 ['<metadata', '<title', '<desc', 'sodipodi', 'inkscape', '<script', '<image', '<text', 'xlink:href']) {
expect(favicon, bad).not.toContain(bad);
}
});
it('jedes url(#…) zeigt auf eine id derselben Datei', () => {
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 = /^<svg\b[^>]*>/.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);
});
});