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