Files
AirCardIOS/web/src/account/account-menu.test.ts
T

359 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLButtonElement>('button.account-avatar');
const panel = () => slot.querySelector<HTMLElement>('.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 = '<div id="account-slot"></div>';
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<HTMLAnchorElement>('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<HTMLElement>('.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: '<i>x</i>@example.com' };
mocks.name = '<b>Kenso</b>';
menu.ready();
expect(head().querySelector('b, i')).toBeNull();
expect(nameLine()?.textContent).toBe('@<b>Kenso</b>');
expect(nameLine()?.getAttribute('title')).toBe('@<b>Kenso</b>');
});
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']);
});
});