feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen

Task 9: die Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6).

- logos/logo-info.ts (neu): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste; ein ausgeblendetes Logo lässt
  sich nicht melden). Der Hochlader sieht „Löschen“ mit Rückfrage (Titel mit Namen, Folgen im Text), Admins „<n> Meldungen“ mit den
  Gründen als Text (listLogoReports, einmal je Logo und Stand), „Ausblenden“/„Einblenden“ und „Löschen“. Gelöscht: deleteLogo,
  forgetLogo, Kachel weg, onSelect(null); ein-/ausgeblendet: setLogoVisibility, forgetLogo (ein Null-Bild bleibt nicht im Zwischen-
  speicher), die Zeile des Pickers wird fortgeschrieben (Einblenden setzt reportCount auf 0). Gewähltes Logo nicht auffindbar: „Das
  gewählte Logo ist nicht mehr verfügbar.“ (ein Netzfehler von getLogo sagt nichts). Zugleich die Meldezeile (role="alert") für Fehler
  von Herz, Hochladen und diesen Aktionen; ein Wechsel der Auswahl räumt sie ab. Immer nur eine Aktion zugleich; die Rechte werden
  nach der Rückfrage noch einmal geprüft; der Aufbau steht einmal da, update() ändert nur Texte und Sichtbarkeit (Fokus bleibt).
- logos/logo-picker.ts: hängt die Infozeile als Letztes unter das Raster (info.el). Herz- und Upload-Fehler gehen in die Infozeile,
  der Statusbereich des Pickers bleibt den Ladefehlern der Liste vorbehalten (ein Klick auf ein Herz löscht ihren Text nicht mehr).
  Aus dem Review von Task 8: requireLogin, das wirft, zeigt den Grund statt einer unbehandelten Ablehnung; select() nach dispose()
  ist wirkungslos. Löschen entfernt Kachel, Zeile, Herz und Eintrag der Liste, setzt den Cursor von „Mehr laden“ auf das Logo davor
  (Appwrite lehnt einen Cursor auf eine gelöschte Zeile ab), hebt die Auswahl über onSelect(null) auf und holt den Fokus nach
  „Kein Logo“, wenn er auf „Löschen“ lag. LogoPickerDeps erweitert um reportLogo, deleteLogo, setLogoVisibility, listLogoReports,
  confirm und reportDialog.
- community/report-dialog.ts: subject 'card' (Standard, unverändert) oder 'logo': Überschrift „Logo melden“, Einleitung „dieses
  Logo“, vier Gründe ohne „Echte Kartendaten“, Duplikat-Dank „Danke, du hast dieses Logo schon gemeldet.“. Der ganze Dialog geht
  durch t()/msg(); Fehler des Dienstes über apiErrorText.
- logos/logos.css: das Herz sitzt in der Textzeile unter der Bildfläche (nicht mehr auf dem Logo, folgt dem Theme), die Kachel füllt
  die Zelle (height: 100%), damit „Kein Logo“ so hoch ist wie sein Nachbar; Stile der Infozeile.
- editor/editor-page.ts: Aus dem Review von Task 8: der Ladefehler der Logos verschwindet, sobald ein Logo danach lädt, aber nur
  der, den der Logo-Zustand selbst ins Fehlerfeld geschrieben hat; jeder andere Text im Feld bleibt.
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen und zum Melden-Dialog.
- Tests: logo-info.test.ts (neu), logo-picker.test.ts (Infozeile, Löschen, Ein-/Ausblenden, Fehler, Cursor, Fokus), report-dialog.test.ts
  (Logo und Englisch), editor-page.test.ts (Fehlerfeld). Vitest 52 Dateien, 1764 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 23:21:33 +00:00
1 parent 4cf9fe192d
commit a75be2d44c
10 files changed
+1976 -81

No files matched your search

+157 -1
View File
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { setLang } from '../i18n/i18n';
import { ApiError, type ReportReason } from './api';
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
@@ -27,7 +28,7 @@ const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function open(opts: { cardTitle?: string; signal?: AbortSignal } = {}): Promise<ReportOutcome> {
function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise<ReportOutcome> {
return reportDialog({ send, ...opts });
}
@@ -239,3 +240,158 @@ describe('Melden-Dialog', () => {
expect(dialogEl()).toBeNull();
});
});
describe('Melden-Dialog für Logos (subject: „logo“)', () => {
const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent);
it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => {
void open({ subject: 'logo' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']);
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']);
expect(radios().some((r) => r.checked)).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Melden')).toBeTruthy();
});
it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => {
expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => {
void open({ subject: 'logo', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
});
it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => {
send.mockResolvedValue({ schonGemeldet: false });
void open({ subject: 'logo' });
choose(value);
submit();
await settle();
expect(send).toHaveBeenCalledExactlyOnceWith(value);
});
it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => {
void open({ subject: 'logo' });
submit();
await settle();
expect(errorText()).toBe('Bitte wähle einen Grund.');
expect(send).not.toHaveBeenCalled();
});
it('neue Meldung: derselbe Dank, „reported“', async () => {
send.mockResolvedValue({ schonGemeldet: false });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
});
it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => {
send.mockResolvedValue({ schonGemeldet: true });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Melden')!.disabled).toBe(false);
});
it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => {
void open({ subject: 'card', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?');
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
});
describe('Melden-Dialog auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => {
void open({ cardTitle: 'Holiday' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?');
expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Real card data',
'Copyright/trademark',
'Spam',
'Other',
]);
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Report')).toBeTruthy();
// ohne Titel
dialogEl()!.remove();
void open();
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?');
});
it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => {
send.mockResolvedValueOnce({ schonGemeldet: true });
const result = open({ subject: 'logo' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Copyright/trademark',
'Spam',
'Other',
]);
submit(); // ohne Grund
await settle();
expect(errorText()).toBe('Please choose a reason.');
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this logo.');
buttonByText('Close')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Dank und Duplikat bei Karten', async () => {
send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true });
const first = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks for your report.');
buttonByText('Close')!.click();
await first;
const second = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this card.');
buttonByText('Close')!.click();
await second;
});
it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('This logo is no longer available.');
});
});
+49 -28
View File
@@ -1,35 +1,45 @@
// Melden-Dialog der Detailseite: Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
// Melden-Dialog für Karten (Detailseite) und Logos (Editor): Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { errorMessage } from '../account/errors';
import { el } from '../editor/dom';
import { ApiError, type ReportReason } from './api';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, type ReportReason } from './api';
/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */
export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [
{ value: 'unangemessen', label: 'Unangemessen' },
{ value: 'kartendaten', label: 'Echte Kartendaten' },
{ value: 'urheberrecht', label: 'Urheberrecht/Marke' },
{ value: 'spam', label: 'Spam' },
{ value: 'sonstiges', label: 'Sonstiges' },
{ value: 'unangemessen', label: msg('Unangemessen') },
{ value: 'kartendaten', label: msg('Echte Kartendaten') },
{ value: 'urheberrecht', label: msg('Urheberrecht/Marke') },
{ value: 'spam', label: msg('Spam') },
{ value: 'sonstiges', label: msg('Sonstiges') },
];
export const REPORT_THANKS = 'Danke für deine Meldung.';
/** Wortlaut aus Spec § 11. */
export const REPORT_DUPLICATE = 'Danke, du hast diese Karte schon gemeldet.';
const NO_REASON = 'Bitte wähle einen Grund.';
/** Ein Logo hat keine Kartendaten: für Logos entfällt „Echte Kartendaten“ (Spec § 6). */
const LOGO_REASONS = REPORT_REASONS.filter((reason) => reason.value !== 'kartendaten');
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
export const REPORT_THANKS = msg('Danke für deine Meldung.');
/** Wortlaut aus Spec § 11. */
export const REPORT_DUPLICATE = msg('Danke, du hast diese Karte schon gemeldet.');
export const LOGO_REPORT_DUPLICATE = msg('Danke, du hast dieses Logo schon gemeldet.');
const NO_REASON = msg('Bitte wähle einen Grund.');
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte (das Logo) schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled';
/** Was gemeldet wird: eine Karte (Standard) oder ein Logo. */
export type ReportSubject = 'card' | 'logo';
export interface ReportDialogOptions {
/** Sendet die Meldung (`reportCard` der API). */
/** Sendet die Meldung (`reportCard` bzw. `reportLogo` der API). */
send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
/** Titel der Karte für die Überschrift des Textes (als Text). */
/** Titel der Karte für die Überschrift des Textes (als Text); für Logos ohne Wirkung (dort heißt es „dieses Logo“). */
cardTitle?: string;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
/** Karte (Standard) oder Logo: Überschrift, Einleitung, Dank bei Duplikat und die Gründe (ohne „Echte Kartendaten“) folgen daraus. */
subject?: ReportSubject;
}
/**
@@ -44,32 +54,43 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const logo = opts.subject === 'logo';
const choices = logo ? LOGO_REASONS : REPORT_REASONS;
let outcome: ReportOutcome = 'cancelled';
let closed = false;
let busy = false;
const radios = REPORT_REASONS.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
const radios = choices.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
const reasons = el(
'fieldset',
{ class: 'report-reasons' },
el('legend', { class: 'report-legend' }, 'Grund der Meldung'),
...REPORT_REASONS.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], label)),
el('legend', { class: 'report-legend' }, t('Grund der Meldung')),
...choices.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], t(label))),
);
const intro = el(
'p',
{ class: 'report-text' },
opts.cardTitle ? `Warum möchtest du „${opts.cardTitle}“ melden?` : 'Warum möchtest du diese Karte melden?',
logo
? t('Warum möchtest du dieses Logo melden?')
: opts.cardTitle
? t('Warum möchtest du „{title}“ melden?', { title: opts.cardTitle })
: t('Warum möchtest du diese Karte melden?'),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Melden');
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Melden'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog', 'aria-labelledby': 'report-title' },
el('div', { class: 'report-card' }, el('h2', { id: 'report-title', class: 'report-title' }, 'Karte melden'), form),
el(
'div',
{ class: 'report-card' },
el('h2', { id: 'report-title', class: 'report-title' }, logo ? t('Logo melden') : t('Karte melden')),
form,
),
);
const finish = () => {
@@ -92,20 +113,20 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
function showResult(duplicate: boolean): void {
outcome = duplicate ? 'duplicate' : 'reported';
errorEl.textContent = '';
statusEl.textContent = duplicate ? REPORT_DUPLICATE : REPORT_THANKS;
statusEl.textContent = t(duplicate ? (logo ? LOGO_REPORT_DUPLICATE : REPORT_DUPLICATE) : REPORT_THANKS);
intro.remove();
reasons.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
}
async function submit(): Promise<void> {
const chosen = REPORT_REASONS.find((_, i) => radios[i].checked);
const chosen = choices.find((_, i) => radios[i].checked);
if (!chosen) {
statusEl.textContent = '';
errorEl.textContent = NO_REASON;
errorEl.textContent = t(NO_REASON);
return;
}
setBusy(true);
@@ -117,8 +138,8 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
showResult(res.schonGemeldet);
} catch (err) {
if (closed) return;
// Der Dienst liefert deutsche Texte (auch für 429); alles andere (Netz, JWT) übersetzt errorMessage.
errorEl.textContent = err instanceof ApiError ? err.message : errorMessage(err, 'load');
// Texte des Dienstes (auch für 429) kommen über ihren Schlüssel in der Sprache der Seite; alles andere (Netz, JWT) über errorMessage.
errorEl.textContent = err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
setBusy(false);
}
}
+86
View File
@@ -1146,6 +1146,92 @@ describe('Community-Logo auf der Karte', () => {
expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true);
});
describe('Fehler „Die Logos konnten nicht geladen werden.“ und das gemeinsame Fehlerfeld', () => {
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
/** Logo „A“ lässt sich nicht laden, alle anderen schon. */
const failA = async (config: { logo: string | null }) => {
if (config.logo === 'A') throw new TypeError('Failed to fetch');
return FOUND;
};
it('der Fehler gehört dem Logo-Zustand: lädt danach ein anderes Logo, verschwindet er', async () => {
mocks.loadCardAssets.mockImplementation(failA);
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo('B') });
await vi.waitFor(() => expect(errorText()).toBe(''));
// und auch „Kein Logo“ ist ein erfolgreicher Ladevorgang
store.set({ config: withLogo('A') });
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo(null) });
await vi.waitFor(() => expect(errorText()).toBe(''));
});
it('ein anderer Fehler im Feld bleibt stehen, auch wenn danach ein Logo lädt', async () => {
mocks.loadCardAssets.mockImplementation(failA);
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
// Ein anderer Fehler ersetzt den Logo-Fehler im Feld …
saveButton().disabled = false;
saveButton().click();
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
// … und gehört nicht dem Logo-Zustand: ein erfolgreicher Ladevorgang löscht ihn nicht.
store.set({ config: withLogo('B') });
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
});
it('ein erfolgreicher Ladevorgang ohne Logo-Fehler lässt einen fremden Fehler ebenfalls stehen', async () => {
store.set({ config: withLogo('A') });
mocks.loadCardAssets.mockResolvedValue(FOUND);
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
saveButton().disabled = false;
saveButton().click();
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
store.set({ config: withLogo('B') });
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
});
it('derselbe Text aus einem gescheiterten Download gehört dem Download und bleibt stehen', async () => {
withImage();
store.set({ config: withLogo('da') });
mocks.loadCardAssets.mockResolvedValueOnce(FOUND).mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue(FOUND);
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
downloadButton().click();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo('db') });
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(3));
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe(LOGO_ERROR);
});
it('eine späte Antwort für einen überholten Schlüssel löscht nichts', async () => {
let finishB!: (v: typeof FOUND) => void;
mocks.loadCardAssets.mockImplementation((config: { logo: string | null }) => {
if (config.logo === 'A') return Promise.reject(new TypeError('Failed to fetch'));
if (config.logo === 'B') return new Promise((resolve) => (finishB = resolve as (v: typeof FOUND) => void));
return Promise.resolve(FOUND);
});
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo('B') }); // lädt noch …
store.set({ config: withLogo('A') }); // … da wird A wieder gewählt und scheitert erneut
finishB(FOUND);
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe(LOGO_ERROR);
});
});
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' } }));
+12
View File
@@ -106,6 +106,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onNewCard: () => void startNewCard(),
});
// Das Fehlerfeld der Vorschau teilen sich alle Fehler des Editors. LOGO_ERROR (ensureLogos) darf verschwinden, sobald ein Logo
// doch lädt, aber nur, solange er noch dort steht, wo ensureLogos ihn hingeschrieben hat: jeder andere Aufruf von setError
// (ein anderer Text, auch derselbe aus einem Download, oder das Leeren) nimmt ihm dieses Recht.
let logoErrorShown = false;
const setPreviewError = preview.setError;
preview.setError = (message) => {
logoErrorShown = false;
setPreviewError(message);
};
// Der Hinweis unter der Vorschau: ein allgemeiner (Bild fehlt, PNG zu groß …) und der zum fehlenden Community-Logo (er gilt,
// solange die geladenen Logos `missing` sind, siehe ensureLogos). Beide teilen sich das Feld, werden aber getrennt gehalten,
// damit das Ende des einen den anderen nicht löscht.
@@ -409,6 +419,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (disposed || token !== logoToken) return;
pendingKey = '';
logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key };
if (logoErrorShown) preview.setError(null); // der Ladefehler dieses Zustands ist erledigt, alle anderen Fehler bleiben
showWarning();
schedule();
},
@@ -417,6 +428,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
pendingKey = '';
failedKey = key;
preview.setError(LOGO_ERROR);
logoErrorShown = true;
},
);
}
+29
View File
@@ -91,6 +91,35 @@ export const EN: Readonly<Record<string, string>> = {
'Nochmal versuchen': 'Try again',
'Mehr laden': 'Load more',
// Infozeile zum gewählten Logo (logos/logo-info.ts). „von {name}“, „Anonym“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle,
// der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ weiter oben (Logo auf der Karte).
'Löschen': 'Delete',
'Ausblenden': 'Hide',
'Einblenden': 'Unhide',
'Die Gründe konnten nicht geladen werden.': 'The reasons couldn’t be loaded.',
'Logo „{name}“ löschen?': 'Delete logo “{name}”?',
'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.':
'The logo, its hearts and reports will be deleted permanently. Saved cards will no longer show it. Cards that were already published stay unchanged.',
// Melden-Dialog für Karten und Logos (community/report-dialog.ts). „Abbrechen“ steht weiter oben.
'Melden': 'Report',
'Karte melden': 'Report card',
'Logo melden': 'Report logo',
'Warum möchtest du diese Karte melden?': 'Why do you want to report this card?',
'Warum möchtest du „{title}“ melden?': 'Why do you want to report “{title}”?',
'Warum möchtest du dieses Logo melden?': 'Why do you want to report this logo?',
'Grund der Meldung': 'Reason for the report',
'Unangemessen': 'Inappropriate',
'Echte Kartendaten': 'Real card data',
'Urheberrecht/Marke': 'Copyright/trademark',
'Spam': 'Spam',
'Sonstiges': 'Other',
'Bitte wähle einen Grund.': 'Please choose a reason.',
'Danke für deine Meldung.': 'Thanks for your report.',
'Danke, du hast diese Karte schon gemeldet.': 'Thanks, you’ve already reported this card.',
'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.',
'Schließen': 'Close',
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.',
+709
View File
@@ -0,0 +1,709 @@
// Infozeile zum gewählten Logo (Spec § 3 und § 6): Anzeige, Melden, Löschen und die Admin-Aktionen. Die Zeile des Logos und die Auswahl
// gehören dem Picker (hier eine Attrappe als `host`); Datenschicht, Rückfrage und Melden-Dialog sind Attrappen. Das Zusammenspiel
// mit dem echten Picker steht in logo-picker.test.ts.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ forgetLogo: vi.fn() }));
// Die Infozeile hängt (über Melden-Dialog und API-Fehler) an Appwrite und Anmeldung; hier kommt alles über `deps`.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({
currentUser: () => null,
currentUserId: () => null,
isAdmin: () => false,
onAuthChange: () => () => {},
onLogout: () => () => {},
}));
vi.mock('./logo-asset', async (importOriginal) => ({
...(await importOriginal<typeof import('./logo-asset')>()),
forgetLogo: mocks.forgetLogo,
}));
import { ApiError } from '../community/api';
import { setLang } from '../i18n/i18n';
import { createLogoInfo, failureText, type LogoInfo, type LogoInfoDeps, type LogoInfoHost } from './logo-info';
import type { CommunityLogo, LogoReport } from './logos';
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'a',
name: 'Logo A',
displayName: 'Kenso',
fileId: 'a',
ownerId: 'u9',
mono: false,
likeCount: 12,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const report = (reason: string, id = reason): LogoReport => ({ id, logoId: 'a', reason, createdAt: '2026-10-04T12:00:00.000Z' });
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const flush = async () => {
for (let i = 0; i < 50; i++) await Promise.resolve();
};
// ---- Attrappen ------------------------------------------------------------------------------------------------------------
let selectedId: string | null;
let row: CommunityLogo | null;
let gone: boolean;
let user: string | null;
let admin: boolean;
let abort: AbortController;
let info: LogoInfo;
const host = {
selectedId: () => selectedId,
selected: () => row,
gone: () => gone,
patchRow: vi.fn((id: string, patch: Partial<CommunityLogo>) => {
if (row?.id === id) row = { ...row, ...patch };
}),
removeLogo: vi.fn<LogoInfoHost['removeLogo']>(),
} satisfies LogoInfoHost;
const fx = {
reportLogo: vi.fn<LogoInfoDeps['reportLogo']>(),
deleteLogo: vi.fn<LogoInfoDeps['deleteLogo']>(),
setLogoVisibility: vi.fn<LogoInfoDeps['setLogoVisibility']>(),
listLogoReports: vi.fn<LogoInfoDeps['listLogoReports']>(),
confirm: vi.fn<LogoInfoDeps['confirm']>(),
reportDialog: vi.fn<LogoInfoDeps['reportDialog']>(),
};
const deps: LogoInfoDeps = { ...fx, userId: () => user, isAdmin: () => admin };
beforeEach(() => {
for (const fn of [...Object.values(fx), mocks.forgetLogo, host.patchRow, host.removeLogo]) fn.mockReset();
host.patchRow.mockImplementation((id, patch) => {
if (row?.id === id) row = { ...row, ...patch };
});
selectedId = null;
row = null;
gone = false;
user = 'u1';
admin = false;
abort = new AbortController();
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
fx.deleteLogo.mockResolvedValue(undefined);
fx.setLogoVisibility.mockResolvedValue(undefined);
fx.listLogoReports.mockResolvedValue([]);
fx.confirm.mockResolvedValue(true);
fx.reportDialog.mockResolvedValue('cancelled');
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
/** Baut die Infozeile; `shown` ist das gewählte Logo (null: keins). */
async function mount(shown: CommunityLogo | null = logo()): Promise<LogoInfo> {
row = shown;
selectedId = shown?.id ?? null;
info = createLogoInfo(host, deps, abort.signal);
document.body.append(info.el);
await flush();
return info;
}
/** Zeigt der Picker ein anderes Logo (oder keins), meldet er es mit update(). */
function choose(next: CommunityLogo | null): void {
row = next;
selectedId = next?.id ?? null;
info.update();
}
// ---- Zugriffe auf das DOM -------------------------------------------------------------------------------------------------
/** Sichtbar: weder selbst noch ein Vorfahre ist mit `hidden` verborgen. */
const visible = (node: Element) => node.closest('[hidden]') === null;
const line = () => {
const text = info.el.querySelector('.logo-info-text')!;
return visible(text) ? text.textContent : '';
};
const goneText = () => info.el.querySelector('.logo-info-gone')!.textContent;
const message = () => info.el.querySelector('[role="alert"]')!.textContent;
const buttons = () => [...info.el.querySelectorAll('button')].filter(visible).map((b) => b.textContent);
const button = (label: string) => [...info.el.querySelectorAll('button')].find((b) => b.textContent === label && visible(b)) as HTMLButtonElement;
const reportCount = () => {
const text = info.el.querySelector('.logo-info-reports')!;
return visible(text) ? text.textContent : '';
};
const reasons = () => [...info.el.querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent);
const reasonsNote = () => info.el.querySelector('.logo-info-note')!.textContent;
const hiddenNote = () => {
const note = info.el.querySelector('.logo-info-hidden')!;
return visible(note) ? note.textContent : '';
};
describe('Anzeige', () => {
it('ohne Auswahl ist die Infozeile leer: kein Text, keine Knöpfe, keine Meldung', async () => {
await mount(null);
expect(line()).toBe('');
expect(buttons()).toEqual([]);
expect(goneText()).toBe('');
expect(hiddenNote()).toBe('');
expect(message()).toBe('');
});
it('gewähltes Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste', async () => {
user = null;
await mount();
expect(line()).toBe('Logo A · von Kenso · ♥ 12');
expect(buttons()).toEqual(['Melden']);
expect(button('Melden').type).toBe('button');
});
it('ohne Anzeigename steht „von Anonym“', async () => {
await mount(logo({ displayName: '' }));
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
});
it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => {
await mount(logo({ name: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' }));
expect(line()).toBe('<img src=x onerror=alert(1)> · von <b>fett</b> · ♥ 12');
expect(info.el.querySelector('img')).toBeNull();
expect(info.el.querySelector('b')).toBeNull();
});
it('update() folgt der Zeile des Pickers: neue Zahl, anderes Logo, danach wieder keins', async () => {
await mount();
row = logo({ likeCount: 13 });
info.update();
expect(line()).toBe('Logo A · von Kenso · ♥ 13');
choose(logo({ id: 'b', name: 'Logo B', displayName: 'Anna', likeCount: 0 }));
expect(line()).toBe('Logo B · von Anna · ♥ 0');
choose(null);
expect(line()).toBe('');
expect(buttons()).toEqual([]);
});
it('ein Logo, dessen Zeile noch fehlt (wird gerade geholt), zeigt weder Zeile noch Hinweis', async () => {
await mount(null);
selectedId = 'x'; // gewählt, die Zeile ist noch unbekannt
info.update();
expect(line()).toBe('');
expect(goneText()).toBe('');
expect(buttons()).toEqual([]);
});
it('gewähltes Logo nicht auffindbar: „Das gewählte Logo ist nicht mehr verfügbar.“, ohne Knöpfe', async () => {
await mount(null);
selectedId = 'x';
gone = true;
info.update();
expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(line()).toBe('');
expect(buttons()).toEqual([]);
expect(info.el.querySelector('.logo-info-gone')!.getAttribute('role')).toBe('status');
// Taucht die Zeile doch auf, ist der Hinweis weg.
row = logo({ id: 'x', name: 'Logo X' });
gone = false;
info.update();
expect(goneText()).toBe('');
expect(line()).toBe('Logo X · von Kenso · ♥ 12');
});
it('eine Zeile, die der Picker inzwischen kennt, geht dem Hinweis „nicht mehr verfügbar“ vor, auch wenn gone() noch wahr meldet', async () => {
await mount(null);
selectedId = 'x';
gone = true;
info.update();
expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
row = logo({ id: 'x', name: 'Logo X' });
info.update();
expect(goneText()).toBe('');
expect(line()).toBe('Logo X · von Kenso · ♥ 12');
});
it('ohne einen sichtbaren Knopf ist auch die Knopfleiste verborgen (sonst bliebe ein leerer Abstand in der Karte)', async () => {
const actions = () => info.el.querySelector<HTMLElement>('.logo-info-actions')!;
user = 'u2'; // weder Hochlader noch Admin
await mount(logo({ hidden: true })); // melden lässt es sich nicht, ein- und ausblenden nur ein Admin
expect(buttons()).toEqual([]);
expect(actions().hidden).toBe(true);
expect(hiddenNote()).toBe('Ausgeblendet');
user = 'u9'; // der Hochlader darf löschen
info.update();
expect(buttons()).toEqual(['Löschen']);
expect(actions().hidden).toBe(false);
});
it('Englisch: Zeile, Knopf und Hinweis sind übersetzt', async () => {
setLang('en', { persist: false });
await mount(logo({ displayName: '' }));
expect(line()).toBe('Logo A · by Anonymous · ♥ 12');
expect(buttons()).toEqual(['Report']);
choose(null);
selectedId = 'x';
gone = true;
info.update();
expect(goneText()).toBe('The selected logo is no longer available.');
});
});
describe('Melden', () => {
it('öffnet den Dialog für Logos und sendet reportLogo(id, Grund) – auch als Gast', async () => {
user = null;
await mount();
button('Melden').click();
await flush();
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
const opts = fx.reportDialog.mock.calls[0][0];
expect(opts.subject).toBe('logo');
expect(opts.signal).toBe(abort.signal);
fx.reportLogo.mockResolvedValue({ schonGemeldet: true });
await expect(opts.send('spam')).resolves.toEqual({ schonGemeldet: true });
expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('a', 'spam');
});
it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten; danach geht es wieder', async () => {
const open = deferred<'reported'>();
fx.reportDialog.mockReturnValue(open.promise);
await mount();
button('Melden').click();
button('Melden').click();
await flush();
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
open.resolve('reported');
await flush();
button('Melden').click();
expect(fx.reportDialog).toHaveBeenCalledTimes(2);
});
it('ein Dialog, der sich nicht öffnen lässt, zeigt einen Fehlertext; der Knopf geht danach wieder', async () => {
fx.reportDialog.mockRejectedValueOnce(new Error('kein <dialog>'));
await mount();
button('Melden').click();
await flush();
expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
button('Melden').click(); // der nächste Versuch räumt den Text ab
expect(message()).toBe('');
await flush();
expect(fx.reportDialog).toHaveBeenCalledTimes(2);
});
it('ein ausgeblendetes Logo lässt sich nicht melden (der Dienst kennt es für Besucher nicht)', async () => {
user = 'u9';
await mount(logo({ hidden: true }));
expect(buttons()).toEqual(['Löschen']);
expect(hiddenNote()).toBe('Ausgeblendet');
});
});
describe('Löschen durch den Hochlader', () => {
it('„Löschen“ sehen nur der Hochlader und Admins; Gäste und andere Nutzer nur „Melden“', async () => {
user = 'u9'; // ownerId des Beispiel-Logos
await mount();
expect(buttons()).toEqual(['Melden', 'Löschen']);
user = 'u2';
info.update();
expect(buttons()).toEqual(['Melden']);
user = null;
info.update();
expect(buttons()).toEqual(['Melden']);
expect(reportCount()).toBe('');
expect(button('Ausblenden')).toBeUndefined();
});
it('ein Logo ohne bekannten Hochlader gehört niemandem (auch nicht einem Konto mit leerer ID)', async () => {
user = '';
await mount(logo({ ownerId: '' }));
expect(buttons()).toEqual(['Melden']);
});
it('fragt mit Namen nach; bestätigt: deleteLogo, forgetLogo und removeLogo in dieser Reihenfolge', async () => {
user = 'u9';
const order: string[] = [];
fx.confirm.mockImplementation(async () => {
order.push('confirm');
return true;
});
fx.deleteLogo.mockImplementation(async () => {
order.push('delete');
});
mocks.forgetLogo.mockImplementation(() => {
order.push('forget');
});
host.removeLogo.mockImplementation(() => {
order.push('remove');
});
await mount();
button('Löschen').click();
await flush();
expect(order).toEqual(['confirm', 'delete', 'forget', 'remove']);
expect(fx.confirm).toHaveBeenCalledExactlyOnceWith(
{
title: 'Logo „Logo A“ löschen?',
text: 'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.',
confirmLabel: 'Löschen',
cancelLabel: 'Abbrechen',
},
abort.signal,
);
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(message()).toBe('');
});
it('abgelehnt: es passiert nichts, auch kein Fehlertext', async () => {
user = 'u9';
fx.confirm.mockResolvedValue(false);
await mount();
button('Löschen').click();
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(host.removeLogo).not.toHaveBeenCalled();
expect(message()).toBe('');
expect(buttons()).toEqual(['Melden', 'Löschen']);
});
it('Fehler des Dienstes: der Text steht in der Meldezeile, nichts wird entfernt; ein neuer Versuch geht', async () => {
user = 'u9';
fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await mount();
button('Löschen').click();
await flush();
expect(message()).toBe('Dazu hast du keine Berechtigung.');
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(host.removeLogo).not.toHaveBeenCalled();
button('Löschen').click();
await flush();
expect(message()).toBe('');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
});
it('ein Fehler anderer Art (Netz) bekommt den allgemeinen Text', async () => {
user = 'u9';
fx.deleteLogo.mockRejectedValueOnce(new TypeError('offline'));
await mount();
button('Löschen').click();
await flush();
expect(message()).toBe('Keine Verbindung zum Server.');
});
it('eine Rückfrage, die sich nicht öffnen lässt, löscht nichts und sagt es', async () => {
user = 'u9';
fx.confirm.mockRejectedValueOnce(new Error('kein <dialog>'));
await mount();
button('Löschen').click();
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
it('meldet sich der Nutzer während der Rückfrage ab, wird nichts gelöscht', async () => {
user = 'u9';
const answer = deferred<boolean>();
fx.confirm.mockReturnValue(answer.promise);
await mount();
button('Löschen').click();
user = null;
info.update();
answer.resolve(true);
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
expect(host.removeLogo).not.toHaveBeenCalled();
});
it('solange eine Rückfrage oder Anfrage läuft, bleiben weitere Klicks ohne Wirkung', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').click();
button('Löschen').click(); // die Rückfrage ist noch nicht beantwortet
await flush();
button('Löschen').click(); // die Anfrage läuft
button('Melden').click();
await flush();
expect(fx.confirm).toHaveBeenCalledTimes(1);
expect(fx.deleteLogo).toHaveBeenCalledTimes(1);
expect(fx.reportDialog).not.toHaveBeenCalled();
request.resolve();
await flush();
expect(host.removeLogo).toHaveBeenCalledTimes(1);
});
it('verlässt der Nutzer die Seite während der Rückfrage, wird nichts gelöscht', async () => {
user = 'u9';
const answer = deferred<boolean>();
fx.confirm.mockReturnValue(answer.promise);
await mount();
button('Löschen').click();
abort.abort();
answer.resolve(true);
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
});
it('verlässt der Nutzer die Seite während der Anfrage, vergisst der Zwischenspeicher das Logo trotzdem, die Kachel wird nicht mehr angefasst', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').click();
await flush();
abort.abort();
request.resolve();
await flush();
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).not.toHaveBeenCalled();
});
});
describe('Admin', () => {
beforeEach(() => {
admin = true;
});
it('Nicht-Admins sehen keine Meldungen und kein Ausblenden und fragen die Gründe nicht ab', async () => {
admin = false;
await mount(logo({ reportCount: 3 }));
expect(reportCount()).toBe('');
expect(buttons()).toEqual(['Melden']);
expect(fx.listLogoReports).not.toHaveBeenCalled();
});
it('„<n> Meldungen“ mit den Gründen als Text; dazu „Ausblenden“ und „Löschen“ (auch für fremde Logos)', async () => {
fx.listLogoReports.mockResolvedValue([report('spam'), report('urheberrecht'), report('Eigener Grund')]);
await mount(logo({ reportCount: 3 }));
expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a');
expect(reportCount()).toBe('3 Meldungen');
expect(reasons()).toEqual(['Spam', 'Urheberrecht/Marke', 'Eigener Grund']);
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
});
it('„1 Meldung“ im Singular', async () => {
fx.listLogoReports.mockResolvedValue([report('unangemessen')]);
await mount(logo({ reportCount: 1 }));
expect(reportCount()).toBe('1 Meldung');
expect(reasons()).toEqual(['Unangemessen']);
});
it('ohne Meldungen: „0 Meldungen“ und keine Anfrage', async () => {
await mount(logo({ reportCount: 0 }));
expect(reportCount()).toBe('0 Meldungen');
expect(reasons()).toEqual([]);
expect(fx.listLogoReports).not.toHaveBeenCalled();
});
it('die Gründe werden je Logo nur einmal geholt, auch bei vielen Aktualisierungen', async () => {
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1 }));
row = logo({ reportCount: 1, likeCount: 99 });
info.update();
info.update();
await flush();
expect(fx.listLogoReports).toHaveBeenCalledTimes(1);
expect(reasons()).toEqual(['Spam']);
choose(logo({ id: 'b', reportCount: 1 }));
await flush();
expect(fx.listLogoReports).toHaveBeenLastCalledWith('b');
choose(logo({ reportCount: 1 })); // zurück zu A: frisch holen
await flush();
expect(fx.listLogoReports).toHaveBeenCalledTimes(3);
});
it('ändert sich die Zahl der Meldungen (die Zeile wurde neu geladen), werden die Gründe neu geholt', async () => {
fx.listLogoReports.mockResolvedValueOnce([report('spam')]);
await mount(logo({ reportCount: 1 }));
expect(reasons()).toEqual(['Spam']);
fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]);
row = logo({ reportCount: 2 });
info.update();
await flush();
expect(reportCount()).toBe('2 Meldungen');
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
});
it('wird dasselbe Logo nach „kein Logo“ erneut gewählt, werden die Gründe frisch geholt – auch nach einem Ladefehler', async () => {
fx.listLogoReports.mockRejectedValueOnce(new Error('offline'));
await mount(logo({ reportCount: 1 }));
expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.');
choose(null);
fx.listLogoReports.mockResolvedValueOnce([report('spam')]);
choose(logo({ reportCount: 1 }));
await flush();
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
expect(reasonsNote()).toBe('');
expect(reasons()).toEqual(['Spam']);
});
it('die Gründe lassen sich nicht laden: Hinweis dazu, die Zahl bleibt', async () => {
fx.listLogoReports.mockRejectedValue(new Error('offline'));
await mount(logo({ reportCount: 3 }));
expect(reportCount()).toBe('3 Meldungen');
expect(reasons()).toEqual([]);
expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.');
// Ein anderes Logo, das sich laden lässt, hat den Hinweis nicht.
fx.listLogoReports.mockResolvedValue([report('spam')]);
choose(logo({ id: 'b', reportCount: 1 }));
await flush();
expect(reasonsNote()).toBe('');
expect(reasons()).toEqual(['Spam']);
});
it('eine späte Antwort für ein inzwischen verlassenes Logo wird verworfen', async () => {
const slow = deferred<LogoReport[]>();
fx.listLogoReports.mockReturnValueOnce(slow.promise);
await mount(logo({ reportCount: 2 }));
fx.listLogoReports.mockResolvedValueOnce([report('sonstiges')]);
choose(logo({ id: 'b', reportCount: 1 }));
await flush();
expect(reasons()).toEqual(['Sonstiges']);
slow.resolve([report('spam'), report('spam', 'zwei')]);
await flush();
expect(reasons()).toEqual(['Sonstiges']);
});
it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo, die Zeile wird hidden; die Anzeige folgt', async () => {
const order: string[] = [];
fx.setLogoVisibility.mockImplementation(async () => {
order.push('visibility');
});
mocks.forgetLogo.mockImplementation(() => {
order.push('forget');
});
host.patchRow.mockImplementation((id, patch) => {
order.push('patch');
if (row?.id === id) row = { ...row, ...patch };
});
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1 }));
button('Ausblenden').click();
await flush();
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', false);
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true });
expect(order).toEqual(['visibility', 'forget', 'patch']);
expect(buttons()).toEqual(['Löschen', 'Einblenden']); // „Melden“ gibt es für ein ausgeblendetes Logo nicht
expect(hiddenNote()).toBe('Ausgeblendet');
expect(reportCount()).toBe('1 Meldung'); // Ausblenden ändert die Meldungen nicht
expect(reasons()).toEqual(['Spam']);
expect(message()).toBe('');
});
it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Meldungen sind danach gelöscht (0)', async () => {
fx.listLogoReports.mockResolvedValue([report('spam'), report('sonstiges')]);
await mount(logo({ hidden: true, reportCount: 2 }));
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
button('Einblenden').click();
await flush();
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', true);
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: false, reportCount: 0 });
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
expect(hiddenNote()).toBe('');
expect(reportCount()).toBe('0 Meldungen');
expect(reasons()).toEqual([]);
expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen
});
it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => {
fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await mount(logo());
button('Ausblenden').click();
await flush();
expect(message()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(host.patchRow).not.toHaveBeenCalled();
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
button('Ausblenden').click();
await flush();
expect(message()).toBe('');
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
});
it('der Admin löscht wie der Hochlader, auch ein fremdes Logo', async () => {
await mount(logo({ ownerId: 'u5' }));
button('Löschen').click();
await flush();
expect(fx.confirm).toHaveBeenCalledTimes(1);
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
});
it('wird jemand Admin (Anmeldung), erscheinen die Admin-Teile nach update(); verliert er die Rolle, verschwinden sie', async () => {
admin = false;
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1 }));
expect(buttons()).toEqual(['Melden']);
admin = true;
info.update();
await flush();
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
expect(reasons()).toEqual(['Spam']);
admin = false;
info.update();
expect(buttons()).toEqual(['Melden']);
expect(reportCount()).toBe('');
});
it('Englisch: Meldungen, Gründe und Knöpfe sind übersetzt', async () => {
setLang('en', { persist: false });
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1, ownerId: 'u1' }));
expect(buttons()).toEqual(['Report', 'Delete', 'Hide']);
expect(reasons()).toEqual(['Spam']);
row = logo({ hidden: true, reportCount: 1 });
info.update();
expect(buttons()).toEqual(['Delete', 'Unhide']);
expect(hiddenNote()).toBe('Hidden');
});
});
describe('Meldezeile', () => {
it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => {
await mount();
expect(info.el.querySelector('[role="alert"]')!.classList.contains('auth-msg-error')).toBe(true);
info.setMessage('Etwas ging schief.');
expect(message()).toBe('Etwas ging schief.');
info.setMessage('');
expect(message()).toBe('');
});
it('der Text steht auch ohne Auswahl (Fehler am Herz eines nicht gewählten Logos) und überlebt update()', async () => {
await mount(null);
info.setMessage('Dieses Logo ist nicht (mehr) verfügbar.');
info.update();
expect(message()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
});
it('eine andere Auswahl löscht den Text; dieselbe Auswahl lässt ihn stehen', async () => {
await mount();
info.setMessage('Etwas ging schief.');
info.update();
row = logo({ likeCount: 13 });
info.update();
expect(message()).toBe('Etwas ging schief.');
choose(logo({ id: 'b', name: 'Logo B' }));
expect(message()).toBe('');
});
});
describe('failureText', () => {
it('Fehler des Dienstes über ihren Schlüssel, alles andere über errorMessage', () => {
expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(failureText(new TypeError('offline'))).toBe('Keine Verbindung zum Server.');
setLang('en', { persist: false });
expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('This logo is no longer available.');
});
});
+237
View File
@@ -0,0 +1,237 @@
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“. Wer das Logo
// hochgeladen hat, sieht dazu „Löschen“ (mit Rückfrage), Admins die Meldungen mit ihren Gründen sowie „Ausblenden“/„Einblenden“
// und „Löschen“. Zugleich die Meldezeile des Bereichs: Fehler von Herz, Hochladen und diesen Aktionen erscheinen hier, so bleibt
// der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten.
// Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`.
// Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn.
// Alle Texte gehen als Text in den DOM, nie als HTML.
import '../account/account.css';
import './logos.css';
import { errorMessage } from '../account/errors';
import { ApiError, apiErrorText } from '../community/api';
import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog';
import type { ConfirmOptions } from '../editor/confirm-dialog';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { reportsLabel } from '../pages/format';
import type { deleteLogo, reportLogo, setLogoVisibility } from './logo-api';
import { forgetLogo, LOGO_MISSING } from './logo-asset';
import type { CommunityLogo, listLogoReports, LogoReport } from './logos';
export interface LogoInfoDeps {
reportLogo: typeof reportLogo;
deleteLogo: typeof deleteLogo;
setLogoVisibility: typeof setLogoVisibility;
listLogoReports: typeof listLogoReports;
/** Die Rückfrage vor dem Löschen; `signal` schließt sie (Seite verlassen). Löst mit true auf, wenn bestätigt wurde. */
confirm(opts: ConfirmOptions, signal?: AbortSignal): Promise<boolean>;
/** Der Melden-Dialog (für Logos: `subject: 'logo'`). */
reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>;
userId(): string | null;
isAdmin(): boolean;
}
/** Was die Infozeile vom Picker braucht: Auswahl und Zeile lesen, die Zeile ändern, ein gelöschtes Logo melden. */
export interface LogoInfoHost {
/** Die ID des gewählten Logos; null: kein Logo. */
selectedId(): string | null;
/** Die Zeile des gewählten Logos, sobald bekannt; null: kein Logo oder (noch) unbekannt. */
selected(): CommunityLogo | null;
/** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */
gone(): boolean;
/** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */
patchRow(id: string, patch: Partial<CommunityLogo>): void;
/** Das Logo ist gelöscht: Kachel und Zeile entfernen, steht es in der Auswahl, wird sie aufgehoben. */
removeLogo(id: string): void;
}
export interface LogoInfo {
el: HTMLElement;
/** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung). */
update(): void;
/** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */
setMessage(text: string): void;
}
const REASONS_ERROR = msg('Die Gründe konnten nicht geladen werden.');
const DELETE_TEXT = msg(
'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.',
);
/** Der Grund eines Fehlers zum Anzeigen: Meldungen des Dienstes über ihren Schlüssel, alles andere über `errorMessage`. */
export function failureText(err: unknown): string {
return err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
}
/** Der Grund einer Meldung als Text: bekannte Werte mit ihrer Beschriftung, alles andere unverändert. */
function reasonLabel(reason: string): string {
const known = REPORT_REASONS.find((r) => r.value === reason);
return known ? t(known.label) : reason;
}
/** Die Gründe der Meldungen zu einem Stand (`key`: Logo-ID und Zahl der Meldungen); ein Admin holt sie einmal je Stand. */
interface Reasons {
key: string;
status: 'loading' | 'ready' | 'error';
list: LogoReport[];
}
/** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */
export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo {
/** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung. */
let busy = false;
/** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */
let shownId: string | null = null;
let reasons: Reasons | null = null;
// ---- Aufbau ---------------------------------------------------------------------------------------------------------------
const nameEl = el('strong', { class: 'logo-info-name' });
const byEl = el('span', { class: 'logo-info-by' });
const heartsEl = el('span', { class: 'logo-info-hearts' });
const textEl = el('p', { class: 'logo-info-text' }, nameEl, ' · ', byEl, ' · ', heartsEl);
const hiddenEl = el('span', { class: 'logo-badge logo-info-hidden' }, t('Ausgeblendet'));
const reportButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-report' }, t('Melden'));
const deleteButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small auth-btn-danger logo-info-delete' }, t('Löschen'));
const visibilityButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-visibility' });
const actionsEl = el('div', { class: 'logo-info-actions' }, reportButton, deleteButton, visibilityButton);
const reportsEl = el('p', { class: 'logo-info-reports' });
const reasonsEl = el('ul', { class: 'logo-info-reasons' });
const noteEl = el('p', { class: 'logo-info-note' });
const adminEl = el('div', { class: 'logo-info-admin' }, reportsEl, reasonsEl, noteEl);
const cardEl = el('div', { class: 'logo-info-card' }, textEl, hiddenEl, actionsEl, adminEl);
// Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' });
const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' });
const root = el('div', { class: 'logo-info' }, cardEl, goneEl, messageEl);
// ---- Anzeige --------------------------------------------------------------------------------------------------------------
function setMessage(text: string): void {
messageEl.textContent = text;
}
const isOwner = (logo: CommunityLogo): boolean => {
const me = deps.userId();
return me !== null && logo.ownerId !== '' && logo.ownerId === me;
};
/** Löschen dürfen der Hochlader und Admins (der Dienst prüft es noch einmal). */
const mayDelete = (logo: CommunityLogo): boolean => deps.isAdmin() || isOwner(logo);
/** Lädt die Gründe der Meldungen eines Logos; eine Antwort für ein inzwischen anderes Logo oder einen anderen Stand zählt nicht. */
async function loadReasons(id: string, key: string): Promise<void> {
let loaded: Reasons;
try {
loaded = { key, status: 'ready', list: await deps.listLogoReports(id) };
} catch {
loaded = { key, status: 'error', list: [] };
}
if (signal.aborted || reasons?.key !== key) return;
reasons = loaded;
render();
}
/** Meldungen eines Admins: Anzahl aus der Zeile, Gründe nachgeladen (nur, wenn es Meldungen gibt). */
function renderReports(logo: CommunityLogo): void {
reportsEl.textContent = reportsLabel(logo.reportCount);
const key = `${logo.id}:${logo.reportCount}`;
if (logo.reportCount > 0 && reasons?.key !== key) {
reasons = { key, status: 'loading', list: [] };
void loadReasons(logo.id, key);
}
const current = reasons?.key === key ? reasons : null;
reasonsEl.replaceChildren(...(current?.status === 'ready' ? current.list.map((r) => el('li', {}, reasonLabel(r.reason))) : []));
noteEl.textContent = current?.status === 'error' ? t(REASONS_ERROR) : '';
}
function render(): void {
const id = host.selectedId();
if (id !== shownId) {
shownId = id;
reasons = null;
setMessage(''); // ein Fehler gehört zur Auswahl, bei der er auftrat
}
const logo = host.selected();
cardEl.hidden = logo === null;
goneEl.textContent = logo === null && host.gone() ? t(LOGO_MISSING) : '';
if (logo === null) return;
const admin = deps.isAdmin();
nameEl.textContent = logo.name;
byEl.textContent = t('von {name}', { name: logo.displayName || t('Anonym') });
heartsEl.textContent = `♥ ${logo.likeCount}`;
hiddenEl.hidden = !logo.hidden;
// Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden.
reportButton.hidden = logo.hidden;
deleteButton.hidden = !mayDelete(logo);
visibilityButton.hidden = !admin;
visibilityButton.textContent = logo.hidden ? t('Einblenden') : t('Ausblenden');
actionsEl.hidden = reportButton.hidden && deleteButton.hidden && visibilityButton.hidden;
adminEl.hidden = !admin;
if (admin) renderReports(logo);
}
// ---- Aktionen -------------------------------------------------------------------------------------------------------------
/** Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. */
async function run(action: () => Promise<void>): Promise<void> {
if (busy) return;
busy = true;
setMessage('');
try {
await action();
} catch (err) {
if (!signal.aborted) setMessage(failureText(err));
} finally {
busy = false;
}
}
async function report(): Promise<void> {
const logo = host.selected();
if (!logo) return;
await deps.reportDialog({ subject: 'logo', signal, send: (reason) => deps.reportLogo(logo.id, reason) });
}
async function remove(): Promise<void> {
const logo = host.selected();
if (!logo || !mayDelete(logo)) return;
const confirmed = await deps.confirm(
{
title: t('Logo „{name}“ löschen?', { name: logo.name }),
text: t(DELETE_TEXT),
confirmLabel: t('Löschen'),
cancelLabel: t('Abbrechen'),
},
signal,
);
// Während der Rückfrage kann die Seite verlassen oder die Sitzung gewechselt haben: dann nichts mehr senden.
if (!confirmed || signal.aborted || !mayDelete(logo)) return;
await deps.deleteLogo(logo.id);
forgetLogo(logo.id); // auch nach dem Verlassen der Seite: der Zwischenspeicher darf das gelöschte Logo nicht behalten
if (signal.aborted) return;
host.removeLogo(logo.id);
render();
}
async function toggleVisibility(): Promise<void> {
const logo = host.selected();
if (!logo || !deps.isAdmin()) return;
const show = logo.hidden; // ausgeblendet: einblenden, sonst ausblenden
await deps.setLogoVisibility(logo.id, show);
// Zeile und Bilder neu holen: ein ausgeblendetes Logo gilt für die Karte als nicht verfügbar, ein eingeblendetes wieder als da.
forgetLogo(logo.id);
if (signal.aborted) return;
// Einblenden löscht die Meldungen (Spec § 6).
host.patchRow(logo.id, show ? { hidden: false, reportCount: 0 } : { hidden: true });
render();
}
reportButton.addEventListener('click', () => void run(report));
deleteButton.addEventListener('click', () => void run(remove));
visibilityButton.addEventListener('click', () => void run(toggleVisibility));
render();
return { el: root, update: render, setMessage };
}
+492 -11
View File
@@ -1,9 +1,10 @@
// Bereich „Logo unten rechts“ (Spec § 3 und § 5): Raster, Sortierung, Auswahl, Herzen und Hochladen. Datenschicht, Anmeldung und
// Dialog sind Attrappen; die Kachelbilder laufen über das echte logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in
// logo-image.test.ts).
// Bereich „Logo unten rechts“ (Spec § 3, § 5 und § 6): Raster, Sortierung, Auswahl, Herzen, Hochladen und die Infozeile zum gewählten
// Logo (Melden, Löschen, Admin). Datenschicht, Anmeldung und Dialoge sind Attrappen; die Kachelbilder laufen über das echte
// logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in logo-image.test.ts), die Infozeile über das echte logo-info.ts
// (dessen Einzelfälle stehen in logo-info.test.ts: hier zählt das Zusammenspiel mit Raster, Auswahl und Herzen).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn() }));
const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn(), forgetLogo: vi.fn() }));
// Der Picker hängt (über die Standardwerte) an Appwrite, Anmeldung und Dialogen; hier kommt alles über `deps`.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
@@ -19,6 +20,7 @@ vi.mock('./logo-dialog', () => ({ logoUploadDialog: vi.fn() }));
vi.mock('./logo-asset', async (importOriginal) => ({
...(await importOriginal<typeof import('./logo-asset')>()),
rememberLogo: mocks.rememberLogo,
forgetLogo: mocks.forgetLogo,
}));
import { ApiError } from '../community/api';
@@ -71,6 +73,12 @@ const fx = {
loadLogoBlob: vi.fn<LogoPickerDeps['loadLogoBlob']>(),
requireLogin: vi.fn<LogoPickerDeps['requireLogin']>(),
uploadDialog: vi.fn<LogoPickerDeps['uploadDialog']>(),
reportLogo: vi.fn<LogoPickerDeps['reportLogo']>(),
deleteLogo: vi.fn<LogoPickerDeps['deleteLogo']>(),
setLogoVisibility: vi.fn<LogoPickerDeps['setLogoVisibility']>(),
listLogoReports: vi.fn<LogoPickerDeps['listLogoReports']>(),
confirm: vi.fn<LogoPickerDeps['confirm']>(),
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
onSelect: vi.fn<(id: string | null) => void>(),
};
@@ -88,6 +96,12 @@ const deps: LogoPickerDeps = {
return () => authListeners.delete(fn);
},
uploadDialog: fx.uploadDialog,
reportLogo: fx.reportLogo,
deleteLogo: fx.deleteLogo,
setLogoVisibility: fx.setLogoVisibility,
listLogoReports: fx.listLogoReports,
confirm: fx.confirm,
reportDialog: fx.reportDialog,
};
// Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das
@@ -122,7 +136,7 @@ let host: HTMLElement;
let picker: LogoPicker;
beforeEach(() => {
for (const fn of [...Object.values(fx), mocks.rememberLogo]) fn.mockReset();
for (const fn of [...Object.values(fx), mocks.rememberLogo, mocks.forgetLogo]) fn.mockReset();
user = 'u1';
admin = false;
authListeners.clear();
@@ -142,6 +156,12 @@ beforeEach(() => {
});
fx.requireLogin.mockResolvedValue(true);
fx.uploadDialog.mockResolvedValue(null);
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
fx.deleteLogo.mockResolvedValue(undefined);
fx.setLogoVisibility.mockResolvedValue(undefined);
fx.listLogoReports.mockResolvedValue([]);
fx.confirm.mockResolvedValue(true);
fx.reportDialog.mockResolvedValue('cancelled');
URL.createObjectURL = vi.fn((blob: Blob) => {
const url = `blob:logo-${created.length + 1}`;
created.push(url);
@@ -190,6 +210,18 @@ const uploadButton = () => picker.el.querySelector<HTMLButtonElement>('button.lo
const moreButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-more')!;
const retryButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-retry')!;
const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent;
// Infozeile (logo-info.ts): sichtbar ist, was weder selbst noch in einem Vorfahren mit `hidden` verborgen ist.
const infoEl = () => picker.el.querySelector<HTMLElement>('.logo-info')!;
const shown = (node: Element) => node.closest('[hidden]') === null;
const infoLine = () => {
const text = infoEl().querySelector('.logo-info-text')!;
return shown(text) ? text.textContent : '';
};
const infoGone = () => infoEl().querySelector('.logo-info-gone')!.textContent;
const infoMessage = () => infoEl().querySelector('[role="alert"]')!.textContent;
const infoButtons = () => [...infoEl().querySelectorAll('button')].filter(shown).map((b) => b.textContent);
const infoButton = (label: string) =>
[...infoEl().querySelectorAll('button')].find((b) => b.textContent === label && shown(b)) as HTMLButtonElement;
describe('Aufbau', () => {
it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => {
@@ -645,6 +677,7 @@ describe('Herzen', () => {
expect(heart('Logo A').textContent).toBe('7');
expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 7');
expect(alertText()).toBe('');
expect(infoMessage()).toBe('');
});
it('ein zweiter Klick nimmt das Herz zurück: setHeart(id, false), Zahl − 1', async () => {
@@ -673,7 +706,7 @@ describe('Herzen', () => {
expect(heart('Logo A').textContent).toBe('0');
});
it('Fehler: das Herz springt samt Zahl zurück, der Grund steht im Statusbereich (role="alert")', async () => {
it('Fehler: das Herz springt samt Zahl zurück, der Grund steht in der Infozeile (role="alert"), nicht im Statusbereich der Liste', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null });
fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
await mount();
@@ -681,7 +714,8 @@ describe('Herzen', () => {
await flush();
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false');
expect(heart('Logo A').textContent).toBe('2');
expect(alertText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(alertText()).toBe('');
});
it('Fehler anderer Art: allgemeiner Text; der Fehler verschwindet beim nächsten Versuch, der dann auch wieder möglich ist', async () => {
@@ -689,10 +723,10 @@ describe('Herzen', () => {
await mount();
heart('Logo A').click();
await flush();
expect(alertText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(heartsOn()).toEqual([]);
heart('Logo A').click(); // die Anfrage ist zu Ende: der Knopf ist wieder frei
expect(alertText()).toBe('');
expect(infoMessage()).toBe('');
await flush();
expect(fx.setHeart).toHaveBeenCalledTimes(2);
expect(heartsOn()).toEqual(['Logo A']);
@@ -752,6 +786,7 @@ describe('Herzen', () => {
expect(heartsOn()).toEqual([]);
expect(heart('Logo A').textContent).toBe('0');
expect(alertText()).toBe('');
expect(infoMessage()).toBe('');
heart('Logo A').click(); // kein hängender Zustand: der nächste Klick fragt wieder nach dem Login
await flush();
expect(fx.requireLogin).toHaveBeenCalledTimes(2);
@@ -1000,12 +1035,13 @@ describe('Hochladen', () => {
expect(mocks.rememberLogo).not.toHaveBeenCalled();
});
it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund im Statusbereich', async () => {
it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund in der Infozeile (der Statusbereich der Liste bleibt für Ladefehler)', async () => {
fx.uploadDialog.mockRejectedValue(new Error('kein <dialog>'));
await mount();
uploadButton().click();
await flush();
expect(alertText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(alertText()).toBe('');
});
});
@@ -1099,6 +1135,18 @@ describe('dispose', () => {
expect(fx.myHearts).not.toHaveBeenCalled();
});
it('select() nach dem Aufräumen ändert nichts und fragt nichts mehr an', async () => {
await mount();
picker.dispose();
picker.select('b');
picker.select('x');
await flush();
expect(pressedTiles()).toEqual(['Kein Logo']);
expect(fx.getLogo).not.toHaveBeenCalled();
expect(picker.selected()).toBeNull();
expect(infoLine()).toBe('');
});
it('eine Herz-Antwort nach dem Aufräumen wird verworfen', async () => {
const answer = deferred<{ herz: boolean; likeCount: number }>();
fx.setHeart.mockReturnValue(answer.promise);
@@ -1110,3 +1158,436 @@ describe('dispose', () => {
expect(heart('Logo A').textContent).toBe('1'); // der Stand von vor dem Aufräumen, kein Fehler
});
});
describe('Herzen: Fehler und Anmeldung', () => {
it('Gast: lässt sich der Login-Dialog nicht öffnen (requireLogin wirft), steht der Grund in der Infozeile; das Herz bleibt aus, und es geht danach wieder', async () => {
user = null;
fx.requireLogin.mockRejectedValueOnce(new Error('kein <dialog>'));
await mount();
heart('Logo A').click();
await flush();
expect(fx.setHeart).not.toHaveBeenCalled();
expect(heartsOn()).toEqual([]);
expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
heart('Logo A').click(); // kein hängender Zustand
await flush();
expect(fx.requireLogin).toHaveBeenCalledTimes(2);
expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true);
expect(infoMessage()).toBe('');
});
it('ein Klick auf Herz oder „Hochladen“ löscht den Ladefehler der Liste nicht: der steht allein im Statusbereich', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
moreButton().click();
await flush();
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
heart('Logo A').click();
await flush();
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
uploadButton().click();
await flush();
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
expect(retryButton().hidden).toBe(false);
});
it('der Herz-Fehler steht auch dann in der Infozeile, wenn das Logo gar nicht gewählt ist', async () => {
fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
await mount();
picker.select('c');
heart('Logo A').click();
await flush();
expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0');
picker.select('b'); // eine andere Auswahl räumt den alten Fehler ab
expect(infoMessage()).toBe('');
});
});
describe('Infozeile', () => {
it('sitzt als Letztes im Bereich, unter dem Raster und „Mehr laden“; ohne Auswahl ist sie leer', async () => {
await mount();
expect(picker.el.lastElementChild).toBe(infoEl());
const grid = picker.el.querySelector('ul.logo-grid')!;
expect(grid.compareDocumentPosition(infoEl()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(infoLine()).toBe('');
expect(infoButtons()).toEqual([]);
expect(infoMessage()).toBe('');
});
it('select(id) zeigt „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste; „Kein Logo“ leert sie', async () => {
user = null;
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 7 }), logo('b', { displayName: '' })], nextCursor: null });
await mount();
picker.select('a');
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 7');
expect(infoButtons()).toEqual(['Melden']);
picker.select('b');
expect(infoLine()).toBe('Logo B · von Anonym · ♥ 0');
picker.select(null);
expect(infoLine()).toBe('');
expect(infoButtons()).toEqual([]);
});
it('der Zähler der Infozeile folgt dem Herz: sofort und mit der Antwort des Dienstes', async () => {
const answer = deferred<{ herz: boolean; likeCount: number }>();
fx.setHeart.mockReturnValue(answer.promise);
await mount();
picker.select('a');
heart('Logo A').click();
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 1');
answer.resolve({ herz: true, likeCount: 5 });
await flush();
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 5');
});
it('ein Logo außerhalb der Liste: die Zeile erscheint, sobald getLogo es geholt hat; vorher steht dort nichts', async () => {
const slow = deferred<CommunityLogo | null>();
fx.getLogo.mockReturnValue(slow.promise);
await mount();
picker.select('x');
await flush();
expect(infoLine()).toBe('');
expect(infoGone()).toBe('');
slow.resolve(logo('x', { name: 'Fremdes Logo', displayName: 'Anna', likeCount: 2 }));
await flush();
expect(infoLine()).toBe('Fremdes Logo · von Anna · ♥ 2');
});
it('solange die Liste noch lädt und das Logo noch unbekannt ist, steht nichts da; danach die Zeile aus der Liste', async () => {
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
host.append(picker.el);
picker.select('b');
await flush();
expect(infoLine()).toBe('');
expect(infoGone()).toBe('');
list.resolve({ logos: [logo('a'), logo('b', { likeCount: 3 })], nextCursor: null });
await flush();
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 3');
});
it('ein Logo, das es nicht mehr gibt (getLogo → null): „Das gewählte Logo ist nicht mehr verfügbar.“; eine andere Wahl nimmt den Hinweis weg', async () => {
await mount();
picker.select('weg');
await flush();
expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(infoLine()).toBe('');
expect(infoButtons()).toEqual([]);
picker.select('b');
expect(infoGone()).toBe('');
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0');
picker.select('weg'); // noch einmal: die Antwort von vorhin gilt nicht mehr, es wird neu gefragt
expect(infoGone()).toBe('');
await flush();
expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(fx.getLogo).toHaveBeenCalledTimes(2);
});
it('scheitert getLogo (Netz), sagt die Infozeile nichts: das Logo kann es ja geben', async () => {
fx.getLogo.mockRejectedValue(new Error('offline'));
await mount();
picker.select('x');
await flush();
expect(infoGone()).toBe('');
expect(infoLine()).toBe('');
});
it('eine späte „gibt es nicht“-Antwort für eine inzwischen verlassene Auswahl bleibt ohne Wirkung', async () => {
const slow = deferred<CommunityLogo | null>();
fx.getLogo.mockReturnValueOnce(slow.promise);
await mount();
picker.select('x');
picker.select('b');
slow.resolve(null);
await flush();
expect(infoGone()).toBe('');
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0');
});
it('„Melden“ öffnet den Dialog für Logos (mit dem Signal des Pickers) und sendet reportLogo(id, Grund)', async () => {
await mount();
picker.select('b');
infoButton('Melden').click();
await flush();
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
const opts = fx.reportDialog.mock.calls[0][0];
expect(opts.subject).toBe('logo');
await opts.send('urheberrecht');
expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('b', 'urheberrecht');
expect(opts.signal!.aborted).toBe(false);
picker.dispose();
expect(opts.signal!.aborted).toBe(true);
});
it('Anmelden und Abmelden: „Löschen“ erscheint für den Hochlader und verschwindet wieder', async () => {
user = null;
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null });
await mount();
picker.select('a');
expect(infoButtons()).toEqual(['Melden']);
user = 'u1';
emitAuth();
await flush();
expect(infoButtons()).toEqual(['Melden', 'Löschen']);
user = null;
emitAuth();
expect(infoButtons()).toEqual(['Melden']);
});
it('Admin: Anzahl und Gründe der Meldungen, „Ausblenden“ und „Löschen“ – aus der Zeile des Pickers und listLogoReports', async () => {
admin = true;
fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: null });
fx.listLogoReports.mockResolvedValue([
{ id: 'r1', logoId: 'a', reason: 'spam', createdAt: '2026-10-04T12:00:00.000Z' },
{ id: 'r2', logoId: 'a', reason: 'sonstiges', createdAt: '2026-10-04T12:01:00.000Z' },
]);
await mount();
picker.select('a');
await flush();
expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a');
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('2 Meldungen');
expect([...infoEl().querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent)).toEqual(['Spam', 'Sonstiges']);
expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
});
it('Englisch: Zeile und Knöpfe sind übersetzt', async () => {
setLang('en', { persist: false });
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1', displayName: '' })], nextCursor: null });
await mount();
picker.select('a');
expect(infoLine()).toBe('Logo A · by Anonymous · ♥ 0');
expect(infoButtons()).toEqual(['Report', 'Delete']);
});
});
describe('Löschen (mit Raster und Auswahl)', () => {
beforeEach(() => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: null });
});
it('bestätigt: deleteLogo, forgetLogo, die Kachel ist weg, die Auswahl wird aufgehoben (onSelect(null)); die übrigen bleiben', async () => {
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('b');
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b');
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']);
expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null);
expect(picker.selected()).toBeNull();
expect(infoLine()).toBe('');
expect(infoGone()).toBe('');
expect(infoMessage()).toBe('');
expect(moreButton().hidden).toBe(true); // es gab keine weitere Seite, und das Löschen erfindet keine
expect(revoked).toContain(created[1]); // das Bild der Kachel ist freigegeben
});
it('der Editor übernimmt onSelect(null) mit select(null): „Kein Logo“ ist gewählt, nichts bleibt hängen', async () => {
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
picker.select(null);
expect(pressedTiles()).toEqual(['Kein Logo']);
expect(infoLine()).toBe('');
expect(fx.getLogo).not.toHaveBeenCalled();
});
it('abgelehnt: nichts passiert', async () => {
fx.confirm.mockResolvedValue(false);
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(fx.onSelect).not.toHaveBeenCalled();
expect(infoMessage()).toBe('');
});
it('Fehler: der Text steht in der Infozeile; Kachel, Zeile und Auswahl bleiben', async () => {
fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
expect(infoMessage()).toBe('Dazu hast du keine Berechtigung.');
expect(alertText()).toBe('');
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(fx.onSelect).not.toHaveBeenCalled();
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(picker.selected()).toMatchObject({ id: 'b' });
});
it('Löschen sehen nur Hochlader und Admins', async () => {
await mount();
picker.select('a'); // gehört u9
expect(infoButtons()).toEqual(['Melden']);
picker.select('b'); // gehört u1 = ich
expect(infoButtons()).toEqual(['Melden', 'Löschen']);
});
it('das letzte Logo gelöscht: der Leer-Text erscheint', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null });
await mount();
picker.select('a');
infoButton('Löschen').click();
await flush();
expect(names()).toEqual(['Kein Logo']);
expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.');
});
it('ein angeheftetes Logo (nicht in der Liste) lässt sich löschen: seine Kachel verschwindet', async () => {
fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', ownerId: 'u1' }));
await mount();
picker.select('x');
await flush();
expect(names()).toContain('Fremdes Logo');
infoButton('Löschen').click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
it('hat sich die Auswahl während der Anfrage geändert, bleibt die neue Auswahl unberührt (kein onSelect)', async () => {
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
picker.select('c');
request.resolve();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']);
expect(fx.onSelect).not.toHaveBeenCalled();
expect(pressedTiles()).toEqual(['Logo C']);
expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0');
});
it('war das gelöschte Logo der Cursor für „Mehr laden“ (die Anfrage braucht ein Logo, das es gibt), setzt das Nachladen beim Logo davor an', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c', { ownerId: 'u1' })], nextCursor: 'c' });
await mount();
picker.select('c');
infoButton('Löschen').click();
await flush();
expect(moreButton().hidden).toBe(false);
fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null });
moreButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'b' });
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D']);
});
it('ein anderes gelöschtes Logo lässt den Cursor für „Mehr laden“ stehen', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' });
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null });
moreButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'c' });
});
it('ein Logo mit derselben ID, das später auftaucht, erbt das Herz des gelöschten nicht', async () => {
fx.myHearts.mockResolvedValueOnce(new Set(['b']));
await mount();
expect(heartsOn()).toEqual(['Logo B']);
picker.select('b');
infoButton('Löschen').click();
await flush();
picker.select(null);
fx.myHearts.mockReturnValue(new Promise(() => {})); // die Herzen der neuen Liste kommen noch
fx.listLogos.mockResolvedValue({ logos: [logo('b', { name: 'Neues B' })], nextCursor: null });
sortButton('Neu').click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Neues B']);
expect(heartsOn()).toEqual([]);
});
it('der Fokus wandert von „Löschen“, das verschwindet, auf „Kein Logo“', async () => {
await mount();
picker.select('b');
infoButton('Löschen').focus();
expect(document.activeElement).toBe(infoButton('Löschen'));
infoButton('Löschen').click();
await flush();
expect(document.activeElement).toBe(tile('Kein Logo'));
});
it('der Fokus bleibt, wo er ist, wenn er nicht in der Infozeile lag', async () => {
await mount();
picker.select('b');
infoButton('Löschen').click();
tile('Logo C').focus();
await flush();
expect(document.activeElement).toBe(tile('Logo C'));
});
});
describe('Ausblenden und Einblenden (Admin, mit Raster und Auswahl)', () => {
beforeEach(() => {
admin = true;
fx.listLogos.mockResolvedValue({
logos: [logo('a'), logo('b', { reportCount: 3 }), logo('c', { hidden: true, reportCount: 3 })],
nextCursor: null,
});
});
it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo; die Zeile des Pickers ist hidden, die Kachel bleibt', async () => {
await mount();
picker.select('b');
infoButton('Ausblenden').click();
await flush();
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('b', false);
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b');
expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, reportCount: 3 });
expect(names()).toContain('Logo B');
expect(infoButtons()).toEqual(['Löschen', 'Einblenden']);
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('3 Meldungen');
});
it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Zeile ist sichtbar und hat 0 Meldungen', async () => {
await mount();
picker.select('c');
expect(infoButtons()).toEqual(['Löschen', 'Einblenden']);
infoButton('Einblenden').click();
await flush();
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('c', true);
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('c');
expect(picker.selected()).toMatchObject({ id: 'c', hidden: false, reportCount: 0 });
expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('0 Meldungen');
});
it('Fehler: der Text steht in der Infozeile, die Zeile bleibt unverändert', async () => {
fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await mount();
picker.select('b');
infoButton('Ausblenden').click();
await flush();
expect(infoMessage()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(picker.selected()).toMatchObject({ id: 'b', hidden: false });
expect(mocks.forgetLogo).not.toHaveBeenCalled();
});
it('der Herz-Zähler, der sich während der Anfrage ändert, geht nicht verloren (die Zeile wird mit dem aktuellen Stand fortgeschrieben)', async () => {
const request = deferred<void>();
fx.setLogoVisibility.mockReturnValue(request.promise);
fx.setHeart.mockResolvedValue({ herz: true, likeCount: 9 });
await mount();
picker.select('b');
infoButton('Ausblenden').click();
heart('Logo B').click();
await flush();
request.resolve();
await flush();
expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, likeCount: 9 });
});
});
+94 -31
View File
@@ -1,29 +1,29 @@
// Bereich „Logo unten rechts“ im Editor (Spec § 3 und § 5): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem
// Knopf „Hochladen“. Die Daten kommen über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialog „Logo hochladen“); alle Texte
// gehen als Text in den DOM, nie als HTML. Die Auswahl gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert
// wird über `select()`. Infozeile, Melden, Löschen und die Admin-Aktionen zum gewählten Logo kommen mit Task 9 dazu.
// Bereich „Logo unten rechts“ im Editor (Spec § 3, § 5 und § 6): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem
// Knopf „Hochladen“, darunter die Infozeile zum gewählten Logo (logo-info.ts: Melden, Löschen, Admin-Aktionen). Die Daten kommen
// über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialoge); alle Texte gehen als Text in den DOM, nie als HTML. Die Auswahl
// gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert wird über `select()`. Fehler von Herz und Hochladen
// zeigt die Infozeile; der Statusbereich des Pickers ist den Ladefehlern der Liste vorbehalten.
import '../account/account.css';
import './logos.css';
import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth';
import { errorMessage } from '../account/errors';
import { requireLogin } from '../account/login-dialog';
import { ApiError, apiErrorText } from '../community/api';
import { reportDialog } from '../community/report-dialog';
import { confirmDialog } from '../editor/confirm-dialog';
import { el, icon } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { setHeart } from './logo-api';
import { deleteLogo, reportLogo, setHeart, setLogoVisibility } from './logo-api';
import { rememberLogo } from './logo-asset';
import { logoUploadDialog } from './logo-dialog';
import { showLogoImage } from './logo-image';
import { getLogo, listLogos, loadLogoBlob, myHearts, type CommunityLogo, type LogoFilter, type LogoSort } from './logos';
import { createLogoInfo, failureText, type LogoInfoDeps } from './logo-info';
import { getLogo, listLogoReports, listLogos, loadLogoBlob, myHearts, type CommunityLogo, type LogoFilter, type LogoSort } from './logos';
export interface LogoPickerDeps {
export interface LogoPickerDeps extends LogoInfoDeps {
listLogos: typeof listLogos;
getLogo: typeof getLogo;
myHearts: typeof myHearts;
setHeart: typeof setHeart;
loadLogoBlob: typeof loadLogoBlob;
userId(): string | null;
isAdmin(): boolean;
requireLogin(): Promise<boolean>;
/** Meldet jede Änderung des Anmeldestatus; gibt die Abmeldung zurück. */
onAuthChange(fn: () => void): () => void;
@@ -35,7 +35,7 @@ export interface LogoPicker {
el: HTMLElement;
/** Markiert die Auswahl (null: kein Logo). Ein Logo, das nicht in der Liste steht, wird geholt und als zweite Kachel angeheftet. */
select(logoId: string | null): void;
/** Die Zeile des gewählten Logos, sobald bekannt (für die Infozeile in Task 9); null = kein Logo oder unbekannt. */
/** Die Zeile des gewählten Logos, sobald bekannt; null = kein Logo oder unbekannt. */
selected(): CommunityLogo | null;
/** Gibt die Object-URLs der Kacheln frei, schließt einen offenen Upload-Dialog und beendet das Abonnement der Anmeldung. */
dispose(): void;
@@ -82,14 +82,15 @@ function defaultDeps(): LogoPickerDeps {
requireLogin: () => requireLogin('login'),
onAuthChange,
uploadDialog: (signal) => logoUploadDialog({ signal }),
reportLogo,
deleteLogo,
setLogoVisibility,
listLogoReports,
confirm: (opts, signal) => confirmDialog(opts, signal),
reportDialog,
};
}
/** Der Grund eines Fehlers zum Anzeigen: Meldungen des Dienstes über ihren Schlüssel, alles andere über `errorMessage`. */
function failureText(err: unknown): string {
return err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
}
export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps: LogoPickerDeps = defaultDeps()): LogoPicker {
const abort = new AbortController();
let disposed = false;
@@ -115,6 +116,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
/** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */
let pinnedId: string | null = null;
let resolvingId: string | null = null;
/** Das gewählte Logo, nach dem `getLogo` nichts fand: es gibt es nicht (mehr). */
let goneId: string | null = null;
// Herzen
/** Die Logos, die ich mit einem Herz markiert habe. */
@@ -130,6 +133,24 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
// ---- Aufbau ---------------------------------------------------------------------------------------------------------------
/** Die Zeile des gewählten Logos, sobald bekannt. */
const selectedRow = (): CommunityLogo | null => (selectedId === null ? null : (known.get(selectedId) ?? null));
const info = createLogoInfo(
{
selectedId: () => selectedId,
selected: selectedRow,
gone: () => selectedId !== null && goneId === selectedId,
patchRow(id, patch) {
const row = known.get(id);
if (row) setRow({ ...row, ...patch });
},
removeLogo,
},
deps,
abort.signal,
);
const uploadButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-upload' }, icon('plus'), t('Hochladen'));
const sortBar = el('div', { class: 'logo-sort', role: 'group', 'aria-label': t('Sortierung') });
const sortButtons = new Map<View, HTMLButtonElement>();
@@ -142,7 +163,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
const noneCell = el('li', { class: 'logo-cell' }, noneButton);
const grid = el('ul', { class: 'logo-grid', 'aria-labelledby': 'logo-picker-heading' }, noneCell);
const emptyEl = el('p', { class: 'logo-empty' });
// Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
// Ladefehler der Liste. Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' });
const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen'));
const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden'));
@@ -163,6 +184,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
statusEl,
retryButton,
moreButton,
info.el,
);
// ---- Kacheln --------------------------------------------------------------------------------------------------------------
@@ -176,13 +198,15 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
tile.count.textContent = String(logo.likeCount);
}
/** Merkt die jüngste Zeile eines Logos und frischt seine Kachel auf. */
/** Merkt die jüngste Zeile eines Logos und frischt seine Kachel (und, wenn es gewählt ist, die Infozeile) auf. */
function setRow(row: CommunityLogo): void {
known.set(row.id, row);
const tile = tiles.get(row.id);
if (!tile) return;
tile.logo = row;
renderTile(tile);
if (tile) {
tile.logo = row;
renderTile(tile);
}
if (row.id === selectedId) info.update();
}
function createTile(logo: CommunityLogo): Tile {
@@ -214,6 +238,26 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
tiles.delete(tile.logo.id);
}
/** Das Logo ist gelöscht: Kachel und Zeile fallen weg; war es gewählt, hebt der Editor die Auswahl auf (`onSelect(null)`). */
function removeLogo(id: string): void {
const tile = tiles.get(id);
if (tile) dropTile(tile);
known.delete(id);
order = order.filter((other) => other !== id);
// „Mehr laden“ setzt hinter dem letzten geladenen Logo an (Cursor); die Abfrage braucht ein Logo, das es noch gibt.
if (nextCursor === id) nextCursor = order[order.length - 1] ?? null;
hearts.delete(id); // eine später vergebene gleiche ID erbt das Herz nicht
if (pinnedId === id) pinnedId = null;
renderState(); // aus der letzten Kachel wird der Leer-Text
if (selectedId === id) {
// Der Fokus lag auf „Löschen“, das mit dem Logo verschwindet: er geht auf „Kein Logo“, die neue Auswahl.
const focused = info.el.contains(document.activeElement);
handlers.onSelect(null);
if (focused) noneButton.focus();
}
info.update();
}
/** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */
function syncPin(): void {
const row = selectedId !== null && !order.includes(selectedId) ? known.get(selectedId) : undefined;
@@ -340,25 +384,36 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
if (id === null || known.has(id) || resolvingId === id) return;
resolvingId = id;
let row: CommunityLogo | null = null;
let answered = false;
try {
row = await deps.getLogo(id);
answered = true;
} catch {
// bleibt ohne Kachel
// bleibt ohne Kachel; ein Netzfehler heißt nicht, dass es das Logo nicht mehr gibt
} finally {
if (resolvingId === id) resolvingId = null;
}
if (disposed || row === null || selectedId !== id) return; // inzwischen eine andere Auswahl
if (disposed || selectedId !== id) return; // inzwischen eine andere Auswahl
if (row === null) {
if (answered) {
goneId = id;
info.update();
}
return;
}
setRow(row);
syncPin();
void loadHearts([id]);
}
function select(id: string | null): void {
if (id === selectedId) return; // der Editor ruft das bei jeder Änderung seines Zustands auf
if (disposed || id === selectedId) return; // der Editor ruft das bei jeder Änderung seines Zustands auf
selectedId = id;
goneId = null;
noneButton.setAttribute('aria-pressed', String(id === null));
for (const tile of tiles.values()) renderTile(tile);
syncPin();
info.update();
// Erst wenn die Liste da ist: steht das Logo darin, braucht es weder eine Anfrage noch einen angehefteten Eintrag.
if (loaded || failed !== null) void resolveSelection();
}
@@ -403,11 +458,18 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
async function toggleHeart(id: string): Promise<void> {
if (pending.has(id)) return; // eine Anfrage für dieses Herz läuft: weitere Klicks bleiben ohne Wirkung
pending.add(id);
statusEl.textContent = '';
info.setMessage('');
try {
let on = !hearts.has(id);
if (deps.userId() === null) {
if (!(await deps.requireLogin()) || disposed) return; // Anmeldung abgebrochen: es passiert nichts
let loggedIn: boolean;
try {
loggedIn = await deps.requireLogin();
} catch (err) {
if (!disposed) info.setMessage(failureText(err)); // der Login-Dialog ließ sich nicht öffnen
return;
}
if (!loggedIn || disposed) return; // Anmeldung abgebrochen: es passiert nichts
on = true; // nach der Anmeldung wird das Herz gesetzt
}
const row = known.get(id);
@@ -420,7 +482,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
} catch (err) {
if (disposed) return;
showHeart(id, before.on, before.likeCount);
statusEl.textContent = failureText(err);
info.setMessage(failureText(err));
}
} finally {
pending.delete(id);
@@ -432,7 +494,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
async function upload(): Promise<void> {
if (uploading) return;
uploading = true;
statusEl.textContent = '';
info.setMessage('');
try {
if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts
if (disposed) return;
@@ -442,7 +504,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
addFirst(logo);
handlers.onSelect(logo.id);
} catch (err) {
if (!disposed) statusEl.textContent = t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) });
if (!disposed) info.setMessage(t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) }));
} finally {
uploading = false;
}
@@ -462,6 +524,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
void load('reset');
}
renderSortBar();
info.update(); // Hochlader und Admins sehen mehr Knöpfe
if (deps.userId() === null) {
hearts.clear();
for (const tile of tiles.values()) renderTile(tile);
@@ -477,7 +540,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
return {
el: root,
select,
selected: () => (selectedId === null ? null : (known.get(selectedId) ?? null)),
selected: selectedRow,
dispose() {
if (disposed) return;
disposed = true;
+111 -10
View File
@@ -1,6 +1,6 @@
/* Community-Logos. Dialog „Logo hochladen“: Hülle, Felder, Pflicht-Häkchen, Meldungen und Knöpfe kommen aus community.css
(Melden-/Veröffentlichen-Dialog) und account.css; hier steht nur, was neu ist: das Datei-Feld und die Vorschau.
Weiter unten der Bereich „Logo unten rechts“ im Editor (Raster, Sortierung, Herzen); seine Kacheln beruhen auf `.tile` aus editor.css. */
Weiter unten der Bereich „Logo unten rechts“ im Editor (Raster, Sortierung, Herzen, Infozeile); seine Kacheln beruhen auf `.tile` aus editor.css. */
/* Etwas breiter als der Veröffentlichen-Dialog (480 px): zwei Vorschauen nebeneinander. Mit zwei Klassen, damit die Breite
nicht von der Reihenfolge der Stylesheets abhängt. */
@@ -228,11 +228,13 @@
min-width: 0;
}
/* `height: 100%` füllt die Zelle (das Raster streckt sie auf die Höhe der Reihe): „Kein Logo“ ist so hoch wie sein Nachbar. */
.logo-tile {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
height: 100%;
min-width: 0;
padding: 8px;
}
@@ -293,13 +295,20 @@
line-height: 1.3;
}
/* Herz: ein eigener Knopf neben dem Auswahlknopf, über der Bildfläche (also immer auf dunklem Grund). Gedrückt: gefülltes Herz. */
/* Name und „von …“ lassen rechts Platz für das Herz (nur Logo-Kacheln: „Kein Logo“ hat keins). */
.logo-tile:not(.logo-tile-none) .logo-tile-name,
.logo-tile:not(.logo-tile-none) .logo-tile-by {
padding-right: 64px;
}
/* Herz: ein eigener Knopf neben dem Auswahlknopf, am Ende der Textzeile unter der Bildfläche (liegt also nicht auf dem Logo, sondern
auf dem Grund der Kachel und folgt dem Theme), mittig neben Name und „von …“. Gedrückt: gefülltes Herz. */
.logo-heart {
appearance: none;
position: absolute;
top: 14px;
right: 14px;
right: 8px;
bottom: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -308,24 +317,26 @@
min-height: 32px;
margin: 0;
padding: 0 9px;
border: 0;
border: 1px solid var(--line);
border-radius: 999px;
background: rgb(15 24 40 / 0.78);
color: #e8eef7;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.8rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 0.12s;
transition: background-color 0.12s, border-color 0.12s;
}
.logo-heart:hover {
background: rgb(15 24 40 / 0.95);
border-color: var(--line-strong);
background: var(--panel-2);
}
.logo-heart[aria-pressed='true'] {
color: #ff7a8a;
border-color: var(--danger-text);
color: var(--danger-text);
}
.logo-heart[aria-pressed='true'] .icon {
@@ -355,6 +366,96 @@
align-self: center;
}
/* Infozeile zum gewählten Logo (unter dem Raster): Karte mit Name, Urheber und Herzen, Knöpfen und, für Admins, den Meldungen.
Ohne Auswahl ist die Karte verborgen; die beiden Meldezeilen (Hinweis, Fehler) sind immer da. */
.logo-info {
display: flex;
flex-direction: column;
gap: 8px;
}
/* `hidden` gilt auch dort, wo ein Stil `display` setzt (und außerhalb des Editors, der `.editor [hidden]` mitbringt). */
.logo-info [hidden] {
display: none !important;
}
.logo-info-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.logo-info-text {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.logo-info-name {
color: var(--text);
font-size: 0.95rem;
font-weight: 700;
}
.logo-info-hearts {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.logo-info-hidden {
align-self: flex-start;
}
.logo-info-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.logo-info-admin {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.logo-info-reports {
margin: 0;
font-size: 0.9rem;
font-weight: 700;
}
.logo-info-reasons {
margin: 0;
padding-left: 20px;
color: var(--muted);
font-size: 0.9rem;
}
.logo-info-reasons:empty,
.logo-info-note:empty {
display: none;
}
.logo-info-note {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
}
.logo-info-gone {
padding-left: 4px;
color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
.logo-sort-btn,
.logo-heart {