waffensachkunde

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

/ app tests renderer-einstellungen.test.tsx

39,4 KB Rohdatei
app/tests/renderer-einstellungen.test.tsx — 1082 Zeilen
1 /**
2 * Die beiden Einstellungen, die zuvor nur im Vertrag standen.
3 *
4 * `optionenMischen` und `vorlesen` waren in `Einstellungen` deklariert, mit
5 * Standardwert versehen und vom Anwendungskern gespeichert – nur wirkte
6 * keine von beiden. Diese Datei prüft, dass sie ankommen: in der
7 * Zusammenstellung der Sitzung, im Prüfungsauftrag und in der Sprachausgabe.
8 *
9 * Die Sprachausgabe wird dabei nachgebildet. Ob dieses Gerät eine deutsche
10 * Stimme besitzt, entscheidet das Betriebssystem und darf keinen Testlauf
11 * scheitern lassen; geprüft wird, **was** gesprochen würde und **wann**.
12 */
13
14 import { render, screen, waitFor } from '@testing-library/react';
15 import userEvent from '@testing-library/user-event';
16 import { afterEach, describe, expect, it, vi } from 'vitest';
17
18 import { App } from '../src/renderer/src/App';
19 import { ANZEIGEGROESSEN, anzeigegroesseText } from '../src/shared/ansicht';
20 import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
21 import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar';
22 import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte';
23 import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen';
24 import type { Erklaerungen } from '../src/shared/erklaerungen';
25 import type { Glossar } from '../src/shared/glossar';
26 import {
27 EINSTELLUNGEN_STANDARD,
28 type AnwendungsInfo,
29 type Einstellungen,
30 type LernAppBridge,
31 } from '../src/shared/ipc';
32 import type { Frage, Katalog, RichText } from '../src/shared/katalog';
33 import type { Lernplan } from '../src/shared/lernplan';
34 import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand';
35 import { NORMTEXTE_LEER } from '../src/shared/normtexte';
36 import { THEMEN_LEER } from '../src/shared/themen';
37
38 type Nutzer = ReturnType<typeof userEvent.setup>;
39 type TeilBridge = Partial<LernAppBridge>;
40
41 // ─── Katalogattrappe ────────────────────────────────────────────────────
42
43 function nurText(text: string): RichText {
44 return { text, segmente: [{ t: text }] };
45 }
46
47 const FRAGE: Frage = {
48 id: 'I.1-87',
49 amtliche_nummer: '1.87',
50 kapitel: 'I',
51 abschnitt: 'I.1',
52 typ: 'mc',
53 seite: 23,
54 frage: nurText('Wonach richtet sich der Erwerb nach § 12 Abs. 4 WaffG?'),
55 bilder: [],
56 optionen: [
57 { label: 'a', inhalt: nurText('Nach dem Beschussgesetz'), korrekt: false, bilder: [] },
58 { label: 'b', inhalt: nurText('Nach dem Waffengesetz'), korrekt: true, bilder: [] },
59 { label: 'c', inhalt: nurText('Nach dem Sprengstoffgesetz'), korrekt: false, bilder: [] },
60 ],
61 };
62
63 const KATALOG: Katalog = {
64 meta: {
65 titel: 'Attrappe',
66 herausgeber: 'Attrappe',
67 stand: '2024-01-01',
68 quellenangabe: 'Attrappe',
69 quelle_url: 'https://example.invalid/',
70 quelldatei_sha256: '0'.repeat(64),
71 fragen_gesamt: 1,
72 },
73 kapitel: [
74 {
75 id: 'I',
76 titel: 'Waffenrecht',
77 abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }],
78 },
79 ],
80 fragen: [FRAGE],
81 bilder: [],
82 };
83
84 const ERKLAERUNGEN: Erklaerungen = {
85 meta: { version: 1, stand: '2026-08-01', hinweis: 'Attrappe', gesetzesstand: {} },
86 zuFrage: {
87 'I.1-87': {
88 kurz: 'Der Erwerb richtet sich nach dem WaffG.',
89 text: 'Ausführlich: Der Erwerb richtet sich nach § 12 Abs. 4 WaffG.',
90 fundstellen: [{ gesetz: 'WaffG', norm: '§ 12', absatz: '4' }],
91 },
92 },
93 };
94
95 const GLOSSAR: Glossar = {
96 meta: { version: 1, stand: '2026-08-01', hinweis: 'Attrappe', gesetzesstand: {} },
97 eintraege: [],
98 };
99
100 const PROFIL: Profil = {
101 id: 1,
102 name: 'Standard',
103 pruefungstermin: null,
104 kapitelAusschluss: [],
105 erstelltAm: '2026-01-01T00:00:00.000Z',
106 };
107
108 const LERNPLAN: Lernplan = {
109 termin: null,
110 tageBisTermin: null,
111 gesamtFragen: 1,
112 nieBeantwortet: 1,
113 faellig: 0,
114 zielquote: 0.9,
115 prognoseHeute: 0,
116 prognoseAmTermin: null,
117 pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 },
118 machbarkeit: 'kein_termin',
119 sekundenProFrage: 25,
120 };
121
122 const UEBERSICHT: Lernuebersicht = {
123 fragenGesamt: 1,
124 beantwortet: 0,
125 belegt: 0,
126 reifegrad: 0 / 1,
127 stufe: 'ohne_beleg',
128 deckelnd: [],
129 faellig: 0,
130 gemerkt: 0,
131 offen: 0,
132 fehler: 0,
133 heuteRichtig: 0,
134 heuteFalsch: 0,
135 heuteBearbeitet: 0,
136 tageSeitLetzterAntwort: null,
137 bereiche: [],
138 themengruppen: [],
139 };
140
141 const STAND: FrageStand = {
142 frageId: FRAGE.id,
143 versuche: 1,
144 richtige: 1,
145 zuletztBeantwortet: '2026-01-01T08:00:00.000Z',
146 faelligAb: null,
147 gemerkt: false,
148 letzteBewertung: 'gut',
149 };
150
151 const INFO: AnwendungsInfo = {
152 anwendungsVersion: '0.0.0-attrappe',
153 baukennung: 'abc1234',
154 baustand: '2026-08-21',
155 electronVersion: '43.4.1',
156 chromeVersion: '140.0.0.0',
157 nodeVersion: '22.19.1',
158 plattform: 'win32',
159 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
160 };
161
162 function reihenfolge(frage: Frage): SitzungsFrage {
163 return {
164 frageId: frage.id,
165 optionsReihenfolge: (frage.optionen ?? []).map((o) => o.label),
166 gemerkt: false,
167 };
168 }
169
170 function attrappe(ueberschreibungen: TeilBridge = {}): TeilBridge {
171 return {
172 hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
173 hilfsmittelBeobachten: vi.fn(() => () => undefined),
174 menuebefehlBeobachten: vi.fn(() => () => undefined),
175 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
176 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
177 einstellungenLesen: vi.fn(() =>
178 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
179 ),
180 einstellungenSchreiben: vi.fn((aenderung: Partial<Einstellungen>) =>
181 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }),
182 ),
183 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
184 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
185 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
186 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
187 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
188 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
189 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
190 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
191 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
192 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
193 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
194 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
195 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
196 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE)])),
197 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
198 lernMerken: vi.fn(() => Promise.resolve(STAND)),
199 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
200 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
201 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
202 ...ueberschreibungen,
203 };
204 }
205
206 // ─── Attrappe der Sprachausgabe ─────────────────────────────────────────
207
208 interface GesprochenesStueck {
209 readonly text: string;
210 readonly lang: string;
211 }
212
213 class AeusserungAttrappe {
214 lang = '';
215 voice: unknown = null;
216 onend: (() => void) | null = null;
217 onerror: ((ereignis: { error: string }) => void) | null = null;
218 constructor(public readonly text: string) {}
219 }
220
221 /**
222 * Bildet `window.speechSynthesis` nach.
223 *
224 * `getVoices()` gibt bewusst eine leere Liste zurück – genau so verhält sich
225 * Electron unter Windows vor der ersten Äußerung. Die Anwendung darf davon
226 * nicht abhängen.
227 */
228 function sprachausgabeStellen(): {
229 gesprochen: GesprochenesStueck[];
230 abbrueche: () => number;
231 beenden: () => void;
232 } {
233 const gesprochen: GesprochenesStueck[] = [];
234 let abgebrochen = 0;
235 let letzte: AeusserungAttrappe | null = null;
236
237 vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
238 vi.stubGlobal('speechSynthesis', {
239 speak: (aeusserung: AeusserungAttrappe) => {
240 gesprochen.push({ text: aeusserung.text, lang: aeusserung.lang });
241 letzte = aeusserung;
242 },
243 cancel: () => {
244 abgebrochen += 1;
245 },
246 getVoices: () => [],
247 speaking: false,
248 pending: false,
249 paused: false,
250 });
251
252 return {
253 gesprochen,
254 abbrueche: () => abgebrochen,
255 beenden: () => {
256 letzte?.onend?.();
257 },
258 };
259 }
260
261 function starten(bruecke: TeilBridge = attrappe()): Nutzer {
262 vi.stubGlobal('lernApp', bruecke);
263 const nutzer = userEvent.setup();
264 render(<App />);
265 return nutzer;
266 }
267
268 async function sitzungOeffnen(bruecke: TeilBridge = attrappe()): Promise<Nutzer> {
269 const nutzer = starten(bruecke);
270 const knopf = await screen.findByRole('button', { name: /Weiterlernen/u });
271 await waitFor(() => {
272 expect(knopf).toBeEnabled();
273 });
274 await nutzer.click(knopf);
275 await screen.findByRole('heading', { name: /Frage 1\.87/u });
276 return nutzer;
277 }
278
279 afterEach(() => {
280 vi.unstubAllGlobals();
281 erklaerungenZwischenspeicherLeeren();
282 glossarZwischenspeicherLeeren();
283 normtexteZwischenspeicherLeeren();
284 themenZwischenspeicherLeeren();
285 });
286
287 // ─── Tests ──────────────────────────────────────────────────────────────
288
289 /*
290 Textabstände (WCAG 1.4.12).
291
292 Im Browser erfüllt man das Kriterium, indem nutzereigene Stilblätter
293 toleriert werden. In der ausgelieferten Anwendung gibt es weder
294 Erweiterungen noch Entwicklerwerkzeuge – die Anwendung ist die einzige
295 Stelle, die Zeilen-, Wort- und Zeichenabstand ändern kann, und sie bot es
296 bis 0.22.0 nicht an.
297 */
298 describe('Einstellung „Textdarstellung“', () => {
299 it('steht als eigene Karte in den Einstellungen', async () => {
300 /* Getrennt von der Anzeigegröße: Der Zoom skaliert alles gemeinsam, der
301 Abstand gibt bei gleicher Größe mehr Luft. Wer mit Legasthenie liest,
302 braucht oft das Zweite. */
303 starten();
304
305 expect(await screen.findByRole('heading', { name: 'Textdarstellung' })).toBeInTheDocument();
306 });
307
308 it('beginnt bei der gewohnten Darstellung', async () => {
309 starten();
310
311 expect(
312 await screen.findByRole('button', { name: 'Normal', pressed: true }),
313 ).toBeInTheDocument();
314 });
315
316 it('schreibt die Stufe an das Wurzelelement', async () => {
317 /* Dort und nur dort wirken die Token aus `tokens.css`. Ohne diese Zeile
318 wäre die Einstellung gespeichert und wirkungslos. */
319 const nutzer = starten();
320
321 await nutzer.click(await screen.findByRole('button', { name: 'Weit' }));
322
323 await waitFor(() => {
324 expect(document.documentElement.dataset['textabstand']).toBe('weit');
325 });
326 });
327
328 it('sichert die Stufe im Anwendungskern', async () => {
329 const schreiben = vi.fn((aenderung: Partial<Einstellungen>) =>
330 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }),
331 );
332 const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben }));
333
334 await nutzer.click(await screen.findByRole('button', { name: 'Sehr weit' }));
335
336 expect(schreiben).toHaveBeenCalledWith({ textabstand: 'sehr-weit' });
337 });
338
339 /*
340 Bis 0.24.1 setzte `Textabstandswahl` das Attribut selbst – also nur,
341 solange der Startbildschirm im Baum stand. Die Zuschnittfrage kommt beim
342 Erststart davor, und wer „Sehr weit“ eingestellt hatte, las ausgerechnet
343 sie in der Werksdarstellung.
344 */
345 it('trägt die Stufe schon vor dem Startbildschirm, auf der Zuschnittfrage', async () => {
346 starten(
347 attrappe({
348 einstellungenLesen: vi.fn(() =>
349 Promise.resolve({
350 ...EINSTELLUNGEN_STANDARD,
351 zuschnittGefragt: false,
352 textabstand: 'sehr-weit' as const,
353 }),
354 ),
355 }),
356 );
357
358 /* Die Zuschnittfrage steht, der Startbildschirm noch nicht. */
359 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u });
360
361 await waitFor(() => {
362 expect(document.documentElement.dataset['textabstand']).toBe('sehr-weit');
363 });
364 });
365
366 it('sagt die gewählte Stufe an', async () => {
367 const nutzer = starten();
368
369 await nutzer.click(await screen.findByRole('button', { name: 'Weit' }));
370
371 await waitFor(() => {
372 expect(screen.getByText('Textabstand: Weit.')).toBeInTheDocument();
373 });
374 });
375 });
376
377 describe('Einstellung „Antwortmöglichkeiten mischen“', () => {
378 it('steht als Schalter im Startbildschirm und ist voreingestellt aus', async () => {
379 /* Ab Werk aus: Ein Lernvorteil des Mischens ist nicht belegt, der
380 Aufwand beim Abgleich mit dem amtlichen Katalog dagegen messbar –
381 83 % der Auswahlfragen erscheinen gemischt in anderer Reihenfolge.
382 Siehe docs/entscheidung-antwortreihenfolge.md. */
383 starten();
384
385 const schalter = await screen.findByRole('checkbox', {
386 name: 'Antwortmöglichkeiten mischen',
387 });
388
389 expect(schalter).not.toBeChecked();
390 });
391
392 it('sichert das Ein- und Ausschalten im Anwendungskern', async () => {
393 const schreiben = vi.fn((aenderung: Partial<Einstellungen>) =>
394 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }),
395 );
396 const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben }));
397 const schalter = await screen.findByRole('checkbox', {
398 name: 'Antwortmöglichkeiten mischen',
399 });
400
401 await nutzer.click(schalter);
402 expect(schreiben).toHaveBeenCalledWith({ optionenMischen: true });
403
404 await nutzer.click(schalter);
405 expect(schreiben).toHaveBeenCalledWith({ optionenMischen: false });
406 });
407
408 it('steht unter einer Überschrift „Schwierigkeit“ neben dem zweiten Regler', async () => {
409 /* Die beiden Regler gehören zusammen und sind als das beschriftet, was
410 sie sind: Erschwernisse, die man bewusst wählt. */
411 starten();
412
413 expect(
414 await screen.findByRole('heading', { name: 'Schwierigkeit', level: 3 }),
415 ).toBeInTheDocument();
416 expect(
417 await screen.findByRole('checkbox', { name: 'Anzahl der richtigen Antworten verbergen' }),
418 ).toBeInTheDocument();
419 });
420
421 it('verspricht keinen Lernvorteil und behauptet nichts über den Prüfungsbogen', async () => {
422 /* Der frühere Text sagte „in der Prüfung stehen sie ohnehin anders“ –
423 eine Tatsachenbehauptung über die echte Prüfung, für die es keine
424 Quelle gibt. Weder das BVA-Vorwort noch die AWaffV noch ein
425 Behördenmerkblatt regeln die Anordnung der Antwortmöglichkeiten. */
426 starten();
427
428 expect(
429 await screen.findByText(/Ein Lernvorteil des Mischens ist nicht belegt/u),
430 ).toBeInTheDocument();
431 expect(screen.queryByText(/in der Prüfung stehen sie ohnehin anders/u)).toBeNull();
432 });
433
434 it('reicht die Entscheidung an die Zusammenstellung der Sitzung weiter', async () => {
435 /* Bewusst mit dem vom Standard ABWEICHENDEN Wert geprüft: Stünde hier
436 `false`, wäre der Test seit der Umstellung der Vorgabe inhaltsleer –
437 er bliebe auch dann grün, wenn die Oberfläche die Einstellung gar
438 nicht mehr läse. */
439 const lernSitzung = vi.fn(() => Promise.resolve([reihenfolge(FRAGE)]));
440 const nutzer = starten(
441 attrappe({
442 lernSitzung,
443 einstellungenLesen: vi.fn(() =>
444 Promise.resolve({
445 ...EINSTELLUNGEN_STANDARD,
446 zuschnittGefragt: true,
447 optionenMischen: true,
448 }),
449 ),
450 }),
451 );
452
453 const knopf = await screen.findByRole('button', { name: /Weiterlernen/u });
454 await waitFor(() => {
455 expect(knopf).toBeEnabled();
456 });
457 await nutzer.click(knopf);
458
459 await waitFor(() => {
460 expect(lernSitzung).toHaveBeenCalledWith(
461 1,
462 expect.objectContaining({ optionenMischen: true }),
463 );
464 });
465 });
466
467 it('sagt, dass sie auch in der Prüfungssimulation gilt', async () => {
468 starten();
469
470 expect(await screen.findByText(/gilt auch in der Prüfungssimulation/u)).toBeInTheDocument();
471 });
472 });
473
474 describe('Einstellung „Vorlesen“', () => {
475 it('blendet ohne Sprachausgabe des Systems keinen Vorleseknopf ein', async () => {
476 /* Ohne `speechSynthesis` – der Normalfall in jsdom. */
477 await sitzungOeffnen(
478 attrappe({
479 einstellungenLesen: vi.fn(() =>
480 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
481 ),
482 }),
483 );
484
485 expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument();
486 });
487
488 it('erklärt, wenn das System keine Sprachausgabe anbietet', async () => {
489 starten();
490
491 expect(
492 await screen.findByText('Dieses System bietet keine Sprachausgabe an.'),
493 ).toBeInTheDocument();
494
495 /*
496 Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein
497 natives `disabled`, dafür `aria-disabled`. Gerade hier trägt das etwas –
498 der Satz darunter erklärt ja, WARUM das Kästchen nichts nützt, und ein
499 `disabled` Kästchen nimmt keinen Fokus, sodass er einem Bildschirmleser
500 nie begegnete.
501 */
502 const schalter = screen.getByRole('checkbox', {
503 name: 'Fragen und Antworten vorlesen lassen',
504 });
505 expect(schalter).toBeEnabled();
506 expect(schalter).toHaveAttribute('aria-disabled', 'true');
507 });
508
509 it('lässt das gesperrte Vorlesekästchen nichts umstellen', async () => {
510 const einstellungenSchreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD));
511 const nutzer = starten(attrappe({ einstellungenSchreiben }));
512
513 const schalter = await screen.findByRole('checkbox', {
514 name: 'Fragen und Antworten vorlesen lassen',
515 });
516 await nutzer.click(schalter);
517
518 expect(schalter).not.toBeChecked();
519 expect(einstellungenSchreiben).not.toHaveBeenCalled();
520 });
521
522 it('zeigt den Knopf erst, wenn die Einstellung an ist', async () => {
523 sprachausgabeStellen();
524 await sitzungOeffnen();
525
526 expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument();
527 });
528
529 it('liest die Frage samt Antwortmöglichkeiten und löst Kürzel auf', async () => {
530 const stimme = sprachausgabeStellen();
531 const nutzer = await sitzungOeffnen(
532 attrappe({
533 einstellungenLesen: vi.fn(() =>
534 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
535 ),
536 }),
537 );
538
539 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
540
541 const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' ');
542 expect(gesamt).toContain('Paragraf 12 Absatz 4 Waffengesetz');
543 expect(gesamt).toContain('b: Nach dem Waffengesetz');
544 expect(stimme.gesprochen.every((stueck) => stueck.lang === 'de-DE')).toBe(true);
545 });
546
547 it('bietet während der Ausgabe das Anhalten an (WCAG 1.4.2)', async () => {
548 const stimme = sprachausgabeStellen();
549 const nutzer = await sitzungOeffnen(
550 attrappe({
551 einstellungenLesen: vi.fn(() =>
552 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
553 ),
554 }),
555 );
556
557 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
558
559 const halt = await screen.findByRole('button', { name: /Vorlesen beenden/u });
560 const vorher = stimme.abbrueche();
561 await nutzer.click(halt);
562
563 expect(stimme.abbrueche()).toBeGreaterThan(vorher);
564 expect(await screen.findByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument();
565 });
566
567 it('sagt von selbst nichts, solange nur die Schaltfläche eingeschaltet ist', async () => {
568 /*
569 Gemeldet von einem Nutzer: Nach dem Bestätigen begann die Ansage von
570 selbst. Die eigenen Texte gaben ihm recht – Schalter wie Handbuch sagten
571 eine Schaltfläche zu, von selbstständigem Sprechen stand in keinem etwas.
572 */
573 const stimme = sprachausgabeStellen();
574 const nutzer = await sitzungOeffnen(
575 attrappe({
576 einstellungenLesen: vi.fn(() =>
577 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
578 ),
579 }),
580 );
581
582 stimme.gesprochen.length = 0;
583 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
584 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
585 await screen.findByRole('heading', { name: 'Warum das so ist' });
586
587 expect(stimme.gesprochen).toHaveLength(0);
588 /* Der Knopf ist trotzdem da und liest jetzt die Rückmeldung. */
589 expect(screen.getByRole('button', { name: /Antwort vorlesen/u })).toBeInTheDocument();
590 });
591
592 it('liest Frage und Rückmeldung von selbst, wenn man es einschaltet', async () => {
593 const stimme = sprachausgabeStellen();
594 const nutzer = await sitzungOeffnen(
595 attrappe({
596 einstellungenLesen: vi.fn(() =>
597 Promise.resolve({
598 ...EINSTELLUNGEN_STANDARD,
599 zuschnittGefragt: true,
600 vorlesen: true,
601 vorlesenAutomatisch: true,
602 }),
603 ),
604 }),
605 );
606
607 /* Zuerst die Frage – ohne dass jemand gedrückt hat. */
608 await waitFor(() => {
609 expect(stimme.gesprochen.length).toBeGreaterThan(0);
610 });
611 expect(stimme.gesprochen.map((s) => s.text).join(' ')).toContain('Waffengesetz');
612
613 stimme.gesprochen.length = 0;
614 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
615 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
616
617 await waitFor(() => {
618 expect(stimme.gesprochen.length).toBeGreaterThan(0);
619 });
620 const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' ');
621 expect(gesamt).toContain('Richtig');
622 expect(gesamt).toContain('Richtig ist Antwort b.');
623 /* Die Kurzfassung der Erklärung, nicht der Volltext: Der dauert
624 gesprochen über eine Minute und die nächste Frage wartet. */
625 expect(gesamt).toContain('Der Erwerb richtet sich nach dem Waffengesetz.');
626 expect(gesamt).not.toContain('Ausführlich');
627 });
628
629 it('spricht dieselbe Stelle nicht zweimal', async () => {
630 const stimme = sprachausgabeStellen();
631 const nutzer = await sitzungOeffnen(
632 attrappe({
633 einstellungenLesen: vi.fn(() =>
634 Promise.resolve({
635 ...EINSTELLUNGEN_STANDARD,
636 zuschnittGefragt: true,
637 vorlesen: true,
638 vorlesenAutomatisch: true,
639 }),
640 ),
641 }),
642 );
643
644 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
645 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
646 await waitFor(() => {
647 expect(stimme.gesprochen.length).toBeGreaterThan(0);
648 });
649
650 const nachErgebnis = stimme.gesprochen.length;
651 /* Ein Klick, der die Ansicht neu zeichnet, ohne die Frage zu wechseln. */
652 await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));
653
654 expect(stimme.gesprochen).toHaveLength(nachErgebnis);
655 });
656
657 it('liest die ausführliche Begründung nur bei falscher Antwort', async () => {
658 /*
659 Der zweite Schalter, und zwar allein: Ohne „von selbst vorlesen“ bleibt
660 es bei der einen Ansage, die man bestellt hat – dem langen Text, wenn es
661 danebenging. Genau die Kombination, die sich sonst nicht einstellen
662 ließe: selbst lesen, aber bei einem Fehler die Begründung hören.
663 */
664 const stimme = sprachausgabeStellen();
665 const nutzer = await sitzungOeffnen(
666 attrappe({
667 einstellungenLesen: vi.fn(() =>
668 Promise.resolve({
669 ...EINSTELLUNGEN_STANDARD,
670 zuschnittGefragt: true,
671 vorlesen: true,
672 vorlesenErklaerungBeiFehler: true,
673 }),
674 ),
675 }),
676 );
677
678 stimme.gesprochen.length = 0;
679 /* Die falsche der beiden Antwortmöglichkeiten. */
680 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Sprengstoffgesetz/u }));
681 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
682
683 await waitFor(() => {
684 expect(stimme.gesprochen.length).toBeGreaterThan(0);
685 });
686 const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' ');
687 expect(gesamt).toContain('Ausführlich');
688 /* Nur die Begründung: Die Rückmeldung selbst hat niemand bestellt. */
689 expect(gesamt).not.toContain('Richtig ist Antwort b.');
690 });
691
692 it('schweigt bei richtiger Antwort, auch wenn die Begründung eingeschaltet ist', async () => {
693 const stimme = sprachausgabeStellen();
694 const nutzer = await sitzungOeffnen(
695 attrappe({
696 einstellungenLesen: vi.fn(() =>
697 Promise.resolve({
698 ...EINSTELLUNGEN_STANDARD,
699 zuschnittGefragt: true,
700 vorlesen: true,
701 vorlesenErklaerungBeiFehler: true,
702 }),
703 ),
704 }),
705 );
706
707 stimme.gesprochen.length = 0;
708 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
709 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
710 await screen.findByRole('heading', { name: 'Warum das so ist' });
711
712 expect(stimme.gesprochen).toHaveLength(0);
713 });
714
715 it('zeigt die Zusatzschalter erst, wenn die Sprachausgabe an ist', async () => {
716 /* Nicht als gesperrte Kästchen: Gesperrte Bedienelemente nehmen keinen
717 Fokus, ihre Erklärung würde nie vorgelesen. Dass es sie gibt, sagt der
718 Satz am Schalter darüber. */
719 sprachausgabeStellen();
720 const nutzer = starten(attrappe());
721
722 expect(
723 await screen.findByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }),
724 ).toBeInTheDocument();
725 expect(screen.queryByRole('checkbox', { name: /von selbst vorlesen/u })).toBeNull();
726 expect(screen.queryByRole('checkbox', { name: /ausführliche Begründung/u })).toBeNull();
727
728 await nutzer.click(
729 screen.getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }),
730 );
731
732 expect(
733 await screen.findByRole('checkbox', { name: 'Frage und Rückmeldung von selbst vorlesen' }),
734 ).not.toBeChecked();
735 expect(
736 screen.getByRole('checkbox', {
737 name: 'Bei falscher Antwort die ausführliche Begründung vorlesen',
738 }),
739 ).not.toBeChecked();
740 });
741
742 it('warnt, wenn zusätzlich ein Screenreader läuft', async () => {
743 sprachausgabeStellen();
744 starten(
745 attrappe({
746 hilfsmittelAktiv: vi.fn(() => Promise.resolve(true)),
747 einstellungenLesen: vi.fn(() =>
748 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
749 ),
750 }),
751 );
752
753 expect(await screen.findByText(/Beide zugleich sprechen durcheinander/u)).toBeInTheDocument();
754 });
755 });
756
757 describe('Neu anfangen', () => {
758 it('setzt den Lernstand erst nach einer Rückfrage zurück', async () => {
759 /*
760 Unwiderruflich, deshalb eine Bestätigung (WCAG 3.3.4). Und sie sagt,
761 was verschwindet – eine Rückfrage, die nur „Sind Sie sicher?“ fragt,
762 hilft beim Entscheiden nicht.
763 */
764 const zuruecksetzen = vi.fn(() => Promise.resolve(UEBERSICHT));
765 const nutzer = starten(attrappe({ lernZuruecksetzen: zuruecksetzen }));
766
767 await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }));
768
769 expect(
770 screen.getByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }),
771 ).toBeInTheDocument();
772 expect(zuruecksetzen).not.toHaveBeenCalled();
773
774 await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' }));
775
776 await waitFor(() => {
777 expect(zuruecksetzen).toHaveBeenCalledWith(1, null);
778 });
779 expect(await screen.findByText(/Ihr Lernstand ist zurückgesetzt/u)).toBeInTheDocument();
780 });
781
782 it('gibt den Fokus an den auslösenden Knopf zurück', async () => {
783 /*
784 Die Rückfrage holt den Fokus zu sich. Verschwände sie, ohne ihn
785 zurückzugeben, fiele er auf <body> – wer per Tastatur bedient, stünde
786 am Seitenanfang und müsste sich durch alle Einstellungen zurückarbeiten.
787 */
788 const nutzer = starten();
789 const knopf = await screen.findByRole('button', { name: 'Lernstand zurücksetzen' });
790
791 await nutzer.click(knopf);
792 await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' }));
793
794 expect(screen.getByRole('button', { name: 'Lernstand zurücksetzen' })).toHaveFocus();
795 });
796
797 it('lässt sich abbrechen, ohne etwas zu löschen', async () => {
798 const zuruecksetzen = vi.fn(() => Promise.resolve(UEBERSICHT));
799 const nutzer = starten(attrappe({ lernZuruecksetzen: zuruecksetzen }));
800
801 await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }));
802 await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' }));
803
804 expect(zuruecksetzen).not.toHaveBeenCalled();
805 expect(
806 screen.queryByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }),
807 ).not.toBeInTheDocument();
808 });
809
810 it('sagt vorher, was verschwindet und was bleibt', async () => {
811 starten();
812
813 const text = await screen.findByText(/Setzt Ihren Lernstand auf null/u);
814
815 expect(text).toHaveTextContent('Merkliste');
816 expect(text).toHaveTextContent('Prüfungssimulationen');
817 expect(text).toHaveTextContent('Einstellungen');
818 });
819
820 it('meldet einen Fehlschlag, statt Erfolg vorzutäuschen', async () => {
821 const nutzer = starten(
822 attrappe({
823 lernZuruecksetzen: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))),
824 }),
825 );
826
827 await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }));
828 await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' }));
829
830 expect(await screen.findByText(/Datenbank gesperrt/u)).toBeInTheDocument();
831 });
832 });
833
834 describe('Anzeigegröße über die Tastatur', () => {
835 /*
836 Gemeldet und nachgemessen: `Strg` und `+` taten unter Windows nichts.
837 Electron liest den Beschleunigernamen `Plus` als die umgeschaltete Form von
838 `=` und meldet damit faktisch `Strg+Umschalt+=` beim Betriebssystem an —
839 richtig für eine amerikanische Tastatur, falsch für eine deutsche, auf der
840 `+` eine eigene Taste ohne Umschalt ist.
841
842 Geprüft wird hier das Zeichen, das die Tastatur liefert. Das Layout selbst
843 muss die Anwendung nie kennen — wer es abfragt, trifft Annahmen, die bei
844 der nächsten Tastatur wieder falsch sind. Genau dieser Fehler steckte im
845 Beschleuniger.
846 */
847 /* Die gemeinsame Attrappe dieser Datei fuehrt die Anzeigekanaele bewusst
848 nicht - andere Tests hier pruefen gerade den Fall ohne Anwendungskern.
849 Fuer die Tastatur braucht es sie, also kommen sie hier dazu. */
850 function zoomAttrappe(ueberschreibungen: TeilBridge = {}): TeilBridge {
851 return attrappe({
852 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
853 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
854 ...ueberschreibungen,
855 });
856 }
857
858 /*
859 Der Aufbau ist die einzige Stelle hier, die wirklich warten muss – und die
860 einzige, die je in einen Zeitablauf lief.
861
862 Nachgemessen: Die Schaltfläche „Weiterlernen“ erscheint erst, wenn die
863 gespeicherten Einstellungen geladen sind. Mit einer künstlich um zwei
864 Sekunden verzögerten Antwort wird sie überhaupt nicht gefunden. Es hängen
865 also drei Brückenaufrufe und der vollständige Aufbau der Anwendung an
866 diesem einen Warten.
867
868 Die Vorgabe von Testing Library dafür sind 1000 ms. Im vollen Testlauf mit
869 40 Dateien reicht das gelegentlich nicht: Der Test fiel zweimal aus, beide
870 Male nach 1071 bzw. 1083 ms – die Kennmarke genau dieses Zeitablaufs.
871 Allein lief er immer durch.
872
873 Die 5000 ms sind keine Nachsicht mit einem wackligen Test, sondern die
874 Berichtigung einer Annahme, die nirgends zugesagt ist: Die Anwendung
875 verspricht nicht, unter voller Testlast binnen einer Sekunde aufzubauen.
876 Was hier geprüft werden soll, ist die Tastatur – und die wird unten ohne
877 jedes Warten geprüft.
878 */
879 async function startenMit(bruecke: TeilBridge): Promise<void> {
880 vi.stubGlobal('lernApp', bruecke);
881 render(<App />);
882 await screen.findByRole('button', { name: /Weiterlernen/u }, { timeout: 5000 });
883 }
884
885 function taste(init: KeyboardEventInit): void {
886 window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }));
887 }
888
889 it.each([
890 ['deutsche Plustaste', { key: '+', code: 'Equal', ctrlKey: true }],
891 ['amerikanisch, ohne Umschalt', { key: '=', code: 'Equal', ctrlKey: true }],
892 ['amerikanisch, mit Umschalt', { key: '+', code: 'Equal', ctrlKey: true, shiftKey: true }],
893 ['Ziffernblock', { key: '+', code: 'NumpadAdd', ctrlKey: true }],
894 ])('vergrößert über die %s', async (_name, init) => {
895 const bruecke = zoomAttrappe();
896 await startenMit(bruecke);
897
898 taste(init);
899
900 /* Ohne `waitFor`, und das ist eine Verschärfung: `beiTaste` ruft die
901 Brücke im selben Durchlauf wie das Ereignis. Ein Warten darum herum
902 prüfte nicht mehr, sondern verdeckte nur, wenn es einmal nicht so
903 wäre – und war die einzige Stelle, die ablaufen konnte. */
904 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110);
905 });
906
907 it('verkleinert und setzt zurück', async () => {
908 const bruecke = zoomAttrappe({
909 einstellungenLesen: vi.fn(() =>
910 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 125 }),
911 ),
912 });
913 await startenMit(bruecke);
914
915 /*
916 Erst wenn die gespeicherten 125 Prozent AUF DEM BILDSCHIRM stehen, darf
917 die Taste fallen.
918
919 Bis 0.20.0 stand hier nur der Aufbau aus `startenMit`, und die Prüfung,
920 dass die 125 angekommen sind, lag allein im Ergebnis: Von 100 aus wären
921 es 90 gewesen, nicht 110. Das ist als Zusicherung richtig und als
922 Voraussetzung falsch – die Anwendung sagt nirgends zu, dass mit
923 „Weiterlernen“ auch schon `einstellungenLesen` beantwortet ist. Unter
924 voller Testlast fiel der Test deshalb in einem von drei Läufen um,
925 einzeln lief er immer durch.
926
927 Der Anzeigestand hängt am selben Wert wie der Tastenhaken
928 (`App` reicht `einstellungen.werte.anzeigegroesse` an beide weiter).
929 Steht er da, ist der Wert in derselben Renderrunde auch im Haken
930 angekommen – die Wirkung der Taste bleibt damit ohne jedes Warten
931 geprüft, so wie oben bei `vergrößert über die %s`.
932 */
933 expect(await screen.findByText(anzeigegroesseText(125))).toBeInTheDocument();
934
935 /* Von 100 aus wären es 90, nicht 110 – die Stufe belegt zusätzlich, dass
936 der gelesene Wert und nicht der Vorgabewert gilt. */
937 taste({ key: '-', code: 'Minus', ctrlKey: true });
938 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110);
939
940 taste({ key: '0', code: 'Digit0', ctrlKey: true });
941 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(100);
942 });
943
944 /*
945 Strg und Mausrad – der unter Windows verbreitetste Zoomgriff.
946
947 Er fehlte bis 0.22.0 ausgerechnet in einer Anwendung, deren Anzeigegröße
948 bis 400 Prozent ein zentrales Merkmal ist; Electron zoomt von sich aus
949 nicht. Das Trackpad ist mit abgedeckt: Chromium meldet die Kneifgeste
950 ebenfalls als `wheel` mit `ctrlKey`.
951 */
952 function rad(init: WheelEventInit): WheelEvent {
953 const ereignis = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
954 window.dispatchEvent(ereignis);
955 return ereignis;
956 }
957
958 it('vergrößert beim Drehen nach oben', async () => {
959 const bruecke = zoomAttrappe();
960 await startenMit(bruecke);
961
962 rad({ deltaY: -100, ctrlKey: true });
963
964 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110);
965 });
966
967 it('verkleinert beim Drehen nach unten', async () => {
968 const bruecke = zoomAttrappe();
969 await startenMit(bruecke);
970
971 rad({ deltaY: 100, ctrlKey: true });
972
973 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90);
974 });
975
976 it('hält das Rollen der Seite an, wenn es zoomt', async () => {
977 /* Ohne `{ passive: false }` bliebe `preventDefault()` wirkungslos, und
978 die Seite rollte zusätzlich zum Zoom. */
979 const bruecke = zoomAttrappe();
980 await startenMit(bruecke);
981
982 const ereignis = rad({ deltaY: -100, ctrlKey: true });
983
984 expect(ereignis.defaultPrevented).toBe(true);
985 });
986
987 it('lässt gewöhnliches Rollen in Ruhe', async () => {
988 /* Ohne Strg gehört das Rad der Seite. Griffe die Anwendung hier zu,
989 ließe sich kein Text mehr scrollen. */
990 const bruecke = zoomAttrappe();
991 await startenMit(bruecke);
992
993 const ereignis = rad({ deltaY: -100 });
994
995 expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled();
996 expect(ereignis.defaultPrevented).toBe(false);
997 });
998
999 /*
1000 Die drei Knöpfe waren bis Fassung 0.19.2 `disabled`, sobald die Skala am
1001 Ende war. Wer sich die Schrift größer stellte, bis es nicht weiter ging,
1002 verlor den Knopf im letzten Klick unter dem Finger: Ein gesperrtes Element
1003 fällt aus der Tabulatorreihenfolge, und der Fokus landet auf `<body>` –
1004 also am Seitenanfang, weit weg von der Stelle, an der man gerade war.
1005
1006 Deshalb wird hier der letzte Schritt selbst gegangen, nicht die Endlage
1007 hingestellt: von der vorletzten auf die oberste Stufe. Genau in diesem
1008 Übergang trat es auf. Die beiden Stufen werden aus `ANZEIGEGROESSEN`
1009 gelesen, damit der Test die Endlage prüft und nicht eine Zahl, die beim
1010 Verlängern der Leiter mitten in der Skala liegt (0.20.0: bis 400 statt
1011 bis 200).
1012 */
1013 it('behält den Knopf im letzten Schritt zur Höchstgröße', async () => {
1014 const nutzer = userEvent.setup();
1015 const oberste = Math.max(...ANZEIGEGROESSEN);
1016 const vorletzte = Math.max(...ANZEIGEGROESSEN.filter((stufe) => stufe < oberste));
1017 const bruecke = zoomAttrappe({
1018 einstellungenLesen: vi.fn(() =>
1019 Promise.resolve({
1020 ...EINSTELLUNGEN_STANDARD,
1021 zuschnittGefragt: true,
1022 anzeigegroesse: vorletzte,
1023 }),
1024 ),
1025 });
1026 await startenMit(bruecke);
1027
1028 const groesser = await screen.findByRole('button', { name: /Größer/u });
1029 await nutzer.click(groesser);
1030
1031 await waitFor(() => {
1032 expect(groesser).toHaveAttribute('aria-disabled', 'true');
1033 });
1034 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(oberste);
1035 expect(groesser).not.toBeDisabled();
1036 expect(groesser).toHaveFocus();
1037 });
1038
1039 it('setzt am Ende der Skala nichts mehr, lässt aber die Gegenrichtung zu', async () => {
1040 const nutzer = userEvent.setup();
1041 const bruecke = zoomAttrappe({
1042 einstellungenLesen: vi.fn(() =>
1043 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 80 }),
1044 ),
1045 });
1046 await startenMit(bruecke);
1047
1048 const kleiner = await screen.findByRole('button', { name: /Kleiner/u });
1049 await waitFor(() => {
1050 expect(kleiner).toHaveAttribute('aria-disabled', 'true');
1051 });
1052
1053 await nutzer.click(kleiner);
1054 expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled();
1055
1056 /* Die Gegenprobe: nach oben geht es sehr wohl weiter. */
1057 await nutzer.click(screen.getByRole('button', { name: /Größer/u }));
1058 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90);
1059 });
1060
1061 it('lässt jede andere Kombination mit Strg in Ruhe', async () => {
1062 /* Der Zuhörer liegt am Fenster und sieht alles. Fasste er mehr an als die
1063 drei eigenen Tasten, bräche er Kopieren, Einfügen und Neu laden. */
1064 const bruecke = zoomAttrappe();
1065 await startenMit(bruecke);
1066
1067 for (const init of [
1068 { key: 'a', code: 'KeyA', ctrlKey: true },
1069 { key: 'c', code: 'KeyC', ctrlKey: true },
1070 { key: '1', code: 'Digit1', ctrlKey: true },
1071 // Ohne Strg ist ein Pluszeichen einfach ein Pluszeichen.
1072 { key: '+', code: 'Equal' },
1073 // Unter macOS trägt der Menü-Beschleuniger; sonst zoomte es doppelt.
1074 { key: '+', code: 'Equal', metaKey: true },
1075 ]) {
1076 taste(init);
1077 }
1078
1079 await new Promise((weiter) => setTimeout(weiter, 20));
1080 expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled();
1081 });
1082 });