lsa-planer

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

/ tests ui zeichenflaecheMassstabHaltlinie.test.ts

14,6 KB Rohdatei
tests/ui/zeichenflaecheMassstabHaltlinie.test.ts — 405 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { ProjectStore } from '@/app/store';
4 import type { Lageplan } from '@/domain/geometrie/lageplan';
5 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
6 import { lageplanView } from '@/ui/views/lageplanView';
7
8 /**
9 * Die Tastaturwege "Massstab" und "Haltlinie" der Zeichenflaeche.
10 *
11 * BEFUND (Fassung 5.7.0): `tests/ui/zeichenflaecheTastatur.test.ts` und
12 * `tests/ui/lageplanTastatur.test.ts` fahren ausschliesslich das Werkzeug
13 * "Fahrlinie" - jeder `waehleWerkzeug`-Aufruf lautet dort `'Fahrlinie'`. Von
14 * den vier Werkzeugen waren damit zwei nirgends abgesichert, und es sind
15 * ausgerechnet die beiden, die am Anfang stehen:
16 *
17 * - Der MASSSTAB ist die Kalibrierung. Aus ihm folgt jede Laengenangabe des
18 * Lageplans, und daraus folgen Raeum- und Einfahrweg, aus denen jede
19 * Zwischenzeit gerechnet wird. Ein Massstab, der sich mit der Tastatur
20 * nicht mehr setzen liesse, machte die ganze Vermessung ohne Maus
21 * unmoeglich - die Werkzeuge "Haltlinie" und "Fahrlinie" sind bis dahin
22 * gesperrt.
23 * - Die HALTLINIE ist der Nullpunkt, an dem die Stroeme beginnen, und
24 * zugleich die Zuordnung zur Zufahrt, aus der die Vertraeglichkeit folgt.
25 *
26 * Beide funktionieren heute. Sie wuerden still ausfallen: Kein Test wuerde rot,
27 * und bemerkt haette es erst der Anwender, der ohne Maus arbeitet.
28 *
29 * WCAG 2.1 Erfolgskriterium 2.1.1 "Tastatur" (Stufe A) und 4.1.3
30 * "Statusmeldungen" (AA) - denn eine gesetzte Marke, die nicht angesagt wird,
31 * ist auf einer Zeichenflaeche nicht wahrnehmbar.
32 *
33 * WIE HIER GETIPPT WIRD: an dem Element, das GERADE DEN FOKUS HAT, so wie ein
34 * Browser den Tastendruck zustellt. Wer unmittelbar an die Zeichenflaeche
35 * sendet, prueft eine Zustellung, die es ohne Fokussierbarkeit gar nicht gaebe.
36 *
37 * MASSE: In jsdom hat der Rahmen keine Breite; die Flaeche faellt auf ihre
38 * Mindestmasse von 320 x 320 zurueck, die Sicht ist die Startsicht ohne Zoom.
39 * Ein Bildschirmpunkt ist damit ein Bildpunkt, der Tastenzeiger beginnt bei
40 * (160, 160), und ein Pfeiltastenanschlag traegt fuenf Punkte weit.
41 */
42
43 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
44
45 /** Schrittweite eines gewoehnlichen Pfeiltastenanschlags in Bildpunkten. */
46 const SCHRITT = 5;
47
48 /**
49 * Ein Plan mit Hintergrundbild, aber ohne Massstab - der Ausgangszustand.
50 *
51 * Ohne Bild ist "Massstab" gesperrt; die Bildangaben muessen deshalb stehen.
52 * Geladen wird das Bild in jsdom nie - das braucht der Fall auch nicht: Die
53 * Sperre haengt am Projekt, nicht am geladenen Bild.
54 */
55 function planOhneMassstab(): Lageplan {
56 return {
57 arbeitsbereiche: [],
58 signalgeber: [],
59 bild: {
60 datenUrl: 'data:image/png;base64,iVBORw0KGgo=',
61 breite: 800,
62 hoehe: 600,
63 herkunft: 'Prüfbild',
64 geladenAm: FESTES_DATUM,
65 },
66 kalibrierung: null,
67 haltlinien: [],
68 linien: [],
69 };
70 }
71
72 /** Derselbe Plan, nachdem der Massstab steht: 100 Bildpunkte auf 20 m. */
73 function planMitMassstab(): Lageplan {
74 return {
75 ...planOhneMassstab(),
76 kalibrierung: {
77 von: { x: 0, y: 0 },
78 bis: { x: 100, y: 0 },
79 laengeMeter: 20,
80 herkunft: 'gemessen',
81 },
82 };
83 }
84
85 interface Ansicht {
86 readonly store: ProjectStore;
87 readonly wurzel: HTMLElement;
88 readonly leinwand: HTMLCanvasElement;
89 readonly aufraeumen: () => void;
90 }
91
92 function baueAnsicht(lageplan: Lageplan): Ansicht {
93 const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM));
94 store.update((p) => actions.setLageplan(p, lageplan), { label: 'Pruefplan' });
95
96 const wurzel = document.createElement('div');
97 document.body.append(wurzel);
98 const schliessen = lageplanView.render(wurzel, {
99 store,
100 refresh: () => {},
101 navigate: () => {},
102 target: null,
103 });
104
105 const leinwand = wurzel.querySelector('canvas');
106 if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.');
107
108 return {
109 store,
110 wurzel,
111 leinwand,
112 aufraeumen: () => {
113 leinwand.blur();
114 schliessen();
115 wurzel.remove();
116 },
117 };
118 }
119
120 afterEach(() => {
121 // Ein Fenster, das ein Fall stehen laesst, wuerde den naechsten mit seinem
122 // stillgelegten Hintergrund empfangen.
123 document.body.replaceChildren();
124 });
125
126 /** Werkzeugknopf ueber seine sichtbare Beschriftung - so wie ein Anwender auch. */
127 function werkzeugKnopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement {
128 const knopf = [
129 ...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('.lageplan-werkzeuge button'),
130 ].find((b) => (b.textContent ?? '').trim() === beschriftung);
131 if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`);
132 return knopf;
133 }
134
135 function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void {
136 werkzeugKnopf(ansicht, beschriftung).click();
137 }
138
139 /** Tippt auf das Element, das gerade den Fokus hat. */
140 function tippe(key: string, male = 1): void {
141 for (let i = 0; i < male; i += 1) {
142 const ziel = (document.activeElement as HTMLElement | null) ?? document.body;
143 ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
144 }
145 }
146
147 /** Text des Ansageraums unter der Zeichenflaeche. */
148 function ansage(ansicht: Ansicht): string {
149 return ansicht.wurzel.querySelector('.lageplan-rahmen [role="status"]')?.textContent ?? '';
150 }
151
152 /** Das offene Fenster - Massstab und Haltlinie fragen beide in einem nach. */
153 function fenster(): HTMLElement {
154 const dialog = document.querySelector<HTMLElement>('.dialog');
155 if (dialog === null) throw new Error('Es steht kein Fenster offen.');
156 return dialog;
157 }
158
159 function knopfImFenster(beschriftung: string): HTMLButtonElement {
160 const knopf = [...fenster().querySelectorAll<HTMLButtonElement>('button')].find(
161 (b) => (b.textContent ?? '').trim() === beschriftung,
162 );
163 if (knopf === undefined) throw new Error(`Kein Knopf "${beschriftung}" im Fenster`);
164 return knopf;
165 }
166
167 describe('Werkzeug "Maßstab" mit der Tastatur', () => {
168 it('ist ohne Hintergrundbild gesperrt und mit Bild bedienbar', () => {
169 // Die Reihenfolge der Arbeit steckt in den Sperren: erst Bild, dann
170 // Massstab, dann Haltlinie, dann Fahrlinie. Fiele die Sperre weg, setzte
171 // ein Anwender eine Kalibrierstrecke auf einer leeren Flaeche.
172 const ohne = baueAnsicht({ ...planOhneMassstab(), bild: null });
173 try {
174 expect(werkzeugKnopf(ohne, 'Maßstab').disabled).toBe(true);
175 } finally {
176 ohne.aufraeumen();
177 }
178
179 const mit = baueAnsicht(planOhneMassstab());
180 try {
181 expect(werkzeugKnopf(mit, 'Maßstab').disabled).toBe(false);
182 expect(werkzeugKnopf(mit, 'Haltlinie').disabled, 'Haltlinie ohne Maßstab').toBe(true);
183 expect(werkzeugKnopf(mit, 'Fahrlinie').disabled, 'Fahrlinie ohne Maßstab').toBe(true);
184 } finally {
185 mit.aufraeumen();
186 }
187 });
188
189 it('sagt nach dem ersten Punkt an, was als Nächstes zu tun ist', () => {
190 const ansicht = baueAnsicht(planOhneMassstab());
191 try {
192 waehleWerkzeug(ansicht, 'Maßstab');
193 ansicht.leinwand.focus();
194 tippe(' ');
195
196 // Ohne diesen Satz ist auf einer Zeichenflaeche nicht zu erfahren, dass
197 // ueberhaupt etwas gesetzt wurde - es gibt nichts zu sehen und nichts
198 // anzufassen.
199 expect(ansage(ansicht)).toContain('Erster Punkt der Maßstabsstrecke gesetzt');
200 expect(ansage(ansicht)).toContain('Jetzt das andere Ende');
201 } finally {
202 ansicht.aufraeumen();
203 }
204 });
205
206 it('setzt aus zwei Punkten und einer Länge den Maßstab des Projekts', () => {
207 const ansicht = baueAnsicht(planOhneMassstab());
208 try {
209 waehleWerkzeug(ansicht, 'Maßstab');
210 ansicht.leinwand.focus();
211
212 tippe(' ');
213 // Zwanzig Anschlaege nach rechts: 100 Bildpunkte.
214 tippe('ArrowRight', 20);
215 tippe(' ');
216
217 expect(ansage(ansicht)).toContain('Zweiter Punkt der Maßstabsstrecke gesetzt');
218
219 const feld = fenster().querySelector<HTMLInputElement>('input[type="number"]');
220 expect(feld, 'kein Laengenfeld im Fenster').not.toBeNull();
221 if (feld !== null) {
222 feld.value = '25';
223 feld.dispatchEvent(new Event('change'));
224 }
225 knopfImFenster('Übernehmen').click();
226
227 const kalibrierung = ansicht.store.getProject().lageplan.kalibrierung;
228 expect(kalibrierung, 'kein Maßstab im Projekt').not.toBeNull();
229 // Genau die beiden Punkte, die der Zeiger angefahren hat - Maus und
230 // Tastatur laufen durch dieselbe Funktion `setzePunkt`, und diese Zahlen
231 // sind der Beleg dafuer.
232 expect(kalibrierung?.von).toEqual({ x: 160, y: 160 });
233 expect(kalibrierung?.bis).toEqual({ x: 160 + 20 * SCHRITT, y: 160 });
234 expect(kalibrierung?.laengeMeter).toBe(25);
235 expect(kalibrierung?.herkunft).toBe('gemessen');
236 } finally {
237 ansicht.aufraeumen();
238 }
239 });
240
241 it('gibt nach dem Übernehmen die Werkzeuge frei, die den Maßstab brauchen', () => {
242 const ansicht = baueAnsicht(planOhneMassstab());
243 try {
244 waehleWerkzeug(ansicht, 'Maßstab');
245 ansicht.leinwand.focus();
246 tippe(' ');
247 tippe('ArrowRight', 20);
248 tippe(' ');
249 knopfImFenster('Übernehmen').click();
250
251 expect(werkzeugKnopf(ansicht, 'Haltlinie').disabled).toBe(false);
252 expect(werkzeugKnopf(ansicht, 'Fahrlinie').disabled).toBe(false);
253 } finally {
254 ansicht.aufraeumen();
255 }
256 });
257
258 it('nimmt eine halb gesetzte Strecke mit Escape zurück', () => {
259 const ansicht = baueAnsicht(planOhneMassstab());
260 try {
261 waehleWerkzeug(ansicht, 'Maßstab');
262 ansicht.leinwand.focus();
263 tippe(' ');
264 tippe('Escape');
265
266 expect(ansage(ansicht)).toContain('Zeichnen abgebrochen');
267 // Der halbe Anfang darf nicht liegen bleiben: Der naechste Leertastendruck
268 // im wieder gewaehlten Werkzeug wuerde sonst eine Strecke zu dem Punkt
269 // schliessen, den der Anwender laengst verworfen hat.
270 waehleWerkzeug(ansicht, 'Maßstab');
271 tippe(' ');
272 expect(ansage(ansicht)).toContain('Erster Punkt der Maßstabsstrecke gesetzt');
273 expect(document.querySelector('.dialog'), 'Fenster nach nur einem Punkt').toBeNull();
274 } finally {
275 ansicht.aufraeumen();
276 }
277 });
278
279 it('setzt nichts, solange die Zeichenfläche den Fokus nicht hat', () => {
280 const ansicht = baueAnsicht(planOhneMassstab());
281 try {
282 waehleWerkzeug(ansicht, 'Maßstab');
283 // Der Klick auf den Werkzeugknopf hat den Fokus dort gelassen.
284 expect(document.activeElement).not.toBe(ansicht.leinwand);
285
286 tippe(' ');
287 tippe('ArrowRight', 20);
288 tippe(' ');
289
290 expect(ansicht.store.getProject().lageplan.kalibrierung).toBeNull();
291 expect(document.querySelector('.dialog')).toBeNull();
292 } finally {
293 ansicht.aufraeumen();
294 }
295 });
296 });
297
298 describe('Werkzeug "Haltlinie" mit der Tastatur', () => {
299 it('sagt nach dem ersten Punkt an, wohin der zweite gehört', () => {
300 const ansicht = baueAnsicht(planMitMassstab());
301 try {
302 waehleWerkzeug(ansicht, 'Haltlinie');
303 ansicht.leinwand.focus();
304 tippe(' ');
305
306 expect(ansage(ansicht)).toContain('Erster Punkt der Haltlinie gesetzt');
307 expect(ansage(ansicht)).toContain('der andere Fahrbahnrand');
308 } finally {
309 ansicht.aufraeumen();
310 }
311 });
312
313 it('legt aus zwei Punkten eine Haltlinie mit Namen und Zuordnung an', () => {
314 const ansicht = baueAnsicht(planMitMassstab());
315 try {
316 waehleWerkzeug(ansicht, 'Haltlinie');
317 ansicht.leinwand.focus();
318
319 tippe(' ');
320 // Zwoelf Anschlaege nach unten: 60 Bildpunkte quer ueber die Fahrbahn.
321 tippe('ArrowDown', 12);
322 tippe(' ');
323
324 knopfImFenster('Haltlinie anlegen').click();
325
326 const haltlinien = ansicht.store.getProject().lageplan.haltlinien;
327 expect(haltlinien).toHaveLength(1);
328 expect(haltlinien[0]?.von).toEqual({ x: 160, y: 160 });
329 expect(haltlinien[0]?.bis).toEqual({ x: 160, y: 160 + 12 * SCHRITT });
330 // Art und Zufahrt werden im selben Zug gefragt: Beides entscheidet ueber
331 // die Vertraeglichkeit der Stroeme, die spaeter daran beginnen.
332 expect(haltlinien[0]?.art).toBe('zufahrt');
333 expect(haltlinien[0]?.armId).not.toBeNull();
334 expect((haltlinien[0]?.name ?? '').trim()).not.toBe('');
335 } finally {
336 ansicht.aufraeumen();
337 }
338 });
339
340 it('führt nach dem Anlegen zum nächsten Arbeitsschritt weiter', () => {
341 const ansicht = baueAnsicht(planMitMassstab());
342 try {
343 waehleWerkzeug(ansicht, 'Haltlinie');
344 ansicht.leinwand.focus();
345 tippe(' ');
346 tippe('ArrowDown', 12);
347 tippe(' ');
348 knopfImFenster('Haltlinie anlegen').click();
349
350 /*
351 * Ohne diesen Satz weiss ein Anwender ohne Sicht auf die Zeichnung nicht,
352 * dass das Werkzeug gewechselt hat - der naechste Leertastendruck setzt
353 * dann keinen Haltlinienpunkt mehr, sondern beginnt eine Fahrlinie.
354 *
355 * Er steht in der Kurzmeldung und nicht in der Zeigerlage: Die Zeigerlage
356 * sagt, wo der Zeiger steht, und wird bei jedem Anschlag ueberschrieben;
357 * eine Meldung, die den naechsten Arbeitsschritt nennt, waere darin nach
358 * dem ersten Pfeiltastendruck weg. Der Meldungsstapel bleibt stehen.
359 */
360 const meldung = document.querySelector('.meldungen [role="status"]')?.textContent ?? '';
361 expect(meldung).toContain('angelegt');
362 expect(meldung).toContain('Fahrlinien');
363 expect(werkzeugKnopf(ansicht, 'Fahrlinie').getAttribute('aria-pressed')).toBe('true');
364 expect(werkzeugKnopf(ansicht, 'Haltlinie').getAttribute('aria-pressed')).toBe('false');
365 } finally {
366 ansicht.aufraeumen();
367 }
368 });
369
370 it('nimmt einen halb gesetzten Querschnitt mit Escape zurück', () => {
371 const ansicht = baueAnsicht(planMitMassstab());
372 try {
373 waehleWerkzeug(ansicht, 'Haltlinie');
374 ansicht.leinwand.focus();
375 tippe(' ');
376 tippe('Escape');
377 expect(ansage(ansicht)).toContain('Zeichnen abgebrochen');
378
379 waehleWerkzeug(ansicht, 'Haltlinie');
380 tippe(' ');
381 expect(ansage(ansicht)).toContain('Erster Punkt der Haltlinie gesetzt');
382 expect(document.querySelector('.dialog'), 'Fenster nach nur einem Punkt').toBeNull();
383 expect(ansicht.store.getProject().lageplan.haltlinien).toHaveLength(0);
384 } finally {
385 ansicht.aufraeumen();
386 }
387 });
388
389 it('setzt nichts, solange die Zeichenfläche den Fokus nicht hat', () => {
390 const ansicht = baueAnsicht(planMitMassstab());
391 try {
392 waehleWerkzeug(ansicht, 'Haltlinie');
393 expect(document.activeElement).not.toBe(ansicht.leinwand);
394
395 tippe(' ');
396 tippe('ArrowDown', 12);
397 tippe(' ');
398
399 expect(ansicht.store.getProject().lageplan.haltlinien).toHaveLength(0);
400 expect(document.querySelector('.dialog')).toBeNull();
401 } finally {
402 ansicht.aufraeumen();
403 }
404 });
405 });