359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
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']);
|
||
});
|
||
});
|