lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui signalgeberWerkzeug.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import * as actions from '@/app/actions'; |
| 3 | import { ProjectStore } from '@/app/store'; |
| 4 | import { START_SICHT, zeichne } from '@/ui/lageplan/leinwand'; |
| 5 | import { lageplanView } from '@/ui/views/lageplanView'; |
| 6 | import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; |
| 7 | import type { Lageplan } from '@/domain/geometrie/lageplan'; |
| 8 | |
| 9 | /** |
| 10 | * Das Signalgeberwerkzeug am Bildschirm. |
| 11 | * |
| 12 | * ZWEI DINGE STEHEN HIER AUF DEM PRUEFSTAND, und beide sind Zusagen, die man |
| 13 | * der Oberflaeche nicht ansieht: |
| 14 | * |
| 15 | * - DASS ES DAS WERKZEUG GIBT und dass es gesperrt ist, solange kein Massstab |
| 16 | * vorliegt. Ein Signalgeber ohne Massstab liesse sich setzen, aber nicht |
| 17 | * verorten - und auf dem Blatt stuende er dann irgendwo. |
| 18 | * - DASS DIE ZEICHENFLAECHE IHN ZEICHNET, samt Pfeil und Kennung. Die Marke |
| 19 | * ist die einzige Stelle, an der die Blickrichtung ueberhaupt sichtbar |
| 20 | * wird; ohne sie waere die Ausrichtung eine Zahl, die niemand prueft. |
| 21 | */ |
| 22 | |
| 23 | const DATUM = new Date('2026-09-12T12:00:00Z'); |
| 24 | |
| 25 | /** Zeichenkontext, der Kreise, Vielecke und Beschriftungen mitschreibt. */ |
| 26 | class Aufzeichner { |
| 27 | readonly boegen: { x: number; y: number; r: number }[] = []; |
| 28 | readonly texte: string[] = []; |
| 29 | fuellungen = 0; |
| 30 | striche = 0; |
| 31 | strokeStyle = ''; |
| 32 | fillStyle = ''; |
| 33 | lineWidth = 0; |
| 34 | lineCap = 'butt'; |
| 35 | lineJoin = 'miter'; |
| 36 | globalAlpha = 1; |
| 37 | font = ''; |
| 38 | textAlign = 'left'; |
| 39 | textBaseline = 'alphabetic'; |
| 40 | imageSmoothingQuality = 'low'; |
| 41 | |
| 42 | save(): void {} |
| 43 | restore(): void {} |
| 44 | clearRect(): void {} |
| 45 | fillRect(): void {} |
| 46 | strokeRect(): void {} |
| 47 | drawImage(): void {} |
| 48 | beginPath(): void {} |
| 49 | moveTo(): void {} |
| 50 | lineTo(): void {} |
| 51 | closePath(): void {} |
| 52 | setLineDash(): void {} |
| 53 | fillText(value: string): void { |
| 54 | this.texte.push(value); |
| 55 | } |
| 56 | strokeText(): void {} |
| 57 | measureText(): { width: number } { |
| 58 | return { width: 24 }; |
| 59 | } |
| 60 | arc(x: number, y: number, r: number): void { |
| 61 | this.boegen.push({ x, y, r }); |
| 62 | } |
| 63 | fill(): void { |
| 64 | this.fuellungen += 1; |
| 65 | } |
| 66 | stroke(): void { |
| 67 | this.striche += 1; |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | function planMitGeber(): Lageplan { |
| 72 | return { |
| 73 | bild: null, |
| 74 | kalibrierung: { |
| 75 | von: { x: 0, y: 0 }, |
| 76 | bis: { x: 100, y: 0 }, |
| 77 | laengeMeter: 20, |
| 78 | herkunft: 'gemessen', |
| 79 | }, |
| 80 | haltlinien: [ |
| 81 | { |
| 82 | id: 'hl1', |
| 83 | name: 'Nord', |
| 84 | art: 'zufahrt', |
| 85 | armId: null, |
| 86 | von: { x: 100, y: 200 }, |
| 87 | bis: { x: 160, y: 200 }, |
| 88 | }, |
| 89 | ], |
| 90 | linien: [], |
| 91 | arbeitsbereiche: [], |
| 92 | signalgeber: [ |
| 93 | { id: 'sg1', name: 'K1', punkt: { x: 130, y: 180 }, ausrichtung: 45, signalGroupId: null }, |
| 94 | ], |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | function zeichneAuf(lageplan: Lageplan): Aufzeichner { |
| 99 | const ctx = new Aufzeichner(); |
| 100 | zeichne(ctx as unknown as CanvasRenderingContext2D, 800, 500, { |
| 101 | lageplan, |
| 102 | sicht: START_SICHT, |
| 103 | bild: { naturalWidth: 400, naturalHeight: 300 } as unknown as HTMLImageElement, |
| 104 | aktiveLinieId: null, |
| 105 | aktiveHaltlinieId: null, |
| 106 | haltlinienStart: null, |
| 107 | entwurf: [], |
| 108 | kalibrierStart: null, |
| 109 | zeiger: null, |
| 110 | dunkel: false, |
| 111 | }); |
| 112 | return ctx; |
| 113 | } |
| 114 | |
| 115 | describe('Der Signalgeber auf der Zeichenfläche', () => { |
| 116 | /* |
| 117 | * GEZAEHLT WIRD DIE DIFFERENZ. Die Zeichenflaeche setzt auch anderswo Boegen |
| 118 | * - Greifpunkte, Schnittpunkte -, und eine Zaehlung ueber alle pruefte nicht, |
| 119 | * was sie zu pruefen vorgibt. |
| 120 | */ |
| 121 | function mehrBoegen(mit: Lageplan, ohne: Lageplan): number { |
| 122 | return zeichneAuf(mit).boegen.length - zeichneAuf(ohne).boegen.length; |
| 123 | } |
| 124 | |
| 125 | it('zeichnet Kreis, Pfeilspitze und Kennung', () => { |
| 126 | const plan = planMitGeber(); |
| 127 | expect(mehrBoegen(plan, { ...plan, signalgeber: [] }), 'kein Kreis').toBeGreaterThanOrEqual(1); |
| 128 | expect(zeichneAuf(plan).texte, 'die Kennung fehlt').toContain('K1'); |
| 129 | }); |
| 130 | |
| 131 | it('zeichnet ohne Signalgeber keinen zusätzlichen Kreis und keine Kennung', () => { |
| 132 | const ohne = zeichneAuf({ ...planMitGeber(), signalgeber: [] }); |
| 133 | expect(ohne.texte).not.toContain('K1'); |
| 134 | }); |
| 135 | |
| 136 | it('lässt die Kennung weg, wenn keine vergeben ist', () => { |
| 137 | // Ein leerer Ruecken unter einem leeren Text waere ein weisser Fleck auf |
| 138 | // der Zeichnung - sichtbar, ohne etwas zu sagen. |
| 139 | const plan = planMitGeber(); |
| 140 | const ohneName: Lageplan = { |
| 141 | ...plan, |
| 142 | signalgeber: [{ ...plan.signalgeber[0]!, name: ' ' }], |
| 143 | }; |
| 144 | expect( |
| 145 | mehrBoegen(ohneName, { ...plan, signalgeber: [] }), |
| 146 | 'der Kreis fehlt', |
| 147 | ).toBeGreaterThanOrEqual(1); |
| 148 | expect(zeichneAuf(ohneName).texte.filter((t) => t.trim() === '')).toHaveLength(0); |
| 149 | }); |
| 150 | }); |
| 151 | |
| 152 | describe('Die Länge der Haltlinie steht in der Ansicht und nicht im Ausdruck', () => { |
| 153 | /* |
| 154 | * AUS DEM BETRIEB GEMELDET (Fassung 5.42.1). Die Laenge einer Haltlinie |
| 155 | * stand als Masskette auf dem Lageplanblatt der Planunterlage und sonst |
| 156 | * NIRGENDS im Programm - keine Spalte, kein Fenster, keine Ansage. Der |
| 157 | * Bearbeiter zeichnete nach Augenmass und erfuhr die Zahl erst im Ausdruck, |
| 158 | * wo sie wie eine Messung aussah. |
| 159 | * |
| 160 | * Sie ist jetzt umgekehrt: Auskunft beim Zeichnen, keine Zusicherung in der |
| 161 | * Unterlage. Diese Faelle halten beide Haelften fest - die Masskette prueft |
| 162 | * tests/domain/massketten.test.ts. |
| 163 | */ |
| 164 | function ansichtMitHaltlinie() { |
| 165 | const basis = projektAusAngaben( |
| 166 | { ...ASSISTENT_VORGABE, form: 'einstreifig' }, |
| 167 | 'Arbeitsstelle', |
| 168 | DATUM, |
| 169 | ); |
| 170 | const projekt = actions.setLageplan(basis, planMitGeber()); |
| 171 | const store = new ProjectStore(projekt); |
| 172 | const wurzel = document.createElement('div'); |
| 173 | document.body.appendChild(wurzel); |
| 174 | const aufraeumen = lageplanView.render(wurzel, { |
| 175 | store, |
| 176 | refresh: () => {}, |
| 177 | navigate: () => {}, |
| 178 | } as unknown as Parameters<typeof lageplanView.render>[1]); |
| 179 | return { |
| 180 | wurzel, |
| 181 | aufraeumen: () => { |
| 182 | if (typeof aufraeumen === 'function') aufraeumen(); |
| 183 | wurzel.remove(); |
| 184 | }, |
| 185 | }; |
| 186 | } |
| 187 | |
| 188 | it('führt die gemessene Länge in der Haltlinientabelle', () => { |
| 189 | /* |
| 190 | * Die Haltlinie des Prüfstücks läuft von y=200 bis y=200 über 60 |
| 191 | * Bildpunkte bei 20 m auf 100 Bildpunkten - das sind 12 m. |
| 192 | */ |
| 193 | const { wurzel, aufraeumen } = ansichtMitHaltlinie(); |
| 194 | try { |
| 195 | const text = wurzel.textContent ?? ''; |
| 196 | expect(text, 'die Spalte Länge fehlt').toContain('Länge'); |
| 197 | expect(text, 'die gemessene Länge fehlt').toMatch(/12(,0)?\s*m/); |
| 198 | } finally { |
| 199 | aufraeumen(); |
| 200 | } |
| 201 | }); |
| 202 | }); |
| 203 | |
| 204 | describe('Das Signalgeberwerkzeug in der Ansicht Lageplan', () => { |
| 205 | function baueAnsicht(mitMassstab: boolean) { |
| 206 | const basis = projektAusAngaben( |
| 207 | { ...ASSISTENT_VORGABE, form: 'einstreifig' }, |
| 208 | 'Arbeitsstelle', |
| 209 | DATUM, |
| 210 | ); |
| 211 | const projekt = mitMassstab |
| 212 | ? actions.setLageplan(basis, planMitGeber()) |
| 213 | : actions.setLageplan(basis, { ...planMitGeber(), kalibrierung: null, signalgeber: [] }); |
| 214 | const store = new ProjectStore(projekt); |
| 215 | const wurzel = document.createElement('div'); |
| 216 | document.body.appendChild(wurzel); |
| 217 | const aufraeumen = lageplanView.render(wurzel, { |
| 218 | store, |
| 219 | refresh: () => {}, |
| 220 | navigate: () => {}, |
| 221 | } as unknown as Parameters<typeof lageplanView.render>[1]); |
| 222 | return { |
| 223 | wurzel, |
| 224 | store, |
| 225 | aufraeumen: () => { |
| 226 | if (typeof aufraeumen === 'function') aufraeumen(); |
| 227 | wurzel.remove(); |
| 228 | }, |
| 229 | }; |
| 230 | } |
| 231 | |
| 232 | function knopf(wurzel: HTMLElement, beschriftung: string): HTMLButtonElement | undefined { |
| 233 | return [...wurzel.querySelectorAll('button')].find( |
| 234 | (b) => (b.textContent ?? '').trim() === beschriftung, |
| 235 | ); |
| 236 | } |
| 237 | |
| 238 | it('bietet das Werkzeug an, sobald ein Maßstab vorliegt', () => { |
| 239 | const { wurzel, aufraeumen } = baueAnsicht(true); |
| 240 | try { |
| 241 | const werkzeug = knopf(wurzel, 'Signalgeber'); |
| 242 | expect(werkzeug, 'der Werkzeugknopf fehlt').toBeDefined(); |
| 243 | expect(werkzeug!.disabled, 'das Werkzeug ist trotz Maßstab gesperrt').toBe(false); |
| 244 | } finally { |
| 245 | aufraeumen(); |
| 246 | } |
| 247 | }); |
| 248 | |
| 249 | it('sperrt es ohne Maßstab', () => { |
| 250 | /* |
| 251 | * Ein Signalgeber ohne Massstab liesse sich setzen und nicht verorten. Er |
| 252 | * stuende auf dem Blatt an einer Stelle, die niemand nachmessen kann - und |
| 253 | * das Blatt sagt nicht, dass es so ist. |
| 254 | */ |
| 255 | const { wurzel, aufraeumen } = baueAnsicht(false); |
| 256 | try { |
| 257 | expect(knopf(wurzel, 'Signalgeber')!.disabled).toBe(true); |
| 258 | } finally { |
| 259 | aufraeumen(); |
| 260 | } |
| 261 | }); |
| 262 | |
| 263 | it('führt die erfassten Signalgeber in der Seitenleiste auf', () => { |
| 264 | const { wurzel, aufraeumen } = baueAnsicht(true); |
| 265 | try { |
| 266 | const text = wurzel.textContent ?? ''; |
| 267 | expect(text).toContain('Signalgeber'); |
| 268 | expect(text, 'die Kennung fehlt in der Liste').toContain('K1'); |
| 269 | expect(text, 'die Blickrichtung fehlt').toContain('45°'); |
| 270 | expect(text, 'die fehlende Zuordnung wird verschwiegen').toContain('ohne Zuordnung'); |
| 271 | } finally { |
| 272 | aufraeumen(); |
| 273 | } |
| 274 | }); |
| 275 | |
| 276 | it('sagt beim gewählten Werkzeug, dass der Signalgeber nicht mitrechnet', () => { |
| 277 | /* |
| 278 | * Der Satz ist die Bedingung, unter der es das Objekt gibt. Steht er nicht |
| 279 | * dort, wo gezeichnet wird, liest ihn niemand - und der naechste Bearbeiter |
| 280 | * haelt den Standort fuer eine Rechengroesse. |
| 281 | */ |
| 282 | const { wurzel, store, aufraeumen } = baueAnsicht(true); |
| 283 | try { |
| 284 | knopf(wurzel, 'Signalgeber')!.click(); |
| 285 | const text = wurzel.textContent ?? ''; |
| 286 | expect(text).toContain('keine Rechnung'); |
| 287 | expect(store.getProject().lageplan.signalgeber).toHaveLength(1); |
| 288 | } finally { |
| 289 | aufraeumen(); |
| 290 | } |
| 291 | }); |
| 292 | |
| 293 | it('legt über die Tastatur einen Signalgeber an', () => { |
| 294 | /* |
| 295 | * DER GANZE WEG, so wie ein Anwender ihn geht: Werkzeug waehlen, Standort |
| 296 | * setzen, Richtungspunkt setzen, im Fenster bestaetigen. Beide Punkte |
| 297 | * gehen durch dieselbe Stelle wie jedes andere Zeichnen - deshalb genuegt |
| 298 | * die Tastatur, und deshalb prueft dieser Fall sie und nicht die Maus. |
| 299 | */ |
| 300 | const { wurzel, store, aufraeumen } = baueAnsicht(true); |
| 301 | try { |
| 302 | const leinwand = wurzel.querySelector('canvas'); |
| 303 | expect(leinwand, 'die Ansicht hat keine Zeichenfläche').not.toBeNull(); |
| 304 | knopf(wurzel, 'Signalgeber')!.click(); |
| 305 | |
| 306 | const druecke = (key: string): void => { |
| 307 | leinwand!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); |
| 308 | }; |
| 309 | druecke(' '); |
| 310 | expect( |
| 311 | wurzel.querySelector('[role="status"]')?.textContent ?? '', |
| 312 | 'nach dem ersten Punkt fehlt die Ansage', |
| 313 | ).toContain('Blickrichtung'); |
| 314 | druecke('ArrowRight'); |
| 315 | druecke(' '); |
| 316 | |
| 317 | const anlegen = [...document.querySelectorAll('button')].find( |
| 318 | (b) => (b.textContent ?? '').trim() === 'Signalgeber anlegen', |
| 319 | ); |
| 320 | expect(anlegen, 'das Fenster zum Anlegen erscheint nicht').toBeDefined(); |
| 321 | anlegen!.click(); |
| 322 | |
| 323 | const geber = store.getProject().lageplan.signalgeber; |
| 324 | expect(geber, 'kein Signalgeber angelegt').toHaveLength(2); |
| 325 | expect(store.getState().undoLabel).toBe('Signalgeber gezeichnet'); |
| 326 | } finally { |
| 327 | aufraeumen(); |
| 328 | } |
| 329 | }); |
| 330 | |
| 331 | it('nimmt den halb gesetzten Signalgeber mit der Rückschritttaste zurück', () => { |
| 332 | // Sonst bliebe der Standort stehen, und der naechste Tastendruck setzte |
| 333 | // die Richtung gegen einen Punkt, den der Anwender verwerfen wollte. |
| 334 | const { wurzel, store, aufraeumen } = baueAnsicht(true); |
| 335 | try { |
| 336 | const leinwand = wurzel.querySelector('canvas')!; |
| 337 | knopf(wurzel, 'Signalgeber')!.click(); |
| 338 | leinwand.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); |
| 339 | leinwand.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })); |
| 340 | expect(wurzel.querySelector('[role="status"]')?.textContent ?? '').toContain( |
| 341 | 'zurückgenommen', |
| 342 | ); |
| 343 | expect(store.getProject().lageplan.signalgeber).toHaveLength(1); |
| 344 | } finally { |
| 345 | aufraeumen(); |
| 346 | } |
| 347 | }); |
| 348 | |
| 349 | it('löscht einen Signalgeber über die Liste', () => { |
| 350 | const { wurzel, store, aufraeumen } = baueAnsicht(true); |
| 351 | try { |
| 352 | const loeschen = [...wurzel.querySelectorAll('button')].find((b) => |
| 353 | (b.getAttribute('aria-label') ?? '').includes('Signalgeber "K1" löschen'), |
| 354 | ); |
| 355 | expect(loeschen, 'kein Löschknopf').toBeDefined(); |
| 356 | loeschen!.click(); |
| 357 | expect(store.getProject().lageplan.signalgeber).toHaveLength(0); |
| 358 | } finally { |
| 359 | aufraeumen(); |
| 360 | } |
| 361 | }); |
| 362 | }); |