Files
AirCardIOS/web/src/community/publish-dialog.test.ts
T
ClaudeandClaude Opus 5.5 0be5abd954 feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
  sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
  wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
  ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
  der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
  loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
  Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
  createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
  gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
  Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
  rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
  Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
  ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
  auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
  fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
  es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
  logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
  Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:32:45 +00:00

480 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});