lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui luftbildentwurf.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 type { LuftbildErgebnis } from '@/ui/lageplan/luftbild'; |
| 7 | import { lageplanView } from '@/ui/views/lageplanView'; |
| 8 | |
| 9 | /** |
| 10 | * `ladeLuftbild()` raeumte den halb gezeichneten Entwurf nicht. |
| 11 | * |
| 12 | * WAS DER ANWENDER SONST ERLEBT |
| 13 | * |
| 14 | * Er setzt zwei Punkte einer Fahrlinie und holt dann - noch mitten im Zug - |
| 15 | * ein amtliches Luftbild. Das Bild wird ersetzt, das Werkzeug springt auf |
| 16 | * "Ansehen", und ueber dem neuen Ausschnitt steht weiter das gestrichelte |
| 17 | * Vorschauband des alten Entwurfs. Seine Punkte sind Bildpunkte des BISHERIGEN |
| 18 | * Bildes und bezeichnen auf dem neuen eine andere Stelle. Wer den Zug spaeter |
| 19 | * fortsetzt, beginnt seine Fahrlinie an einem Punkt, den er nie dort gesetzt |
| 20 | * hat - und der erste Punkt ist der Nullpunkt von Raeum- und Einfahrweg. |
| 21 | * |
| 22 | * `ladeBild()` raeumt seit Befund 22; nur dieser zweite Weg tat es nicht. |
| 23 | */ |
| 24 | |
| 25 | const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; |
| 26 | |
| 27 | /** Farbe des Entwurfs einer Fahrlinie (leinwand.ts, FARBE.entwurf). */ |
| 28 | const ENTWURF = '#1f5fa8'; |
| 29 | /** Farbe der Haltlinien - und damit auch des gestrichelten Haltlinienbandes. */ |
| 30 | const HALTLINIE = '#111827'; |
| 31 | |
| 32 | interface Strich { |
| 33 | readonly farbe: string; |
| 34 | readonly breite: number; |
| 35 | readonly strich: string; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Zeichenkontext, der jeden Strich mitschreibt. |
| 40 | * |
| 41 | * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nicht das |
| 42 | * Bild, sondern die Frage, OB das Vorschauband noch gezeichnet wird - und das |
| 43 | * ist eine Folge von Anweisungen. |
| 44 | */ |
| 45 | class Aufzeichner { |
| 46 | readonly striche: Strich[] = []; |
| 47 | strokeStyle: string = ''; |
| 48 | fillStyle: string = ''; |
| 49 | lineWidth = 0; |
| 50 | lineCap = 'butt'; |
| 51 | lineJoin = 'miter'; |
| 52 | globalAlpha = 1; |
| 53 | font = ''; |
| 54 | textAlign = 'left'; |
| 55 | imageSmoothingQuality = 'low'; |
| 56 | private strich = ''; |
| 57 | |
| 58 | save(): void {} |
| 59 | restore(): void {} |
| 60 | clearRect(): void {} |
| 61 | fillRect(): void {} |
| 62 | strokeRect(): void {} |
| 63 | drawImage(): void {} |
| 64 | beginPath(): void {} |
| 65 | moveTo(): void {} |
| 66 | lineTo(): void {} |
| 67 | arc(): void {} |
| 68 | fill(): void {} |
| 69 | fillText(): void {} |
| 70 | setTransform(): void {} |
| 71 | measureText(): { width: number } { |
| 72 | return { width: 24 }; |
| 73 | } |
| 74 | |
| 75 | setLineDash(muster: readonly number[] = []): void { |
| 76 | this.strich = muster.join(','); |
| 77 | } |
| 78 | |
| 79 | stroke(): void { |
| 80 | this.striche.push({ |
| 81 | farbe: String(this.strokeStyle), |
| 82 | breite: this.lineWidth, |
| 83 | strich: this.strich, |
| 84 | }); |
| 85 | } |
| 86 | |
| 87 | leeren(): void { |
| 88 | this.striche.length = 0; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | const stift = new Aufzeichner(); |
| 93 | |
| 94 | /** |
| 95 | * Was der Luftbildabruf liefert. |
| 96 | * |
| 97 | * Ersetzt wird nur die Aussenwelt: der Abrufweg zum Kartendienst und sein |
| 98 | * Fenster. Alles andere an der Ansicht bleibt echt. |
| 99 | */ |
| 100 | const LUFTBILD: LuftbildErgebnis = { |
| 101 | bild: { |
| 102 | datenUrl: 'data:image/png;base64,iVBORw0KGgo=', |
| 103 | breite: 2000, |
| 104 | hoehe: 2000, |
| 105 | herkunft: 'Luftbild Prüfland', |
| 106 | geladenAm: FESTES_DATUM, |
| 107 | }, |
| 108 | kantenlaenge: 200, |
| 109 | }; |
| 110 | |
| 111 | vi.mock('@/ui/lageplan/luftbild', async (importOriginal) => { |
| 112 | const echt = await importOriginal<typeof import('@/ui/lageplan/luftbild')>(); |
| 113 | return { |
| 114 | ...echt, |
| 115 | luftbildVerfuegbar: (): boolean => true, |
| 116 | luftbildDialog: (): Promise<LuftbildErgebnis | null> => Promise.resolve(LUFTBILD), |
| 117 | }; |
| 118 | }); |
| 119 | |
| 120 | /** Bildelement, das sofort geladen meldet - jsdom holt keine Daten-URL ab. */ |
| 121 | class SofortBild { |
| 122 | onload: (() => void) | null = null; |
| 123 | onerror: (() => void) | null = null; |
| 124 | naturalWidth = 2000; |
| 125 | naturalHeight = 2000; |
| 126 | set src(_wert: string) { |
| 127 | this.onload?.(); |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | const urspruenglichesPath2D = (globalThis as { Path2D?: unknown }).Path2D; |
| 132 | const urspruenglichesImage = (globalThis as { Image?: unknown }).Image; |
| 133 | |
| 134 | beforeAll(() => { |
| 135 | (globalThis as { Path2D?: unknown }).Path2D = class { |
| 136 | moveTo(): void {} |
| 137 | lineTo(): void {} |
| 138 | }; |
| 139 | (globalThis as { Image?: unknown }).Image = SofortBild; |
| 140 | vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( |
| 141 | () => stift as unknown as CanvasRenderingContext2D, |
| 142 | ); |
| 143 | }); |
| 144 | |
| 145 | afterAll(() => { |
| 146 | (globalThis as { Path2D?: unknown }).Path2D = urspruenglichesPath2D; |
| 147 | (globalThis as { Image?: unknown }).Image = urspruenglichesImage; |
| 148 | }); |
| 149 | |
| 150 | /** Plan mit Maßstab und einer waagerechten Haltlinie, aber noch ohne Bild. */ |
| 151 | function planMitHaltlinie(): Lageplan { |
| 152 | return { |
| 153 | arbeitsbereiche: [], |
| 154 | signalgeber: [], |
| 155 | bild: null, |
| 156 | kalibrierung: { |
| 157 | von: { x: 0, y: 0 }, |
| 158 | bis: { x: 100, y: 0 }, |
| 159 | laengeMeter: 20, |
| 160 | herkunft: 'gemessen', |
| 161 | }, |
| 162 | haltlinien: [ |
| 163 | { |
| 164 | id: 'hl1', |
| 165 | name: 'Nord', |
| 166 | art: 'zufahrt', |
| 167 | armId: null, |
| 168 | von: { x: 100, y: 200 }, |
| 169 | bis: { x: 220, y: 200 }, |
| 170 | }, |
| 171 | ], |
| 172 | linien: [], |
| 173 | }; |
| 174 | } |
| 175 | |
| 176 | interface Ansicht { |
| 177 | readonly store: ProjectStore; |
| 178 | readonly wurzel: HTMLElement; |
| 179 | readonly leinwand: HTMLCanvasElement; |
| 180 | readonly aufraeumen: () => void; |
| 181 | } |
| 182 | |
| 183 | function baueAnsicht(): Ansicht { |
| 184 | const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); |
| 185 | store.update((p) => actions.setLageplan(p, planMitHaltlinie()), { label: 'Pruefplan' }); |
| 186 | |
| 187 | const wurzel = document.createElement('div'); |
| 188 | document.body.append(wurzel); |
| 189 | const schliessen = lageplanView.render(wurzel, { |
| 190 | store, |
| 191 | refresh: () => {}, |
| 192 | navigate: () => {}, |
| 193 | target: null, |
| 194 | }); |
| 195 | |
| 196 | const leinwand = wurzel.querySelector('canvas'); |
| 197 | if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.'); |
| 198 | |
| 199 | return { |
| 200 | store, |
| 201 | wurzel, |
| 202 | leinwand, |
| 203 | aufraeumen: () => { |
| 204 | leinwand.blur(); |
| 205 | schliessen(); |
| 206 | wurzel.remove(); |
| 207 | }, |
| 208 | }; |
| 209 | } |
| 210 | |
| 211 | afterEach(() => { |
| 212 | document.body.replaceChildren(); |
| 213 | stift.leeren(); |
| 214 | }); |
| 215 | |
| 216 | /** Laesst die angestossenen Zusagen der Reihe nach ablaufen. */ |
| 217 | async function ruhe(): Promise<void> { |
| 218 | for (let i = 0; i < 6; i += 1) { |
| 219 | await new Promise((erfuellen) => setTimeout(erfuellen, 0)); |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void { |
| 224 | const knopf = [ |
| 225 | ...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('.lageplan-werkzeuge button'), |
| 226 | ].find((b) => (b.textContent ?? '').trim() === beschriftung); |
| 227 | if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`); |
| 228 | knopf.click(); |
| 229 | } |
| 230 | |
| 231 | function knopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement { |
| 232 | const treffer = [...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('button')].find( |
| 233 | (b) => (b.textContent ?? '').trim() === beschriftung, |
| 234 | ); |
| 235 | if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}"`); |
| 236 | return treffer; |
| 237 | } |
| 238 | |
| 239 | function tippe(key: string, male = 1): void { |
| 240 | for (let i = 0; i < male; i += 1) { |
| 241 | const ziel = (document.activeElement as HTMLElement | null) ?? document.body; |
| 242 | ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | /** Wurde das gestrichelte Vorschauband des Entwurfs gezeichnet? */ |
| 247 | function vorschauband(): boolean { |
| 248 | return stift.striche.some((s) => s.farbe === ENTWURF && s.breite === 2 && s.strich === '5,3'); |
| 249 | } |
| 250 | |
| 251 | /** Wurde das gestrichelte Band einer halb gesetzten Haltlinie gezeichnet? */ |
| 252 | function haltlinienband(): boolean { |
| 253 | return stift.striche.some((s) => s.farbe === HALTLINIE && s.breite === 4 && s.strich === '8,5'); |
| 254 | } |
| 255 | |
| 256 | /** Setzt zwei Punkte einer Fahrlinie, ohne die Linie abzuschließen. */ |
| 257 | function halberZug(ansicht: Ansicht): void { |
| 258 | waehleWerkzeug(ansicht, 'Fahrlinie'); |
| 259 | ansicht.leinwand.focus(); |
| 260 | tippe(' '); |
| 261 | tippe('ArrowDown', 6); |
| 262 | tippe(' '); |
| 263 | } |
| 264 | |
| 265 | describe('Amtliches Luftbild holen mit halb gezeichneter Fahrlinie', () => { |
| 266 | it('räumt den Entwurf, statt sein Vorschauband über dem neuen Ausschnitt stehen zu lassen', async () => { |
| 267 | const ansicht = baueAnsicht(); |
| 268 | try { |
| 269 | halberZug(ansicht); |
| 270 | // Grundlage des Falls: Es steht wirklich ein Entwurf. |
| 271 | expect(ansicht.wurzel.textContent ?? '').toContain('2 Punkt(e) gesetzt'); |
| 272 | |
| 273 | stift.leeren(); |
| 274 | knopf(ansicht, 'Amtliches Luftbild').click(); |
| 275 | await ruhe(); |
| 276 | |
| 277 | // Grundlage: Das Luftbild ist wirklich angekommen - sonst zeichnet die |
| 278 | // Flaeche nur "Kein Lageplan geladen" und der Fall waere nicht gestellt. |
| 279 | expect(ansicht.store.getProject().lageplan.bild?.breite).toBe(2000); |
| 280 | expect(vorschauband(), 'das Vorschauband des alten Entwurfs steht noch').toBe(false); |
| 281 | } finally { |
| 282 | ansicht.aufraeumen(); |
| 283 | } |
| 284 | }); |
| 285 | |
| 286 | it('räumt auch den halb gesetzten Anfang einer Haltlinie', async () => { |
| 287 | // Dieselbe Ursache, andere Grösse: `haltlinienStart` ist ebenfalls ein |
| 288 | // Bildpunkt des bisherigen Bildes. Bliebe er liegen, deckte sein Band |
| 289 | // ausserdem jede weitere Vorschau zu - `zeichneEntwurf` prüft ihn zuerst |
| 290 | // und kehrt danach zurück. |
| 291 | const ansicht = baueAnsicht(); |
| 292 | try { |
| 293 | waehleWerkzeug(ansicht, 'Haltlinie'); |
| 294 | ansicht.leinwand.focus(); |
| 295 | tippe('ArrowDown', 6); |
| 296 | tippe(' '); |
| 297 | |
| 298 | stift.leeren(); |
| 299 | knopf(ansicht, 'Amtliches Luftbild').click(); |
| 300 | await ruhe(); |
| 301 | |
| 302 | expect(ansicht.store.getProject().lageplan.bild?.breite).toBe(2000); |
| 303 | expect(haltlinienband(), 'das Band der halb gesetzten Haltlinie steht noch').toBe(false); |
| 304 | } finally { |
| 305 | ansicht.aufraeumen(); |
| 306 | } |
| 307 | }); |
| 308 | }); |