Files
AirCardIOS/web/src/logos/logo-asset.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

407 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CommunityLogo } from './logos';
const mocks = vi.hoisted(() => ({ getLogo: vi.fn(), loadLogoBlob: vi.fn(), createImageBitmap: vi.fn() }));
// Zeile und Datei kommen über getLogo/loadLogoBlob; Appwrite, Bilddekodierung und Canvas sind hier ersetzt.
vi.mock('./logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
/** Eine frische Kopie des Moduls, damit der Zwischenspeicher je Test leer beginnt. */
async function fresh() {
vi.resetModules();
return import('./logo-asset');
}
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'l1',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'l1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h }) as unknown as ImageBitmap;
const file = () => new Blob(['png'], { type: 'image/png' });
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
interface CanvasCall {
name: string;
args: unknown[];
/** Stand von `globalCompositeOperation` und `fillStyle` im Moment des Aufrufs. */
globalCompositeOperation: unknown;
fillStyle: unknown;
}
interface FakeCanvas {
width: number;
height: number;
calls: CanvasCall[];
getContext: ReturnType<typeof vi.fn>;
}
const canvases: FakeCanvas[] = [];
/** `document.createElement('canvas')` liefert aufzeichnende Fakes (happy-dom hat keinen 2D-Kontext). */
function stubCanvas(options: { noContext?: boolean } = {}): void {
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
if (tag !== 'canvas') throw new Error(`unerwartetes Element: ${tag}`);
const ctx: Record<string, unknown> = { globalCompositeOperation: 'source-over', fillStyle: '#000000' };
const canvas: FakeCanvas = { width: 0, height: 0, calls: [], getContext: vi.fn(() => (options.noContext ? null : ctx)) };
const record =
(name: string) =>
(...args: unknown[]) =>
void canvas.calls.push({ name, args, globalCompositeOperation: ctx.globalCompositeOperation, fillStyle: ctx.fillStyle });
ctx.drawImage = record('drawImage');
ctx.fillRect = record('fillRect');
canvases.push(canvas);
return canvas as unknown as HTMLElement;
}) as typeof document.createElement);
}
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
mocks.getLogo.mockResolvedValue(logo());
mocks.loadLogoBlob.mockResolvedValue(file());
mocks.createImageBitmap.mockResolvedValue(bitmap(200, 100));
vi.stubGlobal('createImageBitmap', mocks.createImageBitmap);
canvases.length = 0;
stubCanvas();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('LOGO_MISSING', () => {
it('der Hinweis aus der Spec, auf Englisch übersetzt', async () => {
const { LOGO_MISSING } = await fresh();
// Nach resetModules ist auch i18n frisch (die Sprache des Setups gilt für die alte Kopie): die Sprache hier selbst setzen.
const { t, setLang } = await import('../i18n/i18n');
setLang('de', { persist: false });
expect(LOGO_MISSING).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(t(LOGO_MISSING)).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
setLang('en', { persist: false });
expect(t(LOGO_MISSING)).toBe('The selected logo is no longer available.');
});
});
describe('loadLogoAsset: Laden', () => {
it('mehrfarbiges Logo: das dekodierte Bild in Originalgröße, ohne Canvas', async () => {
const { loadLogoAsset } = await fresh();
const blob = file();
const decoded = bitmap(200, 100);
mocks.loadLogoBlob.mockResolvedValue(blob);
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBe(decoded);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.getLogo).toHaveBeenCalledWith('l1');
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'l1', fileId: 'l1' }));
expect(mocks.createImageBitmap).toHaveBeenCalledWith(blob);
expect(canvases).toHaveLength(0);
});
it('fehlende Zeile (gelöscht oder ausgeblendet für mich): null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('ausgeblendetes Logo (Hochlader und Admins können die Zeile lesen): trotzdem null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('Datei fehlt (404): null', async () => {
const { loadLogoAsset } = await fresh();
mocks.loadLogoBlob.mockRejectedValue(fileGone());
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.loadLogoBlob.mockRejectedValue(Object.assign(new Error('weg'), { code: 404 }));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('ein echter Ladefehler (Netz, 5xx) ist kein fehlendes Logo: er wird weitergereicht', async () => {
const { loadLogoAsset } = await fresh();
const failure = Object.assign(new Error('Bad gateway'), { code: 502, type: 'general_server_error' });
mocks.loadLogoBlob.mockRejectedValue(failure);
await expect(loadLogoAsset('l1', 'light')).rejects.toBe(failure);
const offline = new TypeError('Failed to fetch');
mocks.getLogo.mockRejectedValue(offline);
await expect(loadLogoAsset('l2', 'light')).rejects.toBe(offline);
});
it('nicht lesbare Datei: null (das Logo ist nicht verfügbar)', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockRejectedValue(new DOMException('kaputt', 'InvalidStateError'));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(0, 0));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('mehr als 1 000 000 Pixel (nur eine veränderte Datei): null, genau 1 000 000 und die größte erlaubte Größe: ein Bild', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockResolvedValue(bitmap(1001, 1000));
await expect(loadLogoAsset('big', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(1000, 1000));
await expect(loadLogoAsset('limit', 'light')).resolves.toMatchObject({ w: 1000, h: 1000 });
mocks.createImageBitmap.mockResolvedValue(bitmap(720, 250));
await expect(loadLogoAsset('max', 'light')).resolves.toMatchObject({ w: 720, h: 250 });
});
it('zu großes einfarbiges Logo: null, es wird kein Canvas dafür angelegt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(bitmap(30000, 30000));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(canvases).toHaveLength(0);
});
});
describe('loadLogoAsset: einfarbige Logos', () => {
it.each([
['light', '#FFFFFF'],
['dark', '#111111'],
] as const)('Details %s: Canvas in Bildgröße, Bild zeichnen, dann source-in mit %s füllen', async (tone, color) => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', tone);
expect(canvases).toHaveLength(1);
const canvas = canvases[0];
expect(asset?.source).toBe(canvas);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect([canvas.width, canvas.height]).toEqual([200, 100]);
expect(canvas.getContext).toHaveBeenCalledWith('2d');
expect(canvas.calls.map((c) => c.name)).toEqual(['drawImage', 'fillRect']);
// erst das Bild (die Form kommt aus dem Alphakanal) …
expect(canvas.calls[0].args[0]).toBe(decoded);
expect(canvas.calls[0].globalCompositeOperation).toBe('source-over');
// … dann die Farbe nur dort, wo das Bild deckt
expect(canvas.calls[1]).toMatchObject({ args: [0, 0, 200, 100], globalCompositeOperation: 'source-in', fillStyle: color });
});
it('mehrfarbige Logos bleiben unverändert (kein Canvas, beide Tonarten dasselbe Bild)', async () => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.createImageBitmap.mockResolvedValue(decoded);
expect((await loadLogoAsset('l1', 'light'))?.source).toBe(decoded);
expect((await loadLogoAsset('l1', 'dark'))?.source).toBe(decoded);
expect(canvases).toHaveLength(0);
});
it('ohne 2D-Kontext wirft es (das ist kein fehlendes Logo)', async () => {
const { loadLogoAsset } = await fresh();
stubCanvas({ noContext: true });
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow(/Canvas/);
});
});
describe('loadLogoAsset: Zwischenspeicher', () => {
it('gleiche (id, tone) lädt nur einmal, auch bei gleichzeitigen Aufrufen und später', async () => {
const { loadLogoAsset } = await fresh();
const a = loadLogoAsset('l1', 'light');
const b = loadLogoAsset('l1', 'light');
expect(b).toBe(a);
const first = await a;
expect(await loadLogoAsset('l1', 'light')).toBe(first);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(1);
expect(mocks.createImageBitmap).toHaveBeenCalledTimes(1);
});
it('eine andere Tonart ist ein eigenes Bild, die Zeile wird aber nur einmal geholt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const light = await loadLogoAsset('l1', 'light');
const dark = await loadLogoAsset('l1', 'dark');
expect(dark).not.toBe(light);
expect(canvases).toHaveLength(2);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlendes Logo (null) bleibt ebenfalls zwischengespeichert', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('forgetLogo: danach wird Zeile und Bild neu geladen', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
forgetLogo('l1');
mocks.getLogo.mockResolvedValue(null); // inzwischen gelöscht
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
expect(first).not.toBeNull();
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('forgetLogo vergisst nur dieses Logo', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
mocks.getLogo.mockImplementation(async (id: string) => logo({ id, fileId: id }));
const other = await loadLogoAsset('l2', 'light');
await loadLogoAsset('l1', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l2', 'light')).toBe(other);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
// l1 und l1.x sind verschiedene Logos: das Präfix darf nicht auch l1.x erwischen
mocks.getLogo.mockClear();
const dotted = await loadLogoAsset('l1.x', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l1.x', 'light')).toBe(dotted);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlgeschlagener Ladevorgang bleibt nicht im Cache (Zeile und Bild)', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow('Failed to fetch');
await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
mocks.loadLogoBlob.mockRejectedValueOnce(Object.assign(new Error('Bad gateway'), { code: 502 }));
await expect(loadLogoAsset('l2', 'light')).rejects.toThrow('Bad gateway');
await expect(loadLogoAsset('l2', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
});
it('ein Fehler nach forgetLogo wirft den neuen Eintrag nicht aus dem Cache', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
let fail!: (e: Error) => void;
mocks.getLogo.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
const stale = loadLogoAsset('l1', 'light');
stale.catch(() => undefined);
forgetLogo('l1');
const current = await loadLogoAsset('l1', 'light');
fail(new Error('spät'));
await expect(stale).rejects.toThrow('spät');
expect(await loadLogoAsset('l1', 'light')).toBe(current);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('rememberLogo legt die Zeile in den Cache: getLogo wird nicht gebraucht', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ id: 'neu', fileId: 'neu' }));
await expect(loadLogoAsset('neu', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).not.toHaveBeenCalled();
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'neu', fileId: 'neu' }));
});
it('rememberLogo mit einem ausgeblendeten Logo: null', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('rememberLogo mit einem jetzt ausgeblendeten Logo verwirft schon geladene Bilder (ein gesperrtes Logo wird nicht mehr gezeichnet)', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
await expect(loadLogoAsset('l1', 'light')).resolves.not.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.not.toBeNull();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
});
it('rememberLogo mit einem sichtbaren Logo lässt geladene Bilder stehen', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
rememberLogo(logo({ likeCount: 7 }));
expect(await loadLogoAsset('l1', 'light')).toBe(first);
});
});
describe('loadLogoAsset: ohne createImageBitmap', () => {
class FakeImage {
static mode: 'load' | 'error' = 'load';
static size = { w: 200, h: 100 };
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
#src = '';
get src() {
return this.#src;
}
set src(value: string) {
this.#src = value;
this.naturalWidth = FakeImage.size.w;
this.naturalHeight = FakeImage.size.h;
queueMicrotask(() => (FakeImage.mode === 'load' ? this.onload?.() : this.onerror?.()));
}
}
const created: string[] = [];
const revoked: string[] = [];
const realCreate = URL.createObjectURL;
const realRevoke = URL.revokeObjectURL;
beforeEach(() => {
FakeImage.mode = 'load';
FakeImage.size = { w: 200, h: 100 };
created.length = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => {
created.push(`blob:fake/${created.length + 1}`);
return created[created.length - 1];
});
URL.revokeObjectURL = vi.fn((url: string) => void revoked.push(url));
vi.stubGlobal('createImageBitmap', undefined);
vi.stubGlobal('Image', FakeImage);
});
afterEach(() => {
URL.createObjectURL = realCreate;
URL.revokeObjectURL = realRevoke;
});
it('lädt das Logo über ein Image aus einer Object-URL und gibt die URL danach frei', async () => {
const { loadLogoAsset } = await fresh();
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBeInstanceOf(FakeImage);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.createImageBitmap).not.toHaveBeenCalled();
expect(created).toEqual(['blob:fake/1']);
expect(revoked).toEqual(['blob:fake/1']);
});
it('Ladefehler des Bildes: null, die URL ist trotzdem freigegeben', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.mode = 'error';
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(revoked).toEqual(['blob:fake/1']);
});
it('auch hier gilt die Pixelgrenze', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.size = { w: 2000, h: 1000 };
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
});
it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const asset = await loadLogoAsset('l1', 'dark');
expect(asset?.source).toBe(canvases[0]);
expect(canvases[0].calls[0].args[0]).toBeInstanceOf(FakeImage);
expect(canvases[0].calls[1]).toMatchObject({ globalCompositeOperation: 'source-in', fillStyle: '#111111' });
});
});