Files
AirCardIOS/web/src/card/render.test.ts
T
ClaudeandClaude Opus 5.5 0be5abd954 feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
  sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
  wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
  ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
  der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
  loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
  Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
  createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
  gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
  Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
  rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
  Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
  ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
  auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
  fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
  es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
  logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
  Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:32:45 +00:00

539 lines
23 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadLogoAsset } from '../logos/logo-asset';
import { DEFAULT_CONFIG, type CardConfig } from './config';
import { fitLogo } from './geometry';
import { loadMarkImage } from './marks';
import {
drawCard,
exportCard,
loadCardAssets,
renderCard,
type CardAssets,
type CardImage,
type Ctx,
type LogoAsset,
} from './render';
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
vi.mock('./marks', () => {
const mark = (id: string, viewBox: string, maxH: number) => ({
id,
group: 'Test',
label: id,
svg: `<svg viewBox="${viewBox}"></svg>`,
tint: true,
maxH,
});
return {
getBrandMark: (id: string) => mark(id, '0 0 100 100', 100),
svgAspect: (svg: string) => {
const p = /viewBox="([^"]+)"/.exec(svg)![1].split(' ').map(Number);
return p[2] / p[3];
},
loadMarkImage: vi.fn(),
};
});
// Der Standard-Lader der Logos (Appwrite, Dekodierung) kommt in diesen Tests nie zum Einsatz.
vi.mock('../logos/logo-asset', () => ({ loadLogoAsset: vi.fn() }));
interface Call {
name: string;
args: unknown[];
fillStyle: unknown;
strokeStyle: unknown;
lineWidth: number;
lineCap: string;
font: string;
textAlign: string;
}
interface Gradient {
kind: 'gradient';
args: number[];
stops: [number, string][];
addColorStop(offset: number, color: string): void;
}
const METHODS = [
'save', 'restore', 'scale', 'setTransform', 'fillRect', 'drawImage', 'fillText', 'beginPath',
'arc', 'fill', 'stroke', 'roundRect', 'clip', 'moveTo', 'lineTo',
] as const;
/** Zeichnender Fake: protokolliert jeden Aufruf samt den Stilwerten im Moment des Aufrufs. */
function fakeCtx() {
const calls: Call[] = [];
const ctx: Record<string, unknown> = {
fillStyle: '#000000',
strokeStyle: '#000000',
lineWidth: 1,
lineCap: 'butt',
font: '10px sans-serif',
textAlign: 'start',
textBaseline: 'alphabetic',
};
const record = (name: string, args: unknown[]) =>
calls.push({
name,
args,
fillStyle: ctx.fillStyle,
strokeStyle: ctx.strokeStyle,
lineWidth: ctx.lineWidth as number,
lineCap: ctx.lineCap as string,
font: ctx.font as string,
textAlign: ctx.textAlign as string,
});
for (const name of METHODS) ctx[name] = (...args: unknown[]) => void record(name, args);
ctx.measureText = (text: string) => ({ width: text.length * 20 });
ctx.createLinearGradient = (...args: number[]) => {
record('createLinearGradient', args);
const g: Gradient = {
kind: 'gradient',
args,
stops: [],
addColorStop(offset, color) {
this.stops.push([offset, color]);
},
};
return g;
};
const strokePath = (c: Ctx, d: string) => {
void c;
record('strokePath', [d]);
};
return { ctx: ctx as unknown as Ctx, calls, strokePath };
}
const LOGO_SOURCE = { tag: 'logo' } as unknown as CanvasImageSource;
const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
/** Ein Community-Logo mit Seitenverhältnis 2 : 1 (die Größe in Pixeln spielt für den Platz auf der Karte keine Rolle). */
const LOGO: LogoAsset = { source: LOGO_SOURCE, w: 200, h: 100 };
const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
image: null,
logo: LOGO,
brand: BRAND,
...over,
});
const photo = (w: number, h: number): CardImage => ({ source: PHOTO, w, h });
const cfg = (over: Partial<CardConfig> = {}): CardConfig => ({
...DEFAULT_CONFIG,
crop: { ...DEFAULT_CONFIG.crop },
...over,
});
function render(config: CardConfig, a: CardAssets) {
const f = fakeCtx();
renderCard(f.ctx, config, a, f.strokePath);
return f.calls;
}
const named = (calls: Call[], name: string) => calls.filter((c) => c.name === name);
const drawsOf = (calls: Call[], source: unknown) =>
named(calls, 'drawImage').filter((c) => c.args[0] === source);
const letters = (calls: Call[]) => named(calls, 'fillText').filter((c) => /[A-Z]/.test(c.args[0] as string));
describe('renderCard: Grund und Bild', () => {
it('beginnt mit dem Kartengrund, auch mit Bild', () => {
for (const a of [assets(), assets({ image: photo(1536, 969) })]) {
const first = render(cfg({ template: 'image' }), a)[0];
expect(first.name).toBe('fillRect');
expect(first.args).toEqual([0, 0, 1536, 969]);
expect(first.fillStyle).toBe('#1B2738');
}
});
it('Vorlage image: genau ein drawImage (das Bild), kein Text', () => {
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
expect(named(calls, 'drawImage')).toHaveLength(1);
expect(drawsOf(calls, PHOTO)).toHaveLength(1);
expect(named(calls, 'fillText')).toHaveLength(0);
});
it('ohne Bild: kein Bild-drawImage, Grund gefüllt', () => {
const calls = render(cfg({ template: 'classic' }), assets());
expect(drawsOf(calls, PHOTO)).toHaveLength(0);
expect(calls[0]).toMatchObject({ name: 'fillRect', fillStyle: '#1B2738' });
});
it('Bild ohne Zuschnitt füllt die Karte genau', () => {
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
expect(drawsOf(calls, PHOTO)[0].args).toEqual([PHOTO, 0, 0, 1536, 969]);
});
it('Bild 3000x1000 mit crop.x = 9999: Rechteck deckt die ganze Karte', () => {
const config = cfg({ template: 'image', crop: { x: 9999, y: 0, zoom: 1 } });
const [, x, y, w, h] = drawsOf(render(config, assets({ image: photo(3000, 1000) })), PHOTO)[0].args as number[];
expect(x).toBeCloseTo(0, 1);
expect(y).toBeCloseTo(0, 1);
expect(x + w).toBeGreaterThanOrEqual(1536 - 0.01);
expect(y + h).toBeGreaterThanOrEqual(969 - 0.01);
});
it('kaputte oder zu große Ausschnitte (anderes Bild, NaN, Zoom 10) lassen keine Streifen', () => {
const crops = [
{ x: 9999, y: -9999, zoom: 1 },
{ x: -500, y: 400, zoom: 10 },
{ x: Number.NaN, y: Number.POSITIVE_INFINITY, zoom: Number.NaN },
{ x: 120, y: 80, zoom: 0.2 },
];
for (const img of [photo(3000, 1000), photo(800, 2400), photo(1536, 969)]) {
for (const crop of crops) {
const calls = render(cfg({ template: 'image', crop }), assets({ image: img }));
const [, x, y, w, h] = drawsOf(calls, PHOTO)[0].args as number[];
expect(x).toBeLessThanOrEqual(0.01);
expect(y).toBeLessThanOrEqual(0.01);
expect(x + w).toBeGreaterThanOrEqual(1536 - 0.01);
expect(y + h).toBeGreaterThanOrEqual(969 - 0.01);
}
}
});
});
describe('renderCard: Vorlagen', () => {
it('minimal: nur das Community-Logo unten rechts (Seitenverhältnis 2 : 1 → 250 × 125, rechte Kante 1480, Unterkante 913)', () => {
const calls = render(cfg({ template: 'minimal' }), assets());
expect(named(calls, 'drawImage')).toHaveLength(1);
const [src, x, y, w, h] = drawsOf(calls, LOGO_SOURCE)[0].args as number[];
expect(src).toBe(LOGO_SOURCE);
expect([x, y, w, h]).toEqual([1230, 788, 250, 125]);
expect(named(calls, 'fillText')).toHaveLength(0);
});
it('das Logo sitzt dort, wo fitLogo es hinlegt (Seitenverhältnis aus w und h des Logos)', () => {
const wide: LogoAsset = { source: LOGO_SOURCE, w: 720, h: 72 };
const [src, x, y, w, h] = drawsOf(render(cfg({ template: 'classic' }), assets({ logo: wide })), LOGO_SOURCE)[0].args as number[];
const box = fitLogo(10);
expect(src).toBe(LOGO_SOURCE);
expect([x, y, w, h]).toEqual([box.x, box.y, box.w, box.h]);
expect(w).toBe(360);
});
it('Vorlage image: auch mit Logo wird es nicht gezeichnet', () => {
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(0);
expect(named(calls, 'drawImage')).toHaveLength(1);
});
it('ohne Logo (assets.logo null) wird nichts unten rechts gezeichnet, auch wenn die Konfiguration eines wählt', () => {
for (const template of ['minimal', 'classic', 'signature'] as const) {
const calls = render(cfg({ template, logo: 'abc123' }), assets({ logo: null }));
expect(drawsOf(calls, LOGO_SOURCE), template).toHaveLength(0);
}
});
it('das Logo hängt nur an den Bildern, nicht an config.logo (die Bilder gehören zur Konfiguration, mit der sie geladen wurden)', () => {
const calls = render(cfg({ template: 'minimal', logo: null }), assets());
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
});
it('classic dunkel: Text in #111111, PLATINUM endet bei x 1480, Ziffern und Punkte stimmen', () => {
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
const texts = named(calls, 'fillText');
expect(texts.length).toBe(8 + 4);
expect(texts.every((c) => c.fillStyle === '#111111')).toBe(true);
const plat = letters(calls);
expect(plat.map((c) => c.args[0]).join('')).toBe('PLATINUM');
expect(plat.every((c) => c.args[2] === 123 && c.textAlign === 'left' && c.font === '700 46px "Nunito Sans Variable"')).toBe(true);
const last = plat[plat.length - 1];
expect((last.args[1] as number) + 20).toBeCloseTo(1480, 1);
// Abstand zwischen zwei Buchstaben = Breite 20 + Laufweite 9
expect((plat[1].args[1] as number) - (plat[0].args[1] as number)).toBeCloseTo(29, 1);
const eights = texts.filter((c) => c.args[0] === '8');
expect(eights).toHaveLength(4);
expect(eights.map((c) => c.args[1] as number)[0]).toBeCloseTo(863.8, 1);
expect(eights.map((c) => c.args[1] as number)[1]).toBeCloseTo(909.4, 1);
expect(eights.map((c) => c.args[1] as number)[2]).toBeCloseTo(955, 1);
expect(eights.map((c) => c.args[1] as number)[3]).toBeCloseTo(1000.6, 1);
expect(eights.every((c) => c.args[2] === 650 && c.textAlign === 'center' && c.font === '700 72px "Nunito Sans Variable"')).toBe(true);
const bullets = named(calls, 'arc');
expect(bullets).toHaveLength(12);
expect(bullets.every((c) => c.args[1] === 624 && c.args[2] === 8 && c.fillStyle === '#111111')).toBe(true);
expect(bullets[0].args[0]).toBeCloseTo(179.8, 1);
expect(bullets[3].args[0]).toBeCloseTo(316.6, 1);
expect(bullets[4].args[0]).toBeCloseTo(157 + 45.6 * 5 + 22.8, 1);
expect(bullets[11].args[0]).toBeCloseTo(157 + 45.6 * 13 + 22.8, 1);
expect(named(calls, 'fill').length).toBeGreaterThanOrEqual(12);
});
it('classic hell: Text in Weiß', () => {
const calls = render(cfg({ template: 'classic', details: 'light' }), assets());
expect(named(calls, 'fillText').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
});
it('classic: Brand-Logo links oben, Community-Logo unten rechts', () => {
const calls = render(cfg({ template: 'classic' }), assets());
expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]);
expect(drawsOf(calls, LOGO_SOURCE)[0].args).toEqual([LOGO_SOURCE, 1230, 788, 250, 125]);
});
it('classic: Kontaktlos-Bögen in Details-Farbe mit runden Enden', () => {
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
const arcs = named(calls, 'strokePath');
expect(arcs).toHaveLength(1);
expect(String(arcs[0].args[0]).startsWith('M548.75 379.89')).toBe(true);
expect(arcs[0]).toMatchObject({ strokeStyle: '#111111', lineWidth: 11.8, lineCap: 'round' });
});
it('signature, Brand none: kein Brand-drawImage, PLATINUM beginnt bei x 60', () => {
const calls = render(cfg({ template: 'signature', brand: 'none' }), assets());
expect(drawsOf(calls, BRAND)).toHaveLength(0);
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
const plat = letters(calls);
expect(plat[0].args).toEqual(['P', 60, 226]);
expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
expect(plat[0].textAlign).toBe('left');
// Laufweite 8: Buchstabe 2 beginnt bei 60 + 20 + 8
expect(plat[1].args[1]).toBeCloseTo(88, 1);
expect(named(calls, 'roundRect')).toHaveLength(0);
expect(named(calls, 'arc')).toHaveLength(0);
});
it('signature mit Brand: Logo oben links', () => {
const calls = render(cfg({ template: 'signature' }), assets());
expect(drawsOf(calls, BRAND)).toHaveLength(1);
});
it('fehlende Logo-Bilder werden übersprungen', () => {
const calls = render(cfg({ template: 'classic' }), assets({ logo: null, brand: null }));
expect(named(calls, 'drawImage')).toHaveLength(0);
expect(letters(calls)).toHaveLength(8);
});
});
describe('renderCard: Chip', () => {
it('hell: heller Verlauf, Linien #7A7A7A, Breite 3, auf das Rechteck beschnitten', () => {
const calls = render(cfg({ template: 'classic', details: 'light' }), assets());
const grad = named(calls, 'createLinearGradient');
expect(grad).toHaveLength(1);
const g = calls.find((c) => c.fillStyle && (c.fillStyle as Gradient).kind === 'gradient')!.fillStyle as Gradient;
expect(g.stops).toEqual([[0, '#F2F2F2'], [1, '#A9A9A9']]);
const rects = named(calls, 'roundRect');
expect(rects).toHaveLength(2); // Füllung und Beschnitt
expect(rects[0].args).toEqual([157, 326, 226, 205, 32]);
expect(rects[1].args).toEqual([157, 326, 226, 205, 32]);
const names = calls.map((c) => c.name);
const clipAt = names.indexOf('clip');
expect(names.slice(clipAt - 3, clipAt)).toEqual(['save', 'beginPath', 'roundRect']);
const restoreAt = names.indexOf('restore', clipAt);
const strokes = calls.slice(clipAt, restoreAt).filter((c) => c.name === 'stroke');
expect(strokes.length).toBeGreaterThanOrEqual(1);
expect(strokes.every((c) => c.strokeStyle === '#7A7A7A' && c.lineWidth === 3)).toBe(true);
// Linien: 3 waagerecht über die ganze Chipbreite, 2 senkrecht von 383 bis 474
const moves = calls.slice(clipAt, restoreAt).filter((c) => c.name === 'moveTo').map((c) => c.args);
const lines = calls.slice(clipAt, restoreAt).filter((c) => c.name === 'lineTo').map((c) => c.args);
expect(moves).toEqual([[157, 383], [157, 429], [157, 474], [234, 383], [307, 383]]);
expect(lines).toEqual([[383, 383], [383, 429], [383, 474], [234, 474], [307, 474]]);
});
it('dunkel: dunkler Verlauf, Linien #0A0A0A', () => {
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
const g = calls.find((c) => c.fillStyle && (c.fillStyle as Gradient).kind === 'gradient')!.fillStyle as Gradient;
expect(g.stops).toEqual([[0, '#4A4A4A'], [1, '#1C1C1C']]);
const strokes = named(calls, 'stroke');
expect(strokes.length).toBeGreaterThanOrEqual(1);
expect(strokes.every((c) => c.strokeStyle === '#0A0A0A' && c.lineWidth === 3)).toBe(true);
});
});
describe('renderCard: Reihenfolge', () => {
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Logo', () => {
const calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
const order = [
at((c) => c.name === 'fillRect'),
at((c) => c.name === 'drawImage' && c.args[0] === PHOTO),
at((c) => c.name === 'createLinearGradient'),
at((c) => c.name === 'strokePath'),
at((c) => c.name === 'arc'),
at((c) => c.name === 'fillText' && c.args[0] === 'P'),
at((c) => c.name === 'drawImage' && c.args[0] === BRAND),
at((c) => c.name === 'drawImage' && c.args[0] === LOGO_SOURCE),
];
expect(order.every((i) => i >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
});
describe('renderCard: strokePath-Standard', () => {
afterEach(() => vi.unstubAllGlobals());
it('ohne eigenen strokePath wird ctx.stroke(new Path2D(d)) benutzt', () => {
class FakePath {
constructor(public d: string) {}
}
vi.stubGlobal('Path2D', FakePath);
const f = fakeCtx();
const strokes: unknown[] = [];
(f.ctx as unknown as { stroke: (p?: unknown) => void }).stroke = (p) => void strokes.push(p);
renderCard(f.ctx, cfg({ template: 'classic' }), assets());
const paths = strokes.filter((p) => p instanceof FakePath) as FakePath[];
expect(paths).toHaveLength(1);
expect(paths[0].d.startsWith('M548.75 379.89')).toBe(true);
});
});
describe('drawCard', () => {
it('holt den Kontext ohne Alpha, setzt Transform zurück und skaliert auf 1536x969', () => {
const f = fakeCtx();
const getContext = vi.fn(() => f.ctx);
const canvas = { width: 768, height: 485, getContext } as unknown as HTMLCanvasElement;
drawCard(canvas, cfg({ template: 'minimal' }), assets());
expect(getContext).toHaveBeenCalledWith('2d', { alpha: false });
expect(f.calls[0]).toMatchObject({ name: 'setTransform', args: [1, 0, 0, 1, 0, 0] });
expect(f.calls[1].name).toBe('scale');
expect(f.calls[1].args[0]).toBeCloseTo(768 / 1536, 5);
expect(f.calls[1].args[1]).toBeCloseTo(485 / 969, 5);
expect(f.calls[2]).toMatchObject({ name: 'fillRect', args: [0, 0, 1536, 969], fillStyle: '#1B2738' });
});
it('wirft verständlich, wenn kein 2D-Kontext zu haben ist', () => {
const canvas = { width: 10, height: 10, getContext: () => null } as unknown as HTMLCanvasElement;
expect(() => drawCard(canvas, cfg(), assets())).toThrow(/Canvas/);
});
it('transparentes Bild: Grund wird zuerst gefüllt, Canvas ohne Alpha', () => {
const f = fakeCtx();
const getContext = vi.fn(() => f.ctx);
const canvas = { width: 1536, height: 969, getContext } as unknown as HTMLCanvasElement;
drawCard(canvas, cfg({ template: 'image' }), assets({ image: photo(400, 400) }));
expect(getContext).toHaveBeenCalledWith('2d', { alpha: false });
const draws = f.calls.filter((c) => c.name === 'fillRect' || c.name === 'drawImage').map((c) => c.name);
expect(draws).toEqual(['fillRect', 'drawImage']);
});
});
describe('loadCardAssets', () => {
const BRAND_IMG = { tag: 'brand-img' };
const fontsLoad = vi.fn();
const mocked = vi.mocked(loadMarkImage);
const defaultLoader = vi.mocked(loadLogoAsset);
const loadLogo = vi.fn<(id: string, tone: 'light' | 'dark') => Promise<LogoAsset | null>>();
beforeEach(() => {
fontsLoad.mockReset().mockResolvedValue([]);
mocked.mockReset().mockImplementation(async () => BRAND_IMG as unknown as HTMLImageElement);
defaultLoader.mockReset().mockResolvedValue(LOGO);
loadLogo.mockReset().mockResolvedValue(LOGO);
Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
});
afterEach(() => {
Reflect.deleteProperty(document, 'fonts');
});
it('lädt Community-Logo und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
const image = photo(100, 100);
const result = await loadCardAssets(cfg({ logo: 'abc123', brand: 'aircards-text', details: 'dark', template: 'image' }), image, loadLogo);
expect(result).toEqual({ image, logo: LOGO, brand: BRAND_IMG, logoMissing: false });
expect(result.image).toBe(image);
expect(loadLogo).toHaveBeenCalledTimes(1);
expect(loadLogo).toHaveBeenCalledWith('abc123', 'dark');
expect(mocked).toHaveBeenCalledTimes(1);
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['aircards-text', 'dark']]);
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
});
it('ohne Logo (logo null): kein Lader-Aufruf, kein fehlendes Logo', async () => {
const result = await loadCardAssets(cfg({ logo: null }), null, loadLogo);
expect(loadLogo).not.toHaveBeenCalled();
expect(result.logo).toBeNull();
expect(result.logoMissing).toBe(false);
});
it('eine leere logo-Angabe zählt wie kein Logo', async () => {
const result = await loadCardAssets(cfg({ logo: '' }), null, loadLogo);
expect(loadLogo).not.toHaveBeenCalled();
expect(result.logoMissing).toBe(false);
});
it('der Lader liefert null (gelöscht, ausgeblendet): logo null und logoMissing true, Brand-Logo wird trotzdem geladen', async () => {
loadLogo.mockResolvedValue(null);
const result = await loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo);
expect(result.logo).toBeNull();
expect(result.logoMissing).toBe(true);
expect(result.brand).toBe(BRAND_IMG);
});
it('der Lader wirft: loadCardAssets wirft (ein echter Ladefehler ist kein fehlendes Logo)', async () => {
loadLogo.mockRejectedValue(new Error('offline'));
await expect(loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo)).rejects.toThrow('offline');
});
it('ohne dritten Parameter lädt loadLogoAsset', async () => {
const result = await loadCardAssets(cfg({ logo: 'abc123', details: 'light' }), null);
expect(defaultLoader).toHaveBeenCalledWith('abc123', 'light');
expect(result.logo).toBe(LOGO);
});
it('Brand none: kein Brand-Logo laden', async () => {
const result = await loadCardAssets(cfg({ brand: 'none' }), null, loadLogo);
expect(result.image).toBeNull();
expect(result.brand).toBeNull();
expect(mocked).not.toHaveBeenCalled();
});
it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
fontsLoad.mockRejectedValue(new Error('offline'));
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
Reflect.deleteProperty(document, 'fonts');
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
});
it('Marken-Fehler wird weitergereicht', async () => {
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: aircards-symbol'));
await expect(loadCardAssets(cfg(), null, loadLogo)).rejects.toThrow(/Logo/);
});
});
describe('exportCard', () => {
afterEach(() => vi.restoreAllMocks());
function stubCanvas(blob: Blob | null) {
const f = fakeCtx();
const toBlob = vi.fn((cb: (b: Blob | null) => void) => cb(blob));
const canvas = { width: 0, height: 0, getContext: vi.fn(() => f.ctx), toBlob };
const create = vi.spyOn(document, 'createElement').mockReturnValue(canvas as unknown as HTMLElement);
return { f, canvas, toBlob, create };
}
it('Canvas width x round(width*969/1536), zeichnet und liefert den Blob', async () => {
const blob = new Blob(['x'], { type: 'image/png' });
const { f, canvas, toBlob, create } = stubCanvas(blob);
const out = await exportCard(cfg({ template: 'minimal' }), assets(), { width: 1536, type: 'image/png' });
expect(out).toBe(blob);
expect(create).toHaveBeenCalledWith('canvas');
expect(canvas.width).toBe(1536);
expect(canvas.height).toBe(969);
expect(canvas.getContext).toHaveBeenCalledWith('2d', { alpha: false });
expect(f.calls.some((c) => c.name === 'fillRect')).toBe(true);
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png', undefined);
});
it('Vorschaugröße 480 x 303 als WebP mit Qualität', async () => {
const { canvas, toBlob } = stubCanvas(new Blob(['x']));
await exportCard(cfg(), assets(), { width: 480, type: 'image/webp', quality: 0.8 });
expect(canvas.width).toBe(480);
expect(canvas.height).toBe(303);
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/webp', 0.8);
});
it('lehnt ab, wenn toBlob nichts liefert', async () => {
stubCanvas(null);
await expect(exportCard(cfg(), assets(), { width: 100, type: 'image/png' })).rejects.toThrow();
});
});