waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests renderer-unterstuetzung.test.tsx
| 1 | /** |
| 2 | * Der Block „Diese Arbeit unterstützen“ in „Über diese Software“. |
| 3 | * |
| 4 | * Geprüft wird beides, und das ist der Punkt: der Zustand von heute (keine |
| 5 | * Adresse eingetragen – das Angebot fehlt vollständig) und der von morgen |
| 6 | * (eine Adresse eingetragen – der Knopf öffnet genau sie). Die Komponente |
| 7 | * bekommt das Ziel deshalb als Eigenschaft; die eine Stelle, an der die |
| 8 | * Adresse steht, ist `shared/unterstuetzung.ts` und wird in |
| 9 | * `tests/unterstuetzung.test.ts` geprüft. |
| 10 | * |
| 11 | * Das Angebot ist eine Bitte und kein Handel: Es schaltet nichts frei, es |
| 12 | * nennt keinen Betrag, und es kommt von selbst nie wieder. Die letzten beiden |
| 13 | * Zusagen stehen unten als eigene Prüfungen – sie sind der Grund, warum ein |
| 14 | * Spendenknopf in dieser Anwendung überhaupt vertretbar ist. |
| 15 | */ |
| 16 | |
| 17 | import { render, screen, waitFor, within } from '@testing-library/react'; |
| 18 | import userEvent from '@testing-library/user-event'; |
| 19 | import axe from 'axe-core'; |
| 20 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 21 | |
| 22 | import { Unterstuetzung } from '../src/renderer/src/components/ueber/Unterstuetzung'; |
| 23 | import { KONTAKT } from '../src/shared/kontakt'; |
| 24 | import { jsdomOptionen } from '../src/shared/wcag'; |
| 25 | import { UNTERSTUETZUNG_KNOPF, UNTERSTUETZUNG_TITEL } from '../src/shared/unterstuetzung'; |
| 26 | |
| 27 | /** Eine erfundene, aber formgerechte Adresse – die echte steht noch nicht fest. */ |
| 28 | const ZIEL = 'https://beispiel.example/unterstuetzen'; |
| 29 | |
| 30 | /** Attrappe des einzigen Kanals, den dieser Block braucht. */ |
| 31 | function bruecke(oeffnen: (url: string) => Promise<boolean>): void { |
| 32 | vi.stubGlobal('lernApp', { unterstuetzungOeffnen: vi.fn(oeffnen) }); |
| 33 | } |
| 34 | |
| 35 | function knopf(): HTMLElement { |
| 36 | return screen.getByRole('button', { name: UNTERSTUETZUNG_KNOPF }); |
| 37 | } |
| 38 | |
| 39 | afterEach(() => { |
| 40 | vi.unstubAllGlobals(); |
| 41 | }); |
| 42 | |
| 43 | describe('Unterstützungsangebot – wann es erscheint', () => { |
| 44 | it('erscheint, sobald eine Adresse eingetragen ist', () => { |
| 45 | bruecke(() => Promise.resolve(true)); |
| 46 | render(<Unterstuetzung ziel={ZIEL} />); |
| 47 | |
| 48 | expect( |
| 49 | screen.getByRole('heading', { name: UNTERSTUETZUNG_TITEL, level: 2 }), |
| 50 | ).toBeInTheDocument(); |
| 51 | expect(knopf()).toBeInTheDocument(); |
| 52 | // Wer gleich den Browser verlässt, soll vorher lesen können, wohin. |
| 53 | expect(screen.getByText(ZIEL)).toBeInTheDocument(); |
| 54 | }); |
| 55 | |
| 56 | it('fehlt vollständig, wenn keine Adresse eingetragen ist', () => { |
| 57 | /* |
| 58 | Vollständig heißt: kein Knopf, keine Überschrift, kein leerer Block und |
| 59 | auch keine Zeile „zurzeit nicht verfügbar“. Ein toter Knopf sagt zu, dass |
| 60 | es dort weitergeht; eine Erklärung, warum gerade nichts geht, ist eine |
| 61 | Auskunft über den Betreiber und keine über die Anwendung. |
| 62 | */ |
| 63 | bruecke(() => Promise.resolve(true)); |
| 64 | const { container } = render(<Unterstuetzung ziel={null} />); |
| 65 | |
| 66 | expect(container).toBeEmptyDOMElement(); |
| 67 | expect(screen.queryByRole('button')).toBeNull(); |
| 68 | expect(screen.queryByRole('heading')).toBeNull(); |
| 69 | }); |
| 70 | |
| 71 | it('fehlt vollständig, wenn der Anwendungskern den Kanal nicht anbietet', () => { |
| 72 | /* Der Anwendungskern kann älter sein als die Oberfläche. Ein Knopf, der |
| 73 | auf Druck erklären müsste, dass er nicht kann, wäre eine Zusage ohne |
| 74 | Deckung – dieselbe Regel wie beim Kopierknopf im Meldeblock. */ |
| 75 | vi.stubGlobal('lernApp', {}); |
| 76 | const { container } = render(<Unterstuetzung ziel={ZIEL} />); |
| 77 | |
| 78 | expect(container).toBeEmptyDOMElement(); |
| 79 | }); |
| 80 | |
| 81 | it('fehlt vollständig, wenn gar keine Brücke da ist', () => { |
| 82 | /* Im Browser ohne Preload – etwa in diesen Tests, wenn niemand eine |
| 83 | Attrappe setzt. */ |
| 84 | const { container } = render(<Unterstuetzung ziel={ZIEL} />); |
| 85 | |
| 86 | expect(container).toBeEmptyDOMElement(); |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | describe('Unterstützungsangebot – was der Knopf tut', () => { |
| 91 | it('öffnet genau die angezeigte Adresse und keine andere', async () => { |
| 92 | const oeffnen = vi.fn<(url: string) => Promise<boolean>>().mockResolvedValue(true); |
| 93 | vi.stubGlobal('lernApp', { unterstuetzungOeffnen: oeffnen }); |
| 94 | const nutzer = userEvent.setup(); |
| 95 | render(<Unterstuetzung ziel={ZIEL} />); |
| 96 | |
| 97 | await nutzer.click(knopf()); |
| 98 | |
| 99 | /* Auf die Aufrufliste und nicht auf `toHaveBeenCalledWith`: So fällt auch |
| 100 | ein zweiter Aufruf mit einer anderen Adresse auf. */ |
| 101 | expect(oeffnen.mock.calls).toEqual([[ZIEL]]); |
| 102 | }); |
| 103 | |
| 104 | it('geht ausschließlich über den Kanal des Hauptprozesses', async () => { |
| 105 | /* |
| 106 | Die Anwendung öffnet von sich aus keine Verbindung nach außen – kein |
| 107 | `fetch`, kein `net.request`, und auch kein `window.open`, das an der |
| 108 | Prüfung im Hauptprozess vorbeiliefe. Der Knopf ist ein `<button>` und |
| 109 | kein `<a href="https://…">`: Diese Anwendung navigiert nicht, sie stößt |
| 110 | den Systembrowser an. |
| 111 | */ |
| 112 | const geoeffnet = vi.fn(); |
| 113 | const geladen = vi.fn(); |
| 114 | vi.stubGlobal('open', geoeffnet); |
| 115 | vi.stubGlobal('fetch', geladen); |
| 116 | const oeffnen = vi.fn<(url: string) => Promise<boolean>>().mockResolvedValue(true); |
| 117 | vi.stubGlobal('lernApp', { unterstuetzungOeffnen: oeffnen }); |
| 118 | const nutzer = userEvent.setup(); |
| 119 | render(<Unterstuetzung ziel={ZIEL} />); |
| 120 | |
| 121 | expect(screen.queryAllByRole('link')).toEqual([]); |
| 122 | await nutzer.click(knopf()); |
| 123 | |
| 124 | expect(geoeffnet).not.toHaveBeenCalled(); |
| 125 | expect(geladen).not.toHaveBeenCalled(); |
| 126 | expect(oeffnen).toHaveBeenCalledTimes(1); |
| 127 | }); |
| 128 | |
| 129 | it('zeigt die Adresse zum Abschreiben, wenn der Browser sich nicht öffnen lässt', async () => { |
| 130 | const nutzer = userEvent.setup(); |
| 131 | bruecke(() => Promise.resolve(false)); |
| 132 | render(<Unterstuetzung ziel={ZIEL} />); |
| 133 | |
| 134 | await nutzer.click(knopf()); |
| 135 | |
| 136 | const meldung = await screen.findByRole('alert'); |
| 137 | expect(meldung).toHaveTextContent(/Standardbrowser/u); |
| 138 | // Die Adresse bleibt stehen – die Meldung verweist auf sie. |
| 139 | expect(screen.getByText(ZIEL)).toBeInTheDocument(); |
| 140 | }); |
| 141 | |
| 142 | it('meldet auch einen Fehler des Kanals, statt stumm zu bleiben', async () => { |
| 143 | const nutzer = userEvent.setup(); |
| 144 | bruecke(() => Promise.reject(new Error('Kanal weg'))); |
| 145 | render(<Unterstuetzung ziel={ZIEL} />); |
| 146 | |
| 147 | await nutzer.click(knopf()); |
| 148 | |
| 149 | await waitFor(() => { |
| 150 | expect(screen.getByRole('alert')).toHaveTextContent(/Kanal weg/u); |
| 151 | }); |
| 152 | }); |
| 153 | }); |
| 154 | |
| 155 | describe('Unterstützungsangebot – kein Dark Pattern', () => { |
| 156 | it('nennt keinen Betrag und keinen Vorschlag', () => { |
| 157 | /* Kein „schon ab 3 Euro“, kein Schieberegler, keine Stufen. Wer geben |
| 158 | will, entscheidet auf der Seite des Dienstes, wie viel. */ |
| 159 | bruecke(() => Promise.resolve(true)); |
| 160 | const { container } = render(<Unterstuetzung ziel={ZIEL} />); |
| 161 | const text = container.textContent; |
| 162 | |
| 163 | expect(text).not.toMatch(/€|EUR\b|Euro|Dollar|\$/u); |
| 164 | expect(text).not.toMatch(/\bab \d/u); |
| 165 | }); |
| 166 | |
| 167 | it('sagt ausdrücklich, dass nichts freigeschaltet wird', () => { |
| 168 | /* |
| 169 | Store-Richtlinie und Haltung zugleich: Hinter dem Verweis wird kein |
| 170 | digitales Gut und keine Funktion verkauft. Das steht nicht nur im |
| 171 | Quelltext, sondern für den Lernenden lesbar auf dem Bildschirm – sonst |
| 172 | wäre es eine Zusage, die nur der Entwickler kennt. |
| 173 | */ |
| 174 | bruecke(() => Promise.resolve(true)); |
| 175 | const { container } = render(<Unterstuetzung ziel={ZIEL} />); |
| 176 | const text = container.textContent; |
| 177 | |
| 178 | expect(text).toMatch(/kostenlos/u); |
| 179 | expect(text).toMatch(/Funktionsumfang ändert das nichts/u); |
| 180 | expect(text).toMatch(/freizuschalten/u); |
| 181 | }); |
| 182 | |
| 183 | it('sagt zu, dass die Bitte beim Lernen nicht wiederkommt', () => { |
| 184 | bruecke(() => Promise.resolve(true)); |
| 185 | const { container } = render(<Unterstuetzung ziel={ZIEL} />); |
| 186 | |
| 187 | expect(container.textContent).toMatch(/beim Lernen kommt diese Bitte nicht wieder/u); |
| 188 | }); |
| 189 | |
| 190 | /* |
| 191 | Bis Fassung 0.24.1 stand hier „ihr Quelltext ist offen“. Ein öffentliches |
| 192 | Quelltextarchiv gibt es nicht, und die Anwendung nannte weder eine Adresse |
| 193 | noch den Weg über die Anfrage – wer danach suchte, fand nichts. |
| 194 | Updatebericht und Datenschutzerklärung sagen seit jeher „auf Anfrage“. |
| 195 | */ |
| 196 | it('verspricht kein Quelltextarchiv, sondern nennt den Weg dorthin', () => { |
| 197 | bruecke(() => Promise.resolve(true)); |
| 198 | const { container } = render(<Unterstuetzung ziel={ZIEL} />); |
| 199 | const text = container.textContent; |
| 200 | |
| 201 | expect(text).not.toMatch(/Quelltext ist offen/u); |
| 202 | expect(text).toMatch(/Quelltext gibt es auf Anfrage/u); |
| 203 | expect(text).toContain(KONTAKT.epost); |
| 204 | }); |
| 205 | }); |
| 206 | |
| 207 | describe('Unterstützungsangebot – Barrierefreiheit', () => { |
| 208 | it('nennt im zugänglichen Namen, dass der Browser geöffnet wird', () => { |
| 209 | /* WCAG 3.2: keine Überraschung. Und WCAG 2.5.3: Die sichtbare |
| 210 | Beschriftung ist der ganze zugängliche Name – es gibt kein |
| 211 | `aria-label`, das mit der Zeit danebenliefe. */ |
| 212 | bruecke(() => Promise.resolve(true)); |
| 213 | render(<Unterstuetzung ziel={ZIEL} />); |
| 214 | |
| 215 | const schaltflaeche = knopf(); |
| 216 | expect(schaltflaeche).toHaveAccessibleName(UNTERSTUETZUNG_KNOPF); |
| 217 | expect(schaltflaeche.textContent).toBe(UNTERSTUETZUNG_KNOPF); |
| 218 | expect(schaltflaeche).not.toHaveAttribute('aria-label'); |
| 219 | expect(schaltflaeche).toHaveTextContent(/Browser/u); |
| 220 | }); |
| 221 | |
| 222 | it('trägt die Klasse, die Klickfläche und Fokusring mitbringt', () => { |
| 223 | /* Die 44 Pixel (`--klickflaeche-min`, WCAG 2.5.8) und der Fokusring |
| 224 | hängen an `.knopf` bzw. an der zentralen Regel in `global.css`. Unter |
| 225 | jsdom lässt sich keine Größe messen – axe misst `target-size` |
| 226 | ausdrücklich nur im echten Fenster (siehe `shared/wcag.ts`). Geprüft |
| 227 | wird deshalb, dass der Knopf an dieser Regel hängt und sich nicht sein |
| 228 | eigenes Aussehen gibt. */ |
| 229 | bruecke(() => Promise.resolve(true)); |
| 230 | render(<Unterstuetzung ziel={ZIEL} />); |
| 231 | |
| 232 | expect(knopf()).toHaveClass('knopf'); |
| 233 | }); |
| 234 | |
| 235 | it('meldet keine Verstöße gegen WCAG 2.1 A/AA (axe-core)', async () => { |
| 236 | const nutzer = userEvent.setup(); |
| 237 | bruecke(() => Promise.resolve(false)); |
| 238 | /* In einem `main`, damit die Überschrift nicht als landmarkenlos |
| 239 | auffällt – so steht der Block auch in der Anwendung. */ |
| 240 | render( |
| 241 | <main> |
| 242 | <h1>Über diese Software</h1> |
| 243 | <Unterstuetzung ziel={ZIEL} /> |
| 244 | </main>, |
| 245 | ); |
| 246 | |
| 247 | // Auch mit sichtbarer Fehlermeldung – der Kasten gehört mit geprüft. |
| 248 | await nutzer.click(knopf()); |
| 249 | await screen.findByRole('alert'); |
| 250 | |
| 251 | const ergebnis = await axe.run(document.body, jsdomOptionen()); |
| 252 | |
| 253 | expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]); |
| 254 | }); |
| 255 | |
| 256 | it('steht als eigener Block mit einer Überschrift zweiter Ebene', () => { |
| 257 | /* In „Über diese Software“ trägt jeder Block eine `h2`. Eine `h1` wäre |
| 258 | eine zweite Ansichtsüberschrift, eine `h3` machte den Block zum Teil des |
| 259 | Blocks darüber – hier: der Fremdkomponenten. */ |
| 260 | bruecke(() => Promise.resolve(true)); |
| 261 | const { container } = render(<Unterstuetzung ziel={ZIEL} />); |
| 262 | |
| 263 | const block = container.firstElementChild as HTMLElement; |
| 264 | expect(block).toHaveClass('ueber__block'); |
| 265 | expect(within(block).getByRole('heading', { level: 2 })).toHaveTextContent( |
| 266 | UNTERSTUETZUNG_TITEL, |
| 267 | ); |
| 268 | }); |
| 269 | }); |