waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests renderer-profile.test.tsx

24,5 KB Rohdatei
app/tests/renderer-profile.test.tsx — 640 Zeilen
1 /**
2 * Verhaltenstests der Lernprofile.
3 *
4 * Schwerpunkt: Ein Profil bleibt unsichtbar, mehrere werden gefragt – und
5 * nichts wechselt, ohne dass jemand es verlangt hat. Der Anwendungskern
6 * konnte Profile von Anfang an; geprüft wird hier der Weg dorthin.
7 */
8
9 import { render, screen, waitFor, within } from '@testing-library/react';
10 import userEvent from '@testing-library/user-event';
11 import axe from 'axe-core';
12 import { jsdomOptionen } from '../src/shared/wcag';
13 import { afterEach, describe, expect, it, vi } from 'vitest';
14
15 import { App } from '../src/renderer/src/App';
16 import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
17 import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar';
18 import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte';
19 import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen';
20 import type { Frage, Katalog, RichText } from '../src/shared/katalog';
21 import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc';
22 import type { Erklaerungen } from '../src/shared/erklaerungen';
23 import type { Glossar } from '../src/shared/glossar';
24 import type { Lernplan } from '../src/shared/lernplan';
25 import type { Lernuebersicht, Profil } from '../src/shared/lernstand';
26 import { NORMTEXTE_LEER } from '../src/shared/normtexte';
27 import { THEMEN_LEER } from '../src/shared/themen';
28
29 type TeilBridge = { -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] };
30
31 function nurText(text: string): RichText {
32 return { text, segmente: [{ t: text }] };
33 }
34
35 const FRAGE: Frage = {
36 id: 'I.1-01',
37 amtliche_nummer: '1.01',
38 kapitel: 'I',
39 abschnitt: 'I.1',
40 typ: 'mc',
41 seite: 6,
42 frage: nurText('Was regelt das Waffengesetz?'),
43 bilder: [],
44 optionen: [
45 { label: 'a', inhalt: nurText('Den Umgang mit Waffen.'), korrekt: true, bilder: [] },
46 { label: 'b', inhalt: nurText('Den Handel mit Autos.'), korrekt: false, bilder: [] },
47 ],
48 };
49
50 const KATALOG: Katalog = {
51 meta: {
52 titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)',
53 herausgeber: 'Bundesverwaltungsamt',
54 stand: '2024-12-16',
55 quellenangabe: 'Amtlicher Fragenkatalog des Bundesverwaltungsamtes, Stand 16.12.2024.',
56 quelle_url: 'https://www.bva.bund.de/',
57 quelldatei_sha256: '7e5e41fa',
58 fragen_gesamt: 1,
59 },
60 kapitel: [
61 {
62 id: 'I',
63 titel: 'Waffenrecht und sonstige Rechtsvorschriften',
64 abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }],
65 },
66 ],
67 bilder: [],
68 fragen: [FRAGE],
69 };
70
71 function profil(id: number, name: string): Profil {
72 return {
73 id,
74 name,
75 pruefungstermin: null,
76 kapitelAusschluss: [],
77 erstelltAm: '2026-01-01T08:00:00.000Z',
78 };
79 }
80
81 const OLAF = profil(1, 'Olaf');
82 const ANNA = profil(2, 'Anna');
83
84 const UEBERSICHT: Lernuebersicht = {
85 fragenGesamt: 575,
86 beantwortet: 120,
87 belegt: 48,
88 reifegrad: 48 / 575,
89 stufe: 'zurueck',
90 deckelnd: [],
91 faellig: 12,
92 gemerkt: 5,
93 offen: 0,
94 fehler: 0,
95 heuteRichtig: 8,
96 heuteFalsch: 2,
97 heuteBearbeitet: 0,
98 tageSeitLetzterAntwort: null,
99 bereiche: [],
100 themengruppen: [],
101 };
102
103 const LERNPLAN: Lernplan = {
104 termin: null,
105 tageBisTermin: null,
106 gesamtFragen: 8,
107 nieBeantwortet: 6,
108 faellig: 1,
109 zielquote: 0.9,
110 prognoseHeute: 0.25,
111 prognoseAmTermin: null,
112 pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 },
113 machbarkeit: 'kein_termin',
114 sekundenProFrage: 25,
115 };
116
117 const INFO: AnwendungsInfo = {
118 anwendungsVersion: '0.0.0-attrappe',
119 baukennung: 'abc1234',
120 baustand: '2026-08-21',
121 electronVersion: '43.4.1',
122 chromeVersion: '140.0.0.0',
123 nodeVersion: '22.19.1',
124 plattform: 'win32',
125 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
126 };
127
128 const ERKLAERUNGEN: Erklaerungen = {
129 meta: {
130 version: 1,
131 stand: '2026-08-20',
132 gesetzesstand: { WaffG: 'Pruefstand' },
133 hinweis: 'Eigener redaktioneller Inhalt.',
134 },
135 zuFrage: {},
136 };
137
138 const GLOSSAR: Glossar = {
139 meta: {
140 version: 1,
141 stand: '2026-08-21',
142 gesetzesstand: { WaffG: 'Pruefstand' },
143 hinweis: 'Eigener redaktioneller Inhalt.',
144 },
145 eintraege: [],
146 };
147
148 function attrappe(ueberschreibungen: TeilBridge = {}): TeilBridge {
149 return {
150 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
151 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
152 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
153 einstellungenLesen: vi.fn(() =>
154 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
155 ),
156 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
157 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
158 katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
159 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
160 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
161 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
162 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
163 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
164 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
165 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
166 profilListe: vi.fn(() => Promise.resolve([OLAF, ANNA])),
167 profilAnlegen: vi.fn(() => Promise.resolve(profil(3, 'Neu'))),
168 profilAktualisieren: vi.fn(() => Promise.resolve(OLAF)),
169 profilLoeschen: vi.fn(() => Promise.resolve([OLAF])),
170 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
171 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
172 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
173 ...ueberschreibungen,
174 };
175 }
176
177 function starten(bruecke: TeilBridge = attrappe()): ReturnType<typeof userEvent.setup> {
178 vi.stubGlobal('lernApp', bruecke);
179 const nutzer = userEvent.setup();
180 render(<App />);
181 return nutzer;
182 }
183
184 /** Beantwortet die Startfrage, damit der Startbildschirm sichtbar wird. */
185 async function durchsTor(nutzer: ReturnType<typeof userEvent.setup>): Promise<void> {
186 await nutzer.click(await screen.findByRole('button', { name: 'Weiter' }));
187 await screen.findByRole('heading', { name: 'Heute lernen' });
188 }
189
190 /** Öffnet den Abschnitt „Profile“ und liefert seine Liste. */
191 async function profilliste(): Promise<HTMLElement> {
192 await screen.findByRole('heading', { name: 'Profile' });
193 const listen = screen.getAllByRole('list');
194 const treffer = listen.find((liste) => liste.classList.contains('profile__liste'));
195 if (treffer === undefined) {
196 throw new Error('Die Profilliste steht nicht im Dokument.');
197 }
198 return treffer;
199 }
200
201 afterEach(() => {
202 vi.unstubAllGlobals();
203 erklaerungenZwischenspeicherLeeren();
204 glossarZwischenspeicherLeeren();
205 normtexteZwischenspeicherLeeren();
206 themenZwischenspeicherLeeren();
207 });
208
209 describe('Startfrage „Wer lernt?“', () => {
210 it('bleibt aus, wenn es nur ein Profil gibt', async () => {
211 starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) }));
212
213 /* Wer allein an seinem Rechner lernt, soll keinen Anmeldebildschirm
214 wegklicken müssen. */
215 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
216 expect(screen.queryByRole('heading', { name: 'Wer lernt?' })).not.toBeInTheDocument();
217 });
218
219 it('nennt bei einem Profil auch nicht, wessen Lernstand da steht', async () => {
220 starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) }));
221
222 await screen.findByRole('heading', { name: 'Heute lernen' });
223 expect(screen.queryByText(/Lernstand von/)).not.toBeInTheDocument();
224 });
225
226 it('fragt ab dem zweiten Profil und wählt das zuletzt benutzte vor', async () => {
227 starten(
228 attrappe({
229 einstellungenLesen: vi.fn(() =>
230 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, profilId: 2 }),
231 ),
232 }),
233 );
234
235 expect(await screen.findByRole('heading', { name: 'Wer lernt?' })).toBeInTheDocument();
236 expect(screen.getByRole('radio', { name: 'Anna' })).toBeChecked();
237 expect(screen.getByRole('radio', { name: 'Olaf' })).not.toBeChecked();
238 // Der Startbildschirm ist so lange nicht da.
239 expect(screen.queryByRole('heading', { name: 'Heute lernen' })).not.toBeInTheDocument();
240 });
241
242 it('übergeht ein gespeichertes Profil, das es nicht mehr gibt', async () => {
243 starten(
244 attrappe({
245 einstellungenLesen: vi.fn(() =>
246 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, profilId: 99 }),
247 ),
248 }),
249 );
250
251 /* Sonst stünde die Anwendung nach einem gelöschten Profil ohne
252 Lernstand da. */
253 expect(await screen.findByRole('radio', { name: 'Olaf' })).toBeChecked();
254 });
255
256 it('wechselt nicht beim Auswählen, sondern erst beim Weitergehen', async () => {
257 const uebersicht = vi.fn(() => Promise.resolve(UEBERSICHT));
258 const nutzer = starten(attrappe({ lernUebersicht: uebersicht }));
259
260 await screen.findByRole('heading', { name: 'Wer lernt?' });
261 uebersicht.mockClear();
262
263 await nutzer.click(screen.getByRole('radio', { name: 'Anna' }));
264 /* WCAG 3.2.2: Eine Auswahl allein darf den Zusammenhang nicht
265 verändern. Vorher wurde nichts nachgeladen. */
266 expect(uebersicht).not.toHaveBeenCalled();
267
268 await nutzer.click(screen.getByRole('button', { name: 'Weiter' }));
269 await waitFor(() => {
270 expect(uebersicht).toHaveBeenCalledWith(2);
271 });
272 });
273
274 it('merkt sich die Wahl für den nächsten Start', async () => {
275 const schreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD));
276 const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben }));
277
278 await screen.findByRole('heading', { name: 'Wer lernt?' });
279 await nutzer.click(screen.getByRole('radio', { name: 'Anna' }));
280 await nutzer.click(screen.getByRole('button', { name: 'Weiter' }));
281
282 await waitFor(() => {
283 expect(schreiben).toHaveBeenCalledWith({ profilId: 2 });
284 });
285 });
286
287 it('zeigt danach den Startbildschirm und nennt, wessen Lernstand er zeigt', async () => {
288 const nutzer = starten();
289
290 await screen.findByRole('heading', { name: 'Wer lernt?' });
291 await nutzer.click(screen.getByRole('radio', { name: 'Anna' }));
292 await nutzer.click(screen.getByRole('button', { name: 'Weiter' }));
293
294 const ueberschrift = await screen.findByRole('heading', { name: 'Heute lernen' });
295 expect(ueberschrift).toBeInTheDocument();
296 // Der Fokus wandert mit – sonst stünde man wieder ganz oben.
297 expect(ueberschrift).toHaveFocus();
298
299 const einstieg = screen.getByRole('region', { name: 'Heute lernen' });
300 expect(within(einstieg).getByText('Anna')).toBeInTheDocument();
301 });
302
303 it('ist frei von automatisch prüfbaren Verstößen', async () => {
304 starten();
305 await screen.findByRole('heading', { name: 'Wer lernt?' });
306
307 const ergebnis = await axe.run(document.body, jsdomOptionen());
308 expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]);
309 });
310 });
311
312 describe('Erststart-Frage nach dem abwählbaren Kapitel', () => {
313 /*
314 Der Schalter dafür liegt unter „Ihr Lernplan“ – und wer nie
315 hinunterscrollt, schleppt 89 von 575 Fragen mit, ein knappes Sechstel des
316 Katalogs. Die Frage holt ihn dorthin, wo er wirkt.
317
318 PLAN.md sagt drei Zielprofile zu (Sportschütze, Jäger, Wassersport).
319 Nachgezählt trägt der Katalog das nicht: Die Fragen zum Wassersport liegen
320 vollständig in Kapitel IV, die zu Jagd und Sportschießen verteilen sich
321 auf drei bzw. vier Bereiche und sind Pflichtstoff. Abtrennbar ist genau
322 ein Teil – deshalb eine Frage statt einer Profilwahl.
323 */
324 const frisch = (ueberschreibungen: Partial<LernAppBridge> = {}) =>
325 attrappe({
326 profilListe: vi.fn(() => Promise.resolve([OLAF])),
327 einstellungenLesen: vi.fn(() =>
328 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: false }),
329 ),
330 lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, beantwortet: 0 })),
331 ...ueberschreibungen,
332 });
333
334 it('fragt beim allerersten Start, bevor der Startbildschirm kommt', async () => {
335 starten(frisch());
336
337 expect(
338 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }),
339 ).toBeInTheDocument();
340 expect(screen.queryByRole('heading', { name: 'Heute lernen' })).not.toBeInTheDocument();
341 });
342
343 it('hat „mitlernen“ vorausgewählt – die vorsichtige Vorgabe', async () => {
344 starten(frisch());
345
346 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u });
347 expect(screen.getByRole('radio', { name: /Ja, mitlernen/u })).toBeChecked();
348 });
349
350 it('schaltet bei „mitlernen“ nur weiter und wählt nichts ab', async () => {
351 const profilAktualisieren = vi.fn(() => Promise.resolve(OLAF));
352 const nutzer = starten(frisch({ profilAktualisieren }));
353
354 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u });
355 await nutzer.click(screen.getByRole('button', { name: 'Weiter' }));
356
357 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
358 expect(profilAktualisieren).not.toHaveBeenCalled();
359 });
360
361 it('wählt bei „weglassen“ das Kapitel dauerhaft ab', async () => {
362 const profilAktualisieren = vi.fn(() => Promise.resolve(OLAF));
363 const nutzer = starten(frisch({ profilAktualisieren }));
364
365 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u });
366 await nutzer.click(screen.getByRole('radio', { name: /Nein, weglassen/u }));
367 await nutzer.click(screen.getByRole('button', { name: 'Weiter' }));
368
369 await waitFor(() => {
370 expect(profilAktualisieren).toHaveBeenCalledWith(
371 1,
372 expect.objectContaining({ kapitelAusschluss: ['IV'] }),
373 );
374 });
375 });
376
377 it('merkt sich, dass gefragt wurde – sonst käme sie beim nächsten Start wieder', async () => {
378 const einstellungenSchreiben = vi.fn(() =>
379 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
380 );
381 const nutzer = starten(frisch({ einstellungenSchreiben }));
382
383 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u });
384 await nutzer.click(screen.getByRole('button', { name: 'Weiter' }));
385
386 expect(einstellungenSchreiben).toHaveBeenCalledWith(
387 expect.objectContaining({ zuschnittGefragt: true }),
388 );
389 });
390
391 it('bleibt aus, wenn schon gelernt wurde', async () => {
392 /* Wer schon gelernt hat, bekommt sie nicht mehr vorgesetzt – für ihn ist
393 es kein erster Start, und der Schalter steht unter „Ihr Lernplan“. */
394 starten(
395 frisch({ lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, beantwortet: 3 })) }),
396 );
397
398 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
399 expect(screen.queryByRole('heading', { name: /Lernen Sie .* mit\?/u })).not.toBeInTheDocument();
400 });
401
402 it('kommt nach „Wer lernt?“, nicht davor', async () => {
403 /* Erst wer, dann was: Die Kapitelwahl gehört zum Lernprofil. */
404 const nutzer = starten(frisch({ profilListe: vi.fn(() => Promise.resolve([OLAF, ANNA])) }));
405
406 await screen.findByRole('heading', { name: 'Wer lernt?' });
407 expect(screen.queryByRole('heading', { name: /Lernen Sie .* mit\?/u })).not.toBeInTheDocument();
408
409 await nutzer.click(screen.getByRole('button', { name: 'Weiter' }));
410
411 expect(
412 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }),
413 ).toBeInTheDocument();
414 });
415 });
416
417 describe('Profile verwalten', () => {
418 it('führt alle Profile auf und kennzeichnet das laufende', async () => {
419 const nutzer = starten();
420 await durchsTor(nutzer);
421
422 const liste = await profilliste();
423 const zeilen = within(liste).getAllByRole('listitem');
424 expect(zeilen).toHaveLength(2);
425 expect(within(zeilen[0]!).getByText('– lernt gerade')).toBeInTheDocument();
426 });
427
428 it('lässt das letzte Profil nicht löschen', async () => {
429 starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) }));
430 await screen.findByRole('heading', { name: 'Heute lernen' });
431
432 const liste = await profilliste();
433 expect(within(liste).queryByRole('button', { name: /^Löschen/ })).not.toBeInTheDocument();
434 expect(screen.getByText(/Das letzte Profil lässt sich nicht löschen/)).toBeInTheDocument();
435 });
436
437 it('legt ein neues Profil an', async () => {
438 const anlegen = vi.fn(() => Promise.resolve(profil(3, 'Bernd')));
439 const nutzer = starten(attrappe({ profilAnlegen: anlegen }));
440 await durchsTor(nutzer);
441
442 await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' }));
443 await nutzer.type(screen.getByLabelText('Name des neuen Profils'), 'Bernd');
444 await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' }));
445
446 await waitFor(() => {
447 expect(anlegen).toHaveBeenCalledWith('Bernd');
448 });
449 });
450
451 it('weist einen leeren Namen ab, ohne den Anwendungskern zu fragen', async () => {
452 const anlegen = vi.fn(() => Promise.resolve(profil(3, 'Neu')));
453 const nutzer = starten(attrappe({ profilAnlegen: anlegen }));
454 await durchsTor(nutzer);
455
456 await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' }));
457 await nutzer.type(screen.getByLabelText('Name des neuen Profils'), ' ');
458 await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' }));
459
460 expect(await screen.findByText('Bitte geben Sie einen Namen ein.')).toBeInTheDocument();
461 expect(anlegen).not.toHaveBeenCalled();
462 expect(screen.getByLabelText('Name des neuen Profils')).toHaveFocus();
463 });
464
465 /*
466 Der Fehlergrund muss am Feld nachlesbar bleiben (WCAG 3.3.1).
467
468 Bis 0.22.0 hing er allein an `aria-errormessage`. Das werten VoiceOver und
469 ältere NVDA- und JAWS-Stände nicht oder unvollständig aus: Beim erneuten
470 Fokussieren hörte man dort nur „ungültig“, aber nicht warum – die
471 `role="alert"`-Ansage beim Erscheinen fängt nur den ersten Augenblick ab.
472 Die Prüfungswahl hält es seit jeher über `aria-describedby`; dies war die
473 einzige Stelle im Baum, die davon abwich.
474 */
475 it('verknüpft den Fehlergrund so, dass jeder Screenreader ihn am Feld findet', async () => {
476 const nutzer = starten(attrappe());
477 await durchsTor(nutzer);
478
479 await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' }));
480 const feld = screen.getByLabelText('Name des neuen Profils');
481 await nutzer.type(feld, ' ');
482 await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' }));
483
484 await screen.findByText('Bitte geben Sie einen Namen ein.');
485 expect(feld).toHaveAttribute('aria-invalid', 'true');
486
487 const fehlerId = feld.getAttribute('aria-errormessage');
488 expect(fehlerId).not.toBeNull();
489 /* Beide Wege zeigen auf dieselbe Stelle – der Hilfetext bleibt daneben
490 stehen, sonst verlöre man ihn im Fehlerfall. */
491 expect(feld.getAttribute('aria-describedby')?.split(' ')).toContain(fehlerId);
492 expect(feld.getAttribute('aria-describedby')?.split(' ').length).toBe(2);
493 });
494
495 it('nimmt den Fehlerverweis wieder heraus, sobald weitergetippt wird', async () => {
496 /* Ein Verweis auf einen Fehlertext, den es nicht mehr gibt, ist eine
497 kaputte Verknüpfung: Der Screenreader nennt dann eine leere Stelle. */
498 const nutzer = starten(attrappe());
499 await durchsTor(nutzer);
500
501 await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' }));
502 const feld = screen.getByLabelText('Name des neuen Profils');
503 await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' }));
504 await screen.findByText('Bitte geben Sie einen Namen ein.');
505
506 await nutzer.type(feld, 'Bernd');
507
508 expect(feld).not.toHaveAttribute('aria-invalid');
509 expect(feld.getAttribute('aria-describedby')?.split(' ').length).toBe(1);
510 });
511
512 it('benennt ein Profil um', async () => {
513 const aktualisieren = vi.fn(() => Promise.resolve(profil(1, 'Olaf W.')));
514 const nutzer = starten(attrappe({ profilAktualisieren: aktualisieren }));
515 await durchsTor(nutzer);
516
517 const liste = await profilliste();
518 const zeile = within(liste).getAllByRole('listitem')[0]!;
519 await nutzer.click(within(zeile).getByRole('button', { name: /^Umbenennen/ }));
520
521 const feld = screen.getByLabelText('Neuer Name');
522 expect(feld).toHaveValue('Olaf');
523 await nutzer.clear(feld);
524 await nutzer.type(feld, 'Olaf W.');
525 await nutzer.click(screen.getByRole('button', { name: 'Namen speichern' }));
526
527 await waitFor(() => {
528 expect(aktualisieren).toHaveBeenCalledWith(1, { name: 'Olaf W.' });
529 });
530 });
531
532 it('gibt den Fokus nach dem Abbrechen an den auslösenden Knopf zurück', async () => {
533 const nutzer = starten();
534 await durchsTor(nutzer);
535
536 const liste = await profilliste();
537 const zeile = within(liste).getAllByRole('listitem')[0]!;
538 const ausloeser = within(zeile).getByRole('button', { name: /^Umbenennen/ });
539 await nutzer.click(ausloeser);
540
541 await nutzer.click(screen.getByRole('button', { name: 'Abbrechen' }));
542
543 /* Ohne diesen Rücksprung fiele der Fokus auf `<body>` – wer per
544 Tastatur bedient, stünde wieder am Seitenanfang. */
545 await waitFor(() => {
546 expect(within(zeile).getByRole('button', { name: /^Umbenennen/ })).toHaveFocus();
547 });
548 });
549
550 it('löscht erst nach einer Rückfrage', async () => {
551 const loeschen = vi.fn(() => Promise.resolve([OLAF]));
552 const nutzer = starten(attrappe({ profilLoeschen: loeschen }));
553 await durchsTor(nutzer);
554
555 const liste = await profilliste();
556 const zeile = within(liste).getAllByRole('listitem')[1]!;
557 await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ }));
558
559 expect(
560 await screen.findByRole('heading', { name: 'Profil „Anna“ wirklich löschen?' }),
561 ).toBeInTheDocument();
562 expect(loeschen).not.toHaveBeenCalled();
563
564 await nutzer.click(screen.getByRole('button', { name: 'Nein, behalten' }));
565 expect(loeschen).not.toHaveBeenCalled();
566
567 await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ }));
568 await nutzer.click(screen.getByRole('button', { name: 'Ja, Profil löschen' }));
569
570 await waitFor(() => {
571 expect(loeschen).toHaveBeenCalledWith(2);
572 });
573 });
574
575 /*
576 Die Knöpfe tragen `aria-disabled` und bleiben deshalb klickbar – ein
577 `disabled` verlöre den Fokus. Ohne Riegel im Behandler rief der zweite
578 Klick den Kanal ein zweites Mal: Bei zwei Profilen ging der erste Versuch
579 durch, der zweite lief in „Das letzte Profil lässt sich nicht löschen“,
580 und der Nutzer las eine rote Fehlermeldung, obwohl sein Profil weg war.
581 */
582 it('löscht auch bei zwei schnellen Klicks nur einmal', async () => {
583 /* Der Kanal bleibt offen: Nur solange er läuft, ist der Riegel zu
584 beobachten. Aufgelöst wird er am Ende, damit der Test nichts
585 Schwebendes hinterlässt. */
586 const gebremst: { fertig: ((profile: Profil[]) => void) | undefined } = { fertig: undefined };
587 const loeschen = vi.fn(
588 () =>
589 new Promise<Profil[]>((aufloesen) => {
590 gebremst.fertig = aufloesen;
591 }),
592 );
593 const nutzer = starten(attrappe({ profilLoeschen: loeschen }));
594 await durchsTor(nutzer);
595
596 const liste = await profilliste();
597 const zeile = within(liste).getAllByRole('listitem')[1]!;
598 await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ }));
599
600 const ja = screen.getByRole('button', { name: 'Ja, Profil löschen' });
601 await nutzer.click(ja);
602 await nutzer.click(ja);
603
604 expect(loeschen).toHaveBeenCalledTimes(1);
605
606 gebremst.fertig?.([OLAF]);
607 await waitFor(() => {
608 expect(loeschen).toHaveBeenCalledTimes(1);
609 });
610 });
611
612 it('wechselt über die Liste und lädt den Lernstand des neuen Profils', async () => {
613 const uebersicht = vi.fn(() => Promise.resolve(UEBERSICHT));
614 const nutzer = starten(attrappe({ lernUebersicht: uebersicht }));
615 await durchsTor(nutzer);
616 uebersicht.mockClear();
617
618 const liste = await profilliste();
619 const zeile = within(liste).getAllByRole('listitem')[1]!;
620 await nutzer.click(within(zeile).getByRole('button', { name: /^Wechseln/ }));
621
622 await waitFor(() => {
623 expect(uebersicht).toHaveBeenCalledWith(2);
624 });
625 expect(await screen.findByText('Sie lernen jetzt als „Anna“.')).toBeInTheDocument();
626 });
627
628 it('sagt es, wenn der Anwendungskern den Kanal nicht anbietet', async () => {
629 const bruecke = attrappe();
630 Reflect.deleteProperty(bruecke, 'profilAnlegen');
631 const nutzer = starten(bruecke);
632 await durchsTor(nutzer);
633
634 await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' }));
635 await nutzer.type(screen.getByLabelText('Name des neuen Profils'), 'Bernd');
636 await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' }));
637
638 expect(await screen.findByText(/profil:anlegen/)).toBeInTheDocument();
639 });
640 });