lsa-planer

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

/ tests ui lageplanTastatur.test.ts

16,8 KB Rohdatei
tests/ui/lageplanTastatur.test.ts — 498 Zeilen
1 import { afterEach, describe, expect, it, vi } 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 { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
6 import { START_SICHT, zeichne, zeigerSchritt } from '@/ui/lageplan/leinwand';
7 import { lageplanView } from '@/ui/views/lageplanView';
8
9 /**
10 * Bedienung der Zeichenflaeche mit der Tastatur.
11 *
12 * WAS HIER ABGESICHERT WIRD
13 *
14 * Ohne Maus liess sich im Lageplan kein Punkt setzen - und mit ihm entfielen
15 * Haltlinie, Fahrlinie und Massstab, also die gesamte Vermessung: Raeumweg,
16 * Einfahrweg, Schnittwinkel, Vertraeglichkeitsvorschlaege. Die Zusage lautet
17 * jetzt, dass die Tastatur dasselbe leistet wie die Maus. "Dasselbe" ist dabei
18 * woertlich zu nehmen und wird auch so geprueft: Dieselbe Stelle im Plan muss
19 * ueber beide Wege dieselbe Linie ergeben. Zwei getrennte Wege durch dieselben
20 * Entscheidungen - Fangregel, Deckelung des Fangradius, Zuordnung zur
21 * Haltlinie - liefen sonst auseinander, und niemand koennte sagen, welcher
22 * Raeumweg der richtige ist.
23 *
24 * DIE MASSE DER PRUEFFLAECHE
25 *
26 * In jsdom hat der Rahmen keine Breite; die Zeichenflaeche faellt damit auf
27 * ihre Mindestmasse von 320 x 320 Bildschirmpunkten zurueck, die Sicht bleibt
28 * die Startsicht ohne Zoom. Ein Bildschirmpunkt ist deshalb genau ein
29 * Bildpunkt, und der Zeiger beginnt in der Mitte bei (160, 160).
30 */
31
32 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
33
34 /** Mitte der Zeichenflaeche - dort beginnt der Tastaturzeiger. */
35 const MITTE = { x: 160, y: 160 };
36
37 /**
38 * Ein Plan mit Massstab und einer Haltlinie quer darunter.
39 *
40 * Der Massstab ist mit Absicht krumm gewaehlt: 100 Bildpunkte auf 20 m ergeben
41 * 0,2 m je Bildpunkt. Damit deckelt MAX_FANG_METER (2,5 m) den Fangradius auf
42 * 12,5 Bildpunkte, waehrend die Bildschirmumrechnung 15 Bildpunkte ergaebe -
43 * die Deckelung ist an dieser Unterlage also wirksam und pruefbar.
44 */
45 function planMitHaltlinie(): Lageplan {
46 return {
47 arbeitsbereiche: [],
48 signalgeber: [],
49 bild: null,
50 kalibrierung: {
51 von: { x: 0, y: 0 },
52 bis: { x: 100, y: 0 },
53 laengeMeter: 20,
54 herkunft: 'gemessen',
55 },
56 haltlinien: [
57 {
58 id: 'hl1',
59 name: 'Nord',
60 art: 'zufahrt',
61 armId: null,
62 von: { x: 100, y: 200 },
63 bis: { x: 220, y: 200 },
64 },
65 ],
66 linien: [],
67 };
68 }
69
70 interface Ansicht {
71 readonly store: ProjectStore;
72 readonly wurzel: HTMLElement;
73 readonly leinwand: HTMLCanvasElement;
74 readonly aufraeumen: () => void;
75 }
76
77 function baueAnsicht(lageplan: Lageplan = planMitHaltlinie()): Ansicht {
78 const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM));
79 store.update((p) => actions.setLageplan(p, lageplan), { label: 'Pruefplan' });
80
81 const wurzel = document.createElement('div');
82 document.body.append(wurzel);
83 const schliessen = lageplanView.render(wurzel, {
84 store,
85 refresh: () => {},
86 navigate: () => {},
87 target: null,
88 });
89
90 const leinwand = wurzel.querySelector('canvas');
91 if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.');
92
93 return {
94 store,
95 wurzel,
96 leinwand,
97 aufraeumen: () => {
98 schliessen();
99 wurzel.remove();
100 },
101 };
102 }
103
104 /** Waehlt ein Werkzeug ueber seine Schaltflaeche - so wie ein Anwender auch. */
105 function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void {
106 const knopf = [...ansicht.wurzel.querySelectorAll('button')].find(
107 (b) => (b.textContent ?? '').trim() === beschriftung,
108 );
109 if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`);
110 knopf.click();
111 }
112
113 interface Zusatztasten {
114 readonly shiftKey?: boolean;
115 readonly ctrlKey?: boolean;
116 }
117
118 function taste(ansicht: Ansicht, key: string, zusatz: Zusatztasten = {}, male = 1): void {
119 for (let i = 0; i < male; i += 1) {
120 ansicht.leinwand.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...zusatz }));
121 }
122 }
123
124 function klick(ansicht: Ansicht, x: number, y: number): void {
125 ansicht.leinwand.dispatchEvent(
126 new MouseEvent('click', { button: 0, detail: 1, clientX: x, clientY: y, bubbles: true }),
127 );
128 }
129
130 /** Text der Live-Region unter der Zeichenflaeche. */
131 function ansage(ansicht: Ansicht): string {
132 return ansicht.wurzel.querySelector('[role="status"]')?.textContent ?? '';
133 }
134
135 function linien(ansicht: Ansicht): readonly Planlinie[] {
136 return ansicht.store.getProject().lageplan.linien;
137 }
138
139 /** Alles ausser der Kennung - die ist zufaellig und vergleicht sich nicht. */
140 function ohneId(linie: Planlinie): Omit<Planlinie, 'id'> {
141 const { id: _id, ...rest } = linie;
142 return rest;
143 }
144
145 afterEach(() => {
146 vi.useRealTimers();
147 });
148
149 describe('Punkte setzen ohne Maus', () => {
150 it('zieht eine vollstaendige Fahrlinie allein mit der Tastatur', () => {
151 const ansicht = baueAnsicht();
152 try {
153 waehleWerkzeug(ansicht, 'Fahrlinie');
154 // Von der Mitte 8 Schritte nach unten: genau auf die Haltlinie bei y=200.
155 taste(ansicht, 'ArrowDown', {}, 8);
156 taste(ansicht, ' ');
157 // Und 16 Schritte zurueck nach oben, in den Knotenpunkt hinein.
158 taste(ansicht, 'ArrowUp', {}, 16);
159 taste(ansicht, ' ');
160 taste(ansicht, 'Enter');
161
162 expect(linien(ansicht)).toHaveLength(1);
163 const linie = linien(ansicht)[0]!;
164 expect(linie.punkte).toEqual([
165 { x: 160, y: 200 },
166 { x: 160, y: 120 },
167 ]);
168 expect(linie.haltlinieId).toBe('hl1');
169 } finally {
170 ansicht.aufraeumen();
171 }
172 });
173
174 it('setzt den ersten Punkt auch ohne vorherige Zeigerbewegung', () => {
175 // Die Leertaste muss vom ersten Anschlag an wirken - sonst faengt der
176 // Anwender mit einer Taste an, die scheinbar nichts tut.
177 const ansicht = baueAnsicht();
178 try {
179 waehleWerkzeug(ansicht, 'Fahrlinie');
180 taste(ansicht, ' ');
181 taste(ansicht, 'ArrowRight', {}, 4);
182 taste(ansicht, ' ');
183 taste(ansicht, 'Enter');
184
185 expect(linien(ansicht)[0]?.punkte).toEqual([MITTE, { x: 180, y: 160 }]);
186 } finally {
187 ansicht.aufraeumen();
188 }
189 });
190 });
191
192 describe('Schrittweiten des Zeigers', () => {
193 /** Zieht eine Linie von der Mitte aus mit `anzahl` Schritten nach rechts. */
194 function zweiterPunktNachRechts(zusatz: Zusatztasten, anzahl: number): { x: number; y: number } {
195 const ansicht = baueAnsicht();
196 try {
197 waehleWerkzeug(ansicht, 'Fahrlinie');
198 taste(ansicht, ' ');
199 taste(ansicht, 'ArrowRight', zusatz, anzahl);
200 taste(ansicht, ' ');
201 taste(ansicht, 'Enter');
202 const punkte = linien(ansicht)[0]?.punkte ?? [];
203 return punkte[1] ?? { x: Number.NaN, y: Number.NaN };
204 } finally {
205 ansicht.aufraeumen();
206 }
207 }
208
209 it('bewegt normal um 5 Bildpunkte je Anschlag', () => {
210 expect(zweiterPunktNachRechts({}, 3)).toEqual({ x: 175, y: 160 });
211 });
212
213 it('bewegt mit Umschalt fein um einen Bildpunkt', () => {
214 // Die feine Stufe ist die, mit der sich eine Markierung im Luftbild
215 // treffen laesst; ohne sie waere der kleinste Schritt 1 m am Boden.
216 expect(zweiterPunktNachRechts({ shiftKey: true }, 3)).toEqual({ x: 163, y: 160 });
217 });
218
219 it('bewegt mit Strg grob um 25 Bildpunkte', () => {
220 expect(zweiterPunktNachRechts({ ctrlKey: true }, 3)).toEqual({ x: 235, y: 160 });
221 });
222
223 it('rechnet die Schrittweite ueber den Zoom um und bleibt bei einem Bildpunkt stehen', () => {
224 // Auf dem Schirm festgelegt, in Bildpunkte umgerechnet - dieselbe Regel wie
225 // bei der Maus. Bei starker Vergroesserung greift die Untergrenze: Feiner
226 // als ein Bildpunkt der Unterlage waere Scheingenauigkeit.
227 expect(zeigerSchritt(START_SICHT, 'normal')).toBe(5);
228 expect(zeigerSchritt({ ...START_SICHT, zoom: 5 }, 'grob')).toBe(5);
229 expect(zeigerSchritt({ ...START_SICHT, zoom: 10 }, 'fein')).toBe(1);
230 });
231 });
232
233 describe('Einrasten auf eine Haltlinie', () => {
234 /** Setzt den ersten Punkt `abwaerts` Schritte unter der Mitte. */
235 function ersteLinie(abwaerts: number): Planlinie | undefined {
236 const ansicht = baueAnsicht();
237 try {
238 waehleWerkzeug(ansicht, 'Fahrlinie');
239 taste(ansicht, 'ArrowDown', {}, abwaerts);
240 taste(ansicht, ' ');
241 taste(ansicht, 'ArrowUp', {}, 10);
242 taste(ansicht, ' ');
243 taste(ansicht, 'Enter');
244 return linien(ansicht)[0];
245 } finally {
246 ansicht.aufraeumen();
247 }
248 }
249
250 it('zieht den ersten Punkt auf die Haltlinie und ordnet die Zufahrt zu', () => {
251 // 6 Schritte: y=190, also 10 Bildpunkte = 2,0 m neben der Haltlinie.
252 const linie = ersteLinie(6);
253 expect(linie?.punkte[0]).toEqual({ x: 160, y: 200 });
254 expect(linie?.haltlinieId).toBe('hl1');
255 expect(linie?.startT).toBeCloseTo(0.5, 6);
256 });
257
258 it('haelt die Deckelung MAX_FANG_METER auch mit der Tastatur ein', () => {
259 // 5 Schritte: y=185, also 15 Bildpunkte = 3,0 m neben der Haltlinie. Nach
260 // der reinen Bildschirmumrechnung (15 Bildschirmpunkte) wuerde der Punkt
261 // hier noch einrasten; am Boden sind 3,0 m aber mehr als eine
262 // Fahrstreifenbreite, und der Nullpunkt des Raeumwegs saesse falsch.
263 const linie = ersteLinie(5);
264 expect(linie?.punkte[0]).toEqual({ x: 160, y: 185 });
265 expect(linie?.haltlinieId).toBeNull();
266 });
267 });
268
269 describe('Ansage der Zeigerlage', () => {
270 it('nennt Abstand, Haltlinie und Lage darauf in Metern', () => {
271 const ansicht = baueAnsicht();
272 try {
273 taste(ansicht, 'ArrowDown');
274 // y=165: 35 Bildpunkte = 7,0 m von der Haltlinie, deren Anfang bei x=100
275 // liegt - der Lotfusspunkt also 12,0 m dahinter.
276 expect(ansage(ansicht)).toContain('Haltlinie „Nord“');
277 expect(ansage(ansicht)).toContain('7,0 m');
278 expect(ansage(ansicht)).toContain('12,0 m ab ihrem Anfang');
279 // Eine Bildkoordinate hilft niemandem, der die Zeichnung nicht sieht.
280 expect(ansage(ansicht)).not.toContain('165');
281 } finally {
282 ansicht.aufraeumen();
283 }
284 });
285
286 it('meldet das bevorstehende Einrasten, sobald es das Ergebnis aendert', () => {
287 vi.useFakeTimers();
288 const ansicht = baueAnsicht();
289 try {
290 waehleWerkzeug(ansicht, 'Fahrlinie');
291 taste(ansicht, 'ArrowDown', {}, 6);
292 // Die Drosselung haelt die Zwischenstaende zurueck; angesagt wird der
293 // erreichte Stand.
294 vi.advanceTimersByTime(500);
295 expect(ansage(ansicht)).toContain('rastet auf Haltlinie „Nord“ ein');
296 } finally {
297 ansicht.aufraeumen();
298 }
299 });
300
301 it('drosselt die Ansage und meldet dann den erreichten Stand', () => {
302 // Eine Live-Region, die jeden Tastendruck meldet, haengt der Wirklichkeit
303 // hinterher: Die Sprachausgabe arbeitet die Warteschlange ab, waehrend der
304 // Zeiger laengst woanders steht.
305 vi.useFakeTimers();
306 const ansicht = baueAnsicht();
307 try {
308 taste(ansicht, 'ArrowUp');
309 const erste = ansage(ansicht);
310 expect(erste).toContain('9,0 m');
311
312 taste(ansicht, 'ArrowUp', {}, 3);
313 expect(ansage(ansicht)).toBe(erste);
314
315 vi.advanceTimersByTime(400);
316 // y=140: 60 Bildpunkte = 12,0 m von der Haltlinie.
317 expect(ansage(ansicht)).toContain('12,0 m');
318 expect(ansage(ansicht)).not.toBe(erste);
319 } finally {
320 ansicht.aufraeumen();
321 }
322 });
323
324 it('meldet den gesetzten Punkt sofort, ohne Drosselung', () => {
325 vi.useFakeTimers();
326 const ansicht = baueAnsicht();
327 try {
328 waehleWerkzeug(ansicht, 'Fahrlinie');
329 taste(ansicht, 'ArrowDown', {}, 6);
330 taste(ansicht, ' ');
331 // Ohne Ablauf einer Frist: Ein gesetzter Punkt ist kein Zwischenstand.
332 expect(ansage(ansicht)).toContain('Erster Punkt gesetzt');
333 expect(ansage(ansicht)).toContain('Haltlinie „Nord“');
334 } finally {
335 ansicht.aufraeumen();
336 }
337 });
338 });
339
340 describe('Tastatur und Maus liefern dasselbe', () => {
341 it('ergibt dieselbe Fahrlinie wie dieselben Stellen mit der Maus', () => {
342 // Der Kern der Zusage. Beide Wege setzen den ersten Punkt 5 Bildpunkte
343 // neben die Haltlinie - er muss beide Male auf sie einrasten - und den
344 // zweiten 80 Bildpunkte weiter in den Knotenpunkt.
345 const mitMaus = baueAnsicht();
346 let mausLinie: Planlinie | undefined;
347 try {
348 waehleWerkzeug(mitMaus, 'Fahrlinie');
349 klick(mitMaus, 160, 205);
350 klick(mitMaus, 160, 120);
351 taste(mitMaus, 'Enter');
352 mausLinie = linien(mitMaus)[0];
353 } finally {
354 mitMaus.aufraeumen();
355 }
356
357 const mitTastatur = baueAnsicht();
358 let tastaturLinie: Planlinie | undefined;
359 try {
360 waehleWerkzeug(mitTastatur, 'Fahrlinie');
361 taste(mitTastatur, 'ArrowDown', {}, 9);
362 taste(mitTastatur, ' ');
363 taste(mitTastatur, 'ArrowUp', {}, 17);
364 taste(mitTastatur, ' ');
365 taste(mitTastatur, 'Enter');
366 tastaturLinie = linien(mitTastatur)[0];
367 } finally {
368 mitTastatur.aufraeumen();
369 }
370
371 expect(mausLinie).toBeDefined();
372 expect(tastaturLinie).toBeDefined();
373 expect(ohneId(tastaturLinie!)).toEqual(ohneId(mausLinie!));
374 // Und nicht nur gleich, sondern auch richtig: eingerastet auf die
375 // Haltlinie, denn daran haengt der Nullpunkt des Raeumwegs.
376 expect(tastaturLinie!.punkte[0]).toEqual({ x: 160, y: 200 });
377 expect(tastaturLinie!.haltlinieId).toBe('hl1');
378 });
379 });
380
381 describe('Der Zeiger ist zu sehen', () => {
382 /** Farbe des weissen Casings aus leinwand.ts - daran ist es zu erkennen. */
383 const CASING = 'rgba(255,255,255,0.92)';
384 /** Radius des Rings, der das Einrasten anzeigt. */
385 const RING = 9;
386
387 /**
388 * Zeichenkontext, der Striche und Kreisboegen mitschreibt.
389 *
390 * jsdom bringt keine Zeichenflaeche mit - und ein Bild waere hier auch nicht
391 * zu gebrauchen: Gefragt ist, WAS gestrichen wurde, nicht wie es aussieht.
392 */
393 class Zaehler {
394 readonly striche: { farbe: string; breite: number }[] = [];
395 readonly boegen: number[] = [];
396 strokeStyle = '';
397 fillStyle = '';
398 lineWidth = 0;
399 lineCap = 'butt';
400 lineJoin = 'miter';
401 globalAlpha = 1;
402 font = '';
403 textAlign = 'left';
404 imageSmoothingQuality = 'low';
405
406 save(): void {}
407 restore(): void {}
408 clearRect(): void {}
409 fillRect(): void {}
410 strokeRect(): void {}
411 drawImage(): void {}
412 beginPath(): void {}
413 moveTo(): void {}
414 lineTo(): void {}
415 fill(): void {}
416 setLineDash(): void {}
417 fillText(): void {}
418 measureText(): { width: number } {
419 return { width: 24 };
420 }
421
422 arc(_x: number, _y: number, radius: number): void {
423 this.boegen.push(radius);
424 }
425
426 stroke(): void {
427 this.striche.push({ farbe: String(this.strokeStyle), breite: this.lineWidth });
428 }
429 }
430
431 function zeichneMitZeiger(eingerastet: boolean | null): Zaehler {
432 const ctx = new Zaehler();
433 zeichne(ctx as unknown as CanvasRenderingContext2D, 320, 320, {
434 lageplan: planMitHaltlinie(),
435 sicht: START_SICHT,
436 bild: { naturalWidth: 400, naturalHeight: 300 } as unknown as HTMLImageElement,
437 aktiveLinieId: null,
438 aktiveHaltlinieId: null,
439 haltlinienStart: null,
440 entwurf: [],
441 kalibrierStart: null,
442 zeiger: null,
443 tastenzeiger: eingerastet === null ? null : { punkt: MITTE, eingerastet },
444 dunkel: false,
445 });
446 return ctx;
447 }
448
449 it('legt den Zeiger auf ein weisses Casing', () => {
450 // Auf einem Luftbild kommt jede Helligkeit vor: Ein dunkler Strich allein
451 // verschwindet ueber Beton, ein heller ueber Asphalt.
452 const ohne = zeichneMitZeiger(null);
453 expect(ohne.striche.some((s) => s.farbe === CASING)).toBe(false);
454
455 const mit = zeichneMitZeiger(false);
456 const letzte = mit.striche.slice(-2);
457 expect(letzte).toEqual([
458 { farbe: CASING, breite: 5 },
459 { farbe: '#111827', breite: 2 },
460 ]);
461 });
462
463 it('zeigt das Einrasten durch eine Form, nicht durch eine Farbe', () => {
464 expect(zeichneMitZeiger(false).boegen).not.toContain(RING);
465 expect(zeichneMitZeiger(true).boegen).toContain(RING);
466 });
467 });
468
469 describe('Auffindbarkeit der Tastenbelegung', () => {
470 it('stellt die Belegung sichtbar unter die Zeichenflaeche', () => {
471 const ansicht = baueAnsicht();
472 try {
473 const aufklapper = ansicht.wurzel.querySelector('details');
474 expect(aufklapper?.querySelector('summary')?.textContent).toContain('Tastenbedienung');
475 const text = aufklapper?.textContent ?? '';
476 expect(text).toContain('Leertaste');
477 expect(text).toContain('Umschalt');
478 expect(text).toContain('Strg');
479 expect(text).toContain('Einpassen');
480 } finally {
481 ansicht.aufraeumen();
482 }
483 });
484
485 it('behauptet im Hinweis der Zeichenflaeche nicht mehr das Gegenteil', () => {
486 const ansicht = baueAnsicht();
487 try {
488 const hinweis = ansicht.wurzel.querySelector('#lageplan-flaeche-hinweis')?.textContent ?? '';
489 expect(hinweis).not.toContain('nicht möglich');
490 expect(hinweis).toContain('Leertaste');
491 // Der Ausweichweg bleibt richtig und bleibt stehen: Der Lageplan ist ein
492 // Hilfsmittel, kein Pflichtteil.
493 expect(hinweis).toContain('Zwischenzeiten');
494 } finally {
495 ansicht.aufraeumen();
496 }
497 });
498 });