/** * Verhaltenstests der Lernprofile. * * Schwerpunkt: Ein Profil bleibt unsichtbar, mehrere werden gefragt – und * nichts wechselt, ohne dass jemand es verlangt hat. Der Anwendungskern * konnte Profile von Anfang an; geprüft wird hier der Weg dorthin. */ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import axe from 'axe-core'; import { jsdomOptionen } from '../src/shared/wcag'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/renderer/src/App'; import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; import type { Frage, Katalog, RichText } from '../src/shared/katalog'; import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc'; import type { Erklaerungen } from '../src/shared/erklaerungen'; import type { Glossar } from '../src/shared/glossar'; import type { Lernplan } from '../src/shared/lernplan'; import type { Lernuebersicht, Profil } from '../src/shared/lernstand'; import { NORMTEXTE_LEER } from '../src/shared/normtexte'; import { THEMEN_LEER } from '../src/shared/themen'; type TeilBridge = { -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] }; function nurText(text: string): RichText { return { text, segmente: [{ t: text }] }; } const FRAGE: Frage = { id: 'I.1-01', amtliche_nummer: '1.01', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 6, frage: nurText('Was regelt das Waffengesetz?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Den Umgang mit Waffen.'), korrekt: true, bilder: [] }, { label: 'b', inhalt: nurText('Den Handel mit Autos.'), korrekt: false, bilder: [] }, ], }; const KATALOG: Katalog = { meta: { titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog des Bundesverwaltungsamtes, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: '7e5e41fa', fragen_gesamt: 1, }, kapitel: [ { id: 'I', titel: 'Waffenrecht und sonstige Rechtsvorschriften', abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }], }, ], bilder: [], fragen: [FRAGE], }; function profil(id: number, name: string): Profil { return { id, name, pruefungstermin: null, kapitelAusschluss: [], erstelltAm: '2026-01-01T08:00:00.000Z', }; } const OLAF = profil(1, 'Olaf'); const ANNA = profil(2, 'Anna'); const UEBERSICHT: Lernuebersicht = { fragenGesamt: 575, beantwortet: 120, belegt: 48, reifegrad: 48 / 575, stufe: 'zurueck', deckelnd: [], faellig: 12, gemerkt: 5, offen: 0, fehler: 0, heuteRichtig: 8, heuteFalsch: 2, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, bereiche: [], themengruppen: [], }; const LERNPLAN: Lernplan = { termin: null, tageBisTermin: null, gesamtFragen: 8, nieBeantwortet: 6, faellig: 1, zielquote: 0.9, prognoseHeute: 0.25, prognoseAmTermin: null, pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 }, machbarkeit: 'kein_termin', sekundenProFrage: 25, }; const INFO: AnwendungsInfo = { anwendungsVersion: '0.0.0-attrappe', baukennung: 'abc1234', baustand: '2026-08-21', electronVersion: '43.4.1', chromeVersion: '140.0.0.0', nodeVersion: '22.19.1', plattform: 'win32', datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' }, }; const ERKLAERUNGEN: Erklaerungen = { meta: { version: 1, stand: '2026-08-20', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt.', }, zuFrage: {}, }; const GLOSSAR: Glossar = { meta: { version: 1, stand: '2026-08-21', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt.', }, eintraege: [], }; function attrappe(ueberschreibungen: TeilBridge = {}): TeilBridge { return { anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), ), einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)), katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')), erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)), glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)), /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0. Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext; den prueft tests/normstelle.test.tsx an eigenen Faellen. */ normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)), themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)), profilListe: vi.fn(() => Promise.resolve([OLAF, ANNA])), profilAnlegen: vi.fn(() => Promise.resolve(profil(3, 'Neu'))), profilAktualisieren: vi.fn(() => Promise.resolve(OLAF)), profilLoeschen: vi.fn(() => Promise.resolve([OLAF])), lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), ...ueberschreibungen, }; } function starten(bruecke: TeilBridge = attrappe()): ReturnType { vi.stubGlobal('lernApp', bruecke); const nutzer = userEvent.setup(); render(); return nutzer; } /** Beantwortet die Startfrage, damit der Startbildschirm sichtbar wird. */ async function durchsTor(nutzer: ReturnType): Promise { await nutzer.click(await screen.findByRole('button', { name: 'Weiter' })); await screen.findByRole('heading', { name: 'Heute lernen' }); } /** Öffnet den Abschnitt „Profile“ und liefert seine Liste. */ async function profilliste(): Promise { await screen.findByRole('heading', { name: 'Profile' }); const listen = screen.getAllByRole('list'); const treffer = listen.find((liste) => liste.classList.contains('profile__liste')); if (treffer === undefined) { throw new Error('Die Profilliste steht nicht im Dokument.'); } return treffer; } afterEach(() => { vi.unstubAllGlobals(); erklaerungenZwischenspeicherLeeren(); glossarZwischenspeicherLeeren(); normtexteZwischenspeicherLeeren(); themenZwischenspeicherLeeren(); }); describe('Startfrage „Wer lernt?“', () => { it('bleibt aus, wenn es nur ein Profil gibt', async () => { starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) })); /* Wer allein an seinem Rechner lernt, soll keinen Anmeldebildschirm wegklicken müssen. */ expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Wer lernt?' })).not.toBeInTheDocument(); }); it('nennt bei einem Profil auch nicht, wessen Lernstand da steht', async () => { starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) })); await screen.findByRole('heading', { name: 'Heute lernen' }); expect(screen.queryByText(/Lernstand von/)).not.toBeInTheDocument(); }); it('fragt ab dem zweiten Profil und wählt das zuletzt benutzte vor', async () => { starten( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, profilId: 2 }), ), }), ); expect(await screen.findByRole('heading', { name: 'Wer lernt?' })).toBeInTheDocument(); expect(screen.getByRole('radio', { name: 'Anna' })).toBeChecked(); expect(screen.getByRole('radio', { name: 'Olaf' })).not.toBeChecked(); // Der Startbildschirm ist so lange nicht da. expect(screen.queryByRole('heading', { name: 'Heute lernen' })).not.toBeInTheDocument(); }); it('übergeht ein gespeichertes Profil, das es nicht mehr gibt', async () => { starten( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, profilId: 99 }), ), }), ); /* Sonst stünde die Anwendung nach einem gelöschten Profil ohne Lernstand da. */ expect(await screen.findByRole('radio', { name: 'Olaf' })).toBeChecked(); }); it('wechselt nicht beim Auswählen, sondern erst beim Weitergehen', async () => { const uebersicht = vi.fn(() => Promise.resolve(UEBERSICHT)); const nutzer = starten(attrappe({ lernUebersicht: uebersicht })); await screen.findByRole('heading', { name: 'Wer lernt?' }); uebersicht.mockClear(); await nutzer.click(screen.getByRole('radio', { name: 'Anna' })); /* WCAG 3.2.2: Eine Auswahl allein darf den Zusammenhang nicht verändern. Vorher wurde nichts nachgeladen. */ expect(uebersicht).not.toHaveBeenCalled(); await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); await waitFor(() => { expect(uebersicht).toHaveBeenCalledWith(2); }); }); it('merkt sich die Wahl für den nächsten Start', async () => { const schreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)); const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben })); await screen.findByRole('heading', { name: 'Wer lernt?' }); await nutzer.click(screen.getByRole('radio', { name: 'Anna' })); await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); await waitFor(() => { expect(schreiben).toHaveBeenCalledWith({ profilId: 2 }); }); }); it('zeigt danach den Startbildschirm und nennt, wessen Lernstand er zeigt', async () => { const nutzer = starten(); await screen.findByRole('heading', { name: 'Wer lernt?' }); await nutzer.click(screen.getByRole('radio', { name: 'Anna' })); await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); const ueberschrift = await screen.findByRole('heading', { name: 'Heute lernen' }); expect(ueberschrift).toBeInTheDocument(); // Der Fokus wandert mit – sonst stünde man wieder ganz oben. expect(ueberschrift).toHaveFocus(); const einstieg = screen.getByRole('region', { name: 'Heute lernen' }); expect(within(einstieg).getByText('Anna')).toBeInTheDocument(); }); it('ist frei von automatisch prüfbaren Verstößen', async () => { starten(); await screen.findByRole('heading', { name: 'Wer lernt?' }); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]); }); }); describe('Erststart-Frage nach dem abwählbaren Kapitel', () => { /* Der Schalter dafür liegt unter „Ihr Lernplan“ – und wer nie hinunterscrollt, schleppt 89 von 575 Fragen mit, ein knappes Sechstel des Katalogs. Die Frage holt ihn dorthin, wo er wirkt. PLAN.md sagt drei Zielprofile zu (Sportschütze, Jäger, Wassersport). Nachgezählt trägt der Katalog das nicht: Die Fragen zum Wassersport liegen vollständig in Kapitel IV, die zu Jagd und Sportschießen verteilen sich auf drei bzw. vier Bereiche und sind Pflichtstoff. Abtrennbar ist genau ein Teil – deshalb eine Frage statt einer Profilwahl. */ const frisch = (ueberschreibungen: Partial = {}) => attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])), einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: false }), ), lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, beantwortet: 0 })), ...ueberschreibungen, }); it('fragt beim allerersten Start, bevor der Startbildschirm kommt', async () => { starten(frisch()); expect( await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }), ).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Heute lernen' })).not.toBeInTheDocument(); }); it('hat „mitlernen“ vorausgewählt – die vorsichtige Vorgabe', async () => { starten(frisch()); await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); expect(screen.getByRole('radio', { name: /Ja, mitlernen/u })).toBeChecked(); }); it('schaltet bei „mitlernen“ nur weiter und wählt nichts ab', async () => { const profilAktualisieren = vi.fn(() => Promise.resolve(OLAF)); const nutzer = starten(frisch({ profilAktualisieren })); await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); expect(profilAktualisieren).not.toHaveBeenCalled(); }); it('wählt bei „weglassen“ das Kapitel dauerhaft ab', async () => { const profilAktualisieren = vi.fn(() => Promise.resolve(OLAF)); const nutzer = starten(frisch({ profilAktualisieren })); await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); await nutzer.click(screen.getByRole('radio', { name: /Nein, weglassen/u })); await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); await waitFor(() => { expect(profilAktualisieren).toHaveBeenCalledWith( 1, expect.objectContaining({ kapitelAusschluss: ['IV'] }), ); }); }); it('merkt sich, dass gefragt wurde – sonst käme sie beim nächsten Start wieder', async () => { const einstellungenSchreiben = vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), ); const nutzer = starten(frisch({ einstellungenSchreiben })); await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); expect(einstellungenSchreiben).toHaveBeenCalledWith( expect.objectContaining({ zuschnittGefragt: true }), ); }); it('bleibt aus, wenn schon gelernt wurde', async () => { /* Wer schon gelernt hat, bekommt sie nicht mehr vorgesetzt – für ihn ist es kein erster Start, und der Schalter steht unter „Ihr Lernplan“. */ starten( frisch({ lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, beantwortet: 3 })) }), ); expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /Lernen Sie .* mit\?/u })).not.toBeInTheDocument(); }); it('kommt nach „Wer lernt?“, nicht davor', async () => { /* Erst wer, dann was: Die Kapitelwahl gehört zum Lernprofil. */ const nutzer = starten(frisch({ profilListe: vi.fn(() => Promise.resolve([OLAF, ANNA])) })); await screen.findByRole('heading', { name: 'Wer lernt?' }); expect(screen.queryByRole('heading', { name: /Lernen Sie .* mit\?/u })).not.toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); expect( await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }), ).toBeInTheDocument(); }); }); describe('Profile verwalten', () => { it('führt alle Profile auf und kennzeichnet das laufende', async () => { const nutzer = starten(); await durchsTor(nutzer); const liste = await profilliste(); const zeilen = within(liste).getAllByRole('listitem'); expect(zeilen).toHaveLength(2); expect(within(zeilen[0]!).getByText('– lernt gerade')).toBeInTheDocument(); }); it('lässt das letzte Profil nicht löschen', async () => { starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) })); await screen.findByRole('heading', { name: 'Heute lernen' }); const liste = await profilliste(); expect(within(liste).queryByRole('button', { name: /^Löschen/ })).not.toBeInTheDocument(); expect(screen.getByText(/Das letzte Profil lässt sich nicht löschen/)).toBeInTheDocument(); }); it('legt ein neues Profil an', async () => { const anlegen = vi.fn(() => Promise.resolve(profil(3, 'Bernd'))); const nutzer = starten(attrappe({ profilAnlegen: anlegen })); await durchsTor(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); await nutzer.type(screen.getByLabelText('Name des neuen Profils'), 'Bernd'); await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); await waitFor(() => { expect(anlegen).toHaveBeenCalledWith('Bernd'); }); }); it('weist einen leeren Namen ab, ohne den Anwendungskern zu fragen', async () => { const anlegen = vi.fn(() => Promise.resolve(profil(3, 'Neu'))); const nutzer = starten(attrappe({ profilAnlegen: anlegen })); await durchsTor(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); await nutzer.type(screen.getByLabelText('Name des neuen Profils'), ' '); await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); expect(await screen.findByText('Bitte geben Sie einen Namen ein.')).toBeInTheDocument(); expect(anlegen).not.toHaveBeenCalled(); expect(screen.getByLabelText('Name des neuen Profils')).toHaveFocus(); }); /* Der Fehlergrund muss am Feld nachlesbar bleiben (WCAG 3.3.1). Bis 0.22.0 hing er allein an `aria-errormessage`. Das werten VoiceOver und ältere NVDA- und JAWS-Stände nicht oder unvollständig aus: Beim erneuten Fokussieren hörte man dort nur „ungültig“, aber nicht warum – die `role="alert"`-Ansage beim Erscheinen fängt nur den ersten Augenblick ab. Die Prüfungswahl hält es seit jeher über `aria-describedby`; dies war die einzige Stelle im Baum, die davon abwich. */ it('verknüpft den Fehlergrund so, dass jeder Screenreader ihn am Feld findet', async () => { const nutzer = starten(attrappe()); await durchsTor(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); const feld = screen.getByLabelText('Name des neuen Profils'); await nutzer.type(feld, ' '); await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); await screen.findByText('Bitte geben Sie einen Namen ein.'); expect(feld).toHaveAttribute('aria-invalid', 'true'); const fehlerId = feld.getAttribute('aria-errormessage'); expect(fehlerId).not.toBeNull(); /* Beide Wege zeigen auf dieselbe Stelle – der Hilfetext bleibt daneben stehen, sonst verlöre man ihn im Fehlerfall. */ expect(feld.getAttribute('aria-describedby')?.split(' ')).toContain(fehlerId); expect(feld.getAttribute('aria-describedby')?.split(' ').length).toBe(2); }); it('nimmt den Fehlerverweis wieder heraus, sobald weitergetippt wird', async () => { /* Ein Verweis auf einen Fehlertext, den es nicht mehr gibt, ist eine kaputte Verknüpfung: Der Screenreader nennt dann eine leere Stelle. */ const nutzer = starten(attrappe()); await durchsTor(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); const feld = screen.getByLabelText('Name des neuen Profils'); await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); await screen.findByText('Bitte geben Sie einen Namen ein.'); await nutzer.type(feld, 'Bernd'); expect(feld).not.toHaveAttribute('aria-invalid'); expect(feld.getAttribute('aria-describedby')?.split(' ').length).toBe(1); }); it('benennt ein Profil um', async () => { const aktualisieren = vi.fn(() => Promise.resolve(profil(1, 'Olaf W.'))); const nutzer = starten(attrappe({ profilAktualisieren: aktualisieren })); await durchsTor(nutzer); const liste = await profilliste(); const zeile = within(liste).getAllByRole('listitem')[0]!; await nutzer.click(within(zeile).getByRole('button', { name: /^Umbenennen/ })); const feld = screen.getByLabelText('Neuer Name'); expect(feld).toHaveValue('Olaf'); await nutzer.clear(feld); await nutzer.type(feld, 'Olaf W.'); await nutzer.click(screen.getByRole('button', { name: 'Namen speichern' })); await waitFor(() => { expect(aktualisieren).toHaveBeenCalledWith(1, { name: 'Olaf W.' }); }); }); it('gibt den Fokus nach dem Abbrechen an den auslösenden Knopf zurück', async () => { const nutzer = starten(); await durchsTor(nutzer); const liste = await profilliste(); const zeile = within(liste).getAllByRole('listitem')[0]!; const ausloeser = within(zeile).getByRole('button', { name: /^Umbenennen/ }); await nutzer.click(ausloeser); await nutzer.click(screen.getByRole('button', { name: 'Abbrechen' })); /* Ohne diesen Rücksprung fiele der Fokus auf `` – wer per Tastatur bedient, stünde wieder am Seitenanfang. */ await waitFor(() => { expect(within(zeile).getByRole('button', { name: /^Umbenennen/ })).toHaveFocus(); }); }); it('löscht erst nach einer Rückfrage', async () => { const loeschen = vi.fn(() => Promise.resolve([OLAF])); const nutzer = starten(attrappe({ profilLoeschen: loeschen })); await durchsTor(nutzer); const liste = await profilliste(); const zeile = within(liste).getAllByRole('listitem')[1]!; await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ })); expect( await screen.findByRole('heading', { name: 'Profil „Anna“ wirklich löschen?' }), ).toBeInTheDocument(); expect(loeschen).not.toHaveBeenCalled(); await nutzer.click(screen.getByRole('button', { name: 'Nein, behalten' })); expect(loeschen).not.toHaveBeenCalled(); await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ })); await nutzer.click(screen.getByRole('button', { name: 'Ja, Profil löschen' })); await waitFor(() => { expect(loeschen).toHaveBeenCalledWith(2); }); }); /* Die Knöpfe tragen `aria-disabled` und bleiben deshalb klickbar – ein `disabled` verlöre den Fokus. Ohne Riegel im Behandler rief der zweite Klick den Kanal ein zweites Mal: Bei zwei Profilen ging der erste Versuch durch, der zweite lief in „Das letzte Profil lässt sich nicht löschen“, und der Nutzer las eine rote Fehlermeldung, obwohl sein Profil weg war. */ it('löscht auch bei zwei schnellen Klicks nur einmal', async () => { /* Der Kanal bleibt offen: Nur solange er läuft, ist der Riegel zu beobachten. Aufgelöst wird er am Ende, damit der Test nichts Schwebendes hinterlässt. */ const gebremst: { fertig: ((profile: Profil[]) => void) | undefined } = { fertig: undefined }; const loeschen = vi.fn( () => new Promise((aufloesen) => { gebremst.fertig = aufloesen; }), ); const nutzer = starten(attrappe({ profilLoeschen: loeschen })); await durchsTor(nutzer); const liste = await profilliste(); const zeile = within(liste).getAllByRole('listitem')[1]!; await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ })); const ja = screen.getByRole('button', { name: 'Ja, Profil löschen' }); await nutzer.click(ja); await nutzer.click(ja); expect(loeschen).toHaveBeenCalledTimes(1); gebremst.fertig?.([OLAF]); await waitFor(() => { expect(loeschen).toHaveBeenCalledTimes(1); }); }); it('wechselt über die Liste und lädt den Lernstand des neuen Profils', async () => { const uebersicht = vi.fn(() => Promise.resolve(UEBERSICHT)); const nutzer = starten(attrappe({ lernUebersicht: uebersicht })); await durchsTor(nutzer); uebersicht.mockClear(); const liste = await profilliste(); const zeile = within(liste).getAllByRole('listitem')[1]!; await nutzer.click(within(zeile).getByRole('button', { name: /^Wechseln/ })); await waitFor(() => { expect(uebersicht).toHaveBeenCalledWith(2); }); expect(await screen.findByText('Sie lernen jetzt als „Anna“.')).toBeInTheDocument(); }); it('sagt es, wenn der Anwendungskern den Kanal nicht anbietet', async () => { const bruecke = attrappe(); Reflect.deleteProperty(bruecke, 'profilAnlegen'); const nutzer = starten(bruecke); await durchsTor(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); await nutzer.type(screen.getByLabelText('Name des neuen Profils'), 'Bernd'); await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); expect(await screen.findByText(/profil:anlegen/)).toBeInTheDocument(); }); });