feat(card): Canvas-Renderer und Export
renderCard zeichnet in 1536 x 969 in fester Reihenfolge (Grund, Bild mit begrenztem Ausschnitt, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk), drawCard skaliert auf beliebige Canvas ohne Alpha, loadCardAssets wartet auf Schrift und Logos, exportCard liefert PNG/WebP als Blob. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a610df31dc
commit
9a46342e0c
2 files changed
+678
No files matched your search
@@ -0,0 +1,472 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG, type CardConfig } from './config';
|
||||
import { loadMarkImage } from './marks';
|
||||
import {
|
||||
drawCard,
|
||||
exportCard,
|
||||
loadCardAssets,
|
||||
renderCard,
|
||||
type CardAssets,
|
||||
type CardImage,
|
||||
type Ctx,
|
||||
} 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 {
|
||||
getNetworkMark: (id: string) => mark(id, '0 0 330 109', 109),
|
||||
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(),
|
||||
};
|
||||
});
|
||||
|
||||
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 NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
|
||||
const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
|
||||
const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
|
||||
|
||||
const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
|
||||
image: null,
|
||||
network: NETWORK,
|
||||
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 Netzwerk-Logo unten rechts', () => {
|
||||
const calls = render(cfg({ template: 'minimal' }), assets());
|
||||
expect(named(calls, 'drawImage')).toHaveLength(1);
|
||||
const [src, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
|
||||
expect(src).toBe(NETWORK);
|
||||
expect(x).toBeCloseTo(1150, 1);
|
||||
expect(y).toBeCloseTo(804, 1);
|
||||
expect(w).toBeCloseTo(330, 1);
|
||||
expect(h).toBeCloseTo(109, 1);
|
||||
expect(named(calls, 'fillText')).toHaveLength(0);
|
||||
});
|
||||
|
||||
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, Netzwerk-Logo unten rechts', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets());
|
||||
expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]);
|
||||
const [, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
|
||||
expect([x, y, w, h].map((v) => Math.round(v))).toEqual([1150, 804, 330, 109]);
|
||||
});
|
||||
|
||||
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, NETWORK)).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({ network: 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, Netzwerk', () => {
|
||||
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] === NETWORK),
|
||||
];
|
||||
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 NET_IMG = { tag: 'net-img' };
|
||||
const BRAND_IMG = { tag: 'brand-img' };
|
||||
const fontsLoad = vi.fn();
|
||||
const mocked = vi.mocked(loadMarkImage);
|
||||
|
||||
beforeEach(() => {
|
||||
fontsLoad.mockReset().mockResolvedValue([]);
|
||||
mocked.mockReset().mockImplementation(async (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
});
|
||||
|
||||
it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
|
||||
const image = photo(100, 100);
|
||||
const result = await loadCardAssets(cfg({ network: 'jcb', brand: 'apple', details: 'dark', template: 'image' }), image);
|
||||
expect(result.image).toBe(image);
|
||||
expect(result.network).toBe(NET_IMG);
|
||||
expect(result.brand).toBe(BRAND_IMG);
|
||||
expect(mocked).toHaveBeenCalledTimes(2);
|
||||
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['jcb', 'dark'], ['apple', 'dark']]);
|
||||
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
|
||||
});
|
||||
|
||||
it('Brand none: kein Brand-Logo laden', async () => {
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null);
|
||||
expect(result.image).toBeNull();
|
||||
expect(result.brand).toBeNull();
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
|
||||
fontsLoad.mockRejectedValue(new Error('offline'));
|
||||
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
|
||||
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
|
||||
});
|
||||
|
||||
it('Logo-Fehler wird weitergereicht', async () => {
|
||||
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: jcb'));
|
||||
await expect(loadCardAssets(cfg({ network: 'jcb' }), null)).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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user