feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename
Task 7: der Dialog zum Hochladen eines Community-Logos (Spec § 4) und der zuletzt benutzte Anzeigename, den er mit dem Veröffentlichen-Dialog teilt. - logos/logo-dialog.ts (neu): logoUploadDialog öffnet ein eigenes <dialog> nach dem Muster von publish-dialog.ts (showModal, Fokus zurück an den Auslöser, Texte nur als Text; beim Hochladen sind Abbrechen, Esc und Hintergrund wirkungslos, nur das Signal schließt immer; ein zweites Absenden wird ignoriert). Die gewählte Datei geht erst durch validateLogoFile, dann durch prepare (Standard prepareLogo); währenddessen ist „Hochladen“ gesperrt, eine neue Wahl macht die laufende Verarbeitung hinfällig. Das fertige PNG erscheint als Object-URL zweimal, auf dunklem und hellem Grund; „Einfarbig“ (nur wählbar mit transparenten Bereichen, bei jeder neuen Datei zurückgesetzt) zeigt stattdessen eine Maske auf dieselbe URL, gefüllt mit TONE_COLOR (Weiß bzw. #111111). Die URL wird beim Schließen und beim Ersetzen freigegeben. Der Name wird aus dem Dateinamen ohne Endung vorbelegt (höchstens 40 Zeichen, nie mitten in einem Emoji), ein selbst getippter Name bleibt. Hochladen über uploadPreparedLogo mit withLogin (abgelaufene Sitzung → Anmeldung); Fehler erscheinen als „Hochladen fehlgeschlagen – <Grund>“ (ApiError über apiErrorText, Texte von prepareLogo im Wortlaut, sonst errorMessage), eine abgebrochene Anmeldung als „Bitte melde dich an“. validateLogoForm prüft Datei, Name (leer, Steuerzeichen, 40), Anzeigename (Steuerzeichen, 30) und Pflicht-Häkchen mit den Texten aus Spec § 4 und § 11. - logos/logos.css (neu): Breite des Dialogs, Datei-Feld, die zwei Vorschauflächen und die Maske; Hülle, Felder und Knöpfe kommen aus community.css und account.css. - account/display-name.ts (neu): DISPLAY_NAME_KEY (aircard.displayName), rememberedDisplayName und rememberDisplayName; jeder Zugriff in try/catch, ein leerer Name entfernt den Schlüssel. - community/publish-dialog.ts: der Anzeigename ist aus dem gemerkten Namen vorbelegt und wird nach erfolgreichem Veröffentlichen gemerkt; CONTROL_CHARACTERS und DISPLAY_NAME_MAX werden exportiert (der Logo-Dialog prüft mit denselben Regeln). - i18n/en.ts: englische Texte zu allen neuen Beschriftungen, Hinweisen und Meldungen („Einfarbig“ heißt „Single color“). - Tests: logo-dialog.test.ts (validateLogoForm, Aufbau, Dateiwahl mit Vorschau, Namensvorschlag, Maske, Object-URLs, veraltete Verarbeitung, Prüfung, Senden mit Doppelklick und Sperren, Fehlertexte in beiden Sprachen, Standardwerte, Schließen), display-name.test.ts, publish-dialog.test.ts (Vorbelegung und Speichern). Vitest 49 Dateien, 1566 Tests grün, tsc und Build sauber. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56d841b8e2
commit
e22919f4dc
8 files changed
+1665
-2
No files matched your search
@@ -0,0 +1,913 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
prepareLogo: vi.fn(),
|
||||
uploadPreparedLogo: vi.fn(),
|
||||
withLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
// api.ts und with-login.ts hängen am Appwrite-Client; `prepare` und `upload` kommen meist von außen, nur die Standardwerte
|
||||
// (prepareLogo, uploadPreparedLogo über withLogin) laufen über die Fakes hier.
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||
vi.mock('../account/with-login', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../account/with-login')>()),
|
||||
withLogin: mocks.withLogin,
|
||||
}));
|
||||
vi.mock('./prepare-logo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./prepare-logo')>()),
|
||||
prepareLogo: mocks.prepareLogo,
|
||||
}));
|
||||
vi.mock('./upload-logo', () => ({ uploadPreparedLogo: mocks.uploadPreparedLogo }));
|
||||
|
||||
import { DISPLAY_NAME_KEY } from '../account/display-name';
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { ApiError } from '../community/api';
|
||||
import { setLang, t } from '../i18n/i18n';
|
||||
import { LOGO_CONSENT_TEXT, logoUploadDialog, validateLogoForm, type LogoDialogOptions } from './logo-dialog';
|
||||
import type { CommunityLogo } from './logos';
|
||||
import { LOGO_ACCEPT, LOGO_EMPTY_MESSAGE, type PreparedLogo } from './prepare-logo';
|
||||
import type { LogoForm } from './upload-logo';
|
||||
|
||||
type Prepare = (file: File) => Promise<PreparedLogo>;
|
||||
type Upload = (prepared: PreparedLogo, form: LogoForm) => Promise<CommunityLogo>;
|
||||
const prepare = vi.fn<Prepare>();
|
||||
const upload = vi.fn<Upload>();
|
||||
|
||||
/** Die Blobs, für die der Dialog Object-URLs angelegt hat (blob:logo-1, blob:logo-2, …), und die wieder freigegebenen URLs. */
|
||||
const created: Blob[] = [];
|
||||
const revoked: string[] = [];
|
||||
|
||||
const prepared = (patch: Partial<PreparedLogo> = {}): PreparedLogo => ({
|
||||
png: new Blob(['png-bytes'], { type: 'image/png' }),
|
||||
width: 200,
|
||||
height: 80,
|
||||
hasTransparency: true,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
|
||||
id: 'L1',
|
||||
name: 'Beispielbank',
|
||||
displayName: '',
|
||||
fileId: 'L1',
|
||||
ownerId: 'u1',
|
||||
mono: false,
|
||||
likeCount: 0,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
createdAt: '2026-10-04T12:00:00.000Z',
|
||||
...patch,
|
||||
});
|
||||
|
||||
const imageFile = (name = 'Beispielbank.png', type = 'image/png') => new File(['x'.repeat(100)], name, { type });
|
||||
|
||||
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.logo-dialog');
|
||||
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
const buttonByText = (text: string) =>
|
||||
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
|
||||
const form = () => dialogEl()!.querySelector('form')!;
|
||||
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
|
||||
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||
const previews = () => [...dialogEl()!.querySelectorAll<HTMLElement>('.logo-preview')];
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
/** Wählt eine Datei im Datei-Feld (oder hebt die Auswahl auf), wie es der Browser mit `change` meldet. */
|
||||
function choose(file: File | null): void {
|
||||
Object.defineProperty(input('file'), 'files', { value: file ? [file] : [], configurable: true });
|
||||
input('file').dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
/** Tippt in ein Textfeld, so dass auch das `input`-Ereignis kommt (das Setzen von `value` allein löst es nicht aus). */
|
||||
function typeInto(name: string, value: string): void {
|
||||
input(name).value = value;
|
||||
input(name).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function fill(values: { name?: string; displayName?: string; consent?: boolean } = {}): void {
|
||||
input('name').value = values.name ?? 'Beispielbank';
|
||||
input('displayName').value = values.displayName ?? '';
|
||||
input('consent').checked = values.consent ?? true;
|
||||
}
|
||||
|
||||
function open(opts: LogoDialogOptions = {}): Promise<CommunityLogo | null> {
|
||||
return logoUploadDialog({ prepare, upload, ...opts });
|
||||
}
|
||||
|
||||
/** Öffnet den Dialog und wählt eine Datei; wartet, bis die Verarbeitung durch ist. Das Ergebnis steckt in einem Objekt (kein Warten auf das Schließen). */
|
||||
async function setup(opts: LogoDialogOptions = {}, file: File = imageFile()): Promise<{ outcome: Promise<CommunityLogo | null> }> {
|
||||
const outcome = open(opts);
|
||||
choose(file);
|
||||
await settle();
|
||||
return { outcome };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin]) fn.mockReset();
|
||||
mocks.withLogin.mockImplementation((fn: () => Promise<unknown>) => fn());
|
||||
prepare.mockResolvedValue(prepared());
|
||||
created.length = 0;
|
||||
revoked.length = 0;
|
||||
let counter = 0;
|
||||
URL.createObjectURL = vi.fn((blob: Blob) => {
|
||||
created.push(blob);
|
||||
return `blob:logo-${++counter}`;
|
||||
});
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('validateLogoForm', () => {
|
||||
const ok = { hasFile: true, name: 'Beispielbank', displayName: '', consent: true };
|
||||
|
||||
it('gültige Werte: kein Problem', () => {
|
||||
expect(validateLogoForm(ok)).toBeNull();
|
||||
expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ohne Datei', () => {
|
||||
expect(validateLogoForm({ ...ok, hasFile: false })).toEqual({ field: 'file', message: 'Bitte wähle eine Datei.' });
|
||||
});
|
||||
|
||||
it('Name leer, auch nur aus Leerzeichen', () => {
|
||||
const problem = { field: 'name', message: 'Bitte gib einen Namen an.' };
|
||||
expect(validateLogoForm({ ...ok, name: '' })).toEqual(problem);
|
||||
expect(validateLogoForm({ ...ok, name: ' ' })).toEqual(problem);
|
||||
});
|
||||
|
||||
it('Name länger als 40 Zeichen; 40 Zeichen gehen durch, Leerzeichen außen zählen nicht', () => {
|
||||
expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })).toEqual({
|
||||
field: 'name',
|
||||
message: 'Der Name ist zu lang (höchstens 40 Zeichen).',
|
||||
});
|
||||
expect(validateLogoForm({ ...ok, name: 'x'.repeat(40) })).toBeNull();
|
||||
expect(validateLogoForm({ ...ok, name: ` ${'x'.repeat(40)} ` })).toBeNull();
|
||||
});
|
||||
|
||||
// Dasselbe Muster wie der Dienst (deploy/api/common.js): Steuer-, Nullbreiten- und Bidi-Zeichen und das BOM.
|
||||
it.each([
|
||||
['Tabulator', 'a\tb'],
|
||||
['Nullbreiten-Leerzeichen', 'ab'],
|
||||
['Rechts-nach-links-Überschreibung', 'logognp.exe'],
|
||||
['C1-Steuerzeichen', 'a\u0085b'],
|
||||
['BOM mitten im Namen', 'ab'],
|
||||
])('Steuerzeichen im Namen werden abgelehnt: %s', (_label, name) => {
|
||||
expect(validateLogoForm({ ...ok, name })).toEqual({ field: 'name', message: 'Der Name enthält ungültige Zeichen.' });
|
||||
});
|
||||
|
||||
it('Anzeigename: höchstens 30 Zeichen, keine Steuerzeichen, leer ist erlaubt', () => {
|
||||
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })).toEqual({
|
||||
field: 'displayName',
|
||||
message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).',
|
||||
});
|
||||
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(30) })).toBeNull();
|
||||
expect(validateLogoForm({ ...ok, displayName: ' ' })).toBeNull();
|
||||
expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toEqual({
|
||||
field: 'displayName',
|
||||
message: 'Der Anzeigename enthält ungültige Zeichen.',
|
||||
});
|
||||
});
|
||||
|
||||
it('ohne Häkchen', () => {
|
||||
expect(validateLogoForm({ ...ok, consent: false })).toEqual({
|
||||
field: 'consent',
|
||||
message: 'Bitte bestätige, dass du das Logo teilen darfst.',
|
||||
});
|
||||
});
|
||||
|
||||
it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Anzeigename, Häkchen', () => {
|
||||
const bad = { hasFile: false, name: '', displayName: 'x'.repeat(31), consent: false };
|
||||
expect(validateLogoForm(bad)?.field).toBe('file');
|
||||
expect(validateLogoForm({ ...bad, hasFile: true })?.field).toBe('name');
|
||||
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('displayName');
|
||||
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank', displayName: '' })?.field).toBe('consent');
|
||||
});
|
||||
|
||||
it('Englisch: die Meldungen kommen in der Sprache der Seite', () => {
|
||||
setLang('en');
|
||||
expect(validateLogoForm({ ...ok, hasFile: false })?.message).toBe('Please choose a file.');
|
||||
expect(validateLogoForm({ ...ok, name: '' })?.message).toBe('Please enter a name.');
|
||||
expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })?.message).toBe('The name is too long (at most 40 characters).');
|
||||
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })?.message).toBe(
|
||||
'The display name is too long (at most 30 characters).',
|
||||
);
|
||||
expect(validateLogoForm({ ...ok, consent: false })?.message).toBe('Please confirm that you’re allowed to share the logo.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Aufbau', () => {
|
||||
it('ist ein eigenes <dialog> mit Überschrift, den Feldern in Formularreihenfolge und beiden Knöpfen', () => {
|
||||
void open();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo hochladen');
|
||||
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'displayName', 'consent']);
|
||||
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
|
||||
'Datei (Pflicht)',
|
||||
'Einfarbig – passt sich Hell/Dunkel an',
|
||||
'Name (Pflicht)',
|
||||
'Anzeigename (optional)',
|
||||
LOGO_CONSENT_TEXT,
|
||||
]);
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
expect(buttonByText('Hochladen')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('das Datei-Feld nimmt PNG, JPG, WebP und SVG und nennt die Grenzen der Spec', () => {
|
||||
void open();
|
||||
expect(input('file').type).toBe('file');
|
||||
expect(input('file').getAttribute('accept')).toBe(LOGO_ACCEPT);
|
||||
expect(dialogEl()!.textContent).toContain('PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.');
|
||||
});
|
||||
|
||||
it('beim Öffnen: Name leer, Häkchen ungesetzt, „Einfarbig“ gesperrt, keine Vorschau, Fokus im Datei-Feld', () => {
|
||||
void open();
|
||||
expect(input('name').value).toBe('');
|
||||
expect(input('displayName').value).toBe('');
|
||||
expect(input('displayName').placeholder).toBe('Anonym');
|
||||
expect(input('consent').checked).toBe(false);
|
||||
expect(input('mono').checked).toBe(false);
|
||||
expect(input('mono').disabled).toBe(true);
|
||||
expect(previews()).toHaveLength(0);
|
||||
expect(document.activeElement).toBe(input('file'));
|
||||
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
||||
});
|
||||
|
||||
it('der Anzeigename ist mit dem zuletzt benutzten Namen vorbelegt', () => {
|
||||
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
|
||||
void open();
|
||||
expect(input('displayName').value).toBe('Kenso');
|
||||
});
|
||||
|
||||
it('das Pflicht-Häkchen hat genau den Wortlaut der Spec', () => {
|
||||
void open();
|
||||
expect(LOGO_CONSENT_TEXT).toBe(
|
||||
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.',
|
||||
);
|
||||
expect(input('consent').closest('label')!.textContent).toBe(LOGO_CONSENT_TEXT);
|
||||
});
|
||||
|
||||
it('jedes Feld hat sein Label, alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => {
|
||||
void open();
|
||||
const labels = [...form().querySelectorAll('label')];
|
||||
const inputs = [...form().querySelectorAll('input')];
|
||||
expect(labels.map((l) => l.control)).toEqual(inputs);
|
||||
for (const control of inputs) expect(control.labels).toHaveLength(1);
|
||||
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
const heading = dialogEl()!.querySelector('h2')!;
|
||||
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id);
|
||||
expect(inputs.map((i) => i.id)).not.toContain(heading.id);
|
||||
});
|
||||
|
||||
it('Englisch: Überschrift, Beschriftungen, Platzhalter und Knöpfe', () => {
|
||||
setLang('en');
|
||||
void open();
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo');
|
||||
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
|
||||
'File (required)',
|
||||
'Single color – adapts to light/dark mode',
|
||||
'Name (required)',
|
||||
'Display name (optional)',
|
||||
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
|
||||
]);
|
||||
expect(input('displayName').placeholder).toBe('Anonymous');
|
||||
expect(buttonByText('Cancel')).toBeTruthy();
|
||||
expect(buttonByText('Upload')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Datei wählen', () => {
|
||||
it('ruft prepare auf, zeigt „Logo wird verarbeitet …“ und danach zwei Vorschauen (dunkel, hell) mit dem fertigen PNG', async () => {
|
||||
const p = prepared();
|
||||
prepare.mockResolvedValue(p);
|
||||
const file = imageFile();
|
||||
void open();
|
||||
choose(file);
|
||||
expect(prepare).toHaveBeenCalledExactlyOnceWith(file);
|
||||
expect(statusText()).toBe('Logo wird verarbeitet …');
|
||||
expect(previews()).toHaveLength(0);
|
||||
await settle();
|
||||
expect(statusText()).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
|
||||
const [dark, light] = previews();
|
||||
expect(previews()).toHaveLength(2);
|
||||
expect(dark.classList.contains('logo-preview-dark')).toBe(true);
|
||||
expect(light.classList.contains('logo-preview-light')).toBe(true);
|
||||
expect(dark.querySelector('figcaption')!.textContent).toBe('Dunkel');
|
||||
expect(light.querySelector('figcaption')!.textContent).toBe('Hell');
|
||||
// Beide zeigen dieselbe Object-URL des fertigen PNGs; sie wird nur einmal angelegt.
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]).toBe(p.png);
|
||||
for (const preview of previews()) {
|
||||
const img = preview.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('blob:logo-1');
|
||||
expect(img.getAttribute('alt')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['einfacher Dateiname', 'Beispielbank.png', 'Beispielbank'],
|
||||
['nur die letzte Endung fällt weg', 'Meine Bank.final.svg', 'Meine Bank.final'],
|
||||
['ohne Endung', 'logo', 'logo'],
|
||||
['Leerzeichen außen fallen weg', ' Mit Leerzeichen .webp', 'Mit Leerzeichen'],
|
||||
['über 40 Zeichen: gekürzt', `${'x'.repeat(60)}.png`, 'x'.repeat(40)],
|
||||
['nach dem Kürzen bleibt kein Leerzeichen am Ende', `${'a'.repeat(39)} bbb.png`, 'a'.repeat(39)],
|
||||
// 😀 belegt zwei Zeichen (UTF-16): ein Schnitt mitten darin würde ein halbes Zeichen hinterlassen.
|
||||
['der Schnitt trennt kein Emoji', `${'a'.repeat(39)}😀b.png`, 'a'.repeat(39)],
|
||||
])('der Name wird aus dem Dateinamen ohne Endung vorbelegt: %s', async (_label, fileName, expected) => {
|
||||
await setup({}, imageFile(fileName));
|
||||
expect(input('name').value).toBe(expected);
|
||||
expect(validateLogoForm({ hasFile: true, name: input('name').value, displayName: '', consent: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('eine zweite Datei ersetzt den vorbelegten Namen, aber keinen selbst getippten', async () => {
|
||||
await setup({}, imageFile('Erste.png'));
|
||||
expect(input('name').value).toBe('Erste');
|
||||
choose(imageFile('Zweite.png'));
|
||||
await settle();
|
||||
expect(input('name').value).toBe('Zweite');
|
||||
|
||||
typeInto('name', 'Mein Name');
|
||||
choose(imageFile('Dritte.png'));
|
||||
await settle();
|
||||
expect(input('name').value).toBe('Mein Name');
|
||||
|
||||
// Wer das Feld leert, bekommt beim nächsten Logo wieder einen Vorschlag.
|
||||
typeInto('name', '');
|
||||
choose(imageFile('Vierte.png'));
|
||||
await settle();
|
||||
expect(input('name').value).toBe('Vierte');
|
||||
});
|
||||
|
||||
it('„Einfarbig“ ist gesperrt, wenn das Logo keine transparenten Bereiche hat', async () => {
|
||||
prepare.mockResolvedValue(prepared({ hasTransparency: false }));
|
||||
await setup();
|
||||
expect(input('mono').disabled).toBe(true);
|
||||
expect(input('mono').checked).toBe(false);
|
||||
});
|
||||
|
||||
it('„Einfarbig“ ist wählbar, wenn das Logo transparente Bereiche hat', async () => {
|
||||
await setup();
|
||||
expect(input('mono').disabled).toBe(false);
|
||||
expect(input('mono').checked).toBe(false);
|
||||
});
|
||||
|
||||
it('„Einfarbig“ zeigt beide Vorschauen als Maske auf dieselbe URL (Weiß auf dunklem, #111111 auf hellem Grund); abwählen bringt das Bild zurück', async () => {
|
||||
await setup();
|
||||
input('mono').click();
|
||||
expect(input('mono').checked).toBe(true);
|
||||
expect(previews()).toHaveLength(2);
|
||||
const [dark, light] = previews();
|
||||
for (const preview of previews()) {
|
||||
expect(preview.querySelector('img')).toBeNull();
|
||||
const mask = preview.querySelector<HTMLElement>('.logo-preview-mask')!;
|
||||
expect(mask.getAttribute('style')).toContain('url("blob:logo-1")');
|
||||
expect(mask.getAttribute('role')).toBe('img');
|
||||
expect(mask.getAttribute('aria-label')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/);
|
||||
}
|
||||
// Gefüllt wie auf der Karte (TONE_COLOR): Weiß auf dunklem, #111111 auf hellem Grund.
|
||||
expect(dark.querySelector<HTMLElement>('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#FFFFFF');
|
||||
expect(light.querySelector<HTMLElement>('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#111111');
|
||||
expect(created).toHaveLength(1); // keine neue URL für die Maske
|
||||
|
||||
input('mono').click();
|
||||
expect(input('mono').checked).toBe(false);
|
||||
for (const preview of previews()) {
|
||||
expect(preview.querySelector('.logo-preview-mask')).toBeNull();
|
||||
expect(preview.querySelector('img')!.getAttribute('src')).toBe('blob:logo-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('eine neue Datei gibt die Object-URL der alten frei und setzt „Einfarbig“ zurück', async () => {
|
||||
await setup({}, imageFile('Erste.png'));
|
||||
input('mono').click();
|
||||
expect(input('mono').checked).toBe(true);
|
||||
prepare.mockResolvedValue(prepared());
|
||||
choose(imageFile('Zweite.png'));
|
||||
expect(revoked).toEqual(['blob:logo-1']); // sofort beim Wählen, die Vorschau gehört nicht mehr zur Auswahl
|
||||
expect(previews()).toHaveLength(0);
|
||||
await settle();
|
||||
expect(input('mono').checked).toBe(false);
|
||||
expect(previews()).toHaveLength(2);
|
||||
expect(previews()[0].querySelector('img')!.getAttribute('src')).toBe('blob:logo-2');
|
||||
expect(revoked).toEqual(['blob:logo-1']);
|
||||
});
|
||||
|
||||
it('die Auswahl aufzuheben räumt Vorschau und Object-URL weg', async () => {
|
||||
await setup();
|
||||
choose(null);
|
||||
await settle();
|
||||
expect(previews()).toHaveLength(0);
|
||||
expect(revoked).toEqual(['blob:logo-1']);
|
||||
expect(input('mono').disabled).toBe(true);
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('falscher Dateityp oder zu groß: Meldung der Spec, prepare wird nicht gerufen, keine Vorschau', async () => {
|
||||
void open();
|
||||
choose(imageFile('notizen.txt', 'text/plain'));
|
||||
await settle();
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect(errorText()).toBe('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.');
|
||||
expect(previews()).toHaveLength(0);
|
||||
|
||||
const big = imageFile('gross.png');
|
||||
Object.defineProperty(big, 'size', { value: 5_242_881 });
|
||||
choose(big);
|
||||
await settle();
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect(errorText()).toBe('Die Datei ist größer als 5 MB.');
|
||||
});
|
||||
|
||||
it('schlägt die Verarbeitung fehl, erscheint ihr Text (z. B. „Das Logo ist leer.“), ohne Vorschau und ohne Name', async () => {
|
||||
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
|
||||
await setup();
|
||||
expect(errorText()).toBe('Das Logo ist leer.');
|
||||
expect(statusText()).toBe('');
|
||||
expect(previews()).toHaveLength(0);
|
||||
expect(input('name').value).toBe('');
|
||||
expect(created).toHaveLength(0);
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('der Text der Verarbeitung erscheint in der Sprache der Seite', async () => {
|
||||
setLang('en');
|
||||
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
|
||||
await setup();
|
||||
expect(errorText()).toBe('The logo is empty.');
|
||||
});
|
||||
|
||||
it('ein unerwarteter Fehler der Verarbeitung zeigt nicht seinen Text, sondern die allgemeine Meldung', async () => {
|
||||
prepare.mockRejectedValue(new Error('boom: interne Details'));
|
||||
await setup();
|
||||
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
});
|
||||
|
||||
it('nach einer gescheiterten Verarbeitung gilt keine Datei als gewählt', async () => {
|
||||
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
expect(errorText()).toBe('Bitte wähle eine Datei.');
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('eine neue Wahl macht die laufende Verarbeitung der alten Datei hinfällig', async () => {
|
||||
let finishFirst!: (p: PreparedLogo) => void;
|
||||
const first = prepared({ png: new Blob(['erste'], { type: 'image/png' }) });
|
||||
const second = prepared({ png: new Blob(['zweite'], { type: 'image/png' }), hasTransparency: false });
|
||||
prepare.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
|
||||
prepare.mockResolvedValueOnce(second);
|
||||
void open();
|
||||
choose(imageFile('Erste.png'));
|
||||
choose(imageFile('Zweite.png'));
|
||||
await settle();
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]).toBe(second.png);
|
||||
expect(input('name').value).toBe('Zweite');
|
||||
expect(input('mono').disabled).toBe(true);
|
||||
|
||||
finishFirst(first); // kommt zu spät und bleibt folgenlos
|
||||
await settle();
|
||||
expect(created).toHaveLength(1);
|
||||
expect(input('name').value).toBe('Zweite');
|
||||
expect(input('mono').disabled).toBe(true);
|
||||
expect(previews()).toHaveLength(2);
|
||||
expect(statusText()).toBe('');
|
||||
});
|
||||
|
||||
it('während der Verarbeitung ist „Hochladen“ gesperrt und ein Absenden bleibt wirkungslos', async () => {
|
||||
let finish!: (p: PreparedLogo) => void;
|
||||
prepare.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
void open();
|
||||
choose(imageFile());
|
||||
fill();
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(true);
|
||||
send();
|
||||
expect(errorText()).toBe('');
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
finish(prepared());
|
||||
await settle();
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('eine Verarbeitung, die erst nach dem Schließen fertig wird, legt keine Object-URL mehr an', async () => {
|
||||
let finish!: (p: PreparedLogo) => void;
|
||||
prepare.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
const outcome = open();
|
||||
choose(imageFile());
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await outcome;
|
||||
finish(prepared());
|
||||
await settle();
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('der Dateiname erscheint als Text, nie als HTML', async () => {
|
||||
await setup({}, imageFile('<img src=x onerror=alert(1)>.png'));
|
||||
expect(input('name').value).toBe('<img src=x onerror=alert(1)>');
|
||||
for (const img of dialogEl()!.querySelectorAll('img')) expect(img.getAttribute('src')).toBe('blob:logo-1');
|
||||
expect(dialogEl()!.querySelectorAll('img')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Prüfung vor dem Senden', () => {
|
||||
const invalid = async (values: Parameters<typeof fill>[0], message: string, field: string) => {
|
||||
await setup();
|
||||
fill(values);
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(message);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input(field).getAttribute('aria-invalid')).toBe('true');
|
||||
expect(document.activeElement).toBe(input(field));
|
||||
};
|
||||
|
||||
it('keine Datei gewählt', () => {
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
expect(errorText()).toBe('Bitte wähle eine Datei.');
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(input('file').getAttribute('aria-invalid')).toBe('true');
|
||||
expect(document.activeElement).toBe(input('file'));
|
||||
});
|
||||
|
||||
it('Name leer', async () => {
|
||||
await invalid({ name: ' ' }, 'Bitte gib einen Namen an.', 'name');
|
||||
});
|
||||
|
||||
it('Name länger als 40 Zeichen', async () => {
|
||||
await invalid({ name: 'x'.repeat(41) }, 'Der Name ist zu lang (höchstens 40 Zeichen).', 'name');
|
||||
});
|
||||
|
||||
it('Steuerzeichen im Namen', async () => {
|
||||
await invalid({ name: 'a\tb' }, 'Der Name enthält ungültige Zeichen.', 'name');
|
||||
});
|
||||
|
||||
it('Anzeigename länger als 30 Zeichen', async () => {
|
||||
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
|
||||
});
|
||||
|
||||
it('Pflicht-Häkchen fehlt', async () => {
|
||||
await invalid({ consent: false }, 'Bitte bestätige, dass du das Logo teilen darfst.', 'consent');
|
||||
});
|
||||
|
||||
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
|
||||
upload.mockResolvedValue(logo());
|
||||
await setup();
|
||||
fill({ name: '' });
|
||||
send();
|
||||
expect(input('name').getAttribute('aria-invalid')).toBe('true');
|
||||
fill({ name: 'Ok' });
|
||||
send();
|
||||
expect(input('name').getAttribute('aria-invalid')).toBeNull();
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Grenzen sind inklusive: 40 Zeichen Name und 30 Zeichen Anzeigename gehen durch', async () => {
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
fill({ name: 'x'.repeat(40), displayName: 'y'.repeat(30) });
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => {
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
fill({ name: 'Größe 😀 – Möwe-Bank', displayName: 'Zoë' });
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Senden', () => {
|
||||
it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => {
|
||||
const p = prepared();
|
||||
prepare.mockResolvedValue(p);
|
||||
const result = logo({ id: 'L9' });
|
||||
upload.mockResolvedValue(result);
|
||||
const { outcome } = await setup();
|
||||
fill({ name: ' Meine Bank ', displayName: ' Kenso ' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe(result);
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', displayName: 'Kenso', mono: false });
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('„Einfarbig“ geht mit: gewählt → mono true', async () => {
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
input('mono').click();
|
||||
fill();
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: true });
|
||||
});
|
||||
|
||||
it('ohne transparente Bereiche geht mono nie als true mit', async () => {
|
||||
prepare.mockResolvedValue(prepared({ hasTransparency: false }));
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
input('mono').click(); // gesperrt: ändert nichts
|
||||
expect(input('mono').checked).toBe(false);
|
||||
input('mono').checked = true; // selbst ein von außen gesetztes Häkchen zählt nicht
|
||||
fill();
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: false });
|
||||
});
|
||||
|
||||
it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
|
||||
localStorage.setItem(DISPLAY_NAME_KEY, 'Alt');
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
expect(input('displayName').value).toBe('Alt');
|
||||
fill({ displayName: '' });
|
||||
send();
|
||||
await outcome;
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => {
|
||||
let finish!: (l: CommunityLogo) => void;
|
||||
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
const { outcome } = await setup();
|
||||
fill();
|
||||
const dialogForm = form();
|
||||
send();
|
||||
send();
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
finish(logo());
|
||||
await outcome;
|
||||
// Ein verspätetes Absenden am schon geschlossenen Dialog bleibt wirkungslos.
|
||||
dialogForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('während des Sendens: Knöpfe und Felder gesperrt, Statustext, danach alles wieder frei', async () => {
|
||||
let fail!: (e: Error) => void;
|
||||
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(true);
|
||||
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
|
||||
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(true);
|
||||
expect(form().getAttribute('aria-busy')).toBe('true');
|
||||
expect(statusText()).toBe('Wird hochgeladen …');
|
||||
|
||||
fail(new Error('weg'));
|
||||
await settle();
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
|
||||
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(false);
|
||||
expect(form().getAttribute('aria-busy')).toBe('false');
|
||||
expect(statusText()).toBe('');
|
||||
});
|
||||
|
||||
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
|
||||
let finish!: (l: CommunityLogo) => void;
|
||||
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
const { outcome } = await setup();
|
||||
fill();
|
||||
send();
|
||||
const esc = new Event('cancel', { cancelable: true });
|
||||
dialogEl()!.dispatchEvent(esc);
|
||||
expect(esc.defaultPrevented).toBe(true);
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
finish(logo());
|
||||
await outcome;
|
||||
});
|
||||
|
||||
it('schließt der Browser den Dialog mitten im Senden, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
|
||||
let finish!: (l: CommunityLogo) => void;
|
||||
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
let resolved: CommunityLogo | null | undefined;
|
||||
const result = logo();
|
||||
const { outcome } = await setup();
|
||||
void outcome.then((value) => (resolved = value));
|
||||
fill();
|
||||
send();
|
||||
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
dialogEl()!.dispatchEvent(new Event('close'));
|
||||
await settle();
|
||||
expect(resolved).toBeUndefined();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
finish(result);
|
||||
await outcome;
|
||||
expect(resolved).toBe(result);
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('scheitert das Senden, nachdem der Browser den Dialog geschlossen hat, ist der Fehler sichtbar (Dialog wieder offen)', async () => {
|
||||
let fail!: (e: Error) => void;
|
||||
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
dialogEl()!.close();
|
||||
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(errorText()).toBe('Hochladen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes: „Hochladen fehlgeschlagen – <Text>“, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
|
||||
upload.mockRejectedValueOnce(
|
||||
new ApiError(
|
||||
429,
|
||||
'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
|
||||
true,
|
||||
'tooManyLogos',
|
||||
),
|
||||
);
|
||||
const { outcome } = await setup();
|
||||
fill({ displayName: 'Kenso' });
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(
|
||||
'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
|
||||
);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
// Die Eingaben bleiben, und der Anzeigename wird erst bei Erfolg gemerkt.
|
||||
expect(input('name').value).toBe('Beispielbank');
|
||||
expect(input('displayName').value).toBe('Kenso');
|
||||
expect(input('consent').checked).toBe(true);
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
|
||||
upload.mockResolvedValueOnce(logo());
|
||||
send();
|
||||
await expect(outcome).resolves.toMatchObject({ id: 'L1' });
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
|
||||
});
|
||||
|
||||
it('Fehlertext des Dienstes in der Sprache der Seite (nach Fehlerschlüssel)', async () => {
|
||||
setLang('en');
|
||||
upload.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Upload failed – You are not allowed to do that.');
|
||||
});
|
||||
|
||||
it('Servertexte erscheinen als Text, nie als HTML', async () => {
|
||||
upload.mockRejectedValue(new ApiError(400, '<img src=x onerror=alert(1)>'));
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Hochladen fehlgeschlagen – <img src=x onerror=alert(1)>');
|
||||
expect(dialogEl()!.querySelector('[role="alert"] img')).toBeNull();
|
||||
});
|
||||
|
||||
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“ statt „Hochladen fehlgeschlagen“', async () => {
|
||||
upload.mockRejectedValue(new LoginCancelled());
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(LOGIN_NEEDED);
|
||||
});
|
||||
|
||||
it('andere Fehler werden übersetzt, interne Details erscheinen nie', async () => {
|
||||
upload.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Hochladen fehlgeschlagen – Keine Verbindung zum Server.');
|
||||
upload.mockRejectedValueOnce(new Error('boom: interne Details'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Standardwerte', () => {
|
||||
it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => {
|
||||
const p = prepared();
|
||||
mocks.prepareLogo.mockResolvedValue(p);
|
||||
const result = logo();
|
||||
mocks.uploadPreparedLogo.mockResolvedValue(result);
|
||||
const file = imageFile();
|
||||
const outcome = logoUploadDialog();
|
||||
choose(file);
|
||||
await settle();
|
||||
expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file);
|
||||
fill({ displayName: 'Kenso' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe(result);
|
||||
expect(mocks.withLogin).toHaveBeenCalledOnce();
|
||||
expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', displayName: 'Kenso', mono: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Schließen', () => {
|
||||
it('„Abbrechen“ schließt ohne zu senden und gibt die Object-URL frei', async () => {
|
||||
const { outcome } = await setup();
|
||||
fill();
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await expect(outcome).resolves.toBeNull();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(revoked).toEqual(['blob:logo-1']);
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen; ohne Senden ist Esc nicht verhindert', async () => {
|
||||
const first = open();
|
||||
const esc = new Event('cancel', { cancelable: true });
|
||||
dialogEl()!.dispatchEvent(esc);
|
||||
expect(esc.defaultPrevented).toBe(false);
|
||||
dialogEl()!.dispatchEvent(new Event('close'));
|
||||
await expect(first).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
|
||||
const second = open();
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await expect(second).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
|
||||
void open();
|
||||
input('name').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it('das Abbrechen-Signal schließt den Dialog, auch während des Sendens; eine spätere Antwort bleibt folgenlos', async () => {
|
||||
const controller = new AbortController();
|
||||
let finish!: (l: CommunityLogo) => void;
|
||||
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
const { outcome } = await setup({ signal: controller.signal });
|
||||
fill({ displayName: 'Kenso' });
|
||||
send();
|
||||
controller.abort();
|
||||
await expect(outcome).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(revoked).toEqual(['blob:logo-1']);
|
||||
finish(logo());
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
|
||||
const controller = new AbortController();
|
||||
let fail!: (e: Error) => void;
|
||||
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
const { outcome } = await setup({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
controller.abort();
|
||||
await outcome;
|
||||
fail(new ApiError(503, 'weg'));
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(open({ signal: controller.signal })).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('der Fokus kehrt zum auslösenden Knopf zurück', async () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
const outcome = open();
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await outcome;
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user