Files
AirCardIOS/web/src/editor/editor-page.test.ts
T
ClaudeandClaude Sonnet 5.5 25ff496548 feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner
- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung
  /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor);
  Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel
  bauen die Seite nicht neu auf.
- Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf,
  Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue.
- Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle
  Tokens, danger-/warn-/ok-text-Tokens statt fester Farben.
- Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg.
- User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:39:09 +00:00

978 lines
41 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.
// Speichern und Öffnen im Editor (Karten-Dienst, Anmeldung und Renderer sind ersetzt).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config';
import type { CardImage } from '../card/render';
import { createStore, initialEditorState, type EditorStore } from './state';
const mocks = vi.hoisted(() => ({
get: vi.fn(),
save: vi.fn(),
loadImage: vi.fn(),
getCurrentUser: vi.fn(),
requireLogin: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
logoutListeners: new Set<() => void>(),
}));
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
currentUserId: () => 'u1',
onAuthChange: () => () => {},
onLogout: (fn: () => void) => {
mocks.logoutListeners.add(fn);
return () => mocks.logoutListeners.delete(fn);
},
}));
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.mock('../account/cards', () => ({
cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }),
}));
vi.mock('../card/render', () => ({
drawCard: vi.fn(),
loadCardAssets: mocks.loadCardAssets,
exportCard: mocks.exportCard,
}));
vi.mock('./upload', async (importOriginal) => ({
...(await importOriginal<typeof import('./upload')>()),
decodeImage: mocks.decodeImage,
}));
import { mountEditor } from './editor-page';
const user: User = { $id: 'u1', email: 'a@b.de' };
const image = (w = 3000, h = 2000): CardImage => ({ source: {} as CanvasImageSource, w, h });
const png = () => new Blob(['png'], { type: 'image/png' });
const savedCard = (patch: Partial<SavedCard> = {}): SavedCard => ({
id: 'k1',
name: 'Urlaub',
imageFileId: 'img1',
previewFileId: 'prev1',
updatedAt: '2026-10-04T12:00:00.000Z',
config: { ...structuredClone(DEFAULT_CONFIG), template: 'minimal', crop: { x: 99999, y: -99999, zoom: 2 } },
...patch,
});
let root: HTMLElement;
let store: EditorStore;
let unmount: (() => void) | null;
beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset();
mocks.logoutListeners.clear();
vi.stubGlobal('requestAnimationFrame', () => 0);
vi.stubGlobal('cancelAnimationFrame', () => {});
history.replaceState(null, '', '/');
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.loadCardAssets.mockResolvedValue({ image: null, network: {}, brand: {} });
mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' }));
unmount = null;
});
afterEach(() => {
unmount?.();
vi.useRealTimers();
vi.unstubAllGlobals();
});
const mount = () => {
unmount = mountEditor(root, store);
};
const saveButton = () => root.querySelector<HTMLButtonElement>('.btn-save')!;
const replaceButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Bild ersetzen') as HTMLButtonElement;
const fileInput = () => root.querySelector<HTMLInputElement>('input[type="file"]')!;
const errorText = () => root.querySelector('.msg-error')!.textContent;
const warningText = () => root.querySelector('.msg-warning')!.textContent;
const noticeText = () => root.querySelector('.msg-notice')!.textContent;
const discardDialogEl = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
/** Der Editor mit einem gewählten Bild, bereit zum Speichern. */
function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage } {
store.set({ image: img, imageBlob: blob, imageChanged: true });
return { blob, img };
}
describe('Speichern', () => {
it('speichert eine neue Karte mit Schnappschuss, Vorschau und Nutzer-Prüfung', async () => {
const { blob } = withImage();
store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, network: 'jcb' } });
const result = savedCard({ id: 'neu', name: 'Mein Urlaub' });
mocks.save.mockResolvedValue(result);
mount();
expect(saveButton().textContent).toBe('Speichern');
saveButton().click();
expect(saveButton().disabled).toBe(true);
expect(saveButton().textContent).toBe('Speichert …');
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.getCurrentUser).toHaveBeenCalled();
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ network: 'jcb' }), expect.anything(), {
width: 480,
type: 'image/webp',
quality: 0.85,
});
const input = mocks.save.mock.calls[0][0];
expect(input).toMatchObject({ name: 'Mein Urlaub', image: blob, imageChanged: true, previous: null });
expect(input.config.network).toBe('jcb');
expect(input.preview.type).toBe('image/webp');
const s = store.get();
expect(s.saved).toEqual({
id: 'neu',
name: 'Mein Urlaub',
imageFileId: 'img1',
previewFileId: 'prev1',
updatedAt: '2026-10-04T12:00:00.000Z',
});
expect(s.imageChanged).toBe(false);
expect(s.saving).toBe(false);
expect(saveButton().disabled).toBe(false);
});
it('„Gespeichert“ verschwindet nach zwei Sekunden', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
withImage();
mocks.save.mockResolvedValue(savedCard());
mount();
saveButton().click();
// vi.waitFor würde die falschen Uhren selbst vorstellen; deshalb nur die Zusagen abarbeiten.
for (let i = 0; i < 50 && saveButton().textContent !== 'Gespeichert'; i += 1) await vi.advanceTimersByTimeAsync(0);
expect(saveButton().textContent).toBe('Gespeichert');
await vi.advanceTimersByTimeAsync(1999);
expect(saveButton().textContent).toBe('Gespeichert');
await vi.advanceTimersByTimeAsync(1);
expect(saveButton().textContent).toBe('Speichern');
});
it('bestehende Karte: previous und imageChanged gehen an den Dienst, danach ist imageChanged false', async () => {
withImage();
const ref = { id: 'k1', name: 'Urlaub', imageFileId: 'img0', previewFileId: 'prev0', updatedAt: '2026-10-03T12:00:00.000Z' };
store.set({ saved: ref, cardName: 'Urlaub', imageChanged: false });
mocks.save.mockResolvedValue(savedCard({ imageFileId: 'img0', previewFileId: 'prev9' }));
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: ref, imageChanged: false });
expect(store.get().saved?.previewFileId).toBe('prev9');
});
it('ohne Bild: Meldung, kein Speichern', () => {
mount();
// Der Knopf ist ohne Bild gesperrt; der Aufruf von außen (z. B. Tastatur-Enter in einem Formular) wird trotzdem abgefangen.
saveButton().disabled = false;
saveButton().click();
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
expect(mocks.save).not.toHaveBeenCalled();
expect(mocks.getCurrentUser).not.toHaveBeenCalled();
});
it('Doppelklick speichert nur einmal', async () => {
withImage();
let finish!: (c: SavedCard) => void;
mocks.save.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
saveButton().click();
saveButton().disabled = false; // selbst wenn der Klick den Knopf erreicht
saveButton().click();
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
finish(savedCard());
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.save).toHaveBeenCalledTimes(1);
});
it('nicht angemeldet: Login-Dialog, nach dem Anmelden wird gespeichert; abgebrochen: nichts', async () => {
withImage();
mocks.getCurrentUser.mockResolvedValue(null);
mocks.requireLogin.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
mocks.save.mockResolvedValue(savedCard());
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Speichern'));
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
expect(mocks.save).not.toHaveBeenCalled();
expect(store.get().saving).toBe(false);
expect(errorText()).toBe('');
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.save).toHaveBeenCalledTimes(1);
});
it('Sitzung abgelaufen (401): einmal anmelden und genau einmal wiederholen', async () => {
withImage();
mocks.save
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
.mockResolvedValueOnce(savedCard());
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
expect(mocks.save).toHaveBeenCalledTimes(2);
expect(errorText()).toBe('');
});
it('Fehler: Meldung mit Präfix, Editor-Zustand bleibt, erneutes Speichern geht', async () => {
const { blob } = withImage();
mocks.save.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce(savedCard());
mount();
saveButton().click();
await vi.waitFor(() => expect(errorText()).toBe('Speichern fehlgeschlagen – Keine Verbindung zum Server.'));
expect(saveButton().disabled).toBe(false);
expect(saveButton().textContent).toBe('Speichern');
expect(store.get().imageBlob).toBe(blob);
expect(store.get().saved).toBeNull();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(errorText()).toBe('');
});
it('während des Speicherns sind „Bild ersetzen“, die Dateiwahl und Ablegen gesperrt', async () => {
const { blob, img } = withImage();
let finish!: (c: SavedCard) => void;
mocks.save.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
saveButton().click();
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
expect(replaceButton().disabled).toBe(true);
expect(fileInput().disabled).toBe(true);
// Ein Bild, das jetzt abgelegt wird, wird ignoriert.
const file = new File(['x'], 'neu.png', { type: 'image/png' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
drop.dataTransfer = { types: ['Files'], files: [file] };
root.querySelector('.editor')!.dispatchEvent(drop);
await new Promise((r) => setTimeout(r, 0));
expect(mocks.decodeImage).not.toHaveBeenCalled();
expect(store.get().imageBlob).toBe(blob);
expect(store.get().image).toBe(img);
finish(savedCard());
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(replaceButton().disabled).toBe(false);
expect(fileInput().disabled).toBe(false);
});
it('nach dem Speichern darf das Bild wieder ersetzt werden und die Karte bleibt „bestehend“', async () => {
withImage();
mocks.save.mockResolvedValue(savedCard());
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
mocks.decodeImage.mockResolvedValue(image(1000, 1000));
const file = new File(['x'], 'neu.png', { type: 'image/png' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
drop.dataTransfer = { types: ['Files'], files: [file] };
root.querySelector('.editor')!.dispatchEvent(drop);
await vi.waitFor(() => expect(store.get().imageChanged).toBe(true));
expect(store.get().saved?.id).toBe('k1');
});
it('ein Speichern, das erst fertig wird, nachdem eine andere Karte geöffnet wurde, überschreibt deren Zustand nicht', async () => {
withImage();
let finish!: (c: SavedCard) => void;
mocks.save.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
saveButton().click();
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
// Eine andere Karte übernimmt den Editor (z. B. Öffnen aus „Meine Karten“).
const otherBlob = png();
const otherRef = { id: 'andere', name: 'Andere', imageFileId: 'i', previewFileId: 'p', updatedAt: '2026-10-04T12:00:00.000Z' };
store.set({ imageBlob: otherBlob, saved: otherRef, cardName: 'Andere', imageChanged: false });
finish(savedCard({ id: 'neu' }));
await vi.waitFor(() => expect(store.get().saving).toBe(false));
expect(store.get().saved).toEqual(otherRef);
expect(store.get().cardName).toBe('Andere');
});
});
describe('Karte öffnen (/gestalten?karte=<id>)', () => {
const open = (id: string) => history.replaceState(null, '', `/gestalten?karte=${id}`);
it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => {
open('k1');
const card = savedCard();
const img = image(3000, 2000);
const blob = new Blob(['i'], { type: 'image/jpeg' });
mocks.get.mockResolvedValue(card);
mocks.loadImage.mockResolvedValue(blob);
mocks.decodeImage.mockResolvedValue(img);
mount();
expect(noticeText()).toBe('Karte wird geladen …');
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(mocks.get).toHaveBeenCalledWith('k1');
expect(mocks.loadImage).toHaveBeenCalledWith(card);
expect(mocks.decodeImage).toHaveBeenCalledWith(blob);
const s = store.get();
expect(s.image).toBe(img);
expect(s.imageBlob).toBe(blob);
expect(s.imageChanged).toBe(false);
expect(s.cardName).toBe('Urlaub');
expect(s.config.template).toBe('minimal');
expect(s.config.crop.zoom).toBe(2);
expect(Math.abs(s.config.crop.x)).toBeLessThan(99999);
expect(Math.abs(s.config.crop.y)).toBeLessThan(99999);
expect(s.saved).toEqual({
id: 'k1',
name: 'Urlaub',
imageFileId: 'img1',
previewFileId: 'prev1',
updatedAt: '2026-10-04T12:00:00.000Z',
});
expect(noticeText()).toBe('');
expect(errorText()).toBe('');
});
it('lädt nicht erneut, wenn der Editor die Karte schon hält', async () => {
open('k1');
store.set({ saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' } });
mount();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.get).not.toHaveBeenCalled();
});
it('ohne ?karte wird nichts geladen', async () => {
mount();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.get).not.toHaveBeenCalled();
});
it('fehlt die Bilddatei, öffnet die Karte ohne Bild mit Hinweis', async () => {
open('k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
const s = store.get();
expect(s.image).toBeNull();
expect(s.imageBlob).toBeNull();
expect(s.imageChanged).toBe(false);
expect(s.cardName).toBe('Urlaub');
expect(s.config.template).toBe('minimal');
expect(warningText()).toBe('Das Bild dieser Karte fehlt.');
expect(noticeText()).toBe('');
expect(mocks.decodeImage).not.toHaveBeenCalled();
});
it('Karte nicht gefunden (angemeldet): Meldung, ein leerer Editor wird zurückgesetzt', async () => {
open('gibtsnicht');
store.set({ config: { ...store.get().config, template: 'minimal' }, cardName: 'Altlast' });
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
mount();
await vi.waitFor(() =>
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'),
);
expect(store.get()).toEqual(initialEditorState());
expect(noticeText()).toBe('');
expect(mocks.requireLogin).not.toHaveBeenCalled();
});
it('Karte nicht gefunden (angemeldet) bei gefülltem Editor: nur die Meldung, der Editor bleibt', async () => {
open('gibtsnicht');
const { blob, img } = withImage();
store.set({ cardName: 'Entwurf' });
const before = store.get();
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
mount();
await vi.waitFor(() =>
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'),
);
expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob);
expect(store.get().image).toBe(img);
expect(store.get().cardName).toBe('Entwurf');
expect(noticeText()).toBe('');
expect(discardDialogEl()).toBeNull();
});
it('Karte nicht gefunden bei gefülltem Editor mit gespeicherter Karte: auch hier bleibt alles', async () => {
open('gibtsnicht');
withImage();
store.set({
saved: { id: 'k9', name: 'Neun', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
imageChanged: false,
});
const before = store.get();
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
mount();
await vi.waitFor(() => expect(errorText()).not.toBe(''));
expect(store.get()).toBe(before);
});
describe('ohne Sitzung (Appwrite antwortet Gästen mit 404, nicht 401)', () => {
it('Gast: zuerst anmelden, dann erst laden; danach wird die Karte geöffnet', async () => {
open('k1');
const order: string[] = [];
mocks.getCurrentUser.mockImplementation(async () => {
order.push('user');
return null;
});
mocks.requireLogin.mockImplementation(async () => {
order.push('login');
return true;
});
mocks.get.mockImplementation(async () => {
order.push('get');
return savedCard();
});
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(order).toEqual(['user', 'login', 'get']);
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
expect(errorText()).toBe('');
});
it('Gast bricht den Dialog ab: Hinweis, nichts wird geladen, der Editor bleibt unberührt', async () => {
open('k1');
const { blob } = withImage();
const before = store.get();
mocks.getCurrentUser.mockResolvedValue(null);
mocks.requireLogin.mockResolvedValue(false);
mount();
await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.'));
expect(mocks.get).not.toHaveBeenCalled();
expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob);
expect(noticeText()).toBe('');
expect(errorText()).toBe('');
});
it('Sitzung läuft mitten im Laden ab (404 row_not_found, Nutzerprüfung ergibt null): anmelden und einmal wiederholen', async () => {
open('k1');
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
mocks.get
.mockRejectedValueOnce(Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }))
.mockResolvedValueOnce(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
expect(mocks.get).toHaveBeenCalledTimes(2);
});
it('Bilddatei 404 ohne Sitzung: anmelden, das Bild wird danach geladen', async () => {
open('k1');
mocks.get.mockResolvedValue(savedCard());
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
mocks.loadImage
.mockRejectedValueOnce(Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }))
.mockResolvedValueOnce(png());
mocks.decodeImage.mockResolvedValue(image());
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
expect(store.get().image).not.toBeNull();
expect(warningText()).toBe('');
});
it('angemeldet und Bilddatei wirklich weg: die Karte öffnet ohne Bild, kein Dialog', async () => {
open('k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(mocks.requireLogin).not.toHaveBeenCalled();
expect(warningText()).toBe('Das Bild dieser Karte fehlt.');
});
});
describe('ungespeicherte Änderungen im Editor', () => {
const dialogButton = (label: string) =>
[...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
const loadable = () => {
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image(1200, 800));
};
it('ein neues, nie gespeichertes Bild: Rückfrage; „Verwerfen“ übernimmt die geöffnete Karte', async () => {
open('k1');
loadable();
withImage();
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?');
// Solange gefragt wird, bleibt der Entwurf.
expect(store.get().saved).toBeNull();
expect(store.get().imageChanged).toBe(true);
dialogButton('Verwerfen').click();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(store.get().imageChanged).toBe(false);
expect(store.get().cardName).toBe('Urlaub');
expect(discardDialogEl()).toBeNull();
expect(noticeText()).toBe('');
});
it('„Abbrechen“: Entwurf bleibt, ?karte= verschwindet aus der Adresse, die Karte wird nicht übernommen', async () => {
open('k1');
loadable();
const { blob, img } = withImage();
const before = store.get();
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
dialogButton('Abbrechen').click();
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
await vi.waitFor(() => expect(noticeText()).toBe(''));
expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob);
expect(store.get().image).toBe(img);
expect(location.pathname + location.search).toBe('/gestalten');
});
it('Esc im Dialog verwirft nichts', async () => {
open('k1');
loadable();
withImage();
const before = store.get();
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
discardDialogEl()!.dispatchEvent(new Event('close'));
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
await vi.waitFor(() => expect(location.search).toBe(''));
expect(store.get()).toBe(before);
});
it('geänderte Konfiguration einer anderen gespeicherten Karte: ebenfalls Rückfrage', async () => {
open('k1');
loadable();
withImage();
const config = store.get().config;
store.set({
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
savedConfig: config,
imageChanged: false,
config: { ...config, template: 'minimal' },
});
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
dialogButton('Verwerfen').click();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
});
it('eine andere gespeicherte Karte ohne Änderungen wird ohne Rückfrage ersetzt', async () => {
open('k1');
loadable();
withImage();
const config = store.get().config;
store.set({
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
savedConfig: config,
imageChanged: false,
});
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(discardDialogEl()).toBeNull();
});
it('ein leerer Editor wird ohne Rückfrage befüllt', async () => {
open('k1');
loadable();
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(discardDialogEl()).toBeNull();
});
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => {
open('k1');
loadable();
withImage();
const before = store.get();
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
unmount?.();
unmount = null;
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
await new Promise((r) => setTimeout(r, 0));
expect(store.get()).toBe(before);
});
});
it('anderer Fehler (Netz): Meldung, Editor unverändert', async () => {
open('k1');
mocks.get.mockRejectedValue(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(errorText()).toBe('Keine Verbindung zum Server.'));
expect(store.get().saved).toBeNull();
});
it('ein Bild, das sich nicht dekodieren lässt, meldet das, ohne die Karte zu übernehmen', async () => {
open('k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockRejectedValue(new Error('Das Bild konnte nicht gelesen werden.'));
mount();
await vi.waitFor(() => expect(errorText()).toBe('Das Bild konnte nicht gelesen werden.'));
expect(store.get().saved).toBeNull();
});
it('ein Bild über 40 Megapixel meldet die Größen-Meldung, ohne die Karte zu übernehmen', async () => {
open('k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
mount();
await vi.waitFor(() => expect(errorText()).toBe('Das Bild ist zu groß (max. 40 Megapixel).'));
expect(store.get().saved).toBeNull();
});
it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => {
open('k1');
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
unmount?.();
unmount = null;
finish(savedCard());
await new Promise((r) => setTimeout(r, 0));
expect(mocks.loadImage).not.toHaveBeenCalled();
expect(store.get().saved).toBeNull();
});
it('wählt der Nutzer während des Ladens selbst ein Bild, gewinnt dieses', async () => {
open('k1');
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mocks.decodeImage.mockResolvedValue(image(500, 500));
mount();
const file = new File(['x'], 'neu.png', { type: 'image/png' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
drop.dataTransfer = { types: ['Files'], files: [file] };
root.querySelector('.editor')!.dispatchEvent(drop);
await vi.waitFor(() => expect(store.get().image?.w).toBe(500));
finish(savedCard());
await new Promise((r) => setTimeout(r, 0));
expect(mocks.loadImage).not.toHaveBeenCalled();
expect(store.get().saved).toBeNull();
expect(noticeText()).toBe('');
});
it('eine unbrauchbare Datei bricht das Laden der Karte nicht ab', async () => {
open('k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mount();
const file = new File(['x'], 'neu.gif', { type: 'image/gif' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
drop.dataTransfer = { types: ['Files'], files: [file] };
root.querySelector('.editor')!.dispatchEvent(drop);
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
});
});
describe('Bindung an eine gespeicherte Karte', () => {
const ref = (patch: Record<string, string> = {}) => ({
id: 'k1',
name: 'Urlaub',
imageFileId: 'img1',
previewFileId: 'prev1',
updatedAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const boundRow = () => root.querySelector<HTMLElement>('.bound-row')!;
const boundLabel = () => root.querySelector<HTMLElement>('.bound-label')!;
const newCardButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Neue Karte') as HTMLButtonElement;
const discardDialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
const dialogButton = (label: string) =>
[...discardDialog()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
/** Ein Editor, der eine gespeicherte Karte hält (nichts Ungespeichertes). */
function boundEditor(patch: Partial<ReturnType<typeof initialEditorState>> = {}) {
const { blob, img } = withImage();
const config = store.get().config;
store.set({ saved: ref(), savedConfig: config, cardName: 'Urlaub', imageChanged: false, ...patch });
return { blob, img, config };
}
describe('Anzeige „Bearbeitet: <Name>“', () => {
it('ist ohne gespeicherte Karte verborgen', () => {
withImage();
mount();
expect(boundRow().hidden).toBe(true);
expect(boundLabel().textContent).toBe('');
});
it('zeigt den Namen der Karte, solange `saved` gesetzt ist', () => {
boundEditor();
mount();
expect(boundRow().hidden).toBe(false);
expect(boundLabel().textContent).toBe('Bearbeitet: Urlaub');
expect(newCardButton().hidden).toBe(false);
});
it('erscheint nach dem ersten Speichern und folgt einer Umbenennung', async () => {
withImage();
mocks.save.mockResolvedValue(savedCard({ id: 'neu', name: 'Meine Karte' }));
mount();
saveButton().click();
await vi.waitFor(() => expect(boundLabel().textContent).toBe('Bearbeitet: Meine Karte'));
store.set({ saved: ref({ id: 'neu', name: 'Umbenannt' }) });
expect(boundLabel().textContent).toBe('Bearbeitet: Umbenannt');
});
it('zeigt den Namen nur als Text, nie als HTML', () => {
boundEditor({ saved: ref({ name: '<img src=x onerror=alert(1)><b>fett</b>' }) });
mount();
expect(boundLabel().textContent).toBe('Bearbeitet: <img src=x onerror=alert(1)><b>fett</b>');
expect(boundLabel().children).toHaveLength(0);
});
it('ist nach dem Öffnen einer Karte da', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mount();
expect(boundRow().hidden).toBe(true);
await vi.waitFor(() => expect(boundRow().hidden).toBe(false));
expect(boundLabel().textContent).toBe('Bearbeitet: Urlaub');
});
it('„Neue Karte“ ist während des Speicherns gesperrt', async () => {
boundEditor();
let finish!: (c: SavedCard) => void;
mocks.save.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
saveButton().click();
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
expect(newCardButton().disabled).toBe(true);
finish(savedCard());
await vi.waitFor(() => expect(newCardButton().disabled).toBe(false));
});
});
describe('„Neue Karte“', () => {
it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => {
history.replaceState(null, '', '/gestalten?karte=k1');
const { config } = boundEditor();
mount();
newCardButton().click();
expect(discardDialog()).toBeNull(); // nichts Ungespeichertes: keine Rückfrage
const s = store.get();
expect(s).toEqual(initialEditorState());
expect(s.saved).toBeNull();
expect(s.savedConfig).toBeNull();
expect(s.image).toBeNull();
expect(s.imageBlob).toBeNull();
expect(s.config).toEqual(DEFAULT_CONFIG);
expect(s.config).not.toBe(DEFAULT_CONFIG);
expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop);
expect(s.config).not.toBe(config);
expect(location.pathname + location.search).toBe('/gestalten');
expect(boundRow().hidden).toBe(true);
});
it('lässt die Adresse unberührt, wenn sie keine Karte nennt', () => {
boundEditor();
mount();
newCardButton().click();
expect(location.pathname + location.search).toBe('/');
expect(store.get().saved).toBeNull();
});
it('bricht eine noch ladende Karte ab', async () => {
history.replaceState(null, '', '/gestalten?karte=k2');
boundEditor();
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(mocks.get).toHaveBeenCalled());
newCardButton().click();
expect(store.get().saved).toBeNull();
finish(savedCard({ id: 'k2' }));
await new Promise((r) => setTimeout(r, 0));
expect(mocks.loadImage).not.toHaveBeenCalled();
expect(store.get().saved).toBeNull();
expect(noticeText()).toBe('');
});
it('verwirft kein noch dekodiertes Bild in den leeren Editor', async () => {
boundEditor();
let decoded!: (i: CardImage) => void;
mocks.decodeImage.mockReturnValue(new Promise<CardImage>((r) => (decoded = r)));
mount();
const file = new File(['x'], 'neu.png', { type: 'image/png' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
drop.dataTransfer = { types: ['Files'], files: [file] };
root.querySelector('.editor')!.dispatchEvent(drop);
await vi.waitFor(() => expect(mocks.decodeImage).toHaveBeenCalled());
// imageChanged ist noch false (das Bild ist nicht fertig), also keine Rückfrage
newCardButton().click();
decoded(image());
await new Promise((r) => setTimeout(r, 0));
expect(store.get().image).toBeNull();
});
it('fragt bei einem ersetzten Bild nach; „Abbrechen“ ändert nichts', async () => {
const { blob } = boundEditor();
store.set({ imageChanged: true });
mount();
newCardButton().click();
await vi.waitFor(() => expect(discardDialog()?.open).toBe(true));
expect(discardDialog()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?');
dialogButton('Abbrechen').click();
await vi.waitFor(() => expect(discardDialog()).toBeNull());
expect(store.get().saved?.id).toBe('k1');
expect(store.get().imageBlob).toBe(blob);
await vi.waitFor(() => expect(newCardButton().disabled).toBe(false));
});
it('fragt bei geänderter Konfiguration nach; „Verwerfen“ setzt zurück', async () => {
boundEditor();
store.set({ config: { ...store.get().config, template: 'minimal' } });
mount();
newCardButton().click();
await vi.waitFor(() => expect(discardDialog()?.open).toBe(true));
dialogButton('Verwerfen').click();
await vi.waitFor(() => expect(store.get().saved).toBeNull());
expect(store.get()).toEqual(initialEditorState());
expect(discardDialog()).toBeNull();
});
it('Esc im Rückfrage-Dialog verwirft nichts', async () => {
boundEditor();
store.set({ imageChanged: true });
mount();
newCardButton().click();
await vi.waitFor(() => expect(discardDialog()?.open).toBe(true));
discardDialog()!.dispatchEvent(new Event('close'));
await vi.waitFor(() => expect(discardDialog()).toBeNull());
expect(store.get().saved?.id).toBe('k1');
});
it('fragt nach dem Speichern nicht mehr (der gespeicherte Stand ist der aktuelle)', async () => {
withImage();
mocks.save.mockResolvedValue(savedCard({ id: 'neu' }));
mount();
saveButton().click();
await vi.waitFor(() => expect(boundRow().hidden).toBe(false));
await vi.waitFor(() => expect(newCardButton().disabled).toBe(false));
newCardButton().click();
expect(discardDialog()).toBeNull();
expect(store.get().saved).toBeNull();
});
it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image(3000, 2000));
mount();
await vi.waitFor(() => expect(boundRow().hidden).toBe(false));
newCardButton().click();
expect(discardDialog()).toBeNull();
expect(store.get().saved).toBeNull();
});
it('nach „Neue Karte“ legt Speichern eine neue Karte an (createRow, kein updateRow)', async () => {
const actual = await vi.importActual<typeof import('../account/cards')>('../account/cards');
const calls: string[] = [];
const api = {
createFile: vi.fn(async (file: File) => {
calls.push(`createFile ${file.name}`);
return { $id: `f${calls.length}` };
}),
deleteFile: vi.fn(async (id: string) => {
calls.push(`deleteFile ${id}`);
}),
fetchFile: vi.fn(),
createRow: vi.fn(async (data: Record<string, string>) => {
calls.push('createRow');
return { $id: 'row-neu', $updatedAt: '2026-10-05T10:00:00.000Z', ...data };
}),
updateRow: vi.fn(async () => {
calls.push('updateRow');
throw new Error('updateRow darf nicht aufgerufen werden');
}),
getRow: vi.fn(),
deleteRow: vi.fn(),
listRows: vi.fn(),
};
const service = actual.createCardsService(api as never, () => 'u1');
mocks.save.mockImplementation((input) => service.save(input));
// Karte 1 ist gespeichert und im Editor; der Nutzer beginnt eine neue Karte und legt ein anderes Foto ab.
boundEditor();
mount();
newCardButton().click();
mocks.decodeImage.mockResolvedValue(image(1000, 1000));
const file = new File(['zweites foto'], 'zwei.png', { type: 'image/png' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
drop.dataTransfer = { types: ['Files'], files: [file] };
root.querySelector('.editor')!.dispatchEvent(drop);
await vi.waitFor(() => expect(store.get().image?.w).toBe(1000));
saveButton().click();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('row-neu'));
expect(calls).toEqual(['createFile image.png', 'createFile preview.webp', 'createRow']);
expect(api.updateRow).not.toHaveBeenCalled();
expect(api.deleteFile).not.toHaveBeenCalled(); // das Original von Karte 1 bleibt unangetastet
});
});
describe('Abmelden während des Ladens', () => {
it('eine noch ladende Karte landet nicht mehr im Editor', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(mocks.get).toHaveBeenCalled());
for (const fn of mocks.logoutListeners) fn();
finish(savedCard());
await new Promise((r) => setTimeout(r, 0));
expect(mocks.loadImage).not.toHaveBeenCalled();
expect(store.get().saved).toBeNull();
expect(noticeText()).toBe('');
});
it('der Editor meldet sich beim Verlassen der Seite vom Abmelde-Signal ab', () => {
mount();
expect(mocks.logoutListeners.size).toBe(1);
unmount?.();
unmount = null;
expect(mocks.logoutListeners.size).toBe(0);
});
});
});
describe('Speichern: Schnappschuss beim Klick', () => {
it('die Wiederholung nach einem 401 speichert genau das, was angeklickt wurde', async () => {
withImage();
store.set({ cardName: 'Beim Klick', config: { ...store.get().config, template: 'classic' } });
mocks.save
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
.mockResolvedValueOnce(savedCard());
// Während der Login-Dialog offen ist, ändert der Nutzer (hinter dem Dialog) den Editor.
mocks.requireLogin.mockImplementation(async () => {
store.set({ cardName: 'Danach', config: { ...store.get().config, template: 'image' } });
return true;
});
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.save).toHaveBeenCalledTimes(2);
for (const [input] of mocks.save.mock.calls) {
expect(input.name).toBe('Beim Klick');
expect(input.config.template).toBe('classic');
}
expect(mocks.exportCard.mock.calls[1][0].template).toBe('classic');
});
});