lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests ui zeichenflaecheTastatur.test.ts

15,8 KB Rohdatei
tests/ui/zeichenflaecheTastatur.test.ts — 472 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { ProjectStore } from '@/app/store';
4 import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan';
5 import type { Punkt } from '@/domain/geometrie/vermessung';
6 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
7 import { lageplanView } from '@/ui/views/lageplanView';
8
9 /**
10 * Die Zeichenflaeche als BEDIENELEMENT - Erreichbarkeit, Name, Rolle, Belegung.
11 *
12 * WAS HIER ABGESICHERT WIRD UND WARUM ES NICHT SCHON DASTAND
13 *
14 * tests/ui/lageplanTastatur.test.ts prueft, was die einzelnen Tasten bewirken -
15 * aber es schickt die Tastendruecke unmittelbar an die Zeichenflaeche, ohne sie
16 * vorher zu fokussieren. Damit haengt der gesamte bisherige Nachweis an einer
17 * Voraussetzung, die nirgends geprueft ist: dass die Flaeche den Fokus
18 * ueberhaupt bekommen kann. Fiele das `tabindex="0"` aus lageplanView.ts
19 * heraus, bliebe der ganze Testbestand gruen, waehrend die Flaeche mit der
20 * Tastatur nicht mehr erreichbar waere - kein Tabulatorstopp, kein
21 * Tastenzeiger, keine Vermessung ohne Maus.
22 *
23 * Deshalb wird hier ausschliesslich ueber das FOKUSSIERTE Element getippt, so
24 * wie ein Browser die Taste zustellt. Ein Tastendruck, der die Flaeche nicht
25 * erreicht, darf nichts zeichnen - und genau das ist der Fall, sobald der
26 * Fokus woanders steht.
27 *
28 * DIE MASSE DER PRUEFFLAECHE
29 *
30 * In jsdom hat der Rahmen keine Breite; die Zeichenflaeche faellt auf ihre
31 * Mindestmasse von 320 x 320 Bildschirmpunkten zurueck, die Sicht bleibt die
32 * Startsicht ohne Zoom. Ein Bildschirmpunkt ist damit genau ein Bildpunkt, und
33 * der Tastenzeiger beginnt in der Mitte bei (160, 160). Eine Zeichenflaeche
34 * bringt jsdom nicht mit - die Ansicht meldet das fehlende getContext auf der
35 * Konsole und baut den Rest trotzdem auf; geprueft wird ohnehin nicht das Bild,
36 * sondern was im Projekt landet und was angesagt wird.
37 */
38
39 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
40
41 /** Mitte der Zeichenflaeche - dort beginnt der Tastaturzeiger. */
42 const MITTE: Punkt = { x: 160, y: 160 };
43
44 /**
45 * Plan mit Massstab und einer waagerechten Haltlinie bei y = 200.
46 *
47 * 100 Bildpunkte auf 20 m ergeben 0,2 m je Bildpunkt. Der Massstab ist noetig,
48 * damit das Werkzeug "Fahrlinie" ueberhaupt freigegeben ist.
49 */
50 function planMitHaltlinie(): Lageplan {
51 return {
52 arbeitsbereiche: [],
53 signalgeber: [],
54 bild: null,
55 kalibrierung: {
56 von: { x: 0, y: 0 },
57 bis: { x: 100, y: 0 },
58 laengeMeter: 20,
59 herkunft: 'gemessen',
60 },
61 haltlinien: [
62 {
63 id: 'hl1',
64 name: 'Nord',
65 art: 'zufahrt',
66 armId: null,
67 von: { x: 100, y: 200 },
68 bis: { x: 220, y: 200 },
69 },
70 ],
71 linien: [],
72 };
73 }
74
75 interface Ansicht {
76 readonly store: ProjectStore;
77 readonly wurzel: HTMLElement;
78 readonly leinwand: HTMLCanvasElement;
79 readonly aufraeumen: () => void;
80 }
81
82 function baueAnsicht(lageplan: Lageplan = planMitHaltlinie()): Ansicht {
83 const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM));
84 store.update((p) => actions.setLageplan(p, lageplan), { label: 'Pruefplan' });
85
86 const wurzel = document.createElement('div');
87 document.body.append(wurzel);
88 const schliessen = lageplanView.render(wurzel, {
89 store,
90 refresh: () => {},
91 navigate: () => {},
92 target: null,
93 });
94
95 const leinwand = wurzel.querySelector('canvas');
96 if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.');
97
98 return {
99 store,
100 wurzel,
101 leinwand,
102 aufraeumen: () => {
103 // Sonst bliebe der Fokus des einen Falls im naechsten stehen.
104 leinwand.blur();
105 schliessen();
106 wurzel.remove();
107 },
108 };
109 }
110
111 /** Waehlt ein Werkzeug ueber seine Schaltflaeche - so wie ein Anwender auch. */
112 function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void {
113 const knopf = werkzeugKnopf(ansicht, beschriftung);
114 knopf.click();
115 }
116
117 function werkzeugKnopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement {
118 const knopf = [...ansicht.wurzel.querySelectorAll('button')].find(
119 (b) => (b.textContent ?? '').trim() === beschriftung,
120 );
121 if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`);
122 return knopf;
123 }
124
125 interface Zusatztasten {
126 readonly shiftKey?: boolean;
127 readonly ctrlKey?: boolean;
128 }
129
130 /**
131 * Tippt auf das Element, das GERADE DEN FOKUS HAT.
132 *
133 * Das ist der Kern dieser Datei: Ein Browser stellt einen Tastendruck dem
134 * fokussierten Element zu, nicht demjenigen, das der Test gern haette. Wer
135 * unmittelbar an die Zeichenflaeche sendet, prueft eine Zustellung, die es ohne
136 * Fokussierbarkeit gar nicht gaebe.
137 *
138 * Zurueck kommt, ob der letzte Anschlag abgefangen wurde - ohne
139 * preventDefault rollt die Seite unter der Zeichenflaeche weg.
140 */
141 function tippe(key: string, zusatz: Zusatztasten = {}, male = 1): boolean {
142 let abgefangen = false;
143 for (let i = 0; i < male; i += 1) {
144 const ziel = (document.activeElement as HTMLElement | null) ?? document.body;
145 const ereignis = new KeyboardEvent('keydown', {
146 key,
147 bubbles: true,
148 cancelable: true,
149 ...zusatz,
150 });
151 ziel.dispatchEvent(ereignis);
152 abgefangen = ereignis.defaultPrevented;
153 }
154 return abgefangen;
155 }
156
157 /** Text der Live-Region unter der Zeichenflaeche. */
158 function ansage(ansicht: Ansicht): string {
159 return ansicht.wurzel.querySelector('[role="status"]')?.textContent ?? '';
160 }
161
162 function linien(ansicht: Ansicht): readonly Planlinie[] {
163 return ansicht.store.getProject().lageplan.linien;
164 }
165
166 describe('Die Zeichenfläche ist mit der Tastatur erreichbar', () => {
167 it('liegt in der Tabulatorfolge und nimmt den Fokus tatsächlich an', () => {
168 const ansicht = baueAnsicht();
169 try {
170 // Beides zusammen: Das Attribut allein sagte nur, dass da etwas steht -
171 // die Zustellung des Fokus ist die Zusage.
172 expect(ansicht.leinwand.getAttribute('tabindex')).toBe('0');
173 expect(ansicht.leinwand.tabIndex).toBe(0);
174
175 expect(document.activeElement).not.toBe(ansicht.leinwand);
176 ansicht.leinwand.focus();
177 expect(document.activeElement).toBe(ansicht.leinwand);
178 } finally {
179 ansicht.aufraeumen();
180 }
181 });
182
183 it('trägt eine Rolle und einen zugänglichen Namen', () => {
184 const ansicht = baueAnsicht();
185 try {
186 // "application" und nicht "img": Die Flaeche nimmt Tastendruecke entgegen.
187 // Von dieser Rolle haengt ab, dass eine Sprachausgabe die Pfeiltasten
188 // durchreicht, statt mit ihnen selbst durch den Text zu wandern.
189 expect(ansicht.leinwand.getAttribute('role')).toBe('application');
190
191 const namensId = ansicht.leinwand.getAttribute('aria-labelledby');
192 expect(namensId).not.toBeNull();
193 const name = document.getElementById(namensId!);
194 // Ein Verweis auf ein Element, das es nicht gibt, ergibt keinen Namen.
195 expect(name).not.toBeNull();
196 expect(ansicht.wurzel.contains(name)).toBe(true);
197 expect((name!.textContent ?? '').trim()).not.toBe('');
198 expect(name!.textContent).toContain('Zeichenfläche');
199 } finally {
200 ansicht.aufraeumen();
201 }
202 });
203
204 it('beschreibt beim Betreten, womit sich die Fläche bedienen lässt', () => {
205 const ansicht = baueAnsicht();
206 try {
207 const hinweisId = ansicht.leinwand.getAttribute('aria-describedby');
208 expect(hinweisId).not.toBeNull();
209 const hinweis = document.getElementById(hinweisId!);
210 expect(hinweis).not.toBeNull();
211 const text = hinweis!.textContent ?? '';
212 expect(text).toContain('Pfeiltasten');
213 expect(text).toContain('Leertaste');
214 } finally {
215 ansicht.aufraeumen();
216 }
217 });
218 });
219
220 describe('Ein Tastendruck wirkt nur auf der fokussierten Zeichenfläche', () => {
221 /** Dieselbe Folge in beiden Fällen: zwei Punkte setzen und abschließen. */
222 function zeichneMitTasten(): void {
223 tippe('ArrowDown', {}, 2);
224 tippe(' ');
225 tippe('ArrowUp', {}, 4);
226 tippe(' ');
227 tippe('Enter');
228 }
229
230 it('zeichnet nichts, solange der Seitenkörper den Fokus hat', () => {
231 const ansicht = baueAnsicht();
232 try {
233 waehleWerkzeug(ansicht, 'Fahrlinie');
234 // Kein focus() auf die Flaeche: Der Fokus liegt auf dem Seitenkoerper,
235 // und dorthin stellt der Browser die Taste zu.
236 expect(document.activeElement).toBe(document.body);
237
238 zeichneMitTasten();
239
240 expect(linien(ansicht)).toHaveLength(0);
241 // Auch der Zeiger hat sich nicht geruehrt - die Live-Region steht noch
242 // auf ihrem Ruhetext.
243 expect(ansage(ansicht)).toContain('noch nicht gesetzt');
244 } finally {
245 ansicht.aufraeumen();
246 }
247 });
248
249 it('zeichnet, sobald die Fläche den Fokus hat', () => {
250 const ansicht = baueAnsicht();
251 try {
252 waehleWerkzeug(ansicht, 'Fahrlinie');
253 ansicht.leinwand.focus();
254
255 zeichneMitTasten();
256
257 expect(linien(ansicht)).toHaveLength(1);
258 expect(linien(ansicht)[0]?.punkte).toEqual([
259 { x: 160, y: 170 },
260 { x: 160, y: 150 },
261 ]);
262 } finally {
263 ansicht.aufraeumen();
264 }
265 });
266
267 it('hört wieder auf zu wirken, wenn die Fläche den Fokus abgibt', () => {
268 const ansicht = baueAnsicht();
269 try {
270 waehleWerkzeug(ansicht, 'Fahrlinie');
271 ansicht.leinwand.focus();
272 tippe(' ');
273 expect(ansage(ansicht)).toContain('Erster Punkt gesetzt');
274
275 ansicht.leinwand.blur();
276 expect(document.activeElement).toBe(document.body);
277 tippe('ArrowRight', {}, 4);
278 tippe(' ');
279 tippe('Enter');
280
281 // Die Linie ist bei einem Punkt stehen geblieben und wurde nie
282 // abgeschlossen.
283 expect(linien(ansicht)).toHaveLength(0);
284 expect(ansage(ansicht)).toContain('Erster Punkt gesetzt');
285 } finally {
286 ansicht.aufraeumen();
287 }
288 });
289 });
290
291 describe('Tastenbelegung der Zeichenfläche', () => {
292 /**
293 * Fuehrt die Tasten auf der fokussierten Flaeche aus, setzt dort einen Punkt
294 * und gibt dessen Lage IM BILD zurueck.
295 *
296 * Der zweite Punkt und der Abschluss dienen nur dazu, die Linie ins Projekt
297 * zu bringen - gefragt ist, wo der erste liegt.
298 */
299 function ersterPunktNach(...tasten: readonly string[]): Punkt | undefined {
300 const ansicht = baueAnsicht();
301 try {
302 waehleWerkzeug(ansicht, 'Fahrlinie');
303 ansicht.leinwand.focus();
304 for (const taste of tasten) tippe(taste);
305 tippe(' ');
306 tippe('ArrowDown');
307 tippe(' ');
308 tippe('Enter');
309 return linien(ansicht)[0]?.punkte[0];
310 } finally {
311 ansicht.aufraeumen();
312 }
313 }
314
315 it('bewegt den Zeiger mit den vier Pfeiltasten in ihre Richtung', () => {
316 // Fuenf Bildpunkte je Anschlag, ausgehend von der Mitte.
317 expect(ersterPunktNach('ArrowLeft')).toEqual({ x: 155, y: 160 });
318 expect(ersterPunktNach('ArrowRight')).toEqual({ x: 165, y: 160 });
319 expect(ersterPunktNach('ArrowUp')).toEqual({ x: 160, y: 155 });
320 expect(ersterPunktNach('ArrowDown')).toEqual({ x: 160, y: 165 });
321 });
322
323 it('fängt Pfeiltasten und Leertaste ab, damit die Seite nicht wegrollt', () => {
324 const ansicht = baueAnsicht();
325 try {
326 waehleWerkzeug(ansicht, 'Fahrlinie');
327 ansicht.leinwand.focus();
328 expect(tippe('ArrowDown')).toBe(true);
329 expect(tippe(' ')).toBe(true);
330 } finally {
331 ansicht.aufraeumen();
332 }
333 });
334
335 it('setzt mit der Leertaste einen Punkt und schließt mit der Eingabetaste ab', () => {
336 const ansicht = baueAnsicht();
337 try {
338 waehleWerkzeug(ansicht, 'Fahrlinie');
339 ansicht.leinwand.focus();
340
341 tippe(' ');
342 expect(ansage(ansicht)).toContain('Erster Punkt gesetzt');
343 // Ein Punkt ist noch keine Linie: Erst der Abschluss traegt sie ein.
344 expect(linien(ansicht)).toHaveLength(0);
345
346 tippe('ArrowRight', {}, 4);
347 tippe(' ');
348 expect(ansage(ansicht)).toContain('Punkt 2 gesetzt');
349 expect(linien(ansicht)).toHaveLength(0);
350
351 tippe('Enter');
352 expect(linien(ansicht)).toHaveLength(1);
353 expect(linien(ansicht)[0]?.punkte).toEqual([MITTE, { x: 180, y: 160 }]);
354 } finally {
355 ansicht.aufraeumen();
356 }
357 });
358
359 it('bricht mit Esc ab und lässt nichts von der Zeichnung stehen', () => {
360 const ansicht = baueAnsicht();
361 try {
362 waehleWerkzeug(ansicht, 'Fahrlinie');
363 ansicht.leinwand.focus();
364 tippe(' ');
365 tippe('ArrowRight', {}, 4);
366 tippe(' ');
367
368 tippe('Escape');
369 expect(ansage(ansicht)).toContain('Zeichnen abgebrochen');
370
371 // Das Werkzeug steht wieder auf "Ansehen" - und zwar ablesbar, nicht nur
372 // an der Farbe des Knopfes.
373 expect(werkzeugKnopf(ansicht, 'Ansehen').getAttribute('aria-pressed')).toBe('true');
374 expect(werkzeugKnopf(ansicht, 'Fahrlinie').getAttribute('aria-pressed')).toBe('false');
375
376 // Der Entwurf ist fort: Die Eingabetaste schliesst nichts mehr ab.
377 tippe('Enter');
378 expect(linien(ansicht)).toHaveLength(0);
379 } finally {
380 ansicht.aufraeumen();
381 }
382 });
383
384 it('nimmt mit der Rückschritttaste den letzten Punkt zurück', () => {
385 const ansicht = baueAnsicht();
386 try {
387 waehleWerkzeug(ansicht, 'Fahrlinie');
388 ansicht.leinwand.focus();
389 tippe(' ');
390 tippe('ArrowRight', {}, 4);
391 tippe(' ');
392
393 tippe('Backspace');
394 expect(ansage(ansicht)).toContain('Letzten Punkt zurückgenommen');
395 expect(ansage(ansicht)).toContain('1 Punkt(e) verbleiben');
396
397 // Nachweis, dass wirklich nur noch ein Punkt steht: Der Abschluss
398 // verweigert sich, eine Fahrlinie braucht zwei.
399 tippe('Enter');
400 expect(linien(ansicht)).toHaveLength(0);
401 } finally {
402 ansicht.aufraeumen();
403 }
404 });
405
406 it('nimmt mit „z“ zurück, mit Strg+z dagegen nicht', () => {
407 // Strg+Z gehoert dem globalen Rueckgaengig. Wer beim Zeichnen aus
408 // Gewohnheit danach greift, darf nicht zugleich einen Punkt verlieren.
409 const ansicht = baueAnsicht();
410 try {
411 waehleWerkzeug(ansicht, 'Fahrlinie');
412 ansicht.leinwand.focus();
413 tippe(' ');
414 tippe('ArrowRight', {}, 4);
415 tippe(' ');
416
417 tippe('z', { ctrlKey: true });
418 expect(ansage(ansicht)).toContain('Punkt 2 gesetzt');
419
420 tippe('z');
421 expect(ansage(ansicht)).toContain('Letzten Punkt zurückgenommen');
422 } finally {
423 ansicht.aufraeumen();
424 }
425 });
426
427 it('vergrößert und verkleinert mit Plus und Minus und nennt die neue Schrittweite', () => {
428 const ansicht = baueAnsicht();
429 try {
430 ansicht.leinwand.focus();
431
432 tippe('+');
433 expect(ansage(ansicht)).toContain('Ansicht vergrößert');
434 // Ein Schritt sind 5 Bildschirmpunkte; bei 1,25-fachem Zoom also 4
435 // Bildpunkte und damit 0,8 m am Boden.
436 expect(ansage(ansicht)).toContain('0,8 m');
437
438 tippe('-');
439 expect(ansage(ansicht)).toContain('Ansicht verkleinert');
440 } finally {
441 ansicht.aufraeumen();
442 }
443 });
444
445 it('lässt den Zeiger beim Vergrößern stehen, wo er stand', () => {
446 // Die Zusage der Tastenhilfe: „vergrößern / verkleinern – der Zeiger bleibt
447 // stehen". Wanderte er mit, finge das Anfahren des Ziels von vorn an.
448 expect(ersterPunktNach('+', '+')).toEqual(MITTE);
449 expect(ersterPunktNach('-')).toEqual(MITTE);
450 });
451
452 it('sagt bei „0“, dass ohne Bild nichts einzupassen ist', () => {
453 const ansicht = baueAnsicht();
454 try {
455 ansicht.leinwand.focus();
456 tippe('0');
457 expect(ansage(ansicht)).toContain('Einpassen nicht möglich');
458 } finally {
459 ansicht.aufraeumen();
460 }
461 });
462
463 it('verschiebt die Sicht mit Bild auf, Bild ab, Pos 1 und Ende', () => {
464 // Ein Viertel der Zeichenflaeche je Anschlag - bei 320 Bildpunkten also 80.
465 // Der Zeiger behaelt seine Stelle auf dem Schirm und wandert im Bild mit;
466 // gemessen wird das an der Stelle, an der der Punkt dann landet.
467 expect(ersterPunktNach('ArrowDown', 'PageUp')).toEqual({ x: 160, y: 85 });
468 expect(ersterPunktNach('ArrowDown', 'PageDown')).toEqual({ x: 160, y: 245 });
469 expect(ersterPunktNach('ArrowDown', 'Home')).toEqual({ x: 80, y: 165 });
470 expect(ersterPunktNach('ArrowDown', 'End')).toEqual({ x: 240, y: 165 });
471 });
472 });