lsa-planer

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

/ tests ui zeichenflaecheMaus.test.ts

10,4 KB Rohdatei
tests/ui/zeichenflaecheMaus.test.ts — 278 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 * Mausbedienung der Zeichenflaeche: Rad, Verschieben, Zoomen waehrend des
10 * Verschiebens (Befunde 52, 53, 64).
11 *
12 * WIE DIE SICHT HIER ABGELESEN WIRD
13 *
14 * Zoom und Versatz der Ansicht sind ansichtsinterne Groessen; von aussen sind
15 * sie nur an ihrer Wirkung zu erkennen. Die Wirkung ist die Umrechnung von
16 * Bildschirm- in Bildpunkte - und die schlaegt sich in den Punkten der
17 * Massstabsstrecke nieder, die im Projekt landen. Genau darueber wird hier
18 * gemessen: gesetzt wird ueber Tastatur oder Maus, abgelesen wird die
19 * Kalibrierung.
20 *
21 * MASSE: In jsdom hat der Rahmen keine Breite, die Flaeche faellt auf ihre
22 * Mindestmasse von 320 x 320 zurueck, und `getBoundingClientRect` liefert
23 * lauter Nullen. Ein Bildschirmpunkt ist damit ein Bildpunkt, der
24 * Tastaturzeiger beginnt bei (160, 160), und clientX/clientY sind unmittelbar
25 * Bildschirmpunkte der Flaeche. Das Hintergrundbild wird in jsdom nie
26 * geladen - die Sicht bleibt deshalb die Startsicht, bis eine Bedienung sie
27 * aendert.
28 */
29
30 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
31
32 /** Schrittweite eines gewoehnlichen Pfeiltastenanschlags in Bildpunkten. */
33 const SCHRITT = 5;
34
35 /** Bitmuster von MouseEvent.buttons: 2 ist die rechte, 4 die mittlere Taste. */
36 const RECHTE_TASTE = 2;
37 const MITTLERE_TASTE = 4;
38
39 /** Plan mit Hintergrundbild, aber ohne Massstab - das Werkzeug ist damit frei. */
40 function planOhneMassstab(): Lageplan {
41 return {
42 arbeitsbereiche: [],
43 signalgeber: [],
44 bild: {
45 datenUrl: 'data:image/png;base64,iVBORw0KGgo=',
46 breite: 800,
47 hoehe: 600,
48 herkunft: 'Prüfbild',
49 geladenAm: FESTES_DATUM,
50 },
51 kalibrierung: null,
52 haltlinien: [],
53 linien: [],
54 };
55 }
56
57 interface Ansicht {
58 readonly store: ProjectStore;
59 readonly wurzel: HTMLElement;
60 readonly leinwand: HTMLCanvasElement;
61 readonly aufraeumen: () => void;
62 }
63
64 function baueAnsicht(): Ansicht {
65 const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM));
66 store.update((p) => actions.setLageplan(p, planOhneMassstab()), { label: 'Pruefplan' });
67
68 const wurzel = document.createElement('div');
69 document.body.append(wurzel);
70 const schliessen = lageplanView.render(wurzel, {
71 store,
72 refresh: () => {},
73 navigate: () => {},
74 target: null,
75 });
76
77 const leinwand = wurzel.querySelector('canvas');
78 if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.');
79
80 return {
81 store,
82 wurzel,
83 leinwand,
84 aufraeumen: () => {
85 leinwand.blur();
86 schliessen();
87 wurzel.remove();
88 },
89 };
90 }
91
92 afterEach(() => {
93 document.body.replaceChildren();
94 });
95
96 function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void {
97 const knopf = [
98 ...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('.lageplan-werkzeuge button'),
99 ].find((b) => (b.textContent ?? '').trim() === beschriftung);
100 if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`);
101 knopf.click();
102 }
103
104 function tippe(key: string, male = 1): void {
105 for (let i = 0; i < male; i += 1) {
106 const ziel = (document.activeElement as HTMLElement | null) ?? document.body;
107 ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
108 }
109 }
110
111 function maus(
112 ansicht: Ansicht,
113 art: string,
114 init: MouseEventInit & { readonly ziel?: 'fenster' },
115 ): void {
116 const ereignis = new MouseEvent(art, { bubbles: true, cancelable: true, ...init });
117 if (init.ziel === 'fenster') window.dispatchEvent(ereignis);
118 else ansicht.leinwand.dispatchEvent(ereignis);
119 }
120
121 /** Dreht am Rad; false heisst, die Geste wurde abgefangen. */
122 function rad(ansicht: Ansicht, init: WheelEventInit): boolean {
123 return ansicht.leinwand.dispatchEvent(
124 new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init }),
125 );
126 }
127
128 /**
129 * Setzt mit der Tastatur eine Massstabsstrecke und liefert ihren Anfangspunkt
130 * IN BILDPUNKTEN.
131 *
132 * Der Anfang liegt in der Mitte der Flaeche - bei unveraenderter Sicht also bei
133 * (160, 160). Jede Abweichung ist die Sicht, die sich zwischenzeitlich
134 * verschoben oder verkleinert hat.
135 */
136 function anfangDerMassstabsstrecke(ansicht: Ansicht): { x: number; y: number } {
137 waehleWerkzeug(ansicht, 'Maßstab');
138 ansicht.leinwand.focus();
139 tippe(' ');
140 tippe('ArrowRight', 20);
141 tippe(' ');
142 uebernimmLaenge(25);
143 const kalibrierung = ansicht.store.getProject().lageplan.kalibrierung;
144 if (kalibrierung === null) throw new Error('Der Maßstab wurde nicht übernommen.');
145 return kalibrierung.von;
146 }
147
148 function uebernimmLaenge(meter: number): void {
149 const fenster = document.querySelector<HTMLElement>('.dialog');
150 if (fenster === null) throw new Error('Es steht kein Fenster offen.');
151 const feld = fenster.querySelector<HTMLInputElement>('input[type="number"]');
152 if (feld !== null) {
153 feld.value = String(meter);
154 feld.dispatchEvent(new Event('change'));
155 }
156 const knopf = [...fenster.querySelectorAll<HTMLButtonElement>('button')].find(
157 (b) => (b.textContent ?? '').trim() === 'Übernehmen',
158 );
159 if (knopf === undefined) throw new Error('Kein Knopf "Übernehmen" im Fenster');
160 knopf.click();
161 }
162
163 describe('Waagerechtes Mausrad (Befund 52)', () => {
164 it('lässt die Ansicht in Ruhe, statt sie zu verkleinern', () => {
165 const ansicht = baueAnsicht();
166 try {
167 // Chromium legt das waagerechte Wischen auf deltaX und laesst deltaY auf
168 // 0. Eine einzige Wischbewegung erzeugt Dutzende solcher Ereignisse;
169 // faellt jedes in den Verkleinern-Zweig, ist der Plan nach wenigen Zuegen
170 // an der unteren Zoomschranke verschwunden.
171 const zugestellt = rad(ansicht, { deltaX: 120, deltaY: 0, clientX: 40, clientY: 40 });
172
173 expect(zugestellt, 'die waagerechte Geste wurde geschluckt').toBe(true);
174 expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160, y: 160 });
175 } finally {
176 ansicht.aufraeumen();
177 }
178 });
179
180 it('zoomt bei senkrechtem Rad weiter wie bisher', () => {
181 const ansicht = baueAnsicht();
182 try {
183 const zugestellt = rad(ansicht, { deltaX: 0, deltaY: -100, clientX: 0, clientY: 0 });
184
185 // Hier gehoert die Geste der Zeichenflaeche - sie darf die Seite nicht
186 // zusaetzlich wegrollen.
187 expect(zugestellt, 'das Zoomen wurde nicht abgefangen').toBe(false);
188 const anfang = anfangDerMassstabsstrecke(ansicht);
189 // Um 1,15 vergroessert, Ankerpunkt (0;0): 160 / 1,15.
190 expect(anfang.x).toBeCloseTo(160 / 1.15, 6);
191 expect(anfang.y).toBeCloseTo(160 / 1.15, 6);
192 } finally {
193 ansicht.aufraeumen();
194 }
195 });
196 });
197
198 describe('Verschieben ohne gedrückte Taste (Befund 53)', () => {
199 it('hört auf zu verschieben, sobald keine Taste mehr gedrückt ist', () => {
200 const ansicht = baueAnsicht();
201 try {
202 maus(ansicht, 'mousedown', { button: 1, buttons: MITTLERE_TASTE, clientX: 10, clientY: 10 });
203 maus(ansicht, 'mousemove', { buttons: MITTLERE_TASTE, clientX: 60, clientY: 40 });
204
205 // Alt+Tab, dort die Taste losgelassen, zurueck zum Fenster: Das
206 // Loslassen erreicht den Renderer nie. Die naechste Bewegung meldet
207 // keine gedrueckte Taste mehr - und darf den Plan nicht mitziehen.
208 maus(ansicht, 'mousemove', { buttons: 0, clientX: 160, clientY: 140 });
209
210 // Verschoben wurde nur der erste Zug: 50 nach rechts, 30 nach unten.
211 expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160 - 50, y: 160 - 30 });
212 } finally {
213 ansicht.aufraeumen();
214 }
215 });
216
217 it('räumt den Ziehzustand, wenn das Fenster den Fokus verliert', () => {
218 const ansicht = baueAnsicht();
219 try {
220 maus(ansicht, 'mousedown', { button: 2, buttons: RECHTE_TASTE, clientX: 10, clientY: 10 });
221 window.dispatchEvent(new Event('blur'));
222 maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 110, clientY: 110 });
223
224 expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160, y: 160 });
225 } finally {
226 ansicht.aufraeumen();
227 }
228 });
229 });
230
231 describe('Zoomen während eines laufenden Verschiebens (Befund 64)', () => {
232 it('hält den Punkt unter dem Mauszeiger fest, statt zurückzuspringen', () => {
233 const ansicht = baueAnsicht();
234 try {
235 waehleWerkzeug(ansicht, 'Maßstab');
236
237 // Rechte Taste halten, am Rad drehen, danach die Maus bewegen.
238 maus(ansicht, 'mousedown', { button: 2, buttons: RECHTE_TASTE, clientX: 100, clientY: 100 });
239 rad(ansicht, { deltaY: -100, clientX: 100, clientY: 100 });
240 maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 100, clientY: 100 });
241 maus(ansicht, 'mouseup', { button: 2, buttons: 0, ziel: 'fenster' });
242
243 // Die Zusage im Quelltext lautet "Der Punkt unter dem Mauszeiger bleibt
244 // stehen". Der Zeiger stand beim Zoomen auf Bildpunkt (100;100) und hat
245 // sich seither nicht bewegt - also muss ein Klick an derselben Stelle
246 // wieder Bildpunkt (100;100) treffen.
247 maus(ansicht, 'click', { button: 0, clientX: 100, clientY: 100 });
248 maus(ansicht, 'click', { button: 0, clientX: 200, clientY: 100 });
249 uebernimmLaenge(25);
250
251 const kalibrierung = ansicht.store.getProject().lageplan.kalibrierung;
252 expect(kalibrierung, 'kein Maßstab im Projekt').not.toBeNull();
253 expect(kalibrierung?.von.x ?? 0).toBeCloseTo(100, 6);
254 expect(kalibrierung?.von.y ?? 0).toBeCloseTo(100, 6);
255 } finally {
256 ansicht.aufraeumen();
257 }
258 });
259 });
260
261 describe('Ungestörtes Verschieben bleibt unverändert', () => {
262 it('zieht den Plan mit, solange die Taste gehalten wird', () => {
263 const ansicht = baueAnsicht();
264 try {
265 maus(ansicht, 'mousedown', { button: 2, buttons: RECHTE_TASTE, clientX: 20, clientY: 20 });
266 maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 60, clientY: 50 });
267 maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 100, clientY: 80 });
268 maus(ansicht, 'mouseup', { button: 2, buttons: 0, ziel: 'fenster' });
269
270 // 80 nach rechts, 60 nach unten verschoben.
271 expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160 - 80, y: 160 - 60 });
272 const bis = ansicht.store.getProject().lageplan.kalibrierung?.bis;
273 expect(bis).toEqual({ x: 160 - 80 + 20 * SCHRITT, y: 160 - 60 });
274 } finally {
275 ansicht.aufraeumen();
276 }
277 });
278 });