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>
This commit is contained in:
1 parent
3051b3b3f9
commit
0be5abd954
44 files changed
+1401
-366
No files matched your search
+98
-32
@@ -1,5 +1,7 @@
|
||||
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,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
type CardAssets,
|
||||
type CardImage,
|
||||
type Ctx,
|
||||
type LogoAsset,
|
||||
} from './render';
|
||||
|
||||
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
|
||||
@@ -22,7 +25,6 @@ vi.mock('./marks', () => {
|
||||
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);
|
||||
@@ -32,6 +34,9 @@ vi.mock('./marks', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// 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[];
|
||||
@@ -99,13 +104,15 @@ function fakeCtx() {
|
||||
return { ctx: ctx as unknown as Ctx, calls, strokePath };
|
||||
}
|
||||
|
||||
const NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
|
||||
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,
|
||||
network: NETWORK,
|
||||
logo: LOGO,
|
||||
brand: BRAND,
|
||||
...over,
|
||||
});
|
||||
@@ -187,18 +194,42 @@ describe('renderCard: Grund und Bild', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Vorlagen', () => {
|
||||
it('minimal: nur das Netzwerk-Logo unten rechts', () => {
|
||||
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, 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);
|
||||
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());
|
||||
|
||||
@@ -238,11 +269,10 @@ describe('renderCard: Vorlagen', () => {
|
||||
expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
|
||||
});
|
||||
|
||||
it('classic: Brand-Logo links oben, Netzwerk-Logo unten rechts', () => {
|
||||
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]);
|
||||
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]);
|
||||
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', () => {
|
||||
@@ -256,7 +286,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
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);
|
||||
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"');
|
||||
@@ -273,7 +303,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
});
|
||||
|
||||
it('fehlende Logo-Bilder werden übersprungen', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ network: null, brand: null }));
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ logo: null, brand: null }));
|
||||
expect(named(calls, 'drawImage')).toHaveLength(0);
|
||||
expect(letters(calls)).toHaveLength(8);
|
||||
});
|
||||
@@ -319,7 +349,7 @@ describe('renderCard: Chip', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Reihenfolge', () => {
|
||||
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Netzwerk', () => {
|
||||
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 = [
|
||||
@@ -330,7 +360,7 @@ describe('renderCard: Reihenfolge', () => {
|
||||
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),
|
||||
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);
|
||||
@@ -386,14 +416,17 @@ describe('drawCard', () => {
|
||||
});
|
||||
|
||||
describe('loadCardAssets', () => {
|
||||
const NET_IMG = { tag: 'net-img' };
|
||||
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 (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
|
||||
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 } });
|
||||
});
|
||||
|
||||
@@ -401,35 +434,68 @@ describe('loadCardAssets', () => {
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
});
|
||||
|
||||
it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
|
||||
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({ network: 'jcb', brand: 'apple', details: 'dark', template: 'image' }), image);
|
||||
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(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(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);
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null, loadLogo);
|
||||
expect(result.image).toBeNull();
|
||||
expect(result.brand).toBeNull();
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
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)).resolves.toMatchObject({ network: NET_IMG });
|
||||
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)).resolves.toMatchObject({ network: NET_IMG });
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_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/);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user