diff --git a/web/src/logos/logo-asset.test.ts b/web/src/logos/logo-asset.test.ts index 29383c1..5d139ec 100644 --- a/web/src/logos/logo-asset.test.ts +++ b/web/src/logos/logo-asset.test.ts @@ -26,7 +26,9 @@ const logo = (patch: Partial = {}): CommunityLogo => ({ ...patch, }); -const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h }) as unknown as ImageBitmap; +/** Ein dekodiertes Bild; `close` zeigt, ob es freigegeben wurde (ein `Image` aus dem Ersatzweg hat keins). */ +const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h, close: vi.fn() }) as unknown as ImageBitmap; +const closed = (b: ImageBitmap) => (b.close as unknown as ReturnType).mock.calls.length; const file = () => new Blob(['png'], { type: 'image/png' }); const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }); @@ -130,6 +132,26 @@ describe('loadLogoAsset: Laden', () => { await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull(); }); + it('ein 404 mit leerem Typ gilt wie ein 404 ohne Typ als fehlende Datei', async () => { + const { loadLogoAsset } = await fresh(); + mocks.loadLogoBlob.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: '' })); + await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull(); + }); + + it.each([ + ['der Bucket fehlt (404 storage_bucket_not_found)', { code: 404, type: 'storage_bucket_not_found' }], + ['das Projekt fehlt (404 project_not_found)', { code: 404, type: 'project_not_found' }], + ['ein anderer 404 (general_route_not_found)', { code: 404, type: 'general_route_not_found' }], + ['der Typ „Datei nicht gefunden“ ohne Status 404', { type: 'storage_file_not_found' }], + ['der Typ „Datei nicht gefunden“ mit einem anderen Status', { code: 500, type: 'storage_file_not_found' }], + ])('%s ist ein echter Fehler, kein fehlendes Logo: er wird weitergereicht und nicht zwischengespeichert', async (_name, fields) => { + const { loadLogoAsset } = await fresh(); + const failure = Object.assign(new Error('Bucket'), fields); + mocks.loadLogoBlob.mockRejectedValueOnce(failure); + await expect(loadLogoAsset('l1', 'light')).rejects.toBe(failure); + await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 }); // der nächste Aufruf versucht es neu + }); + 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' }); @@ -167,6 +189,79 @@ describe('loadLogoAsset: Laden', () => { }); }); +describe('loadLogoAsset: dekodierte Bilder, die nicht bleiben, werden freigegeben (ImageBitmap.close)', () => { + it('zu groß (mehrfarbig): das Bild wird geschlossen, das Ergebnis ist null', async () => { + const { loadLogoAsset } = await fresh(); + const decoded = bitmap(1001, 1000); + mocks.createImageBitmap.mockResolvedValue(decoded); + await expect(loadLogoAsset('big', 'light')).resolves.toBeNull(); + expect(closed(decoded)).toBe(1); + }); + + it('zu groß (einfarbig): das Bild wird geschlossen, ohne dass ein Canvas angelegt wird', async () => { + const { loadLogoAsset } = await fresh(); + mocks.getLogo.mockResolvedValue(logo({ mono: true })); + const decoded = bitmap(30000, 30000); + mocks.createImageBitmap.mockResolvedValue(decoded); + await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull(); + expect(closed(decoded)).toBe(1); + expect(canvases).toHaveLength(0); + }); + + it('ohne Fläche (0 × 0): das Bild wird geschlossen', async () => { + const { loadLogoAsset } = await fresh(); + const decoded = bitmap(0, 0); + mocks.createImageBitmap.mockResolvedValue(decoded); + await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull(); + expect(closed(decoded)).toBe(1); + }); + + it('einfarbig: nach dem Einfärben ist das Zwischenbild geschlossen (erst danach), die Quelle des Ergebnisses ist der Canvas', async () => { + const { loadLogoAsset } = await fresh(); + mocks.getLogo.mockResolvedValue(logo({ mono: true })); + const decoded = bitmap(200, 100); + let drawnBeforeClose: string[] | null = null; + (decoded.close as unknown as ReturnType).mockImplementation(() => { + drawnBeforeClose = canvases[0].calls.map((c) => c.name); + }); + mocks.createImageBitmap.mockResolvedValue(decoded); + const asset = await loadLogoAsset('l1', 'light'); + expect(asset?.source).toBe(canvases[0]); + expect(closed(decoded)).toBe(1); + expect(drawnBeforeClose).toEqual(['drawImage', 'fillRect']); // geschlossen erst, nachdem der Canvas gefüllt ist + }); + + it('einfarbig, aber kein 2D-Kontext: der Fehler wird weitergereicht und das Zwischenbild trotzdem geschlossen', async () => { + const { loadLogoAsset } = await fresh(); + stubCanvas({ noContext: true }); + mocks.getLogo.mockResolvedValue(logo({ mono: true })); + const decoded = bitmap(200, 100); + mocks.createImageBitmap.mockResolvedValue(decoded); + await expect(loadLogoAsset('l1', 'light')).rejects.toThrow(/Canvas/); + expect(closed(decoded)).toBe(1); + }); + + it('mehrfarbig und brauchbar: das Bild bleibt offen, es ist ja das Ergebnis', async () => { + const { loadLogoAsset } = await fresh(); + const decoded = bitmap(200, 100); + mocks.createImageBitmap.mockResolvedValue(decoded); + const asset = await loadLogoAsset('l1', 'light'); + expect(asset?.source).toBe(decoded); + expect(closed(decoded)).toBe(0); + }); + + it('jede Tonart eines einfarbigen Logos dekodiert für sich und schließt ihr eigenes Zwischenbild', async () => { + const { loadLogoAsset } = await fresh(); + mocks.getLogo.mockResolvedValue(logo({ mono: true })); + const first = bitmap(200, 100); + const second = bitmap(200, 100); + mocks.createImageBitmap.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + await loadLogoAsset('l1', 'light'); + await loadLogoAsset('l1', 'dark'); + expect([closed(first), closed(second)]).toEqual([1, 1]); + }); +}); + describe('loadLogoAsset: einfarbige Logos', () => { it.each([ ['light', '#FFFFFF'], @@ -389,12 +484,18 @@ describe('loadLogoAsset: ohne createImageBitmap', () => { expect(revoked).toEqual(['blob:fake/1']); }); - it('auch hier gilt die Pixelgrenze', async () => { + it('auch hier gilt die Pixelgrenze (ein Image hat kein close; das wirft nicht)', async () => { const { loadLogoAsset } = await fresh(); FakeImage.size = { w: 2000, h: 1000 }; await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull(); }); + it('einfarbig, mit Image: das Einfärben schließt nichts und wirft nicht', async () => { + const { loadLogoAsset } = await fresh(); + mocks.getLogo.mockResolvedValue(logo({ mono: true })); + await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 }); + }); + it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => { const { loadLogoAsset } = await fresh(); mocks.getLogo.mockResolvedValue(logo({ mono: true })); diff --git a/web/src/logos/logo-asset.ts b/web/src/logos/logo-asset.ts index 2d9b8ed..48c6822 100644 --- a/web/src/logos/logo-asset.ts +++ b/web/src/logos/logo-asset.ts @@ -56,11 +56,14 @@ function rowFor(id: string): Promise { return request; } -/** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */ +/** + * Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found` (ohne oder mit leerem Typ gilt ein 404 ebenso). + * Jedes andere 404, etwa ein fehlender Bucket (`storage_bucket_not_found`), ist ein echter Fehler (wie `isRowMissing` in logos.ts). + */ function isFileMissing(err: unknown): boolean { if (typeof err !== 'object' || err === null) return false; const { code, type } = err as { code?: unknown; type?: unknown }; - return code === 404 || type === 'storage_file_not_found'; + return code === 404 && (type === 'storage_file_not_found' || type === undefined || type === ''); } /** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */ @@ -74,6 +77,11 @@ function decodeWithImage(blob: Blob): Promise { }).finally(() => URL.revokeObjectURL(url)); } +/** Gibt den Speicher eines dekodierten Bildes frei, das nicht bleibt (ein `ImageBitmap` hat `close`, ein `Image` nicht). */ +function release({ source }: Decoded): void { + if ('close' in source && typeof source.close === 'function') source.close(); +} + /** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */ async function decode(blob: Blob): Promise { try { @@ -84,7 +92,9 @@ async function decode(blob: Blob): Promise { } else { decoded = await decodeWithImage(blob); } - return decoded.w > 0 && decoded.h > 0 ? decoded : null; + if (decoded.w > 0 && decoded.h > 0) return decoded; + release(decoded); + return null; } catch { return null; } @@ -120,9 +130,18 @@ async function build(id: string, tone: DetailsTone): Promise { } const decoded = await decode(blob); - if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null; + if (!decoded) return null; + if (decoded.w * decoded.h > MAX_LOGO_PIXELS) { + release(decoded); + return null; + } if (!logo.mono) return decoded; - return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h }; + // Einfarbig bleibt nur der gefüllte Canvas; das dekodierte Bild darunter wird danach nicht mehr gebraucht (auch nicht, wenn das Füllen scheitert). + try { + return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h }; + } finally { + release(decoded); + } } /**