fix(logos): nur „Datei nicht gefunden“ gilt als fehlendes Logo, nicht genutzte Bilder werden freigegeben
- logo-asset.ts: isFileMissing galt für jedes 404 und für jeden Fehler mit dem Typ storage_file_not_found. Jetzt nur noch ein 404 mit diesem Typ (oder ohne bzw. mit leerem Typ), wie isRowMissing in logos.ts. Ein fehlender Bucket (404 storage_bucket_not_found) und andere Fehler sind echte Fehler: sie werden weitergereicht und nicht zwischengespeichert, statt stillschweigend „Logo nicht verfügbar“ zu melden. - logo-asset.ts: dekodierte ImageBitmaps, die nicht bleiben, werden mit close() freigegeben: das Zwischenbild eines einfarbigen Logos nach dem Einfärben (auch wenn das Füllen scheitert), Bilder über der Pixelgrenze und Bilder ohne Fläche. Das Bild eines mehrfarbigen Logos bleibt offen, es ist das Ergebnis; ein `Image` (Ersatzweg ohne createImageBitmap) hat kein close und wird übersprungen. - Tests: die Fehlerfälle (Bucket, Projekt, andere 404, Typ ohne 404) mit dem Nachweis, dass nichts zwischengespeichert wird; close() in allen Fällen samt Reihenfolge (erst nach dem Einfärben). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1fd08d6373
commit
76c3c3aea2
2 files changed
+127
-7
No files matched your search
@@ -26,7 +26,9 @@ const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
|
|||||||
...patch,
|
...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<typeof vi.fn>).mock.calls.length;
|
||||||
const file = () => new Blob(['png'], { type: 'image/png' });
|
const file = () => new Blob(['png'], { type: 'image/png' });
|
||||||
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
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();
|
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 () => {
|
it('ein echter Ladefehler (Netz, 5xx) ist kein fehlendes Logo: er wird weitergereicht', async () => {
|
||||||
const { loadLogoAsset } = await fresh();
|
const { loadLogoAsset } = await fresh();
|
||||||
const failure = Object.assign(new Error('Bad gateway'), { code: 502, type: 'general_server_error' });
|
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<typeof vi.fn>).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', () => {
|
describe('loadLogoAsset: einfarbige Logos', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['light', '#FFFFFF'],
|
['light', '#FFFFFF'],
|
||||||
@@ -389,12 +484,18 @@ describe('loadLogoAsset: ohne createImageBitmap', () => {
|
|||||||
expect(revoked).toEqual(['blob:fake/1']);
|
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();
|
const { loadLogoAsset } = await fresh();
|
||||||
FakeImage.size = { w: 2000, h: 1000 };
|
FakeImage.size = { w: 2000, h: 1000 };
|
||||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
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 () => {
|
it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => {
|
||||||
const { loadLogoAsset } = await fresh();
|
const { loadLogoAsset } = await fresh();
|
||||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||||
|
|||||||
@@ -56,11 +56,14 @@ function rowFor(id: string): Promise<CommunityLogo | null> {
|
|||||||
return request;
|
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 {
|
function isFileMissing(err: unknown): boolean {
|
||||||
if (typeof err !== 'object' || err === null) return false;
|
if (typeof err !== 'object' || err === null) return false;
|
||||||
const { code, type } = err as { code?: unknown; type?: unknown };
|
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). */
|
/** 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<Decoded> {
|
|||||||
}).finally(() => URL.revokeObjectURL(url));
|
}).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. */
|
/** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */
|
||||||
async function decode(blob: Blob): Promise<Decoded | null> {
|
async function decode(blob: Blob): Promise<Decoded | null> {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +92,9 @@ async function decode(blob: Blob): Promise<Decoded | null> {
|
|||||||
} else {
|
} else {
|
||||||
decoded = await decodeWithImage(blob);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -120,9 +130,18 @@ async function build(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decoded = await decode(blob);
|
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;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user