diff --git a/web/src/community/publish.test.ts b/web/src/community/publish.test.ts index 0e615a7..3148f72 100644 --- a/web/src/community/publish.test.ts +++ b/web/src/community/publish.test.ts @@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({ decodeImage: vi.fn(), loadCardAssets: vi.fn(), exportCard: vi.fn(), + getLogo: vi.fn(), + loadLogoBlob: vi.fn(), })); // Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz; @@ -21,11 +23,15 @@ vi.mock('../account/appwrite', () => ({ })); // Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt. vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard })); +// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt. +vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob })); vi.mock('../editor/upload', async (importOriginal) => ({ ...(await importOriginal()), decodeImage: mocks.decodeImage, })); +import { forgetLogo, loadLogoAsset } from '../logos/logo-asset'; +import type { CommunityLogo } from '../logos/logos'; import { ApiError, publishCard } from './api'; import { LOGO_MISSING_PUBLISH, @@ -358,6 +364,96 @@ describe('Logo der Karte beim Veröffentlichen', () => { }); }); +describe('Zwischenspeicher der Logos beim Veröffentlichen (ein ausgeblendetes Logo darf nicht in die Galerie)', () => { + const photo = { source: {}, w: 3000, h: 2000 }; + const source = new Blob(['src'], { type: 'image/jpeg' }); + const logoRow = (patch: Partial = {}): CommunityLogo => ({ + id: 'zw1', + name: 'Mein Logo', + displayName: 'Kenso', + fileId: 'zw1', + ownerId: 'u1', + mono: false, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: '2026-10-04T10:00:00.000Z', + ...patch, + }); + const withLogo = (id: string, template: TemplateId = 'classic'): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, template, logo: id } }); + + beforeEach(() => { + mocks.decodeImage.mockReset().mockResolvedValue(photo); + mocks.exportCard + .mockReset() + .mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type })); + mocks.getLogo.mockReset().mockResolvedValue(logoRow()); + mocks.loadLogoBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' })); + // Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter. + mocks.loadCardAssets.mockReset().mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, image: unknown) => { + const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null; + return { image, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null }; + }); + vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 })); + }); + + afterEach(() => { + forgetLogo('zw1'); + vi.unstubAllGlobals(); + }); + + it('ein Logo, das nach dem ersten Laden (Editor, Zwischenspeicher) ausgeblendet wurde, wird neu geprüft: Abbruch, nichts wird hochgeladen', async () => { + await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher + mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann hat ein Admin oder die Melder es ausgeblendet + const fake = fakeDeps({ render: renderForGallery }); + await expect(publishSavedCard(withLogo('zw1'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(mocks.exportCard).not.toHaveBeenCalled(); + expect(fake.of('createFile')).toHaveLength(0); + expect(fake.of('publishCard')).toHaveLength(0); + }); + + it('ein Logo, das nach dem ersten Laden gelöscht wurde, ebenso', async () => { + await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); + mocks.getLogo.mockResolvedValue(null); + await expect(renderForGallery(withLogo('zw1'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH); + expect(mocks.exportCard).not.toHaveBeenCalled(); + }); + + it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei gerendert (kein Zwischenspeicher-Treffer)', async () => { + await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); + const out = await renderForGallery(withLogo('zw1'), source); + expect(out.png.type).toBe('image/png'); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2); + expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } }); + }); + + it('beide Tonarten des Logos werden vergessen, nicht nur die der Karte', async () => { + await loadLogoAsset('zw1', 'light'); + await loadLogoAsset('zw1', 'dark'); + mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); + await expect(renderForGallery({ ...withLogo('zw1'), config: { ...withLogo('zw1').config, details: 'dark' } }, source)).rejects.toThrow( + LOGO_MISSING_PUBLISH, + ); + await expect(loadLogoAsset('zw1', 'light')).resolves.toBeNull(); // auch das helle Bild ist weg: es wird neu geprüft + }); + + it('eine Karte ohne Logo vergisst und holt nichts', async () => { + const out = await renderForGallery({ ...card, config: { ...DEFAULT_CONFIG, logo: null } }, source); + expect(out.png.type).toBe('image/png'); + expect(mocks.getLogo).not.toHaveBeenCalled(); + }); + + it('„Nur Bild“ (image) zeichnet nie ein Logo: auch ein inzwischen ausgeblendetes Logo hindert das Veröffentlichen nicht', async () => { + await loadLogoAsset('zw1', 'light'); + mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); + const out = await renderForGallery(withLogo('zw1', 'image'), source); + expect(out.png.type).toBe('image/png'); + expect(mocks.exportCard).toHaveBeenCalledTimes(2); + }); +}); + describe('assertLogoAvailable', () => { const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' }); const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing }); diff --git a/web/src/community/publish.ts b/web/src/community/publish.ts index db480a8..e6e0efc 100644 --- a/web/src/community/publish.ts +++ b/web/src/community/publish.ts @@ -10,6 +10,7 @@ import { clampCrop } from '../card/crop'; import { exportCard, loadCardAssets, type LoadedAssets } from '../card/render'; import { decodeImage } from '../editor/upload'; import { msg } from '../i18n/i18n'; +import { forgetLogo } from '../logos/logo-asset'; import { isDefinitiveFailure, publishCard, type PublishInput } from './api'; /** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */ @@ -67,6 +68,9 @@ export function assertLogoAvailable(config: CardConfig, assets: LoadedAssets): v export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> { const image = await decodeImage(imageBlob); const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; + // Zeile und Bild des Logos neu vom Server prüfen: der Zwischenspeicher lebt so lange wie die Seite, ein inzwischen ausgeblendetes oder + // gelöschtes Logo käme sonst aus ihm in ein öffentliches Kartenbild. + if (config.logo) forgetLogo(config.logo); const assets = await loadCardAssets(config, image); // Lieber abbrechen, als eine Karte ohne ihr Logo zu veröffentlichen. assertLogoAvailable(config, assets); diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 97b5795..0f0977d 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -21,6 +21,8 @@ const mocks = vi.hoisted(() => ({ getGalleryCard: vi.fn(), loadGalleryImage: vi.fn(), createLogoPicker: vi.fn(), + getLogo: vi.fn(), + loadLogoBlob: vi.fn(), logoutListeners: new Set<() => void>(), })); @@ -54,11 +56,14 @@ vi.mock('../community/gallery', () => ({ })); // Der Bereich „Logo unten rechts“ hat seine eigenen Tests (logos/logo-picker.test.ts); hier zählt nur die Verdrahtung. vi.mock('../logos/logo-picker', () => ({ createLogoPicker: mocks.createLogoPicker })); +// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt. +vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob })); vi.mock('./upload', async (importOriginal) => ({ ...(await importOriginal()), decodeImage: mocks.decodeImage, })); +import { forgetLogo, loadLogoAsset } from '../logos/logo-asset'; import { mountEditor } from './editor-page'; const user: User = { $id: 'u1', email: 'a@b.de' }; @@ -1357,6 +1362,125 @@ describe('Community-Logo auf der Karte', () => { }); }); + describe('Download und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => { + const row = (patch: Record = {}) => ({ + id: 'zw3', + name: 'Mein Logo', + displayName: 'Kenso', + fileId: 'zw3', + ownerId: 'u1', + mono: false, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: '2026-10-04T10:00:00.000Z', + ...patch, + }); + /** Das Logo des letzten Zeichnens: null = ohne Logo gezeichnet, undefined = noch nichts gezeichnet. */ + const lastDrawnLogo = () => mocks.drawCard.mock.calls.at(-1)?.[2].logo; + const drawnWithLogo = () => expect(lastDrawnLogo()).toMatchObject({ w: 200, h: 100 }); + + beforeEach(() => { + mocks.getLogo.mockResolvedValue(row()); + mocks.loadLogoBlob.mockResolvedValue(new Blob(['logo'], { type: 'image/png' })); + // Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter. + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, img: unknown) => { + const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null; + return { image: img, logo, brand: {}, logoMissing: Boolean(config.logo) && logo === null }; + }); + vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 })); + }); + + afterEach(() => forgetLogo('zw3')); + + it('ein Logo, das nach dem Laden der Vorschau ausgeblendet wurde, wird neu geprüft: das PNG entsteht ohne Logo, der Hinweis erscheint, die Vorschau folgt', async () => { + withImage(); + store.set({ config: withLogo('zw3') }); + mount(); + await vi.waitFor(drawnWithLogo); // die Vorschau zeigt das Logo + expect(warningText()).toBe(''); + + mocks.getLogo.mockResolvedValue(row({ hidden: true })); // jetzt wird es ausgeblendet + mocks.drawCard.mockClear(); + downloadButton().click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(mocks.exportCard.mock.calls[0][1].logo).toBeNull(); // das PNG: ohne Logo, wie die Vorschau nun auch + expect(errorText()).toBe(''); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); // derselbe Hinweis wie bei jedem fehlenden Logo + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + expect(lastDrawnLogo()).toBeNull(); + expect(store.get().config.logo).toBe('zw3'); // die Auswahl bleibt + }); + + it('der Hinweis steht zusammen mit dem Größenhinweis des Downloads', async () => { + mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' })); + withImage(); + store.set({ config: withLogo('zw3') }); + mount(); + await vi.waitFor(drawnWithLogo); + mocks.getLogo.mockResolvedValue(row({ hidden: true })); + downloadButton().click(); + await vi.waitFor(() => expect(warningText()).toBe(`${PNG_SIZE_WARNING} ${LOGO_GONE}`)); + }); + + it('ein Logo, das inzwischen wieder da ist (eingeblendet), wird ebenso übernommen: Hinweis weg, PNG und Vorschau mit Logo', async () => { + mocks.getLogo.mockResolvedValue(row({ hidden: true })); + withImage(); + store.set({ config: withLogo('zw3') }); + mount(); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + mocks.getLogo.mockResolvedValue(row()); + mocks.drawCard.mockClear(); + downloadButton().click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); + expect(mocks.exportCard.mock.calls[0][1].logo).toMatchObject({ w: 200, h: 100 }); + await vi.waitFor(() => expect(warningText()).toBe('')); + await vi.waitFor(drawnWithLogo); + }); + + it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei heruntergeladen, ohne Hinweis', async () => { + withImage(); + store.set({ config: withLogo('zw3') }); + mount(); + await vi.waitFor(drawnWithLogo); + mocks.drawCard.mockClear(); + downloadButton().click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2); + expect(mocks.exportCard.mock.calls[0][1].logo).toMatchObject({ w: 200, h: 100 }); + expect(warningText()).toBe(''); + expect(mocks.drawCard).not.toHaveBeenCalled(); // nichts hat sich geändert: die Vorschau wird nicht neu gezeichnet + }); + + it('ohne gewähltes Logo wird nichts vergessen oder geholt', async () => { + withImage(); + mount(); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + downloadButton().click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); + expect(mocks.getLogo).not.toHaveBeenCalled(); + }); + + it('hat sich die Auswahl während des Downloads geändert, gilt dessen Ergebnis nicht für die neue Auswahl: kein Hinweis zu „Kein Logo“', async () => { + withImage(); + store.set({ config: withLogo('zw3') }); + mount(); + await vi.waitFor(drawnWithLogo); + let answer!: (logo: unknown) => void; + mocks.getLogo.mockReturnValue(new Promise((resolve) => (answer = resolve))); // die erneute Prüfung des Downloads hängt noch + downloadButton().click(); + store.set({ config: withLogo(null) }); // inzwischen: „Kein Logo“ + mocks.drawCard.mockClear(); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); // der Editor zeichnet schon die neue Auswahl + answer(row({ hidden: true })); // erst jetzt kommt die Antwort: das alte Logo ist ausgeblendet + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 10)); + expect(warningText()).toBe(''); + }); + }); + it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => { history.replaceState(null, '', '/gestalten?karte=k1'); mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } })); diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index 22644c9..e0862db 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -8,10 +8,10 @@ import { LoginCancelled, withLogin } from '../account/with-login'; import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type TemplateId } from '../card/config'; import { clampCrop, panBy, zoomTo } from '../card/crop'; import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; -import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; +import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage, type LoadedAssets } from '../card/render'; import { getGalleryCard, loadGalleryImage } from '../community/gallery'; import { msg, t } from '../i18n/i18n'; -import { LOGO_MISSING } from '../logos/logo-asset'; +import { LOGO_MISSING, forgetLogo } from '../logos/logo-asset'; import { createLogoPicker } from '../logos/logo-picker'; import { dropCardParam, dropTemplateParam } from './card-url'; import { confirmDialog } from './confirm-dialog'; @@ -174,10 +174,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { preview.setError(null); setWarning(null); try { + // Zeile und Bild des Logos neu vom Server prüfen: ein inzwischen ausgeblendetes oder gelöschtes Logo käme sonst aus dem + // Zwischenspeicher (er lebt so lange wie die Seite) ins PNG. + if (s.config.logo) forgetLogo(s.config.logo); // Die Logos kommen aus dem Netz: scheitert das, soll der Nutzer „Logos konnten nicht geladen werden“ lesen, nicht „Failed to fetch“. const assets = await loadCardAssets(s.config, s.image).catch(() => { throw new Error(LOGO_ERROR); }); + followDownload(s.config, assets); const blob = await exportCard(s.config, assets, { width: CARD_W, type: 'image/png' }); saveBlobAs(blob, downloadFileName(s.cardName, new Date())); setWarning(pngSizeWarning(blob.size)); @@ -189,6 +193,17 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { } } + /** + * Der Download hat Zeile und Bild des Logos frisch geholt (siehe download). Hat sich dabei geändert, ob es das Logo gibt (es wurde + * ausgeblendet oder gelöscht, oder ist wieder da), folgen Hinweis und Vorschau: das PNG und die Vorschau zeigen dasselbe. + */ + function followDownload(config: CardConfig, assets: LoadedAssets): void { + if (!logos || logos.key !== logoKey(config) || logos.missing === assets.logoMissing) return; + logos = { ...logos, logo: assets.logo, missing: assets.logoMissing }; + showWarning(); + schedule(); + } + // Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts). let savedFlash = false; let flashTimer: ReturnType | undefined; diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index 92e8f97..570b1c7 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -23,6 +23,8 @@ const mocks = vi.hoisted(() => ({ publishSavedCard: vi.fn(), withdrawCard: vi.fn(), myPublications: vi.fn(), + getLogo: vi.fn(), + loadLogoBlob: vi.fn(), authListeners: new Set<(user: User | null) => void>(), })); @@ -67,9 +69,13 @@ vi.mock('../community/api', async (importOriginal) => ({ withdrawCard: mocks.withdrawCard, })); vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications })); +// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt. +vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob })); import { ApiError } from '../community/api'; import { LOGO_MISSING_PUBLISH } from '../community/publish'; +import { forgetLogo, loadLogoAsset } from '../logos/logo-asset'; +import type { CommunityLogo } from '../logos/logos'; import { mountCardsPage } from './cards-page'; const user: User = { $id: 'u1', email: 'a@b.de' }; @@ -377,6 +383,78 @@ describe('Meine Karten: Herunterladen mit fehlendem Logo', () => { }); }); +describe('Meine Karten: Herunterladen und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => { + const image = { source: {}, w: 3000, h: 2000 }; + const png = new Blob(['png'], { type: 'image/png' }); + const logoRow = (patch: Partial = {}): CommunityLogo => ({ + id: 'zw2', + name: 'Mein Logo', + displayName: 'Kenso', + fileId: 'zw2', + ownerId: 'u1', + mono: false, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: '2026-10-04T10:00:00.000Z', + ...patch, + }); + const msg = () => tileFor('a').querySelector('.card-msg')!; + + beforeEach(() => { + mocks.list.mockResolvedValue([card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'zw2' } })]); + mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' })); + mocks.decodeImage.mockResolvedValue(image); + mocks.exportCard.mockResolvedValue(png); + mocks.getLogo.mockResolvedValue(logoRow()); + mocks.loadLogoBlob.mockResolvedValue(new Blob(['logo'], { type: 'image/png' })); + // Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter. + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, img: unknown) => { + const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null; + return { image: img, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null }; + }); + vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 })); + }); + + afterEach(() => { + forgetLogo('zw2'); + vi.unstubAllGlobals(); + }); + + async function download(): Promise { + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + button(tileFor('a'), 'Herunterladen').click(); + } + + it('ein Logo, das nach dem ersten Laden (Editor) ausgeblendet wurde, wird vor dem Download neu geprüft: Meldung an der Kachel, kein PNG', async () => { + await expect(loadLogoAsset('zw2', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher + mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann wurde es ausgeblendet + await download(); + await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH)); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(mocks.exportCard).not.toHaveBeenCalled(); + expect(mocks.saveBlobAs).not.toHaveBeenCalled(); + }); + + it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei heruntergeladen', async () => { + await expect(loadLogoAsset('zw2', 'light')).resolves.not.toBeNull(); + await download(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2); + expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } }); + expect(msg().textContent).toBe(''); + }); + + it('eine Karte ohne Logo holt nichts vom Server', async () => { + mocks.list.mockResolvedValue([card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: null } })]); + await download(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); + expect(mocks.getLogo).not.toHaveBeenCalled(); + }); +}); + describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => { const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }); const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }); diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index 8f94854..5ae3390 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -21,6 +21,7 @@ import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download import type { EditorStore } from '../editor/state'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload'; import { msg, t } from '../i18n/i18n'; +import { forgetLogo } from '../logos/logo-asset'; import { formatDate } from './format'; import { navigate } from './router'; @@ -438,6 +439,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi const blob = await withLogin(() => service.loadImage(card)); const image = await decodeImage(blob); const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; + // Zeile und Bild des Logos neu vom Server prüfen: ein inzwischen ausgeblendetes oder gelöschtes Logo käme sonst aus dem + // Zwischenspeicher (er lebt so lange wie die Seite) ins PNG. + if (config.logo) forgetLogo(config.logo); const assets = await loadCardAssets(config, image); // Fehlt das gewählte Logo und würde die Vorlage es zeichnen, nicht stillschweigend ohne Logo herunterladen: die Meldung // steht dann an der Kachel (siehe failureText). Bei „Nur Bild“ fehlt nichts.