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