lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests ui luftbildentwurf.test.ts

9,1 KB Rohdatei
tests/ui/luftbildentwurf.test.ts — 308 Zeilen
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 });