feat(cards): Karten speichern und Meine Karten

- account/cards.ts: Kartendienst (neu/bestehend speichern mit Aufräumen halber
  Uploads, Umbenennen, Löschen, Laden) über ein schmales CardsApi-Interface,
  Appwrite-Anschluss mit 22.4.1-Objektparametern, fetchFile über
  client.prepareRequest (Projekt-Header + Cookie, kein getFileView)
- account/with-login.ts: bei 401 Nutzer neu prüfen, einmal anmelden, wiederholen
- editor/save.ts: Speichern-Ablauf (ein Lauf zur Zeit, 401 -> Login -> einmal
  wiederholen, deutsche Fehlermeldung)
- Editor: Speichern mit Schnappschuss und 480-px-Vorschau, Karte öffnen über
  ?karte=<id>, Bildwechsel während des Speicherns gesperrt
- pages/cards-page.ts: /karten mit Kacheln, Öffnen, Herunterladen, Umbenennen,
  Löschen (eigener Dialog), Abmelde- und Leer-Ansicht
- Tests für Dienst, withLogin, Speichern-Ablauf, Karten-Seite und Editor-Anbindung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 09:18:27 +00:00
1 parent a147d3b496
commit ba4d9f76d4
14 files changed
+2849 -16

No files matched your search

+453
View File
@@ -0,0 +1,453 @@
// 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(),
}));
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ getCurrentUser: mocks.getCurrentUser, currentUserId: () => 'u1', onAuthChange: () => () => {} }));
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)) fn.mockReset();
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;
/** 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 (/?karte=<id>)', () => {
const open = (id: string) => history.replaceState(null, '', `/?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: Meldung und leerer Editor', async () => {
open('gibtsnicht');
store.set({ image: image(), imageBlob: png(), 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('');
});
it('Anmeldung abgebrochen: Hinweis, Editor unverändert', async () => {
open('k1');
mocks.get.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
mocks.requireLogin.mockResolvedValue(false);
mount();
await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.'));
expect(store.get().saved).toBeNull();
expect(noticeText()).toBe('');
});
it('Sitzung abgelaufen, Anmeldung klappt: Karte wird danach geöffnet', async () => {
open('k1');
mocks.get
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
.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);
});
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('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'));
});
});