lsa-planer

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

/ tests ui werkzeugwechsel.test.ts

8,3 KB Rohdatei
tests/ui/werkzeugwechsel.test.ts — 274 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 { 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 });