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:
1 parent
ba4d9f76d4
commit
38b361ad7a
20 files changed
+1038
-116
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user