waffensachkunde

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

/ app e2e blickfeld.spec.ts

8,3 KB Rohdatei
app/e2e/blickfeld.spec.ts — 221 Zeilen
1 /**
2 * Was ohne Scrollen erreichbar sein muss.
3 *
4 * Anlass ist eine Rückmeldung aus der Anwendung heraus: „Was mir nicht
5 * gefällt, dass ich zum Lernen erstmal scrollen muss." Nachgemessen bei
6 * 1063 Pixeln sichtbarer Höhe:
7 *
8 * - Der Weg ins Lernen begann bei 2659 px – zweieinhalb Bildschirme
9 * Statusbericht vor der ersten Frage.
10 * - Nach dem Bestätigen begann die Rückmeldung bei rund 950 px und war
11 * damit praktisch unsichtbar; „Nächste Frage" stand bei 2115 px.
12 *
13 * Solche Regressionen fallen in keinem anderen Test auf: Alles ist da,
14 * alles ist bedienbar, alles ist zugänglich – es steht nur zu weit unten.
15 * Deshalb misst dieser Lauf Positionen statt Vorhandensein.
16 *
17 * Gemessen wird bei 1999 × 1125 Fenstergröße. Kleinere Fenster brauchen
18 * zwangsläufig mehr Scrollen; die Zusage gilt der üblichen Arbeitsgröße.
19 */
20
21 import { type ElectronApplication, type Page } from '@playwright/test';
22
23 import { amStart, appStarten, bauPruefen } from './electron-hilfe';
24 import { expect, test } from './konsolenwache';
25
26 let app: ElectronApplication | undefined;
27 let fenster: Page;
28
29 test.beforeAll(async () => {
30 // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen.
31 bauPruefen();
32 ({ app, fenster } = await appStarten());
33 await app.evaluate(({ BrowserWindow }) => {
34 BrowserWindow.getAllWindows()[0]?.setSize(1999, 1125);
35 });
36 await fenster.waitForTimeout(300);
37 });
38
39 test.afterAll(async () => {
40 await app?.close();
41 });
42
43 async function sichtbareHoehe(): Promise<number> {
44 return fenster.evaluate(() => window.innerHeight);
45 }
46
47 /**
48 * Startet eine frische Sitzung und blättert bis zu einer Auswahlfrage.
49 *
50 * Jeder Test holt sich seine eigene Sitzung: Sonst hängt das Ergebnis davon
51 * ab, wo der vorige aufgehört hat – und ein Durchlauf, der zufällig auf der
52 * letzten Frage endet, landet in der Auswertung statt in der Sitzung.
53 */
54 async function sitzungMitAuswahlfrage(): Promise<void> {
55 /* Zurück zum Start – in Schritten: „Sitzung beenden“ führt zur Auswertung,
56 erst von dort geht es „Zum Start“. */
57 for (let i = 0; i < 4; i++) {
58 if (await amStart(fenster)) {
59 break;
60 }
61 const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u });
62 if ((await zurueck.count()) === 0) {
63 break;
64 }
65 await zurueck.first().click();
66 await fenster.waitForTimeout(250);
67 }
68
69 await fenster.getByRole('button', { name: /Weiterlernen/u }).click();
70 await fenster.getByRole('heading', { level: 1, name: /, Frage \d/u }).waitFor();
71
72 for (let i = 0; i < 15; i++) {
73 if ((await fenster.getByRole('radio').count()) > 0) {
74 return;
75 }
76 await fenster
77 .getByRole('button', { name: /Musterantwort anzeigen|^Antwort bestätigen$/u })
78 .first()
79 .click();
80 const bewertung = fenster.getByRole('button', { name: /^(Gewusst|Sicher gewusst)$/u });
81 if ((await bewertung.count()) > 0) {
82 await bewertung.first().click();
83 }
84 const weiter = fenster.getByRole('button', { name: /^Nächste Frage$/u });
85 if ((await weiter.count()) === 0) {
86 /* Ende der Sitzung erreicht, ohne eine Auswahlfrage zu treffen – neu
87 beginnen statt in der Auswertung stehen zu bleiben. */
88 await sitzungMitAuswahlfrage();
89 return;
90 }
91 await weiter.first().click();
92 await fenster.waitForTimeout(150);
93 }
94 }
95
96 test.describe('Ohne Scrollen erreichbar', () => {
97 test('der Weg ins Lernen steht im ersten Bildschirm', async () => {
98 const hoehe = await sichtbareHoehe();
99 const knopf = fenster.getByRole('button', { name: /Weiterlernen/u });
100 await expect(knopf).toBeVisible();
101
102 const unten = await fenster.evaluate(() => {
103 const b = [...document.querySelectorAll('button')].find((e) =>
104 e.textContent.includes('Weiterlernen'),
105 );
106 return b === undefined
107 ? Number.POSITIVE_INFINITY
108 : Math.round(b.getBoundingClientRect().bottom);
109 });
110
111 expect(
112 unten,
113 `„Weiterlernen" endet bei ${String(unten)} px, sichtbar sind ${String(hoehe)} px.`,
114 ).toBeLessThanOrEqual(hoehe);
115 });
116
117 test('alle fünf Einstiege stehen im ersten Bildschirm', async () => {
118 const hoehe = await sichtbareHoehe();
119
120 /* Der fünfte Knopf („Offene Fragen", seit 0.17.0) kostet keine Zeile:
121 Das Raster stellt bei dieser Breite drei Spalten, vier Knöpfe standen
122 also längst in zwei Reihen. Geprüft wird das hier und nicht gerechnet. */
123 for (const name of ['Kapitel wählen', 'Nur Fehler', 'Gemerkte Fragen', 'Offene Fragen']) {
124 const unten = await fenster.evaluate((gesucht) => {
125 const b = [...document.querySelectorAll('button')].find((e) =>
126 e.textContent.includes(gesucht),
127 );
128 return b === undefined
129 ? Number.POSITIVE_INFINITY
130 : Math.round(b.getBoundingClientRect().bottom);
131 }, name);
132
133 expect(unten, `„${name}" endet bei ${String(unten)} px`).toBeLessThanOrEqual(hoehe);
134 }
135 });
136
137 test('die Rückmeldung erscheint ohne Scrollen', async () => {
138 await sitzungMitAuswahlfrage();
139 await fenster.getByRole('radio').first().click();
140 await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click();
141 await fenster.waitForTimeout(250);
142 await fenster.evaluate(() => {
143 window.scrollTo(0, 0);
144 });
145
146 const hoehe = await sichtbareHoehe();
147
148 /*
149 Die Zusage ist genau: Wer bestätigt hat, liest Urteil UND Lösungssatz,
150 ohne die Hand zur Maus zu nehmen. Gemessen wird deshalb die Unterkante
151 des Lösungssatzes, nicht die Oberkante des Kastens – eine Überschrift
152 am unteren Rand wäre keine Antwort.
153 */
154 const unten = await fenster.evaluate(() => {
155 const satz = document.querySelector('.ergebnis p');
156 return satz === null
157 ? Number.POSITIVE_INFINITY
158 : Math.round(satz.getBoundingClientRect().bottom);
159 });
160
161 expect(
162 unten,
163 `Der Lösungssatz endet bei ${String(unten)} px, sichtbar sind ${String(hoehe)} px.`,
164 ).toBeLessThanOrEqual(hoehe);
165 });
166
167 test('die Frage ist die oberste Überschrift der Sitzung', async () => {
168 /*
169 Der Name der Anwendung steht in der Titelleiste des Fensters – ihn im
170 Inhalt zu wiederholen kostete 167 Pixel und lenkte vom Stoff ab. Die
171 oberste Überschrift benennt jetzt, worum es in dieser Ansicht geht:
172 die Frage. Wer per Überschrift navigiert, landet damit an der Sache.
173 */
174 await sitzungMitAuswahlfrage();
175
176 await expect(fenster.locator('.kopfbereich')).toHaveCount(0);
177
178 const ueberschriften = fenster.getByRole('heading', { level: 1 });
179 await expect(ueberschriften).toHaveCount(1);
180 await expect(ueberschriften).toHaveClass(/frage__text/u);
181 });
182
183 test('die Frage steht weit oben', async () => {
184 /*
185 Vorher begann der Fragetext bei 299 Pixeln, in der ursprünglichen
186 Fassung sogar bei 495.
187
188 Gemessen wird der Moment des Ankommens. Jede neue Frage holt den Fokus
189 auf ihre Überschrift (`Lernsitzung.tsx`); erst dann steht die Seite so,
190 wie ein Lernender sie vorfindet. Diese Prüfung holte sich früher weder
191 eine eigene Sitzung noch wartete sie darauf – sie maß, wo die vorige
192 Prüfung den Rollbalken hatte stehen lassen. Nachgemessen über vier
193 Fenstergrößen: Lag der Fokus auf der Überschrift, stand sie bei 166 px
194 und die Seite war ungerollt; hing er noch auf „Nächste Frage", lag sie
195 bei 4 px, bei kleinem Fenster bei −363 px. Das war eine Aussage über
196 den Testablauf, nicht über die Anwendung.
197
198 Die untere Schranke ist deshalb 0 und nicht 1: Eine Überschrift genau
199 an der Oberkante ist der beste Fall, kein Fehler. Unter 0 wäre sie
200 angeschnitten.
201 */
202 await sitzungMitAuswahlfrage();
203 await expect
204 .poll(() => fenster.evaluate(() => document.activeElement?.className ?? ''))
205 .toContain('frage__text');
206
207 const oben = await fenster.evaluate(() => {
208 const ueberschrift = document.querySelector('.frage__text');
209 return ueberschrift === null ? null : Math.round(ueberschrift.getBoundingClientRect().top);
210 });
211
212 if (oben === null) {
213 throw new Error('Die Sitzung zeigt keine Frageüberschrift.');
214 }
215 expect(oben, `Der Fragetext beginnt bei ${String(oben)} px.`).toBeLessThan(220);
216 expect(
217 oben,
218 `Der Fragetext steht über dem Sichtfeld (${String(oben)} px).`,
219 ).toBeGreaterThanOrEqual(0);
220 });
221 });