- 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>
407 lines
17 KiB
TypeScript
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' });
|
|
});
|
|
});
|