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: ``, 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 = { 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 => ({ image: null, logo: LOGO, brand: BRAND, ...over, }); const photo = (w: number, h: number): CardImage => ({ source: PHOTO, w, h }); const cfg = (over: Partial = {}): 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>(); 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(); }); });