Files
AirCardIOS/web/src/pages/cards-page.test.ts
T
ClaudeandClaude Opus 5.5 4aedb1d322 fix(logos): Logo vor Veröffentlichen und Herunterladen frisch prüfen
Schlussreview F4. Zeilen und Bilder der Community-Logos (logo-asset.ts) bleiben bis zum Neuladen der Seite im Speicher. Wurde ein Logo
danach ausgeblendet oder gelöscht, konnte es aus diesem Zwischenspeicher in ein veröffentlichtes Kartenbild oder in ein heruntergeladenes
PNG gelangen.

- renderForGallery (Veröffentlichen), der Download im Editor und der Download in „Meine Karten“ rufen vor dem Laden der Bilder
  forgetLogo(config.logo) (falls eines gewählt ist): Zeile und Bild werden neu vom Server geholt, hidden/fehlt wird also neu geprüft.
  Veröffentlichen bricht bei einem fehlenden Logo wie bisher mit LOGO_MISSING_PUBLISH ab, „Meine Karten“ meldet es an der Kachel.
- Der Download im Editor berücksichtigt assets.logoMissing wie der Hinweis im Editor: das PNG entsteht ohne das fehlende Logo (die Vorschau
  zeigte es bis dahin noch), der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ erscheint und die Vorschau zeichnet ohne Logo, damit
  PNG und Vorschau dasselbe zeigen. Ist ein Logo wieder da, folgt die Vorschau ebenso. Das gilt nur, solange die Auswahl noch dieselbe ist.
- Tests mit dem echten Zwischenspeicher (nur Zeile und Datei aus logos.ts ersetzt): erst geladen, dann ausgeblendet bzw. gelöscht → neu
  geprüft, Veröffentlichen bricht ab / „Meine Karten“ meldet / der Editor lädt ohne Logo herunter, zeigt den Hinweis (auch zusammen mit dem
  Größenhinweis) und zeichnet neu; sichtbares Logo wird frisch geholt; Karte ohne Logo holt nichts; „Nur Bild“ bleibt unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:18:32 +00:00

1522 lines
71 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';
import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
import { createStore, initialEditorState } from '../editor/state';
import type { GalleryCard } from '../community/gallery';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
const mocks = vi.hoisted(() => ({
list: vi.fn(),
loadPreview: vi.fn(),
loadImage: vi.fn(),
rename: vi.fn(),
remove: vi.fn(),
getCurrentUser: vi.fn(),
requireLogin: vi.fn(),
navigate: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
saveBlobAs: vi.fn(),
publishSavedCard: vi.fn(),
withdrawCard: vi.fn(),
myPublications: vi.fn(),
getLogo: vi.fn(),
loadLogoBlob: vi.fn(),
authListeners: new Set<(user: User | null) => void>(),
}));
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
currentUserId: () => 'u1',
onAuthChange: (fn: (user: User | null) => void) => {
mocks.authListeners.add(fn);
return () => mocks.authListeners.delete(fn);
},
}));
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.mock('./router', () => ({ navigate: mocks.navigate }));
vi.mock('../editor/upload', () => ({
READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.',
PIXELS_MESSAGE: 'Das Bild ist zu groß (max. 40 Megapixel).',
decodeImage: mocks.decodeImage,
}));
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
vi.mock('../editor/download', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/download')>()),
saveBlobAs: mocks.saveBlobAs,
}));
vi.mock('../account/cards', async (importOriginal) => ({
...(await importOriginal<typeof import('../account/cards')>()),
cardsService: () => ({
list: mocks.list,
loadPreview: mocks.loadPreview,
loadImage: mocks.loadImage,
rename: mocks.rename,
remove: mocks.remove,
}),
}));
vi.mock('../community/publish', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/publish')>()),
publishSavedCard: mocks.publishSavedCard,
}));
vi.mock('../community/api', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/api')>()),
withdrawCard: mocks.withdrawCard,
}));
vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications }));
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
import { ApiError } from '../community/api';
import { LOGO_MISSING_PUBLISH } from '../community/publish';
import { forgetLogo, loadLogoAsset } from '../logos/logo-asset';
import type { CommunityLogo } from '../logos/logos';
import { mountCardsPage } from './cards-page';
const user: User = { $id: 'u1', email: 'a@b.de' };
const card = (id: string, name: string, patch: Partial<SavedCard> = {}): SavedCard => ({
id,
name,
imageFileId: `img-${id}`,
previewFileId: `prev-${id}`,
updatedAt: '2026-10-04T12:00:00.000Z',
config: structuredClone(DEFAULT_CONFIG),
...patch,
});
let root: HTMLElement;
let unmount: (() => void) | null;
let store: ReturnType<typeof createStore>;
let urlCounter: number;
const revoked: string[] = [];
beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === 'function' && 'mockReset' in fn) fn.mockReset();
mocks.authListeners.clear();
revoked.length = 0;
urlCounter = 0;
URL.createObjectURL = vi.fn(() => `blob:preview-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
document.body.innerHTML = '<main id="app-root"></main>';
root = document.getElementById('app-root') as HTMLElement;
store = createStore(initialEditorState());
mocks.getCurrentUser.mockResolvedValue(user);
mocks.requireLogin.mockResolvedValue(true);
mocks.myPublications.mockResolvedValue(new Map());
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
unmount = null;
});
afterEach(() => {
unmount?.();
});
const mount = () => {
unmount = mountCardsPage(root, store);
};
const text = () => root.textContent ?? '';
const tiles = () => [...root.querySelectorAll<HTMLElement>('.card-tile')];
const tileFor = (id: string) => tiles().find((t) => t.dataset.card === id)!;
const button = (scope: ParentNode, label: string) =>
[...scope.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
describe('Meine Karten: Zustände', () => {
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
expect(mocks.list).not.toHaveBeenCalled();
button(root, 'Anmelden').click();
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
});
it('nach dem Anmelden (Konto-Menü oder Knopf) wird die Liste geladen', async () => {
mocks.getCurrentUser.mockResolvedValue(null);
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
mocks.getCurrentUser.mockResolvedValue(user);
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
});
it('leer: Hinweis und Link zum Editor', async () => {
mocks.list.mockResolvedValue([]);
mount();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!;
expect(link.getAttribute('href')).toBe('/gestalten');
});
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
mocks.list.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(text()).toContain('Keine Verbindung zum Server.'));
button(root, 'Nochmal versuchen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
});
it('Sitzung läuft beim Laden ab: Anmelden, dann wird die Liste angezeigt', async () => {
const unauthorized = Object.assign(new Error('Unauthorized'), { code: 401 });
mocks.list.mockRejectedValueOnce(unauthorized).mockResolvedValueOnce([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
});
it('Sitzung läuft ab und der Dialog wird geschlossen: Anmelden-Ansicht', async () => {
mocks.list.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
mocks.requireLogin.mockResolvedValue(false);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
});
it('Abmelden räumt die Liste weg und gibt die Vorschau-URLs frei', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
for (const fn of mocks.authListeners) fn(null);
expect(text()).toContain('Melde dich an, um deine Karten zu sehen.');
expect(tiles()).toHaveLength(0);
expect(revoked).toEqual(['blob:preview-1']);
});
});
describe('Meine Karten: Kacheln', () => {
it('zeigt Name, Datum und Vorschau', async () => {
const updatedAt = new Date(2026, 9, 4, 12).toISOString();
mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Alpha');
expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('04.10.2026');
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
await vi.waitFor(() => expect(tileFor('b').querySelector('img')).not.toBeNull());
expect(tileFor('a').querySelector('img')!.getAttribute('src')).toMatch(/^blob:preview-/);
expect(mocks.loadPreview).toHaveBeenCalledTimes(2);
});
it('eine Vorschau, die nicht lädt, zeigt „Keine Vorschau“, die Kachel bleibt bedienbar', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadPreview.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(tileFor('a').textContent).toContain('Keine Vorschau'));
expect(button(tileFor('a'), 'Öffnen').disabled).toBe(false);
});
it('lädt höchstens vier Vorschauen gleichzeitig', async () => {
const cards = Array.from({ length: 10 }, (_, i) => card(`c${i}`, `Karte ${i}`));
mocks.list.mockResolvedValue(cards);
let active = 0;
let peak = 0;
mocks.loadPreview.mockImplementation(async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise((r) => setTimeout(r, 5));
active -= 1;
return new Blob(['p'], { type: 'image/webp' });
});
mount();
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(10));
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1);
});
it('beim Verlassen der Seite werden alle Vorschau-URLs freigegeben', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
unmount?.();
unmount = null;
expect([...revoked].sort()).toEqual(['blob:preview-1', 'blob:preview-2']);
});
it('eine Vorschau, die erst nach dem Verlassen ankommt, bekommt keine URL', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (b: Blob) => void;
mocks.loadPreview.mockReturnValue(new Promise<Blob>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(mocks.loadPreview).toHaveBeenCalled());
unmount?.();
unmount = null;
finish(new Blob(['p'], { type: 'image/webp' }));
await new Promise((r) => setTimeout(r, 0));
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
});
describe('Meine Karten: Öffnen und Herunterladen', () => {
it('Öffnen navigiert in den Editor', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Öffnen').click();
expect(mocks.navigate).toHaveBeenCalledWith('/gestalten?karte=a');
});
it('Herunterladen: Bild laden, als PNG rendern und unter aircard-<name>.png speichern', async () => {
const png = new Blob(['png'], { type: 'image/png' });
const image = { source: {}, w: 3000, h: 2000 };
mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]);
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
mocks.decodeImage.mockResolvedValue(image);
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
mocks.exportCard.mockResolvedValue(png);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-mein-urlaub.png');
expect(mocks.exportCard).toHaveBeenCalledWith(expect.anything(), expect.anything(), { width: 1536, type: 'image/png' });
// Der gespeicherte Ausschnitt wird auf das Bild begrenzt, bevor gerendert wird.
const rendered = mocks.exportCard.mock.calls[0][0];
expect(rendered.crop.x).toBeLessThan(99999);
await vi.waitFor(() => expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false));
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('Herunterladen eines über 40 Megapixel großen Bildes zeigt die Größen-Meldung an der Kachel', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' }));
mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() =>
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild ist zu groß (max. 40 Megapixel).'),
);
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
});
it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild dieser Karte fehlt.'));
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false);
});
});
describe('Meine Karten: Herunterladen mit fehlendem Logo', () => {
const image = { source: {}, w: 3000, h: 2000 };
const png = new Blob(['png'], { type: 'image/png' });
/** Eine Karte, die ein Logo wählt, das es nicht mehr gibt (loadCardAssets meldet logoMissing). */
const onTemplate = (template: TemplateId) =>
card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template, logo: 'gone' } });
const msg = () => tileFor('a').querySelector('.card-msg')!;
beforeEach(() => {
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
mocks.decodeImage.mockResolvedValue(image);
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: true });
mocks.exportCard.mockResolvedValue(png);
});
async function download(template: TemplateId, label = 'Herunterladen'): Promise<void> {
mocks.list.mockResolvedValue([onTemplate(template)]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), label).click();
}
it.each(['minimal', 'classic', 'signature'] as const)(
'Vorlage %s zeichnet das Logo: die Meldung steht an der Kachel, es wird nichts heruntergeladen',
async (template) => {
await download(template);
await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH));
expect(msg().textContent).toBe(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
expect(msg().classList.contains('is-warning')).toBe(false);
expect(mocks.exportCard).not.toHaveBeenCalled();
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
// Der Knopf ist danach wieder bedienbar und trägt seine Beschriftung.
const again = button(tileFor('a'), 'Herunterladen');
expect(again.disabled).toBe(false);
expect(tileFor('a').getAttribute('aria-busy')).toBeNull();
},
);
it('„Nur Bild“ (image) zeichnet nie ein Logo: der Download läuft ganz normal, ohne Meldung', async () => {
await download('image');
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
expect(mocks.exportCard).toHaveBeenCalledTimes(1);
expect(msg().textContent).toBe('');
});
it('nach der Meldung geht ein zweiter Versuch (Logo wieder da) durch, die Meldung ist dann weg', async () => {
await download('classic');
await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH));
mocks.loadCardAssets.mockResolvedValue({ image, logo: { source: {}, w: 200, h: 100 }, brand: null, logoMissing: false });
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledTimes(1));
expect(msg().textContent).toBe('');
});
it('Englisch: dieselbe Meldung, übersetzt', async () => {
setLang('en', { persist: false });
await download('signature', 'Download');
await vi.waitFor(() =>
expect(msg().textContent).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.'),
);
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
});
it('eine Karte ohne gewähltes Logo wird normal heruntergeladen', async () => {
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
await download('classic');
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
expect(msg().textContent).toBe('');
});
});
describe('Meine Karten: Herunterladen und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => {
const image = { source: {}, w: 3000, h: 2000 };
const png = new Blob(['png'], { type: 'image/png' });
const logoRow = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'zw2',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'zw2',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const msg = () => tileFor('a').querySelector('.card-msg')!;
beforeEach(() => {
mocks.list.mockResolvedValue([card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'zw2' } })]);
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
mocks.decodeImage.mockResolvedValue(image);
mocks.exportCard.mockResolvedValue(png);
mocks.getLogo.mockResolvedValue(logoRow());
mocks.loadLogoBlob.mockResolvedValue(new Blob(['logo'], { type: 'image/png' }));
// Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter.
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, img: unknown) => {
const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null;
return { image: img, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null };
});
vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 }));
});
afterEach(() => {
forgetLogo('zw2');
vi.unstubAllGlobals();
});
async function download(): Promise<void> {
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
}
it('ein Logo, das nach dem ersten Laden (Editor) ausgeblendet wurde, wird vor dem Download neu geprüft: Meldung an der Kachel, kein PNG', async () => {
await expect(loadLogoAsset('zw2', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann wurde es ausgeblendet
await download();
await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH));
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.exportCard).not.toHaveBeenCalled();
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
});
it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei heruntergeladen', async () => {
await expect(loadLogoAsset('zw2', 'light')).resolves.not.toBeNull();
await download();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } });
expect(msg().textContent).toBe('');
});
it('eine Karte ohne Logo holt nichts vom Server', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: null } })]);
await download();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
expect(mocks.getLogo).not.toHaveBeenCalled();
});
});
describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => {
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
/** Beim Laden der Seite ist der Nutzer noch da; danach (Nutzerprüfung nach dem 404) nicht mehr. */
const sessionEndsAfterLoad = () => mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
it('Vorschau: erst anmelden, dann wird die Vorschau geladen (statt „Keine Vorschau“)', async () => {
sessionEndsAfterLoad();
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadPreview.mockReset();
mocks.loadPreview
.mockRejectedValueOnce(fileGone())
.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
mount();
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.loadPreview).toHaveBeenCalledTimes(2);
});
it('Herunterladen: erst anmelden, dann wird das Bild geladen (statt „Das Bild dieser Karte fehlt.“)', async () => {
const png = new Blob(['png'], { type: 'image/png' });
const image = { source: {}, w: 3000, h: 2000 };
sessionEndsAfterLoad();
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' }));
mocks.decodeImage.mockResolvedValue(image);
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
mocks.exportCard.mockResolvedValue(png);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('Löschen: die Karte gilt nicht als „schon gelöscht“, sondern wird nach dem Anmelden wirklich gelöscht', async () => {
sessionEndsAfterLoad();
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.remove.mockRejectedValueOnce(rowGone()).mockResolvedValueOnce(undefined);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
button(tileFor('b'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.remove).toHaveBeenCalledTimes(2);
});
it('Löschen, Anmeldung abgebrochen: die Karte bleibt, Hinweis an der Kachel', async () => {
sessionEndsAfterLoad();
mocks.requireLogin.mockResolvedValue(false);
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.remove.mockRejectedValue(rowGone());
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen').disabled).toBe(false));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Löschen').click();
await vi.waitFor(() =>
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Bitte melde dich an, um fortzufahren.'),
);
expect(tiles()).toHaveLength(1);
});
});
describe('Meine Karten: Umbenennen', () => {
const startRename = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
button(tileFor('b'), 'Umbenennen').click();
return tileFor('b').querySelector<HTMLInputElement>('input')!;
};
it('zeigt eine Eingabe mit dem Namen, Enter speichert, die Karte steht danach vorn', async () => {
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name.trim(), { updatedAt: '2026-10-06T08:00:00.000Z' }));
const input = await startRename();
expect(input.value).toBe('Beta');
expect(document.activeElement).toBe(input);
input.value = ' Gamma ';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tiles().map((t) => t.dataset.card)).toEqual(['b', 'a']));
expect(mocks.rename).toHaveBeenCalledWith('b', ' Gamma ');
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Gamma');
expect(tileFor('b').querySelector('input')).toBeNull();
});
it('der vorhandene Name ist beim Öffnen komplett markiert (Tippen ersetzt ihn, statt davor einzufügen)', async () => {
const input = await startRename();
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('Beta'.length);
});
it('Esc bricht ab, ohne etwas zu speichern', async () => {
const input = await startRename();
input.value = 'Anders';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
expect(tileFor('b').querySelector('input')).toBeNull();
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Beta');
expect(mocks.rename).not.toHaveBeenCalled();
});
it('„Abbrechen“ bricht ab; ein unveränderter Name löst keine Anfrage aus', async () => {
const input = await startRename();
button(tileFor('b'), 'Abbrechen').click();
expect(tileFor('b').querySelector('input')).toBeNull();
button(tileFor('b'), 'Umbenennen').click();
const again = tileFor('b').querySelector<HTMLInputElement>('input')!;
again.value = ' Beta ';
submit(again.closest('form')!);
expect(tileFor('b').querySelector('input')).toBeNull();
expect(mocks.rename).not.toHaveBeenCalled();
expect(input).not.toBe(again);
});
it('ein Fehler lässt die Eingabe offen und zeigt die Meldung', async () => {
mocks.rename.mockRejectedValue(new TypeError('offline'));
const input = await startRename();
input.value = 'Gamma';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
const open = tileFor('b').querySelector<HTMLInputElement>('input')!;
expect(open.value).toBe('Gamma');
expect(open.readOnly).toBe(false);
});
it('hält die Karte im Editor-Zustand aktuell (sonst überschreibt Speichern den neuen Namen)', async () => {
store.set({
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
cardName: 'Beta',
});
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
const input = await startRename();
input.value = 'Gamma';
submit(input.closest('form')!);
await vi.waitFor(() => expect(store.get().cardName).toBe('Gamma'));
expect(store.get().saved).toMatchObject({ id: 'b', name: 'Gamma', updatedAt: '2026-10-06T08:00:00.000Z' });
});
});
describe('Meine Karten: Löschen', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
/** Klickt „Löschen“ an der Kachel (sobald der Knopf wieder bedienbar ist) und wartet auf den Dialog. */
const openDialog = async (id: string) => {
await vi.waitFor(() => expect(button(tileFor(id), 'Löschen').disabled).toBe(false));
button(tileFor(id), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
const start = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
};
it('fragt in einem eigenen Dialog nach und löscht erst nach „Löschen“', async () => {
mocks.remove.mockResolvedValue(undefined);
const confirm = vi.fn();
vi.stubGlobal('confirm', confirm);
await start();
await openDialog('b');
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Beta“ löschen?');
expect(mocks.remove).not.toHaveBeenCalled();
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(mocks.remove).toHaveBeenCalledWith(expect.objectContaining({ id: 'b', imageFileId: 'img-b', previewFileId: 'prev-b' }));
expect(tiles()[0].dataset.card).toBe('a');
expect(dialog()).toBeNull();
expect(confirm).not.toHaveBeenCalled();
expect(revoked).toHaveLength(1);
});
it('„Abbrechen“, Esc und Klick auf den Hintergrund löschen nichts', async () => {
await start();
await openDialog('b');
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
await openDialog('b');
dialog()!.dispatchEvent(new Event('close'));
await vi.waitFor(() => expect(dialog()).toBeNull());
await openDialog('b');
dialog()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(dialog()).toBeNull());
expect(mocks.remove).not.toHaveBeenCalled();
expect(tiles()).toHaveLength(2);
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
});
it('ein Fehler beim Löschen lässt die Karte stehen und zeigt die Meldung', async () => {
mocks.remove.mockRejectedValue(new TypeError('offline'));
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
expect(tiles()).toHaveLength(2);
expect(button(tileFor('b'), 'Löschen').disabled).toBe(false);
});
it('eine schon gelöschte Karte (row_not_found) verschwindet ohne Fehlermeldung', async () => {
mocks.remove.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'row_not_found' }));
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
});
it('die letzte Karte gelöscht: Leer-Ansicht', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.remove.mockResolvedValue(undefined);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openDialog('a');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
});
it('die im Editor geöffnete Karte wird dort „ungespeichert“, damit Speichern eine neue anlegt', async () => {
store.set({
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
});
mocks.remove.mockResolvedValue(undefined);
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(store.get().saved).toBeNull();
});
it('eine andere Karte im Editor bleibt unberührt', async () => {
const saved = { id: 'a', name: 'Alpha', imageFileId: 'img-a', previewFileId: 'prev-a', updatedAt: '2026-10-04T12:00:00.000Z' };
store.set({ saved });
mocks.remove.mockResolvedValue(undefined);
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(store.get().saved).toEqual(saved);
});
});
describe('Meine Karten: Seite wird verlassen', () => {
const savedRef = (id: string, name: string) => ({
id,
name,
imageFileId: `img-${id}`,
previewFileId: `prev-${id}`,
updatedAt: '2026-10-04T12:00:00.000Z',
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('ein offener Löschen-Dialog wird geschlossen, nichts wird gelöscht', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.remove).not.toHaveBeenCalled();
});
it('Umbenennen, das erst nach dem Verlassen fertig wird, stellt den Editor-Zustand trotzdem richtig', async () => {
store.set({ saved: savedRef('a', 'Alpha'), cardName: 'Alpha' });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (c: SavedCard) => void;
mocks.rename.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Neu';
submit(input.closest('form')!);
await vi.waitFor(() => expect(mocks.rename).toHaveBeenCalled());
unmount?.();
unmount = null;
finish(card('a', 'Neu', { updatedAt: '2026-10-06T08:00:00.000Z' }));
await vi.waitFor(() => expect(store.get().cardName).toBe('Neu'));
expect(store.get().saved).toMatchObject({ id: 'a', name: 'Neu' });
});
it('Löschen, das erst nach dem Verlassen fertig wird, löst den Editor trotzdem von der Karte', async () => {
store.set({ saved: savedRef('a', 'Alpha'), savedConfig: structuredClone(DEFAULT_CONFIG) });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: () => void;
mocks.remove.mockReturnValue(new Promise<void>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalled());
unmount?.();
unmount = null;
finish();
await vi.waitFor(() => expect(store.get().saved).toBeNull());
expect(store.get().savedConfig).toBeNull();
});
it('eine zweite Vorschau derselben Datei legt keine zweite URL an (sonst bliebe die erste ungenutzt)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
const pending: Array<(b: Blob) => void> = [];
mocks.loadPreview.mockImplementation(() => new Promise<Blob>((r) => pending.push(r)));
mount();
await vi.waitFor(() => expect(pending).toHaveLength(1));
// Abmelden und wieder anmelden lädt die Liste neu, während die erste Vorschau noch unterwegs ist.
for (const fn of mocks.authListeners) fn(null);
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(pending).toHaveLength(2));
pending[0](new Blob(['p'], { type: 'image/webp' }));
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
pending[1](new Blob(['p'], { type: 'image/webp' }));
await new Promise((r) => setTimeout(r, 0));
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
unmount?.();
unmount = null;
expect(revoked).toEqual(['blob:preview-1']);
});
});
const published = (id: string, sourceCardId: string, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id,
title: 'Mein Motiv',
issuer: '',
tags: [],
displayName: '',
imageFileId: `gi-${id}`,
previewFileId: `gp-${id}`,
sourceCardId,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const labels = (scope: ParentNode) => [...scope.querySelectorAll('.card-buttons button, .card-buttons a')].map((n) => n.textContent);
const badge = (id: string) => tileFor(id).querySelector('.card-pub')?.textContent ?? '';
const publishDialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const dlgInput = (name: string) => publishDialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const sendForm = () =>
publishDialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const fillDialog = (title = 'Mein Motiv', values: Record<string, string> = {}) => {
dlgInput('title').value = title;
for (const [name, value] of Object.entries(values)) dlgInput(name).value = value;
dlgInput('consent').checked = true;
};
const dlgError = () => publishDialogEl()!.querySelector('[role="alert"]')!.textContent;
const openPublish = async (id: string) => {
await vi.waitFor(() => expect(button(tileFor(id), 'Veröffentlichen').disabled).toBe(false));
button(tileFor(id), 'Veröffentlichen').click();
await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true));
};
describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => {
const pubMap = (...cards: GalleryCard[]) => new Map(cards.map((c) => [c.sourceCardId, c]));
const view = (id: string) => tileFor(id).querySelector<HTMLAnchorElement>('a[data-action="view"]');
const mountWith = async (publications: Map<string, GalleryCard> = new Map()) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(publications);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen')).toBeTruthy());
await vi.waitFor(() => expect(tileFor('a').querySelector('[data-action="publish"], [data-action="withdraw"]')).not.toBeNull());
};
it('lädt den Status einmal je Seitenaufbau (für die angemeldete Nutzer-ID), nicht abgemeldet', async () => {
await mountWith();
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledWith('u1');
unmount?.();
unmount = null;
mocks.myPublications.mockClear();
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
expect(mocks.myPublications).not.toHaveBeenCalled();
});
it('nicht veröffentlicht: „Veröffentlichen“, kein Badge', async () => {
await mountWith();
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(badge('a')).toBe('');
expect(view('a')).toBeNull();
});
it('veröffentlicht: Badge „Veröffentlicht“, „Ansehen“ (/community/<id>) und „Zurückziehen“ statt „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a')));
expect(badge('a')).toBe('Veröffentlicht');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
expect(view('a')!.getAttribute('href')).toBe('/community/g1');
expect(view('a')!.hasAttribute('data-link')).toBe(true);
// Die andere Kachel bleibt unberührt.
expect(badge('b')).toBe('');
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
});
it('ausgeblendet (gemeldet): Badge „Ausgeblendet (gemeldet)“ und „Zurückziehen“, aber kein „Ansehen“ und kein „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a', { hidden: true, reportCount: 3 })));
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Zurückziehen', 'Löschen']);
expect(view('a')).toBeNull();
});
it('solange der Status lädt, gibt es weder „Veröffentlichen“ noch „Zurückziehen“; danach erscheint der Stand', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValue(new Promise((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Löschen']);
finish(pubMap(published('g1', 'a')));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('Status lässt sich nicht laden: Hinweis mit „Nochmal versuchen“, „Veröffentlichen“ bleibt möglich (409 fängt Doppeltes ab)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValueOnce(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('Der Veröffentlichungsstatus konnte nicht geladen werden.'));
expect(labels(tileFor('a'))).toContain('Veröffentlichen');
mocks.myPublications.mockResolvedValue(pubMap(published('g1', 'a')));
button(root, 'Nochmal versuchen').click();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(text()).not.toContain('konnte nicht geladen werden');
});
it('Abmelden räumt den Status weg, nach dem Anmelden wird er neu geholt', async () => {
await mountWith(pubMap(published('g1', 'a')));
for (const fn of mocks.authListeners) fn(null);
expect(tiles()).toHaveLength(0);
mocks.myPublications.mockResolvedValue(new Map());
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it('der Status überlebt Umbenennen (Liste wird neu aufgebaut) ohne neue Anfrage', async () => {
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
await mountWith(pubMap(published('g1', 'a')));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Anders';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Anders'));
expect(badge('a')).toBe('Veröffentlicht');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
});
const GENERIC_HINT =
'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.';
const openDelete = async (id: string) => {
button(tileFor(id), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
it('Löschen einer veröffentlichten Karte (Status bekannt) nennt, dass die Veröffentlichung bleibt und wo man sie zurückzieht', async () => {
await mountWith(pubMap(published('g1', 'a')));
await openDelete('a');
expect(dialog()!.textContent).toContain('bleibt in der Community-Galerie');
expect(dialog()!.textContent).toContain('Community-Seite');
expect(dialog()!.textContent).not.toContain('Falls diese Karte');
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
// Bekannt unveröffentlicht: kein Hinweis.
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
await openDelete('b');
expect(dialog()!.textContent).not.toContain('Community');
});
it('Löschen, solange der Status noch lädt: allgemeiner Hinweis', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockReturnValue(new Promise(() => {}));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openDelete('a');
expect(dialog()!.textContent).toContain(GENERIC_HINT);
});
it('Löschen, wenn der Status nicht geladen werden konnte: allgemeiner Hinweis', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden'));
await openDelete('a');
expect(dialog()!.textContent).toContain(GENERIC_HINT);
});
it('Status-Abfrage, die ein gleichzeitiges Zurückziehen überholt, lässt den Status nie in „lädt“ hängen', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockRejectedValueOnce(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden'));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
// Im Fehlerzustand lässt sich veröffentlichen; danach gibt es „Zurückziehen“.
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
// Zurückziehen läuft (Anfrage unterwegs) ...
let finishWithdraw!: () => void;
mocks.withdrawCard.mockReturnValue(new Promise<void>((r) => (finishWithdraw = r)));
await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false));
button(tileFor('a'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(mocks.withdrawCard).toHaveBeenCalledWith('g9'));
// ... währenddessen „Nochmal versuchen“ (die Abfrage hängt noch) ...
let finishFetch!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishFetch = r)));
button(root, 'Nochmal versuchen').click();
expect(labels(tileFor('b'))).not.toContain('Veröffentlichen'); // Status gilt wieder als unbekannt
// ... und das Zurückziehen wird fertig, bevor die Abfrage antwortet.
finishWithdraw();
await vi.waitFor(() => expect(badge('a')).toBe(''));
finishFetch(new Map());
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen'));
expect(labels(tileFor('a'))).toContain('Veröffentlichen');
expect(text()).not.toContain('konnte nicht geladen werden');
});
});
describe('Meine Karten: Veröffentlichen', () => {
const start = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
};
it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => {
await start();
await openPublish('a');
expect(dlgInput('title').value).toBe('');
expect(dlgInput('displayName').value).toBe('');
expect(dlgInput('displayName').placeholder).toBe('Anonym');
expect(publishDialogEl()!.textContent).not.toContain('a@b.de');
expect(publishDialogEl()!.textContent).toContain('„Alpha“');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Abbrechen veröffentlicht nichts, Fokus zurück auf „Veröffentlichen“', async () => {
await start();
await openPublish('a');
button(publishDialogEl()!, 'Abbrechen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')));
});
it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => {
await start();
await openPublish('a');
dlgInput('title').value = 'Mein Motiv';
sendForm();
expect(dlgError()).toBe('Bitte bestätige die Erklärung zum Bild.');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Erfolg: Badge „Veröffentlicht“ und „Ansehen“ erscheinen sofort, ohne die Liste neu zu laden', async () => {
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
await start();
await openPublish('a');
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.publishSavedCard).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a', name: 'Alpha' }),
{ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' },
);
expect(publishDialogEl()).toBeNull();
const link = tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!;
expect(link.textContent).toBe('Ansehen');
expect(link.getAttribute('href')).toBe('/community/g9');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
// Alle Knöpfe der Kachel sind wieder bedienbar, die andere Kachel ist unberührt.
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Der Fokus geht auf „Ansehen“ (der auslösende Knopf ist weg).
expect(document.activeElement).toBe(link);
});
it('während des Veröffentlichens sind die Kachel-Knöpfe gesperrt und der Dialog lässt sich nicht schließen', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
expect(button(publishDialogEl()!, 'Veröffentlichen').disabled).toBe(true);
expect(button(tileFor('a'), 'Löschen').disabled).toBe(true);
expect(tileFor('a').getAttribute('aria-busy')).toBe('true');
finish({ id: 'g9' });
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(tileFor('a').hasAttribute('aria-busy')).toBe(false);
});
it('Fehler (400): Text im Dialog, kein Status geändert, keine Nachprüfung, neuer Versuch möglich', async () => {
mocks.publishSavedCard.mockRejectedValueOnce(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.'));
expect(publishDialogEl()?.open).toBe(true);
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
mocks.publishSavedCard.mockResolvedValueOnce({ id: 'g9' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('409: „Diese Karte ist schon veröffentlicht.“, der Status wird neu geholt und zeigt „Veröffentlicht“', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(publishDialogEl()!.textContent).toContain('Diese Karte ist schon veröffentlicht.'));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
button(publishDialogEl()!, 'Schließen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(mocks.publishSavedCard).toHaveBeenCalledOnce();
});
describe('Ausgang offen (503, 429, 408, 5xx): Nachprüfung vor der Fehlermeldung', () => {
it('war die Karte doch veröffentlicht: Erfolgszustand statt Fehler', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(publishDialogEl()).toBeNull();
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it.each([408, 429, 500, 504])('auch bei %i wird nachgeprüft', async (code) => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(code, 'Offen.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('war sie es nicht: Fehlermeldung im Dialog, Status unverändert', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
expect(badge('a')).toBe('');
expect(publishDialogEl()?.open).toBe(true);
});
it('scheitert auch die Nachprüfung: die ursprüngliche Meldung bleibt', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('');
});
it('die Nachprüfung geht nicht verloren, wenn währenddessen eine andere Kachel ihren Status ändert', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
mocks.withdrawCard.mockResolvedValue(undefined);
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]]));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Zurückziehen'));
await openPublish('a');
fillDialog();
let finishCheck!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishCheck = r)));
sendForm();
await vi.waitFor(() => expect(mocks.myPublications).toHaveBeenCalledTimes(2));
// Während die Nachprüfung läuft, zieht der Nutzer in einer anderen Kachel eine Karte zurück.
button(tileFor('b'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen'));
finishCheck(new Map([['a', published('g1', 'a')]]));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(publishDialogEl()).toBeNull();
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('die Nachprüfung findet nur die Veröffentlichung dieser Karte (eine andere zählt nicht)', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]]));
sendForm();
await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'));
expect(badge('a')).toBe('');
expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.');
});
});
it('abgelaufene Sitzung (401): Anmelden, dann wird veröffentlicht', async () => {
mocks.publishSavedCard
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
.mockResolvedValueOnce({ id: 'g9' });
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.publishSavedCard).toHaveBeenCalledTimes(2);
});
it('Anmeldung abgebrochen: Hinweis im Dialog, nichts veröffentlicht', async () => {
mocks.requireLogin.mockResolvedValue(false);
mocks.publishSavedCard.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Bitte melde dich an, um fortzufahren.'));
expect(badge('a')).toBe('');
});
it('die Seite wird verlassen: der Dialog geht zu, eine spätere Antwort ändert nichts mehr', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
finish({ id: 'g9' });
await new Promise((r) => setTimeout(r, 0));
expect(root.querySelector('.card-badge')).toBeNull();
});
it('die Seite wird verlassen, solange der Dialog offen ist: er geht zu, nichts wird veröffentlicht', async () => {
await start();
await openPublish('a');
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Titel und Namen erscheinen als Text, nie als HTML', async () => {
mocks.list.mockResolvedValue([card('a', '<img src=x onerror=alert(1)>')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openPublish('a');
expect(publishDialogEl()!.querySelector('img')).toBeNull();
expect(publishDialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Meine Karten: Zurückziehen', () => {
const start = async (patch: Partial<GalleryCard> = {}) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a', patch)]]));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Zurückziehen'));
};
const openConfirm = async () => {
button(tileFor('a'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
it('fragt nach, zieht dann die Veröffentlichung (Galerie-ID) zurück; die Kachel zeigt wieder „Veröffentlichen“', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start();
await openConfirm();
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Mein Motiv“ zurückziehen?');
expect(dialog()!.textContent).toContain('Deine private Karte bleibt erhalten.');
expect(mocks.withdrawCard).not.toHaveBeenCalled();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(dialog()).toBeNull();
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Fokus auf dem neuen „Veröffentlichen“-Knopf.
expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'));
});
it('ausgeblendete Kacheln behalten „Zurückziehen“: danach lässt sich neu veröffentlichen', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start({ hidden: true, reportCount: 3 });
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
});
it('„Abbrechen“ zieht nichts zurück', async () => {
await start();
await openConfirm();
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
expect(mocks.withdrawCard).not.toHaveBeenCalled();
expect(badge('a')).toBe('Veröffentlicht');
await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false));
});
it('Fehler: Text des Dienstes an der Kachel, Status bleibt', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() =>
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('Veröffentlicht');
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
});
it('die Veröffentlichung gibt es schon nicht mehr (404): Ziel erreicht, die Kachel zeigt „Veröffentlichen“', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('abgelaufene Sitzung (401): Anmelden, dann wird zurückgezogen', async () => {
mocks.withdrawCard
.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.'))
.mockResolvedValueOnce(undefined);
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.withdrawCard).toHaveBeenCalledTimes(2);
});
it('Seite verlassen mit offener Rückfrage: nichts wird zurückgezogen', async () => {
await start();
await openConfirm();
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.withdrawCard).not.toHaveBeenCalled();
});
});
describe('Meine Karten auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
const withdrawNote =
'The published card stays in the community gallery until you withdraw it – after deleting this card, you can only do that on its community page.';
const maybeNote = 'If this card is published, it stays in the gallery – you can withdraw it on its community page.';
it('Zustände: Laden, abgemeldet, leer, Fehler', async () => {
mocks.getCurrentUser.mockReturnValue(new Promise(() => {}));
mount();
expect(text()).toContain('Loading cards …');
expect(root.querySelector('h1')!.textContent).toBe('My cards');
expect(root.querySelector('section')!.getAttribute('aria-label')).toBe('Saved cards');
unmount?.();
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Sign in to see your cards.'));
expect(button(root, 'Sign in')).toBeTruthy();
expect(germanLeft(root)).toEqual([]);
unmount?.();
mocks.getCurrentUser.mockResolvedValue(user);
mocks.list.mockResolvedValue([]);
mount();
await vi.waitFor(() => expect(text()).toContain('No cards saved yet.'));
expect(root.querySelector('a[data-link]')!.textContent).toBe('Go to editor');
unmount?.();
mocks.list.mockRejectedValue(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('Can’t reach the server.'));
expect(button(root, 'Try again')).toBeTruthy();
expect(germanLeft(root)).toEqual([]);
});
it('Kacheln: Datum, Vorschau-Texte, Knöpfe, Badges, Umbenennen und der Fehler beim Status', async () => {
const updatedAt = new Date(2026, 9, 4, 12).toISOString();
mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta'), card('c', 'Gamma'), card('d', 'Delta')]);
mocks.myPublications.mockResolvedValue(
new Map([
['a', published('g1', 'a')],
['c', published('g3', 'c', { hidden: true })],
]),
);
mocks.loadPreview.mockImplementation(async (c: SavedCard) => {
if (c.id === 'b') throw Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' });
if (c.id === 'd') return new Promise<Blob>(() => {});
return new Blob(['p'], { type: 'image/webp' });
});
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(4));
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('Oct 4, 2026');
expect(tileFor('a').querySelector('img')!.getAttribute('alt')).toBe('Preview of “Alpha”');
await vi.waitFor(() => expect(tileFor('b').textContent).toContain('No preview'));
expect(tileFor('d').textContent).toContain('Loading preview …');
await vi.waitFor(() => expect(badge('a')).toBe('Published'));
expect(labels(tileFor('a'))).toEqual(['Open', 'Download', 'Rename', 'View', 'Withdraw', 'Delete']);
expect(labels(tileFor('b'))).toEqual(['Open', 'Download', 'Rename', 'Publish', 'Delete']);
expect(badge('c')).toBe('Hidden (reported)');
expect(labels(tileFor('c'))).toEqual(['Open', 'Download', 'Rename', 'Withdraw', 'Delete']);
button(tileFor('b'), 'Rename').click();
expect(tileFor('b').querySelector('input')!.getAttribute('aria-label')).toBe('New name for “Beta”');
expect([...tileFor('b').querySelectorAll('.card-rename button')].map((b) => b.textContent)).toEqual(['Save', 'Cancel']);
expect(germanLeft(root)).toEqual([]);
unmount?.();
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('The publishing status couldn’t be loaded.'));
expect(button(root, 'Try again')).toBeTruthy();
expect(germanLeft(root)).toEqual([]);
});
it('Löschen: Rückfrage mit den Hinweisen zur Veröffentlichung (bekannt, unbekannt, keine)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
mount();
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Publish'));
const ask = async (id: string) => {
button(tileFor(id), 'Delete').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
const shown = dialog()!;
return { title: shown.querySelector('h2')!.textContent, text: shown.querySelector('p')!.textContent, buttons: [...shown.querySelectorAll('button')].map((b) => b.textContent) };
};
const cancel = async () => {
button(dialog()!, 'Cancel').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
};
expect(await ask('a')).toEqual({
title: 'Delete card “Alpha”?',
text: `The card and its image will be deleted permanently. ${withdrawNote}`,
buttons: ['Cancel', 'Delete'],
});
await cancel();
expect((await ask('b')).text).toBe('The card and its image will be deleted permanently.');
await cancel();
unmount?.();
// der Status ist noch unbekannt: allgemeiner Hinweis
mocks.myPublications.mockReturnValue(new Promise(() => {}));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
expect((await ask('a')).text).toBe(`The card and its image will be deleted permanently. ${maybeNote}`);
await cancel();
});
it('Zurückziehen: Rückfrage und der Fehler des Dienstes über seinen Code', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable'));
mount();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Withdraw'));
button(tileFor('a'), 'Withdraw').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
expect(dialog()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mein Motiv”?');
expect(dialog()!.querySelector('p')!.textContent).toBe(
'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.',
);
expect([...dialog()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']);
button(dialog()!, 'Withdraw').click();
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('The service is currently unavailable.'));
// ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status
mocks.withdrawCard.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.'));
await vi.waitFor(() => expect(button(tileFor('a'), 'Withdraw').disabled).toBe(false));
button(tileFor('a'), 'Withdraw').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Withdraw').click();
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('You are not allowed to do that.'));
expect(germanLeft(root)).toEqual([]);
});
it('Herunterladen: „Creating …“ unterwegs, die Fehler (Lesen, Pixel, Anmeldung) als Text der Seite', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockReturnValueOnce(new Promise<Blob>(() => {}));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Download').click();
expect(labels(tileFor('a'))).toContain('Creating …');
unmount?.();
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' }));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
const msg = () => tileFor('a').querySelector('.card-msg')!.textContent;
mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild konnte nicht gelesen werden.'));
button(tileFor('a'), 'Download').click();
await vi.waitFor(() => expect(msg()).toBe('The image couldn’t be read.'));
mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
await vi.waitFor(() => expect(button(tileFor('a'), 'Download').disabled).toBe(false));
button(tileFor('a'), 'Download').click();
await vi.waitFor(() => expect(msg()).toBe('The image is too large (max. 40 megapixels).'));
// die Sitzung ist weg und der Login-Dialog wird geschlossen
unmount?.();
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
mocks.requireLogin.mockResolvedValue(false);
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Download').click();
await vi.waitFor(() => expect(msg()).toBe('Please sign in to continue.'));
expect(germanLeft(root)).toEqual([]);
});
});