lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui zeichenflaecheMaus.test.ts
| 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 | }); |