waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app e2e ueberschriften.spec.ts
| 1 | /** |
| 2 | * Die Überschriftenstruktur jeder Ansicht. |
| 3 | * |
| 4 | * Wer mit einem Screenreader arbeitet, springt nach Überschriftenebenen. |
| 5 | * Dafür muss in jeder Ansicht dasselbe gelten: genau eine h1, die benennt, |
| 6 | * worum es hier geht, und darunter keine übersprungene Ebene. |
| 7 | * |
| 8 | * Vorher war es uneinheitlich – in der Lernsitzung war die Frage die h1, in |
| 9 | * den Nebenansichten der Anwendungsname, und die eigentliche |
| 10 | * Ansichtsüberschrift steckte eine Ebene tiefer. Mal landete man an der |
| 11 | * Sache, mal beim Programmnamen. |
| 12 | */ |
| 13 | |
| 14 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 15 | |
| 16 | import { amStart, appStarten, bauPruefen } from './electron-hilfe'; |
| 17 | import { expect, test } from './konsolenwache'; |
| 18 | |
| 19 | let app: ElectronApplication | undefined; |
| 20 | let fenster: Page; |
| 21 | |
| 22 | test.beforeAll(async () => { |
| 23 | // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. |
| 24 | bauPruefen(); |
| 25 | ({ app, fenster } = await appStarten()); |
| 26 | }); |
| 27 | |
| 28 | test.afterAll(async () => { |
| 29 | await app?.close(); |
| 30 | }); |
| 31 | |
| 32 | interface Befund { |
| 33 | readonly ebenen: number[]; |
| 34 | readonly texte: string[]; |
| 35 | } |
| 36 | |
| 37 | async function ueberschriften(): Promise<Befund> { |
| 38 | return fenster.evaluate(() => { |
| 39 | const alle = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')].filter((h) => { |
| 40 | /* Zugeklappte <details> zählen mit: Ein Screenreader führt sie in der |
| 41 | Überschriftenliste, und die Ebene gilt dort genauso. Wirklich |
| 42 | verborgene Elemente nicht. */ |
| 43 | return h.closest('[hidden]') === null; |
| 44 | }); |
| 45 | return { |
| 46 | ebenen: alle.map((h) => Number(h.tagName.slice(1))), |
| 47 | texte: alle.map((h) => h.textContent.trim().slice(0, 40)), |
| 48 | }; |
| 49 | }); |
| 50 | } |
| 51 | |
| 52 | const ANWENDUNGSNAME = 'Waffensachkunde – Lernsoftware'; |
| 53 | |
| 54 | /** Genau eine h1, Beginn bei 1, kein Sprung nach unten. */ |
| 55 | function pruefen(name: string, befund: Befund, benenntAnsicht = true): void { |
| 56 | const einsen = befund.ebenen.filter((e) => e === 1).length; |
| 57 | const liste = befund.ebenen |
| 58 | .map((e, i) => `h${String(e)} „${befund.texte[i] ?? ''}“`) |
| 59 | .join('\n '); |
| 60 | |
| 61 | expect(einsen, `${name}: ${String(einsen)} h1 statt genau einer.\n ${liste}`).toBe(1); |
| 62 | expect(befund.ebenen[0], `${name}: beginnt nicht mit h1.\n ${liste}`).toBe(1); |
| 63 | |
| 64 | /* |
| 65 | Die h1 muss die Ansicht benennen, nicht das Programm. Sonst ist die |
| 66 | Struktur zwar formal in Ordnung, hilft aber niemandem: Wer per |
| 67 | Überschrift navigiert, landete in jeder Ansicht bei derselben, |
| 68 | nichtssagenden Antwort auf die Frage „wo bin ich?“. |
| 69 | */ |
| 70 | if (benenntAnsicht) { |
| 71 | expect(befund.texte[0], `${name}: Die h1 nennt nur den Anwendungsnamen.`).not.toBe( |
| 72 | ANWENDUNGSNAME, |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | let vorige = 1; |
| 77 | for (const [i, ebene] of befund.ebenen.entries()) { |
| 78 | expect( |
| 79 | ebene, |
| 80 | `${name}: Sprung auf h${String(ebene)} bei „${befund.texte[i] ?? ''}“.\n ${liste}`, |
| 81 | ).toBeLessThanOrEqual(vorige + 1); |
| 82 | vorige = ebene; |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | async function zumStart(): Promise<void> { |
| 87 | for (let i = 0; i < 4; i++) { |
| 88 | if (await amStart(fenster)) { |
| 89 | return; |
| 90 | } |
| 91 | const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); |
| 92 | if ((await zurueck.count()) === 0) { |
| 93 | return; |
| 94 | } |
| 95 | await zurueck.first().click(); |
| 96 | await fenster.waitForTimeout(250); |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | test.describe('Überschriften je Ansicht', () => { |
| 101 | test('Startbildschirm', async () => { |
| 102 | await zumStart(); |
| 103 | /* Hier ist der Anwendungsname die richtige Antwort auf „wo bin ich?“. */ |
| 104 | pruefen('Startbildschirm', await ueberschriften(), false); |
| 105 | }); |
| 106 | |
| 107 | test('Glossar', async () => { |
| 108 | await zumStart(); |
| 109 | await fenster.getByRole('button', { name: /Fachbegriffe nachschlagen/u }).click(); |
| 110 | await fenster.getByRole('heading', { name: 'Glossar', exact: true }).waitFor(); |
| 111 | pruefen('Glossar', await ueberschriften()); |
| 112 | }); |
| 113 | |
| 114 | test('Über diese Software', async () => { |
| 115 | await zumStart(); |
| 116 | await fenster.getByRole('button', { name: /Lizenzen und Herkunft anzeigen/u }).click(); |
| 117 | await fenster.waitForTimeout(400); |
| 118 | pruefen('Über diese Software', await ueberschriften()); |
| 119 | }); |
| 120 | |
| 121 | test('Fragen durchsuchen', async () => { |
| 122 | /* Mit Trefferliste geprüft, nicht leer: Jede Trefferkarte trägt eine h2, |
| 123 | und genau dort könnte die Hierarchie brechen. */ |
| 124 | await zumStart(); |
| 125 | await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); |
| 126 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 127 | await fenster.getByLabel('Suchbegriff').fill('Anscheinswaffe'); |
| 128 | await fenster.waitForTimeout(300); |
| 129 | pruefen('Fragen durchsuchen', await ueberschriften()); |
| 130 | }); |
| 131 | |
| 132 | test('Kapitelwahl', async () => { |
| 133 | await zumStart(); |
| 134 | await fenster.getByRole('button', { name: /Kapitel wählen/u }).click(); |
| 135 | await fenster.waitForTimeout(300); |
| 136 | pruefen('Kapitelwahl', await ueberschriften()); |
| 137 | }); |
| 138 | |
| 139 | test('Prüfungswahl', async () => { |
| 140 | await zumStart(); |
| 141 | await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click(); |
| 142 | await fenster.waitForTimeout(400); |
| 143 | pruefen('Prüfungswahl', await ueberschriften()); |
| 144 | }); |
| 145 | |
| 146 | test('Lernsitzung', async () => { |
| 147 | await zumStart(); |
| 148 | await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); |
| 149 | await fenster.waitForTimeout(500); |
| 150 | pruefen('Lernsitzung', await ueberschriften()); |
| 151 | }); |
| 152 | |
| 153 | test('Lernsitzung nach der Antwort', async () => { |
| 154 | /* |
| 155 | Gesucht ist irgendeine Auswahlfrage, nicht ausdruecklich eine mit |
| 156 | Einfachauswahl: Die Sitzung wird zufaellig zusammengestellt, und ob |
| 157 | unter den ersten Fragen eine Einfachauswahl ist, entscheidet der |
| 158 | Zufall. Der Test hat deshalb sporadisch versagt, ohne dass etwas |
| 159 | kaputt war. Fuer die Ueberschriftenstruktur macht es keinen |
| 160 | Unterschied, ob angekreuzt oder angehakt wird. |
| 161 | */ |
| 162 | const auswahlfeld = fenster.getByRole('radio').or(fenster.getByRole('checkbox')); |
| 163 | |
| 164 | for (let i = 0; i < 20; i++) { |
| 165 | if ((await auswahlfeld.count()) > 0) { |
| 166 | break; |
| 167 | } |
| 168 | await fenster |
| 169 | .getByRole('button', { name: /Musterantwort anzeigen|^Antwort bestätigen$/u }) |
| 170 | .first() |
| 171 | .click(); |
| 172 | const b = fenster.getByRole('button', { name: /^(Gewusst|Sicher gewusst)$/u }); |
| 173 | if ((await b.count()) > 0) { |
| 174 | await b.first().click(); |
| 175 | } |
| 176 | const w = fenster.getByRole('button', { name: /^Nächste Frage$/u }); |
| 177 | if ((await w.count()) === 0) { |
| 178 | break; |
| 179 | } |
| 180 | await w.first().click(); |
| 181 | await fenster.waitForTimeout(150); |
| 182 | } |
| 183 | await auswahlfeld.first().click(); |
| 184 | await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click(); |
| 185 | await fenster.waitForTimeout(300); |
| 186 | pruefen('Lernsitzung nach der Antwort', await ueberschriften()); |
| 187 | }); |
| 188 | }); |