- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
480 lines
20 KiB
TypeScript
480 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen.
|
||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||
|
||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||
import { ApiError } from './api';
|
||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
|
||
import {
|
||
CONSENT_TEXT,
|
||
publishDialog,
|
||
publishFailureText,
|
||
validatePublishForm,
|
||
type PublishFormValues,
|
||
type PublishOutcome,
|
||
} from './publish-dialog';
|
||
|
||
type Submit = (form: PublishForm) => Promise<{ id: string }>;
|
||
const submit = vi.fn<Submit>();
|
||
|
||
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
|
||
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||
const consent = () => input('consent');
|
||
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 settle = async () => {
|
||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||
};
|
||
|
||
function fill(values: Partial<PublishFormValues> = {}): void {
|
||
const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values };
|
||
input('title').value = v.title;
|
||
input('issuer').value = v.issuer;
|
||
input('tags').value = v.tags;
|
||
input('displayName').value = v.displayName;
|
||
consent().checked = v.consent;
|
||
}
|
||
|
||
function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<PublishOutcome> {
|
||
return publishDialog({ submit, ...opts });
|
||
}
|
||
|
||
beforeEach(() => {
|
||
submit.mockReset();
|
||
document.body.innerHTML = '';
|
||
});
|
||
|
||
afterEach(() => {
|
||
document.body.innerHTML = '';
|
||
});
|
||
|
||
describe('Veröffentlichen-Dialog: Felder', () => {
|
||
it('zeigt die Felder der Spec in Reihenfolge, alle leer, die Checkbox ungesetzt', () => {
|
||
void open({ cardName: 'Urlaub' });
|
||
expect(dialogEl()?.open).toBe(true);
|
||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
|
||
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'displayName', 'consent']);
|
||
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
|
||
'Titel (Pflicht)',
|
||
'Herausgeber / Bank (optional)',
|
||
'Tags (optional)',
|
||
'Anzeigename (optional)',
|
||
]);
|
||
for (const name of ['title', 'issuer', 'tags', 'displayName']) expect(input(name).value).toBe('');
|
||
expect(consent().type).toBe('checkbox');
|
||
expect(consent().checked).toBe(false);
|
||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||
expect(buttonByText('Veröffentlichen')).toBeTruthy();
|
||
});
|
||
|
||
it('der Anzeigename ist leer und nennt „Anonym“ als Platzhalter (nie eine E-Mail)', () => {
|
||
void open();
|
||
expect(input('displayName').value).toBe('');
|
||
expect(input('displayName').placeholder).toBe('Anonym');
|
||
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
||
});
|
||
|
||
it('die Pflicht-Checkbox hat genau den Wortlaut der Spec', () => {
|
||
void open();
|
||
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
|
||
expect(consent().closest('label')!.textContent).toBe(CONSENT_TEXT);
|
||
});
|
||
|
||
it('jedes Feld hat sein Label (label.control === Eingabe), 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).toHaveLength(5);
|
||
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('nennt den Kartennamen als Text, nie als HTML', () => {
|
||
void open({ cardName: '<img src=x onerror=alert(1)>' });
|
||
expect(dialogEl()!.querySelector('img')).toBeNull();
|
||
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
|
||
});
|
||
});
|
||
|
||
describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||
const invalid = async (values: Partial<PublishFormValues>, message: string, field: string) => {
|
||
void open();
|
||
fill(values);
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe(message);
|
||
expect(submit).not.toHaveBeenCalled();
|
||
expect(dialogEl()?.open).toBe(true);
|
||
expect(input(field).getAttribute('aria-invalid')).toBe('true');
|
||
expect(document.activeElement).toBe(input(field));
|
||
};
|
||
|
||
it('Titel leer (auch nur Leerzeichen)', async () => {
|
||
await invalid({ title: ' ' }, 'Bitte gib einen Titel ein.', 'title');
|
||
});
|
||
|
||
it('Titel länger als 60 Zeichen', async () => {
|
||
await invalid({ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).', 'title');
|
||
});
|
||
|
||
it('Herausgeber länger als 40 Zeichen', async () => {
|
||
await invalid({ issuer: 'x'.repeat(41) }, 'Herausgeber / Bank ist zu lang (höchstens 40 Zeichen).', 'issuer');
|
||
});
|
||
|
||
it('sechs Tags', async () => {
|
||
await invalid({ tags: 'a, b, c, d, e, f' }, 'Bitte gib höchstens 5 Tags an.', 'tags');
|
||
});
|
||
|
||
it('ein Tag länger als 20 Zeichen', async () => {
|
||
await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags');
|
||
});
|
||
|
||
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('Checkbox fehlt', async () => {
|
||
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
|
||
});
|
||
|
||
it('Steuerzeichen werden abgelehnt (der Server täte es auch)', async () => {
|
||
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||
});
|
||
|
||
// Dasselbe Muster wie deploy/api/app.js: C1-Steuerzeichen, Nullbreiten-, Bidi-Zeichen und BOM sind unsichtbar oder ordnen Text um.
|
||
it.each([
|
||
['Nullbreiten-Leerzeichen', 'a\u200bb'],
|
||
['Rechts-nach-links-Überschreibung', 'foto\u202egnp.exe'],
|
||
['Isolate', 'a\u2066b'],
|
||
['Wortverbinder', 'a\u2060b'],
|
||
['C1-Steuerzeichen', 'a\u0085b'],
|
||
['BOM mitten im Titel', 'a\ufeffb'],
|
||
])('unsichtbare Zeichen werden abgelehnt: %s (Titel)', async (_name, title) => {
|
||
await invalid({ title }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||
});
|
||
|
||
it('ein Titel nur aus unsichtbaren Zeichen gilt als ungültig', async () => {
|
||
await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||
});
|
||
|
||
it('unsichtbare Zeichen in Herausgeber, Tags und Anzeigename', async () => {
|
||
await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer');
|
||
});
|
||
|
||
it('unsichtbares Zeichen in einem Tag', async () => {
|
||
await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags');
|
||
});
|
||
|
||
it('unsichtbares Zeichen im Anzeigenamen', async () => {
|
||
await invalid({ displayName: 'Ken\u2060so' }, 'Der Anzeigename enthält ungültige Zeichen.', 'displayName');
|
||
});
|
||
|
||
it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => {
|
||
submit.mockResolvedValue({ id: 'g1' });
|
||
const outcome = open();
|
||
fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', displayName: 'Zoë', tags: 'grün, ✨' });
|
||
send();
|
||
await expect(outcome).resolves.toBe('published');
|
||
});
|
||
|
||
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
|
||
submit.mockResolvedValue({ id: 'g1' });
|
||
const outcome = open();
|
||
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30), tags: 'A, a, , b, c, d, e,' });
|
||
send();
|
||
await expect(outcome).resolves.toBe('published');
|
||
expect(submit).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
|
||
submit.mockResolvedValue({ id: 'g1' });
|
||
void open();
|
||
fill({ title: '' });
|
||
send();
|
||
expect(input('title').getAttribute('aria-invalid')).toBe('true');
|
||
fill({ title: 'Ok' });
|
||
send();
|
||
expect(input('title').getAttribute('aria-invalid')).toBeNull();
|
||
expect(errorText()).toBe('');
|
||
});
|
||
|
||
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
|
||
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
|
||
expect(validatePublishForm(ok)).toBeNull();
|
||
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
|
||
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
|
||
expect(validatePublishForm({ ...ok, consent: false })?.field).toBe('consent');
|
||
});
|
||
});
|
||
|
||
describe('Veröffentlichen-Dialog: Senden', () => {
|
||
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => {
|
||
submit.mockResolvedValue({ id: 'g1' });
|
||
const outcome = open();
|
||
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' });
|
||
send();
|
||
await expect(outcome).resolves.toBe('published');
|
||
expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
|
||
expect(dialogEl()).toBeNull();
|
||
});
|
||
|
||
it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => {
|
||
submit.mockResolvedValue({ id: 'g1' });
|
||
const outcome = open();
|
||
fill({ title: 'Nur Titel' });
|
||
send();
|
||
await outcome;
|
||
expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' });
|
||
});
|
||
|
||
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
|
||
let finish!: (r: { id: string }) => void;
|
||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||
const outcome = open();
|
||
fill();
|
||
send();
|
||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(true);
|
||
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
|
||
expect(input('title').disabled).toBe(true);
|
||
expect(consent().disabled).toBe(true);
|
||
expect(form().getAttribute('aria-busy')).toBe('true');
|
||
expect(statusText()).toBe('Wird veröffentlicht …');
|
||
send();
|
||
expect(submit).toHaveBeenCalledOnce();
|
||
finish({ id: 'g1' });
|
||
await expect(outcome).resolves.toBe('published');
|
||
});
|
||
|
||
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
|
||
let finish!: (r: { id: string }) => void;
|
||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||
const outcome = open();
|
||
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({ id: 'g1' });
|
||
await outcome;
|
||
});
|
||
|
||
it('schließt der Browser den Dialog mitten im Senden (close-Ereignis), geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
|
||
let finish!: (r: { id: string }) => void;
|
||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||
let resolved: PublishOutcome | null = null;
|
||
const outcome = open().then((o) => (resolved = o));
|
||
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).toBeNull();
|
||
expect(dialogEl()?.open).toBe(true);
|
||
finish({ id: 'g1' });
|
||
await outcome;
|
||
expect(resolved).toBe('published');
|
||
expect(dialogEl()).toBeNull();
|
||
});
|
||
|
||
it('wurde der Dialog mitten im Senden geschlossen und das Senden scheitert, ist der Fehler sichtbar (Dialog wieder offen)', async () => {
|
||
let fail!: (e: Error) => void;
|
||
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
|
||
void open();
|
||
fill();
|
||
send();
|
||
dialogEl()!.close();
|
||
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||
await settle();
|
||
expect(dialogEl()?.open).toBe(true);
|
||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
|
||
});
|
||
|
||
it('Fehler des Dienstes: Servertext im Dialog, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
|
||
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||
const outcome = open();
|
||
fill();
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
|
||
expect(dialogEl()?.open).toBe(true);
|
||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
|
||
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
|
||
expect(input('title').disabled).toBe(false);
|
||
expect(input('title').value).toBe('Meine Karte');
|
||
expect(consent().checked).toBe(true);
|
||
expect(statusText()).toBe('');
|
||
|
||
submit.mockResolvedValueOnce({ id: 'g1' });
|
||
send();
|
||
await expect(outcome).resolves.toBe('published');
|
||
expect(submit).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('Validierungsfehler des Dienstes (400) erscheinen mit ihrem Text', async () => {
|
||
submit.mockRejectedValue(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
|
||
void open();
|
||
fill();
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.');
|
||
});
|
||
|
||
it('409: „Diese Karte ist schon veröffentlicht.“, kein neuer Versuch, Ergebnis „already“ nach „Schließen“', async () => {
|
||
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
|
||
const outcome = open();
|
||
fill();
|
||
send();
|
||
await settle();
|
||
expect(statusText()).toBe('Diese Karte ist schon veröffentlicht.');
|
||
expect(errorText()).toBe('');
|
||
expect(form().querySelector('input')).toBeNull();
|
||
expect(buttonByText('Veröffentlichen')).toBeUndefined();
|
||
send();
|
||
expect(submit).toHaveBeenCalledOnce();
|
||
buttonByText('Schließen')!.click();
|
||
await expect(outcome).resolves.toBe('already');
|
||
expect(dialogEl()).toBeNull();
|
||
});
|
||
|
||
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“', async () => {
|
||
submit.mockRejectedValue(new LoginCancelled());
|
||
void open();
|
||
fill();
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe(LOGIN_NEEDED);
|
||
});
|
||
|
||
it('deutsche Meldungen aus dem Rendern und andere Fehler werden übersetzt', async () => {
|
||
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
|
||
void open();
|
||
fill();
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${TOO_LARGE}`);
|
||
submit.mockRejectedValueOnce(new Error(NOT_RENDERED));
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${NOT_RENDERED}`);
|
||
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Keine Verbindung zum Server.');
|
||
submit.mockRejectedValueOnce(new Error('boom: interne Details'));
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||
});
|
||
});
|
||
|
||
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
|
||
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
|
||
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
|
||
void open();
|
||
fill();
|
||
send();
|
||
await settle();
|
||
expect(errorText()).toBe(
|
||
'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||
);
|
||
expect(errorText()).toContain(LOGO_MISSING_PUBLISH);
|
||
expect(errorText()).not.toContain('schiefgelaufen');
|
||
});
|
||
|
||
it('publishFailureText reicht ihn ebenso durch', () => {
|
||
expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`);
|
||
});
|
||
});
|
||
|
||
describe('Veröffentlichen-Dialog: Schließen', () => {
|
||
it('„Abbrechen“ schließt ohne zu senden', async () => {
|
||
const outcome = open();
|
||
fill();
|
||
buttonByText('Abbrechen')!.click();
|
||
await expect(outcome).resolves.toBe('cancelled');
|
||
expect(submit).not.toHaveBeenCalled();
|
||
expect(dialogEl()).toBeNull();
|
||
});
|
||
|
||
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen', async () => {
|
||
const first = open();
|
||
dialogEl()!.dispatchEvent(new Event('close'));
|
||
await expect(first).resolves.toBe('cancelled');
|
||
expect(dialogEl()).toBeNull();
|
||
|
||
const second = open();
|
||
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
await expect(second).resolves.toBe('cancelled');
|
||
});
|
||
|
||
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
|
||
void open();
|
||
input('title').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
expect(dialogEl()?.open).toBe(true);
|
||
});
|
||
|
||
it('das Abbrechen-Signal (Seite verlassen) schließt den Dialog, auch während des Sendens', async () => {
|
||
const controller = new AbortController();
|
||
let finish!: (r: { id: string }) => void;
|
||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||
const outcome = open({ signal: controller.signal });
|
||
fill();
|
||
send();
|
||
controller.abort();
|
||
await expect(outcome).resolves.toBe('cancelled');
|
||
expect(dialogEl()).toBeNull();
|
||
// Eine danach eintreffende Antwort bleibt folgenlos.
|
||
finish({ id: 'g1' });
|
||
await settle();
|
||
});
|
||
|
||
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
|
||
const controller = new AbortController();
|
||
controller.abort();
|
||
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
|
||
expect(dialogEl()).toBeNull();
|
||
});
|
||
|
||
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
|
||
const controller = new AbortController();
|
||
let fail!: (e: Error) => void;
|
||
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
|
||
const outcome = open({ signal: controller.signal });
|
||
fill();
|
||
send();
|
||
controller.abort();
|
||
await outcome;
|
||
fail(new ApiError(503, 'weg'));
|
||
await settle();
|
||
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);
|
||
});
|
||
});
|