fix(logos): Logo vor Veröffentlichen und Herunterladen frisch prüfen

Schlussreview F4. Zeilen und Bilder der Community-Logos (logo-asset.ts) bleiben bis zum Neuladen der Seite im Speicher. Wurde ein Logo
danach ausgeblendet oder gelöscht, konnte es aus diesem Zwischenspeicher in ein veröffentlichtes Kartenbild oder in ein heruntergeladenes
PNG gelangen.

- renderForGallery (Veröffentlichen), der Download im Editor und der Download in „Meine Karten“ rufen vor dem Laden der Bilder
  forgetLogo(config.logo) (falls eines gewählt ist): Zeile und Bild werden neu vom Server geholt, hidden/fehlt wird also neu geprüft.
  Veröffentlichen bricht bei einem fehlenden Logo wie bisher mit LOGO_MISSING_PUBLISH ab, „Meine Karten“ meldet es an der Kachel.
- Der Download im Editor berücksichtigt assets.logoMissing wie der Hinweis im Editor: das PNG entsteht ohne das fehlende Logo (die Vorschau
  zeigte es bis dahin noch), der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ erscheint und die Vorschau zeichnet ohne Logo, damit
  PNG und Vorschau dasselbe zeigen. Ist ein Logo wieder da, folgt die Vorschau ebenso. Das gilt nur, solange die Auswahl noch dieselbe ist.
- Tests mit dem echten Zwischenspeicher (nur Zeile und Datei aus logos.ts ersetzt): erst geladen, dann ausgeblendet bzw. gelöscht → neu
  geprüft, Veröffentlichen bricht ab / „Meine Karten“ meldet / der Editor lädt ohne Logo herunter, zeigt den Hinweis (auch zusammen mit dem
  Größenhinweis) und zeichnet neu; sichtbares Logo wird frisch geholt; Karte ohne Logo holt nichts; „Nur Bild“ bleibt unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 01:18:32 +00:00
1 parent b3c6e65801
commit 4aedb1d322
6 files changed
+323 -2

No files matched your search

+96
View File
@@ -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<typeof import('../editor/upload')>()),
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> = {}): 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 });
+4
View File
@@ -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);
+124
View File
@@ -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<typeof import('./upload')>()),
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<string, unknown> = {}) => ({
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' } }));
+17 -2
View File
@@ -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<typeof setTimeout> | undefined;
+78
View File
@@ -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> = {}): 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<void> {
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' });
+4
View File
@@ -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.