fix(card): fehlendes Logo bricht nur ab, wenn die Vorlage es zeichnet; Download aus „Meine Karten“ meldet es

Entscheidung des Controllers zu Task 5: Ein fehlendes Community-Logo ist beim Rendern einer gespeicherten Karte nur dann ein
Problem, wenn die Vorlage es zeichnet (layoutFor(template).logo). Bei „Nur Bild“ wird ein Logo nie gezeichnet, dort fehlt
nichts. Der Editor-Hinweis (Auswahlzustand) und der Download im Editor bleiben unverändert.

- community/publish.ts: neue gemeinsame Prüfung assertLogoAvailable(config, assets); sie wirft LOGO_MISSING_PUBLISH, wenn
  assets.logoMissing gilt und die Vorlage das Logo zeichnet (minimal, classic, signature). renderForGallery nutzt sie: bei
  einer zeichnenden Vorlage Abbruch vor dem Rendern (nichts hochgeladen), bei „Nur Bild“ wird normal gerendert und
  veröffentlicht.
- pages/cards-page.ts: der Download einer gespeicherten Karte ruft dieselbe Prüfung. Statt stillschweigend ohne Logo
  herunterzuladen, zeigt die Kachel den Text LOGO_MISSING_PUBLISH über den vorhandenen Fehlermechanismus (run() und
  failureText() schreiben in .card-msg), übersetzt mit t(); es wird nichts exportiert oder gespeichert. Bei „Nur Bild“ läuft
  der Download normal.
- Tests: publish.test.ts (classic/minimal/signature mit fehlendem Logo brechen ab, „Nur Bild“ veröffentlicht normal,
  Wahrheitstabelle für assertLogoAvailable) und cards-page.test.ts (Download mit fehlendem Logo: Meldung, kein Download;
  „Nur Bild“ und vorhandenes Logo: Download wie sonst; zweiter Versuch; Englisch). Vitest 45 Dateien, 1338 Tests grün, tsc
  und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 20:38:23 +00:00
1 parent 0be5abd954
commit 8205f7c37f
4 files changed
+147 -14

No files matched your search

+49 -7
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config'; import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
import type { SavedCard } from '../account/cards'; import type { SavedCard } from '../account/cards';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -30,6 +30,7 @@ import { ApiError, publishCard } from './api';
import { import {
LOGO_MISSING_PUBLISH, LOGO_MISSING_PUBLISH,
PNG_MAX_BYTES, PNG_MAX_BYTES,
assertLogoAvailable,
normalizeTags, normalizeTags,
publishSavedCard, publishSavedCard,
renderForGallery, renderForGallery,
@@ -263,7 +264,12 @@ describe('publishSavedCard', () => {
describe('Logo der Karte beim Veröffentlichen', () => { describe('Logo der Karte beim Veröffentlichen', () => {
const photo = { source: {}, w: 3000, h: 2000 }; const photo = { source: {}, w: 3000, h: 2000 };
const source = new Blob(['src'], { type: 'image/jpeg' }); const source = new Blob(['src'], { type: 'image/jpeg' });
const withLogo = (logo: string | null): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, logo } }); const withLogo = (logo: string | null, template: TemplateId = DEFAULT_CONFIG.template): SavedCard => ({
...card,
config: { ...DEFAULT_CONFIG, template, logo },
});
/** Die Vorlagen, die das Community-Logo zeichnen (alle außer „Nur Bild“). */
const DRAWING = ['minimal', 'classic', 'signature'] as const;
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch }); const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
beforeEach(() => { beforeEach(() => {
@@ -304,10 +310,21 @@ describe('Logo der Karte beim Veröffentlichen', () => {
expect(out.png.type).toBe('image/png'); expect(out.png.type).toBe('image/png');
}); });
it('fehlendes Logo (gelöscht oder ausgeblendet): Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', async () => { it.each(DRAWING)(
'fehlendes Logo (gelöscht oder ausgeblendet), Vorlage %s zeichnet es: Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert',
async (template) => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
await expect(renderForGallery(withLogo('gone', template), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.exportCard).not.toHaveBeenCalled();
},
);
it('fehlendes Logo bei „Nur Bild“ (image): die Vorlage zeichnet es nie, es wird ganz normal gerendert', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
await expect(renderForGallery(withLogo('gone'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH); const out = await renderForGallery(withLogo('gone', 'image'), source);
expect(mocks.exportCard).not.toHaveBeenCalled(); expect(mocks.exportCard).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
}); });
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => { it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
@@ -316,16 +333,24 @@ describe('Logo der Karte beim Veröffentlichen', () => {
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure); await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
}); });
it('publishSavedCard: mit fehlendem Logo wird nichts hochgeladen, nichts gelöscht und die API nicht gerufen', async () => { it('publishSavedCard: classic mit fehlendem Logo – nichts wird hochgeladen, nichts gelöscht, die API nicht gerufen', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery }); const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH); await expect(publishSavedCard(withLogo('gone', 'classic'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(fake.names()).toEqual(['userId', 'loadImage']); expect(fake.names()).toEqual(['userId', 'loadImage']);
expect(fake.of('createFile')).toHaveLength(0); expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('deleteFile')).toHaveLength(0); expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0); expect(fake.of('publishCard')).toHaveLength(0);
}); });
it('publishSavedCard: „Nur Bild“ (image) mit fehlendem Logo – wird veröffentlicht, als wäre nichts', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone', 'image'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
expect(fake.of('deleteFile')).toHaveLength(0);
});
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => { it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
const fake = fakeDeps({ render: renderForGallery }); const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' }); await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
@@ -333,6 +358,23 @@ describe('Logo der Karte beim Veröffentlichen', () => {
}); });
}); });
describe('assertLogoAvailable', () => {
const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' });
const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing });
it.each(['minimal', 'classic', 'signature'] as const)('Logo fehlt und Vorlage %s zeichnet es: wirft LOGO_MISSING_PUBLISH', (template) => {
expect(() => assertLogoAvailable(config(template), assets(true))).toThrow(LOGO_MISSING_PUBLISH);
});
it('Logo fehlt, aber „Nur Bild“ zeichnet nie ein Logo: wirft nicht', () => {
expect(() => assertLogoAvailable(config('image'), assets(true))).not.toThrow();
});
it.each(['image', 'minimal', 'classic', 'signature'] as const)('Logo ist da (oder keins gewählt): Vorlage %s wirft nicht', (template) => {
expect(() => assertLogoAvailable(config(template), assets(false))).not.toThrow();
});
});
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => { describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
const json = (status: number, body: unknown) => const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+16 -5
View File
@@ -4,9 +4,10 @@
import { ID, Permission, Role } from 'appwrite'; import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite'; import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards'; import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; import type { CardConfig } from '../card/config';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH, layoutFor } from '../card/geometry';
import { clampCrop } from '../card/crop'; import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets } from '../card/render'; import { exportCard, loadCardAssets, type LoadedAssets } from '../card/render';
import { decodeImage } from '../editor/upload'; import { decodeImage } from '../editor/upload';
import { msg } from '../i18n/i18n'; import { msg } from '../i18n/i18n';
import { isDefinitiveFailure, publishCard, type PublishInput } from './api'; import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
@@ -15,7 +16,7 @@ import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
export const PNG_MAX_BYTES = 5_242_880; export const PNG_MAX_BYTES = 5_242_880;
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).'; export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: veröffentlicht wird sie nicht ohne dieses Logo. */ /** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: ohne dieses Logo wird sie weder veröffentlicht noch heruntergeladen. */
export const LOGO_MISSING_PUBLISH = msg( export const LOGO_MISSING_PUBLISH = msg(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.', 'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
); );
@@ -52,12 +53,22 @@ export function normalizeTags(input: string): string[] {
return tags; return tags;
} }
/**
* Prüft nach dem Laden der Bilder einer gespeicherten Karte, ob ihr beim Rendern etwas fehlt (Veröffentlichen und Herunterladen
* aus „Meine Karten“): Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet) und zeichnet die Vorlage es, wirft
* das LOGO_MISSING_PUBLISH. So wird nie eine andere Karte veröffentlicht oder heruntergeladen, als der Nutzer sie gestaltet hat.
* Bei „Nur Bild“ wird ein Logo nie gezeichnet: dort fehlt nichts, und es wird ganz normal gerendert.
*/
export function assertLogoAvailable(config: CardConfig, assets: LoadedAssets): void {
if (assets.logoMissing && layoutFor(config.template).logo) throw new Error(LOGO_MISSING_PUBLISH);
}
export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> { export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
const image = await decodeImage(imageBlob); const image = await decodeImage(imageBlob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
const assets = await loadCardAssets(config, image); const assets = await loadCardAssets(config, image);
// Ohne das gewählte Logo würde eine andere Karte veröffentlicht, als der Nutzer sie gestaltet hat: lieber abbrechen. // Lieber abbrechen, als eine Karte ohne ihr Logo zu veröffentlichen.
if (assets.logoMissing) throw new Error(LOGO_MISSING_PUBLISH); assertLogoAvailable(config, assets);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY }); const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
return { png, preview }; return { png, preview };
+76 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth'; import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards'; import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config'; import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
import { createStore, initialEditorState } from '../editor/state'; import { createStore, initialEditorState } from '../editor/state';
import type { GalleryCard } from '../community/gallery'; import type { GalleryCard } from '../community/gallery';
@@ -67,6 +67,7 @@ vi.mock('../community/api', async (importOriginal) => ({
vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications })); vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications }));
import { ApiError } from '../community/api'; import { ApiError } from '../community/api';
import { LOGO_MISSING_PUBLISH } from '../community/publish';
import { mountCardsPage } from './cards-page'; import { mountCardsPage } from './cards-page';
const user: User = { $id: 'u1', email: 'a@b.de' }; const user: User = { $id: 'u1', email: 'a@b.de' };
@@ -301,6 +302,80 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
}); });
}); });
describe('Meine Karten: Herunterladen mit fehlendem Logo', () => {
const image = { source: {}, w: 3000, h: 2000 };
const png = new Blob(['png'], { type: 'image/png' });
/** Eine Karte, die ein Logo wählt, das es nicht mehr gibt (loadCardAssets meldet logoMissing). */
const onTemplate = (template: TemplateId) =>
card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template, logo: 'gone' } });
const msg = () => tileFor('a').querySelector('.card-msg')!;
beforeEach(() => {
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
mocks.decodeImage.mockResolvedValue(image);
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: true });
mocks.exportCard.mockResolvedValue(png);
});
async function download(template: TemplateId): Promise<void> {
mocks.list.mockResolvedValue([onTemplate(template)]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
}
it.each(['minimal', 'classic', 'signature'] as const)(
'Vorlage %s zeichnet das Logo: die Meldung steht an der Kachel, es wird nichts heruntergeladen',
async (template) => {
await download(template);
await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH));
expect(msg().textContent).toBe(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
expect(msg().classList.contains('is-warning')).toBe(false);
expect(mocks.exportCard).not.toHaveBeenCalled();
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
// Der Knopf ist danach wieder bedienbar und trägt seine Beschriftung.
const again = button(tileFor('a'), 'Herunterladen');
expect(again.disabled).toBe(false);
expect(tileFor('a').getAttribute('aria-busy')).toBeNull();
},
);
it('„Nur Bild“ (image) zeichnet nie ein Logo: der Download läuft ganz normal, ohne Meldung', async () => {
await download('image');
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
expect(mocks.exportCard).toHaveBeenCalledTimes(1);
expect(msg().textContent).toBe('');
});
it('nach der Meldung geht ein zweiter Versuch (Logo wieder da) durch, die Meldung ist dann weg', async () => {
await download('classic');
await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH));
mocks.loadCardAssets.mockResolvedValue({ image, logo: { source: {}, w: 200, h: 100 }, brand: null, logoMissing: false });
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledTimes(1));
expect(msg().textContent).toBe('');
});
it('Englisch: dieselbe Meldung, übersetzt', async () => {
const { setLang } = await import('../i18n/i18n');
setLang('en', { persist: false });
await download('signature');
await vi.waitFor(() =>
expect(msg().textContent).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.'),
);
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
});
it('eine Karte ohne gewähltes Logo wird normal heruntergeladen', async () => {
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
await download('classic');
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
expect(msg().textContent).toBe('');
});
});
describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => { 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 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' }); const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
+6 -1
View File
@@ -12,13 +12,14 @@ import { CARD_W } from '../card/geometry';
import { exportCard, loadCardAssets } from '../card/render'; import { exportCard, loadCardAssets } from '../card/render';
import { ApiError, isAlreadyPublished, isDefinitiveFailure, withdrawCard } from '../community/api'; import { ApiError, isAlreadyPublished, isDefinitiveFailure, withdrawCard } from '../community/api';
import { myPublications } from '../community/gallery'; import { myPublications } from '../community/gallery';
import { publishSavedCard, type PublishForm } from '../community/publish'; import { LOGO_MISSING_PUBLISH, assertLogoAvailable, publishSavedCard, type PublishForm } from '../community/publish';
import { publishDialog } from '../community/publish-dialog'; import { publishDialog } from '../community/publish-dialog';
import { confirmDialog } from '../editor/confirm-dialog'; import { confirmDialog } from '../editor/confirm-dialog';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download'; import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
import type { EditorStore } from '../editor/state'; import type { EditorStore } from '../editor/state';
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
import { t } from '../i18n/i18n';
import { formatDate } from './format'; import { formatDate } from './format';
import { navigate } from './router'; import { navigate } from './router';
@@ -429,6 +430,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const image = await decodeImage(blob); const image = await decodeImage(blob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
const assets = await loadCardAssets(config, image); 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.
assertLogoAvailable(config, assets);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
saveBlobAs(png, downloadFileName(card.name, new Date())); saveBlobAs(png, downloadFileName(card.name, new Date()));
const warning = pngSizeWarning(png.size); const warning = pngSizeWarning(png.size);
@@ -533,6 +537,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
if (err instanceof LoginCancelled) return LOGIN_NEEDED; if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return err.message; // der Dienst antwortet auf Deutsch if (err instanceof ApiError) return err.message; // der Dienst antwortet auf Deutsch
if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message; if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message;
if (err instanceof Error && err.message === LOGO_MISSING_PUBLISH) return t(LOGO_MISSING_PUBLISH);
return errorMessage(err, 'load'); return errorMessage(err, 'load');
} }