- 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>
539 lines
23 KiB
TypeScript
539 lines
23 KiB
TypeScript
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();
|
||
});
|
||
});
|