lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui werkzeugwechsel.test.ts
| 1 | import { afterAll, afterEach, beforeAll, describe, expect, it, vi } 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 | * Befund 22: Ein halb gesetzter Haltlinienpunkt ueberlebte den Werkzeugwechsel. |
| 10 | * |
| 11 | * WAS DER ANWENDER SONST ERLEBT |
| 12 | * |
| 13 | * Er waehlt "Haltlinie", setzt den ersten Fahrbahnrand und wechselt dann in der |
| 14 | * Seitenleiste auf "Fahrlinie". Der halbe Anfang bleibt liegen, und daraus |
| 15 | * folgen zwei Dinge: |
| 16 | * |
| 17 | * 1. Er zieht eine Fahrlinie durch den Knotenpunkt und SIEHT DAVON NICHTS - |
| 18 | * `zeichneEntwurf` prueft den Haltlinienpunkt zuerst und kehrt danach |
| 19 | * zurueck; gezeichnet wird nur noch ein gestricheltes Band von einer |
| 20 | * laengst verworfenen Stelle zum Zeiger. |
| 21 | * 2. Waehlt er spaeter wieder "Haltlinie", schliesst der naechste Punkt eine |
| 22 | * Haltlinie zu dem alten Anfang ab. Die Haltlinie ist der Nullpunkt jeder |
| 23 | * Wegmessung; liegt sie falsch, faellt der Raeumweg aller daran |
| 24 | * beginnenden Stroeme zu kurz aus - die gefaehrliche Richtung. |
| 25 | * |
| 26 | * Escape (lageplanView.ts) und das Haltlinienfenster raeumen den halben Anfang |
| 27 | * seit jeher; der Werkzeugknopf war die einzige Ausnahme. |
| 28 | */ |
| 29 | |
| 30 | const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; |
| 31 | |
| 32 | /** Farbe des Entwurfs einer Fahrlinie. */ |
| 33 | const ENTWURF = '#1f5fa8'; |
| 34 | /** Farbe der Haltlinien - und damit auch des gestrichelten Haltlinienbandes. */ |
| 35 | const HALTLINIE = '#111827'; |
| 36 | |
| 37 | interface Strich { |
| 38 | readonly farbe: string; |
| 39 | readonly breite: number; |
| 40 | readonly strich: string; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Zeichenkontext, der jeden Strich mitschreibt. |
| 45 | * |
| 46 | * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nicht das |
| 47 | * Bild, sondern die Frage, OB die Vorschau der Fahrlinie ueberhaupt gezeichnet |
| 48 | * wird - und das ist eine Folge von Anweisungen. |
| 49 | */ |
| 50 | class Aufzeichner { |
| 51 | readonly striche: Strich[] = []; |
| 52 | strokeStyle: string = ''; |
| 53 | fillStyle: string = ''; |
| 54 | lineWidth = 0; |
| 55 | lineCap = 'butt'; |
| 56 | lineJoin = 'miter'; |
| 57 | globalAlpha = 1; |
| 58 | font = ''; |
| 59 | textAlign = 'left'; |
| 60 | imageSmoothingQuality = 'low'; |
| 61 | private strich = ''; |
| 62 | |
| 63 | save(): void {} |
| 64 | restore(): void {} |
| 65 | clearRect(): void {} |
| 66 | fillRect(): void {} |
| 67 | strokeRect(): void {} |
| 68 | drawImage(): void {} |
| 69 | beginPath(): void {} |
| 70 | moveTo(): void {} |
| 71 | lineTo(): void {} |
| 72 | arc(): void {} |
| 73 | fill(): void {} |
| 74 | fillText(): void {} |
| 75 | setTransform(): void {} |
| 76 | measureText(): { width: number } { |
| 77 | return { width: 24 }; |
| 78 | } |
| 79 | |
| 80 | setLineDash(muster: readonly number[] = []): void { |
| 81 | this.strich = muster.join(','); |
| 82 | } |
| 83 | |
| 84 | stroke(): void { |
| 85 | this.striche.push({ |
| 86 | farbe: String(this.strokeStyle), |
| 87 | breite: this.lineWidth, |
| 88 | strich: this.strich, |
| 89 | }); |
| 90 | } |
| 91 | |
| 92 | leeren(): void { |
| 93 | this.striche.length = 0; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | const stift = new Aufzeichner(); |
| 98 | |
| 99 | const urspruenglichesPath2D = (globalThis as { Path2D?: unknown }).Path2D; |
| 100 | const urspruenglichesImage = (globalThis as { Image?: unknown }).Image; |
| 101 | |
| 102 | /** |
| 103 | * Bildelement, das sofort geladen meldet. |
| 104 | * |
| 105 | * jsdom holt keine Daten-URL ab, `onload` bliebe also aus - und ohne geladenes |
| 106 | * Bild zeichnet `zeichne` nur den Satz "Kein Lageplan geladen" und kehrt |
| 107 | * zurueck. Geprueft werden soll aber genau das, was danach kaeme. |
| 108 | */ |
| 109 | class SofortBild { |
| 110 | onload: (() => void) | null = null; |
| 111 | onerror: (() => void) | null = null; |
| 112 | naturalWidth = 800; |
| 113 | naturalHeight = 600; |
| 114 | set src(_wert: string) { |
| 115 | this.onload?.(); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | beforeAll(() => { |
| 120 | // jsdom bringt weder eine Zeichenflaeche noch Path2D mit. Gebraucht wird |
| 121 | // hier nur der Behaelter, nicht seine Geometrie - gezaehlt werden Striche. |
| 122 | (globalThis as { Path2D?: unknown }).Path2D = class { |
| 123 | moveTo(): void {} |
| 124 | lineTo(): void {} |
| 125 | }; |
| 126 | (globalThis as { Image?: unknown }).Image = SofortBild; |
| 127 | vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( |
| 128 | () => stift as unknown as CanvasRenderingContext2D, |
| 129 | ); |
| 130 | }); |
| 131 | |
| 132 | afterAll(() => { |
| 133 | (globalThis as { Path2D?: unknown }).Path2D = urspruenglichesPath2D; |
| 134 | (globalThis as { Image?: unknown }).Image = urspruenglichesImage; |
| 135 | }); |
| 136 | |
| 137 | /** Plan mit Bild und Massstab: 100 Bildpunkte auf 20 m, noch keine Haltlinie. */ |
| 138 | function planMitMassstab(): Lageplan { |
| 139 | return { |
| 140 | arbeitsbereiche: [], |
| 141 | signalgeber: [], |
| 142 | bild: { |
| 143 | datenUrl: 'data:image/png;base64,iVBORw0KGgo=', |
| 144 | breite: 800, |
| 145 | hoehe: 600, |
| 146 | herkunft: 'Prüfbild', |
| 147 | geladenAm: FESTES_DATUM, |
| 148 | }, |
| 149 | kalibrierung: { |
| 150 | von: { x: 0, y: 0 }, |
| 151 | bis: { x: 100, y: 0 }, |
| 152 | laengeMeter: 20, |
| 153 | herkunft: 'gemessen', |
| 154 | }, |
| 155 | haltlinien: [], |
| 156 | linien: [], |
| 157 | }; |
| 158 | } |
| 159 | |
| 160 | interface Ansicht { |
| 161 | readonly store: ProjectStore; |
| 162 | readonly wurzel: HTMLElement; |
| 163 | readonly leinwand: HTMLCanvasElement; |
| 164 | readonly aufraeumen: () => void; |
| 165 | } |
| 166 | |
| 167 | function baueAnsicht(lageplan: Lageplan = planMitMassstab()): Ansicht { |
| 168 | const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); |
| 169 | store.update((p) => actions.setLageplan(p, lageplan), { label: 'Pruefplan' }); |
| 170 | |
| 171 | const wurzel = document.createElement('div'); |
| 172 | document.body.append(wurzel); |
| 173 | const schliessen = lageplanView.render(wurzel, { |
| 174 | store, |
| 175 | refresh: () => {}, |
| 176 | navigate: () => {}, |
| 177 | target: null, |
| 178 | }); |
| 179 | |
| 180 | const leinwand = wurzel.querySelector('canvas'); |
| 181 | if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.'); |
| 182 | |
| 183 | return { |
| 184 | store, |
| 185 | wurzel, |
| 186 | leinwand, |
| 187 | aufraeumen: () => { |
| 188 | leinwand.blur(); |
| 189 | schliessen(); |
| 190 | wurzel.remove(); |
| 191 | }, |
| 192 | }; |
| 193 | } |
| 194 | |
| 195 | afterEach(() => { |
| 196 | document.body.replaceChildren(); |
| 197 | stift.leeren(); |
| 198 | }); |
| 199 | |
| 200 | function werkzeugKnopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement { |
| 201 | const knopf = [ |
| 202 | ...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('.lageplan-werkzeuge button'), |
| 203 | ].find((b) => (b.textContent ?? '').trim() === beschriftung); |
| 204 | if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`); |
| 205 | return knopf; |
| 206 | } |
| 207 | |
| 208 | function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void { |
| 209 | werkzeugKnopf(ansicht, beschriftung).click(); |
| 210 | } |
| 211 | |
| 212 | /** Tippt auf das Element, das gerade den Fokus hat. */ |
| 213 | function tippe(key: string, male = 1): void { |
| 214 | for (let i = 0; i < male; i += 1) { |
| 215 | const ziel = (document.activeElement as HTMLElement | null) ?? document.body; |
| 216 | ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | function ansage(ansicht: Ansicht): string { |
| 221 | return ansicht.wurzel.querySelector('.lageplan-rahmen [role="status"]')?.textContent ?? ''; |
| 222 | } |
| 223 | |
| 224 | describe('Werkzeugwechsel mit halb gesetzter Haltlinie', () => { |
| 225 | it('lässt den nächsten Haltlinienpunkt wieder der erste sein, statt zum verworfenen Anfang abzuschließen', () => { |
| 226 | const ansicht = baueAnsicht(); |
| 227 | try { |
| 228 | waehleWerkzeug(ansicht, 'Haltlinie'); |
| 229 | ansicht.leinwand.focus(); |
| 230 | tippe(' '); |
| 231 | expect(ansage(ansicht)).toContain('Erster Punkt der Haltlinie gesetzt'); |
| 232 | |
| 233 | // Werkzeugwechsel hin und zurueck - ohne Escape, ohne Fenster. |
| 234 | waehleWerkzeug(ansicht, 'Fahrlinie'); |
| 235 | waehleWerkzeug(ansicht, 'Haltlinie'); |
| 236 | ansicht.leinwand.focus(); |
| 237 | tippe('ArrowDown', 12); |
| 238 | tippe(' '); |
| 239 | |
| 240 | expect(ansage(ansicht)).toContain('Erster Punkt der Haltlinie gesetzt'); |
| 241 | expect(document.querySelector('.dialog'), 'Fenster nach nur einem Punkt').toBeNull(); |
| 242 | expect(ansicht.store.getProject().lageplan.haltlinien).toHaveLength(0); |
| 243 | } finally { |
| 244 | ansicht.aufraeumen(); |
| 245 | } |
| 246 | }); |
| 247 | |
| 248 | it('zeichnet nach dem Wechsel wieder die Vorschau der Fahrlinie statt des alten Haltlinienbandes', () => { |
| 249 | const ansicht = baueAnsicht(); |
| 250 | try { |
| 251 | waehleWerkzeug(ansicht, 'Haltlinie'); |
| 252 | ansicht.leinwand.focus(); |
| 253 | tippe(' '); |
| 254 | |
| 255 | waehleWerkzeug(ansicht, 'Fahrlinie'); |
| 256 | ansicht.leinwand.focus(); |
| 257 | tippe(' '); |
| 258 | stift.leeren(); |
| 259 | tippe('ArrowRight', 4); |
| 260 | |
| 261 | const gestricheltesBand = stift.striche.some( |
| 262 | (s) => s.farbe === HALTLINIE && s.breite === 4 && s.strich === '8,5', |
| 263 | ); |
| 264 | const fahrlinienEntwurf = stift.striche.some((s) => s.farbe === ENTWURF && s.breite === 2); |
| 265 | |
| 266 | expect(gestricheltesBand, 'das Band der verworfenen Haltlinie wird noch gezeichnet').toBe( |
| 267 | false, |
| 268 | ); |
| 269 | expect(fahrlinienEntwurf, 'die Fahrlinie wird ohne Vorschau gezogen').toBe(true); |
| 270 | } finally { |
| 271 | ansicht.aufraeumen(); |
| 272 | } |
| 273 | }); |
| 274 | }); |