- 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>
978 lines
41 KiB
TypeScript
978 lines
41 KiB
TypeScript
// 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');
|
||
});
|
||
});
|
||
|