feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- 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>
This commit is contained in:
1 parent
3051b3b3f9
commit
0be5abd954
44 files changed
+1401
-366
No files matched your search
@@ -6,10 +6,11 @@ vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: ()
|
||||
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { ApiError } from './api';
|
||||
import { NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
|
||||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
|
||||
import {
|
||||
CONSENT_TEXT,
|
||||
publishDialog,
|
||||
publishFailureText,
|
||||
validatePublishForm,
|
||||
type PublishFormValues,
|
||||
type PublishOutcome,
|
||||
@@ -382,6 +383,25 @@ describe('Veröffentlichen-Dialog: Senden', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
|
||||
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
|
||||
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(
|
||||
'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
expect(errorText()).toContain(LOGO_MISSING_PUBLISH);
|
||||
expect(errorText()).not.toContain('schiefgelaufen');
|
||||
});
|
||||
|
||||
it('publishFailureText reicht ihn ebenso durch', () => {
|
||||
expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Schließen', () => {
|
||||
it('„Abbrechen“ schließt ohne zu senden', async () => {
|
||||
const outcome = open();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { el } from '../editor/dom';
|
||||
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
|
||||
import { ApiError, isAlreadyPublished } from './api';
|
||||
import { NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||||
|
||||
/** Wortlaut aus Spec § 6. */
|
||||
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
|
||||
@@ -67,7 +67,7 @@ export function validatePublishForm(values: PublishFormValues): { field: Publish
|
||||
}
|
||||
|
||||
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
|
||||
const GERMAN_MESSAGES = new Set([TOO_LARGE, NOT_RENDERED, READ_MESSAGE, PIXELS_MESSAGE]);
|
||||
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
|
||||
|
||||
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
|
||||
export function publishFailureText(err: unknown): string {
|
||||
|
||||
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG } from '../card/config';
|
||||
import type { SavedCard } from '../account/cards';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createJWT: vi.fn(),
|
||||
decodeImage: vi.fn(),
|
||||
loadCardAssets: vi.fn(),
|
||||
exportCard: vi.fn(),
|
||||
}));
|
||||
|
||||
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
|
||||
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
|
||||
@@ -14,9 +19,22 @@ vi.mock('../account/appwrite', () => ({
|
||||
storage: {},
|
||||
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||||
}));
|
||||
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
|
||||
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||||
vi.mock('../editor/upload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../editor/upload')>()),
|
||||
decodeImage: mocks.decodeImage,
|
||||
}));
|
||||
|
||||
import { ApiError, publishCard } from './api';
|
||||
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish';
|
||||
import {
|
||||
LOGO_MISSING_PUBLISH,
|
||||
PNG_MAX_BYTES,
|
||||
normalizeTags,
|
||||
publishSavedCard,
|
||||
renderForGallery,
|
||||
type PublishDeps,
|
||||
} from './publish';
|
||||
|
||||
const card: SavedCard = {
|
||||
id: 'card1',
|
||||
@@ -242,6 +260,79 @@ describe('publishSavedCard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo der Karte beim Veröffentlichen', () => {
|
||||
const photo = { source: {}, w: 3000, h: 2000 };
|
||||
const source = new Blob(['src'], { type: 'image/jpeg' });
|
||||
const withLogo = (logo: string | null): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, logo } });
|
||||
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.decodeImage.mockReset().mockResolvedValue(photo);
|
||||
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
|
||||
mocks.exportCard
|
||||
.mockReset()
|
||||
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
|
||||
});
|
||||
|
||||
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
|
||||
const { t, setLang } = await import('../i18n/i18n');
|
||||
expect(LOGO_MISSING_PUBLISH).toBe(
|
||||
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
try {
|
||||
setLang('en', { persist: false });
|
||||
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
|
||||
} finally {
|
||||
setLang('de', { persist: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
|
||||
const out = await renderForGallery(withLogo('abc123'), source);
|
||||
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
|
||||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
|
||||
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
|
||||
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
|
||||
});
|
||||
|
||||
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
|
||||
const out = await renderForGallery(withLogo(null), source);
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
|
||||
expect(out.png.type).toBe('image/png');
|
||||
});
|
||||
|
||||
it('fehlendes Logo (gelöscht oder ausgeblendet): Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
await expect(renderForGallery(withLogo('gone'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
|
||||
const failure = new TypeError('Failed to fetch');
|
||||
mocks.loadCardAssets.mockRejectedValue(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 () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('gone'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage']);
|
||||
expect(fake.of('createFile')).toHaveLength(0);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
expect(fake.of('publishCard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
|
||||
const json = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
@@ -8,12 +8,17 @@ import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { clampCrop } from '../card/crop';
|
||||
import { exportCard, loadCardAssets } from '../card/render';
|
||||
import { decodeImage } from '../editor/upload';
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
|
||||
|
||||
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
|
||||
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 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. */
|
||||
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.',
|
||||
);
|
||||
const GALLERY_TYPES = ['image/png', 'image/webp'];
|
||||
|
||||
export interface PublishForm {
|
||||
@@ -47,10 +52,12 @@ export function normalizeTags(input: string): string[] {
|
||||
return tags;
|
||||
}
|
||||
|
||||
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 config = { ...card.config, crop: clampCrop(image, card.config.crop) };
|
||||
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.
|
||||
if (assets.logoMissing) throw new Error(LOGO_MISSING_PUBLISH);
|
||||
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 });
|
||||
return { png, preview };
|
||||
|
||||
Reference in new issue
Block a user