fix(cards): Editor an Karte und Konto koppeln (Neue Karte, Abmelden, Kontowechsel)

- Editor zeigt „Bearbeitet: <Name>“, solange eine gespeicherte Karte gebunden ist,
  und bietet „Neue Karte“ (setzt zurück, bricht Laden ab, entfernt ?karte=;
  fragt bei ungespeicherten Änderungen per eigenem Dialog „Verwerfen?“)
- EditorState.savedConfig als Vergleichsbasis für ungespeicherte Änderungen
- bindEditorToAuth: Abmelden leert den Editor, Kontowechsel A -> B entkoppelt die
  Karte (Entwurf bleibt), Anmelden ohne Vorgänger und abgelaufene Sitzung behalten
  den Entwurf; auth.onLogout unterscheidet eigenes Abmelden von bemerktem Ablauf
- Speichern nimmt den Schnappschuss pro Klick (auch für die Wiederholung nach 401)
- save: row_not_found beim Update legt eine neue Karte an (inkl. Aufräumen)
- Karten-Seite: Editor-Abgleich auch nach dem Verlassen der Seite, Löschen-Dialog
  wird beim Verlassen geschlossen, keine doppelten Vorschau-URLs
- confirm-dialog als gemeinsames Modul (Löschen, Verwerfen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 09:32:19 +00:00
1 parent ba4d9f76d4
commit 38b361ad7a
20 files changed
+1038 -116

No files matched your search

+85
View File
@@ -454,3 +454,88 @@ describe('Meine Karten: Löschen', () => {
expect(store.get().saved).toEqual(saved);
});
});
describe('Meine Karten: Seite wird verlassen', () => {
const savedRef = (id: string, name: string) => ({
id,
name,
imageFileId: `img-${id}`,
previewFileId: `prev-${id}`,
updatedAt: '2026-10-04T12:00:00.000Z',
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('ein offener Löschen-Dialog wird geschlossen, nichts wird gelöscht', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.remove).not.toHaveBeenCalled();
});
it('Umbenennen, das erst nach dem Verlassen fertig wird, stellt den Editor-Zustand trotzdem richtig', async () => {
store.set({ saved: savedRef('a', 'Alpha'), cardName: 'Alpha' });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (c: SavedCard) => void;
mocks.rename.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Neu';
submit(input.closest('form')!);
await vi.waitFor(() => expect(mocks.rename).toHaveBeenCalled());
unmount?.();
unmount = null;
finish(card('a', 'Neu', { updatedAt: '2026-10-06T08:00:00.000Z' }));
await vi.waitFor(() => expect(store.get().cardName).toBe('Neu'));
expect(store.get().saved).toMatchObject({ id: 'a', name: 'Neu' });
});
it('Löschen, das erst nach dem Verlassen fertig wird, löst den Editor trotzdem von der Karte', async () => {
store.set({ saved: savedRef('a', 'Alpha'), savedConfig: structuredClone(DEFAULT_CONFIG) });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: () => void;
mocks.remove.mockReturnValue(new Promise<void>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalled());
unmount?.();
unmount = null;
finish();
await vi.waitFor(() => expect(store.get().saved).toBeNull());
expect(store.get().savedConfig).toBeNull();
});
it('eine zweite Vorschau derselben Datei legt keine zweite URL an (sonst bliebe die erste ungenutzt)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
const pending: Array<(b: Blob) => void> = [];
mocks.loadPreview.mockImplementation(() => new Promise<Blob>((r) => pending.push(r)));
mount();
await vi.waitFor(() => expect(pending).toHaveLength(1));
// Abmelden und wieder anmelden lädt die Liste neu, während die erste Vorschau noch unterwegs ist.
for (const fn of mocks.authListeners) fn(null);
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(pending).toHaveLength(2));
pending[0](new Blob(['p'], { type: 'image/webp' }));
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
pending[1](new Blob(['p'], { type: 'image/webp' }));
await new Promise((r) => setTimeout(r, 0));
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
unmount?.();
unmount = null;
expect(revoked).toEqual(['blob:preview-1']);
});
});