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 => ({ 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; } 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 = { 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' }); }); });