import { beforeEach, describe, expect, it, vi } from 'vitest'; type TestUser = { $id: string; email: string; labels?: string[] }; const mocks = vi.hoisted(() => ({ user: null as TestUser | null, listeners: new Set<(user: TestUser | null) => void>(), logout: vi.fn(), requireLogin: vi.fn(), /** Der Benutzername des angemeldeten Kontos laut Zwischenspeicher (null: keiner oder unbekannt). */ name: null as string | null, nameListeners: new Set<(userId: string, username: string) => void>(), usernameDialog: vi.fn(), })); vi.mock('./auth', () => ({ currentUser: () => mocks.user, logout: mocks.logout, onAuthChange: (fn: (user: TestUser | null) => void) => { mocks.listeners.add(fn); return () => mocks.listeners.delete(fn); }, })); vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin })); vi.mock('./profile', () => ({ myUsername: () => mocks.name, onUsernameChange: (fn: (userId: string, username: string) => void) => { mocks.nameListeners.add(fn); return () => mocks.nameListeners.delete(fn); }, })); vi.mock('./username-dialog', () => ({ usernameDialog: mocks.usernameDialog })); import { mountAccountMenu, type AccountMenu } from './account-menu'; let slot: HTMLElement; let menu: AccountMenu; const avatar = () => slot.querySelector('button.account-avatar'); const panel = () => slot.querySelector('.account-dropdown'); const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text); function setUser(user: TestUser | null) { mocks.user = user; for (const fn of [...mocks.listeners]) fn(user); } /** Der Name des Kontos ändert sich (eigene Änderung oder nach dem Lesen), wie `onUsernameChange` es meldet. */ function setName(userId: string, name: string) { mocks.name = name; for (const fn of [...mocks.nameListeners]) fn(userId, name); } beforeEach(() => { mocks.user = null; mocks.name = null; mocks.listeners.clear(); mocks.nameListeners.clear(); mocks.logout.mockReset(); mocks.requireLogin.mockReset(); mocks.usernameDialog.mockReset(); mocks.usernameDialog.mockResolvedValue(null); document.body.innerHTML = '
'; slot = document.getElementById('account-slot') as HTMLElement; menu = mountAccountMenu(slot); }); describe('Konto-Menü', () => { it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => { expect(slot.childElementCount).toBe(0); }); it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => { menu.ready(); buttonByText('Anmelden')!.click(); expect(mocks.requireLogin).toHaveBeenCalledWith('login'); expect(avatar()).toBeNull(); }); it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; menu.ready(); expect(avatar()?.textContent).toBe('K'); expect(avatar()?.getAttribute('aria-expanded')).toBe('false'); expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü'); expect(panel()?.hidden).toBe(true); }); it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; menu.ready(); avatar()!.click(); expect(avatar()?.getAttribute('aria-expanded')).toBe('true'); expect(panel()?.hidden).toBe(false); expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com'); const link = panel()!.querySelector('a[data-link]')!; expect(link.textContent).toBe('Meine Karten'); expect(link.getAttribute('href')).toBe('/karten'); expect(buttonByText('Abmelden')).toBeDefined(); }); it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; menu.ready(); avatar()!.click(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(panel()?.hidden).toBe(true); expect(avatar()?.getAttribute('aria-expanded')).toBe('false'); expect(document.activeElement).toBe(avatar()); }); it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; menu.ready(); avatar()!.click(); document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(panel()?.hidden).toBe(true); avatar()!.click(); panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(panel()?.hidden).toBe(true); }); it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; menu.ready(); avatar()!.click(); panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(panel()?.hidden).toBe(false); }); it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; mocks.logout.mockRejectedValueOnce(new TypeError('offline')); menu.ready(); avatar()!.click(); const signOut = buttonByText('Abmelden')!; signOut.click(); expect(mocks.logout).toHaveBeenCalledOnce(); expect(signOut.disabled).toBe(true); await vi.waitFor(() => expect(signOut.disabled).toBe(false)); expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.'); expect(panel()?.hidden).toBe(false); }); it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; menu.ready(); avatar()!.click(); setUser(null); expect(avatar()).toBeNull(); expect(buttonByText('Anmelden')).toBeDefined(); }); it('dispose leert den Slot und meldet die Listener ab', () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; menu.ready(); menu.dispose(); expect(slot.childElementCount).toBe(0); expect(mocks.listeners.size).toBe(0); expect(mocks.nameListeners.size).toBe(0); }); }); describe('Konto-Menü: Benutzername', () => { const signedIn = () => { mocks.user = { $id: 'u1', email: 'kenso@example.com' }; }; const head = () => panel()!.querySelector('.account-head')!; const nameLine = () => panel()!.querySelector('.account-name'); const items = () => [...panel()!.querySelectorAll('.account-item')].map((item) => item.textContent); it('mit Namen steht „@name“ oben und die E-Mail klein darunter', () => { signedIn(); mocks.name = 'Kenso'; menu.ready(); expect(nameLine()?.textContent).toBe('@Kenso'); expect(panel()!.querySelector('.account-email')?.textContent).toBe('kenso@example.com'); expect([...head().children].map((child) => child.className)).toEqual(['account-name', 'account-email']); // Name zuerst expect(head().parentElement).toBe(panel()); expect(head().nextElementSibling?.textContent).toBe('Meine Karten'); // die Kopfzeile steht vor den Einträgen }); it('der Avatar bleibt bei der Initiale der E-Mail', () => { signedIn(); mocks.name = 'Zebra'; menu.ready(); expect(avatar()?.textContent).toBe('K'); }); it('ohne Namen steht nur die E-Mail oben (wie bisher)', () => { signedIn(); menu.ready(); expect(nameLine()).toBeNull(); expect(head().textContent).toBe('kenso@example.com'); }); it('Name und E-Mail erscheinen als Text, nie als HTML', () => { mocks.user = { $id: 'u1', email: 'x@example.com' }; mocks.name = 'Kenso'; menu.ready(); expect(head().querySelector('b, i')).toBeNull(); expect(nameLine()?.textContent).toBe('@Kenso'); expect(nameLine()?.getAttribute('title')).toBe('@Kenso'); }); it('Eintrag „Benutzername ändern“ mit Namen, „Benutzername wählen“ ohne – zwischen „Meine Karten“ und „Abmelden“', () => { signedIn(); mocks.name = 'Kenso'; menu.ready(); expect(items()).toEqual(['Meine Karten', 'Benutzername ändern', 'Abmelden']); setUser(null); mocks.name = null; setUser({ $id: 'u1', email: 'kenso@example.com' }); expect(items()).toEqual(['Meine Karten', 'Benutzername wählen', 'Abmelden']); }); it('der Eintrag öffnet den Dialog im Modus „change“ und schließt das Menü', () => { signedIn(); mocks.name = 'Kenso'; menu.ready(); avatar()!.click(); buttonByText('Benutzername ändern')!.click(); expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' }); expect(panel()?.hidden).toBe(true); expect(avatar()?.getAttribute('aria-expanded')).toBe('false'); }); it('ohne Namen öffnet „Benutzername wählen“ denselben Dialog', () => { signedIn(); menu.ready(); buttonByText('Benutzername wählen')!.click(); expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' }); }); it('nach dem Dialog geht der Fokus zurück an den Avatar (der Eintrag im geschlossenen Menü kann ihn nicht halten)', async () => { signedIn(); mocks.name = 'Kenso'; menu.ready(); avatar()!.click(); buttonByText('Benutzername ändern')!.click(); await vi.waitFor(() => expect(document.activeElement).toBe(avatar())); }); it('ändert sich der Name (Speichern im Dialog), zeigt das Menü sofort den neuen', () => { signedIn(); mocks.name = 'Alt'; menu.ready(); expect(nameLine()?.textContent).toBe('@Alt'); setName('u1', 'Neu'); expect(nameLine()?.textContent).toBe('@Neu'); expect(items()).toContain('Benutzername ändern'); }); it('ein erst später gelesener Name erscheint ohne weiteres Zutun (Menü steht schon, der Name kommt nach)', () => { signedIn(); menu.ready(); expect(nameLine()).toBeNull(); expect(buttonByText('Benutzername wählen')).toBeDefined(); setName('u1', 'Kenso'); expect(nameLine()?.textContent).toBe('@Kenso'); expect(buttonByText('Benutzername ändern')).toBeDefined(); }); it('meldet onUsernameChange denselben Namen noch einmal (Lesen des eigenen Profils nach dem Start), bleibt das Menü unangetastet: offen und mit dem Fokus', () => { signedIn(); mocks.name = 'Kenso'; menu.ready(); avatar()!.click(); const before = { avatar: avatar(), panel: panel(), head: head() }; const signOut = buttonByText('Abmelden')!; signOut.focus(); setName('u1', 'Kenso'); expect(avatar()).toBe(before.avatar); // dieselben Elemente, nichts wurde neu gebaut expect(panel()).toBe(before.panel); expect(head()).toBe(before.head); expect(panel()?.hidden).toBe(false); expect(avatar()?.getAttribute('aria-expanded')).toBe('true'); expect(document.activeElement).toBe(signOut); expect(nameLine()?.textContent).toBe('@Kenso'); }); it('ein anderer Name oder nur eine andere Schreibweise zeichnet dagegen neu', () => { signedIn(); mocks.name = 'kenso'; menu.ready(); const before = avatar(); setName('u1', 'Kenso'); // „kenso“ → „Kenso“ ist erlaubt (Spec § 4) und soll zu sehen sein expect(avatar()).not.toBe(before); expect(nameLine()?.textContent).toBe('@Kenso'); const second = avatar(); setName('u1', 'Mia'); expect(avatar()).not.toBe(second); expect(nameLine()?.textContent).toBe('@Mia'); }); it('die Namensmeldung eines anderen Kontos ändert nichts am Menü des angemeldeten', () => { signedIn(); mocks.name = 'Kenso'; // der Name des angemeldeten Kontos bleibt menu.ready(); avatar()!.click(); const before = panel(); for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia'); // das Konto u2 hat sich umbenannt (etwa in einem eben beendeten Aufruf) expect(panel()).toBe(before); expect(panel()?.hidden).toBe(false); expect(nameLine()?.textContent).toBe('@Kenso'); }); it('abgemeldet bleibt der Anmelden-Knopf stehen, auch wenn ein Name gemeldet wird (das Konto, das speicherte, ist nicht mehr angemeldet)', () => { menu.ready(); const signIn = buttonByText('Anmelden'); for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia'); expect(buttonByText('Anmelden')).toBe(signIn); }); it('nach dem Abmelden ist das Menü unabhängig vom Namen wieder „Anmelden“', () => { signedIn(); mocks.name = 'Kenso'; menu.ready(); setUser(null); expect(slot.querySelector('.account-name')).toBeNull(); expect(buttonByText('Anmelden')).toBeDefined(); }); it('vor ready() und nach dispose zeichnet auch eine Namensänderung nichts', () => { signedIn(); setName('u1', 'Kenso'); expect(slot.childElementCount).toBe(0); menu.ready(); menu.dispose(); setName('u1', 'Neu'); expect(slot.childElementCount).toBe(0); }); it('lässt sich der Dialog nicht öffnen, bleibt es bei einer Meldung in der Konsole (kein unbehandelter Fehler), der Fokus geht trotzdem zurück', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}); mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt')); signedIn(); menu.ready(); avatar()!.click(); buttonByText('Benutzername wählen')!.click(); await vi.waitFor(() => expect(error).toHaveBeenCalled()); await vi.waitFor(() => expect(document.activeElement).toBe(avatar())); error.mockRestore(); }); it('auf Englisch: Einträge in der Sprache der Seite', async () => { (await import('../i18n/i18n')).setLang('en', { persist: false }); signedIn(); mocks.name = 'Kenso'; menu.ready(); expect(items()).toEqual(['My cards', 'Change username', 'Sign out']); expect(nameLine()?.textContent).toBe('@Kenso'); mocks.name = null; setUser({ $id: 'u1', email: 'kenso@example.com' }); expect(items()).toEqual(['My cards', 'Choose a username', 'Sign out']); }); });