lsa-planer

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

/ tests ui signalgeberWerkzeug.test.ts

12,1 KB Rohdatei
tests/ui/signalgeberWerkzeug.test.ts — 362 Zeilen
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 });