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

+313 -2
View File
@@ -15,10 +15,19 @@ const mocks = vi.hoisted(() => ({
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: () => () => {} }));
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 }),
@@ -54,7 +63,8 @@ let store: EditorStore;
let unmount: (() => void) | null;
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
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, '', '/');
@@ -451,3 +461,304 @@ describe('Karte öffnen (/?karte=<id>)', () => {
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, '', '/?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, '', '/?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('/');
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, '', '/?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, '', '/?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, '', '/?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');
});
});