waffensachkunde

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

/ app tests renderer-unterstuetzung.test.tsx

10,9 KB Rohdatei
app/tests/renderer-unterstuetzung.test.tsx — 269 Zeilen
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 });