lsa-planer

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

/ tests ui lageplanGenauigkeit.test.ts

9,1 KB Rohdatei
tests/ui/lageplanGenauigkeit.test.ts — 246 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import type { Lageplan } from '@/domain/geometrie/lageplan';
4 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
5 import { lageplanView } from '@/ui/views/lageplanView';
6
7 /**
8 * Genauigkeitsfuehrung und Fangradien im Lageplan.
9 *
10 * Beides haengt an der Bodenaufloesung der Unterlage, und beides wurde erst
11 * gefaehrlich, als sich Ausschnitte bis 2000 m holen lassen:
12 *
13 * - Ein Uebersichtsbild sieht aus wie ein Knotenpunktbild, traegt aber
14 * 28,6 statt 12,5 cm je Bildpunkt. Wer darauf eine Fussgaengerfurt
15 * abgreift, erhaelt eine zu KURZE Zwischenzeit.
16 * - Die Fangtoleranzen sind in Bildschirmpunkten festgelegt. Ohne Deckelung
17 * am Boden waechst der Fangradius mit dem Ausschnitt mit und rastet den
18 * Anfangspunkt einer Fahrlinie auf die Haltlinie des Nachbarknotenpunkts.
19 *
20 * Geprueft wird gegen die aufgebaute Ansicht, nicht gegen die Rechnung: Die
21 * Einstufung selbst ist in der Domaene geprueft. Hier steht die Frage, ob sie
22 * beim Anwender ankommt und ob der Fang sie beachtet.
23 */
24
25 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
26
27 /** Waagerechte Haltlinie bei y = 200, von x = 100 bis x = 160. */
28 const HALTLINIE = {
29 id: 'hl1',
30 name: 'Nord',
31 art: 'zufahrt' as const,
32 armId: null,
33 von: { x: 100, y: 200 },
34 bis: { x: 160, y: 200 },
35 };
36
37 /**
38 * Lageplan mit georeferenziertem Massstab.
39 *
40 * `kantenlaenge / bildbreite` ist die Bodenaufloesung - genau die Groesse, an
41 * der Einstufung und Fangradius haengen. Ein Hintergrundbild wird nicht
42 * gebraucht: Ohne Bild bleibt die Sicht unveraendert (Zoom 1), und damit ist
43 * ein Bildschirmpunkt ein Bildpunkt.
44 */
45 function planMit(kantenlaengeMeter: number, bildbreite: number): Lageplan {
46 return {
47 arbeitsbereiche: [],
48 signalgeber: [],
49 bild: null,
50 kalibrierung: {
51 von: { x: 0, y: 0 },
52 bis: { x: bildbreite, y: 0 },
53 laengeMeter: kantenlaengeMeter,
54 herkunft: 'georeferenziert',
55 },
56 haltlinien: [HALTLINIE],
57 linien: [],
58 };
59 }
60
61 interface Ansicht {
62 readonly wurzel: HTMLElement;
63 readonly aufraeumen: () => void;
64 }
65
66 function baueAnsicht(lageplan: Lageplan | null): Ansicht {
67 const grundlage = projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM);
68 const store = new ProjectStore(lageplan === null ? grundlage : { ...grundlage, lageplan });
69 const wurzel = document.createElement('div');
70 document.body.append(wurzel);
71 const aufraeumen = lageplanView.render(wurzel, {
72 store,
73 refresh: () => {},
74 navigate: () => {},
75 target: null,
76 });
77 return {
78 wurzel,
79 aufraeumen: () => {
80 aufraeumen();
81 wurzel.remove();
82 },
83 };
84 }
85
86 /** Text des Hinweisbalkens mit dieser Ueberschrift; null, wenn es ihn nicht gibt. */
87 function balkenText(wurzel: HTMLElement, titel: string): string | null {
88 for (const balken of wurzel.querySelectorAll('.hinweisbalken')) {
89 if (balken.querySelector('strong')?.textContent === titel) return balken.textContent ?? '';
90 }
91 return null;
92 }
93
94 /** Alle Ueberschriften der Hinweisbalken - zum Nachweis, dass keiner dasteht. */
95 function balkenTitel(wurzel: HTMLElement): string[] {
96 return [...wurzel.querySelectorAll('.hinweisbalken strong')].map((k) => k.textContent ?? '');
97 }
98
99 function knopf(wurzel: HTMLElement, beschriftung: string): HTMLButtonElement {
100 const treffer = [...wurzel.querySelectorAll('button')].find(
101 (b) => b.textContent === beschriftung,
102 );
103 if (treffer === undefined) throw new Error(`Knopf "${beschriftung}" nicht gefunden`);
104 return treffer;
105 }
106
107 /** Setzt den ersten Punkt einer Fahrlinie an dieser Stelle der Zeichenflaeche. */
108 function zeichnePunkt(wurzel: HTMLElement, x: number, y: number): void {
109 knopf(wurzel, 'Fahrlinie').click();
110 wurzel
111 .querySelector('canvas')
112 ?.dispatchEvent(new MouseEvent('click', { button: 0, detail: 1, clientX: x, clientY: y }));
113 }
114
115 describe('Genauigkeit der Unterlage in der Seitenleiste', () => {
116 it('nennt bei feiner Unterlage die Eignung samt Fehler in Sekunden', () => {
117 // 100 m auf 2000 Bildpunkte = 5 cm je Bildpunkt.
118 const { wurzel, aufraeumen } = baueAnsicht(planMit(100, 2000));
119 try {
120 const text = balkenText(wurzel, 'Zum Vermessen geeignet');
121 expect(text).not.toBeNull();
122 expect(text).toContain('5 cm');
123 // Zwei Bildpunkte Zeichenfehler auf Raeum- UND Einfahrweg einer Furt:
124 // 2 * 0,05 m * (1/1,0 + 1/11,1) = 0,109009 s, ausgegeben als 0,11 s.
125 expect(text).toContain('0,11 s');
126 expect(text).toContain('0,25 s');
127 } finally {
128 aufraeumen();
129 }
130 });
131
132 it('warnt bei einem 2000-m-Ausschnitt vor Furten und nennt beide Fehler', () => {
133 // Der Regelfall der neuen Mosaike: 2000 m auf 7000 Bildpunkte.
134 const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000));
135 try {
136 const text = balkenText(wurzel, 'Nur für Kfz-Beziehungen genau genug');
137 expect(text).not.toBeNull();
138 expect(text).toContain('28,6 cm');
139 // Kein Text ohne Zahl: der Fehler an der Furt und der beim Kfz.
140 // Furt: 2 * 0,285714 m * (1/1,0 + 1/11,1) = 0,622909 s.
141 // Kfz: 2 * 0,285714 m * (1/7,0 + 1/11,1) = 0,133113 s.
142 expect(text).toContain('0,62 s');
143 expect(text).toContain('0,13 s');
144 expect(text).toContain('Radquerungen');
145 } finally {
146 aufraeumen();
147 }
148 });
149
150 it('meldet eine unbrauchbare Unterlage als Fehler, nicht als Warnung', () => {
151 // 1000 m auf 1000 Bildpunkte = 1 m je Bildpunkt.
152 const { wurzel, aufraeumen } = baueAnsicht(planMit(1000, 1000));
153 try {
154 const text = balkenText(wurzel, 'Als Messgrundlage nicht geeignet');
155 expect(text).not.toBeNull();
156 // Furt: 2 * 1,0 m * (1/1,0 + 1/11,1) = 2,180180 s.
157 // Kfz: 2 * 1,0 m * (1/7,0 + 1/11,1) = 0,465894 s - auch der reisst die
158 // Schranke, deshalb Fehler statt Warnung.
159 expect(text).toContain('2,18 s');
160 expect(text).toContain('0,47 s');
161 // Die Einstufung darf nicht allein in der Farbe stehen - aber die Farbe
162 // gehoert dazu: Ein Fehler sieht anders aus als eine Warnung.
163 const balken = [...wurzel.querySelectorAll('.hinweisbalken')].find(
164 (b) => b.querySelector('strong')?.textContent === 'Als Messgrundlage nicht geeignet',
165 );
166 expect(balken?.classList.contains('hinweisbalken-fehler')).toBe(true);
167 } finally {
168 aufraeumen();
169 }
170 });
171
172 it('behauptet ohne Massstab keine Genauigkeit', () => {
173 const { wurzel, aufraeumen } = baueAnsicht(null);
174 try {
175 const titel = balkenTitel(wurzel);
176 expect(titel).toContain('Maßstab fehlt');
177 expect(titel).not.toContain('Zum Vermessen geeignet');
178 expect(titel).not.toContain('Nur für Kfz-Beziehungen genau genug');
179 expect(titel).not.toContain('Als Messgrundlage nicht geeignet');
180 } finally {
181 aufraeumen();
182 }
183 });
184
185 it('warnt beim Zeichnen einer Furt noch einmal ausdruecklich', () => {
186 const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000));
187 try {
188 knopf(wurzel, 'Fahrlinie').click();
189 expect(balkenTitel(wurzel)).not.toContain('Fußgängerfurt auf dieser Unterlage vermessen?');
190
191 const auswahl = [...wurzel.querySelectorAll('select')].find((s) =>
192 [...s.options].some((o) => o.value === 'fuss'),
193 );
194 expect(auswahl).not.toBeUndefined();
195 auswahl!.value = 'fuss';
196 auswahl!.dispatchEvent(new Event('change'));
197
198 const text = balkenText(wurzel, 'Fußgängerfurt auf dieser Unterlage vermessen?');
199 expect(text).not.toBeNull();
200 // Derselbe Furtfehler wie im Balken der Seitenleiste: 0,622909 s.
201 expect(text).toContain('0,62 s');
202 } finally {
203 aufraeumen();
204 }
205 });
206 });
207
208 describe('Fangradius am Boden gedeckelt', () => {
209 it('faengt bei grobem Ausschnitt nicht mehr aus 3,4 m Entfernung', () => {
210 // 2000 m auf 7000 Bildpunkte: 15 Bildschirmpunkte waeren 4,3 m am Boden.
211 // Der Klick liegt 12 Bildpunkte = 3,4 m neben der Haltlinie - ohne
212 // Deckelung raste der Anfangspunkt darauf ein, und mit ihm der Nullpunkt
213 // von Raeum- und Einfahrweg.
214 const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000));
215 try {
216 zeichnePunkt(wurzel, 130, 212);
217 expect(wurzel.textContent).toContain('keiner Zufahrt zugeordnet');
218 } finally {
219 aufraeumen();
220 }
221 });
222
223 it('faengt bei grobem Ausschnitt weiterhin aus 1,4 m Entfernung', () => {
224 // 5 Bildpunkte = 1,4 m: innerhalb der 2,5 m, das Einrasten bleibt bequem.
225 const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000));
226 try {
227 zeichnePunkt(wurzel, 130, 205);
228 expect(wurzel.textContent).toContain('Beginnt an der Haltlinie');
229 } finally {
230 aufraeumen();
231 }
232 });
233
234 it('nimmt bei feinem Ausschnitt nichts vom bisherigen Fang weg', () => {
235 // 200 m auf 2000 Bildpunkte = 10 cm je Bildpunkt: 2,5 m waeren 25
236 // Bildpunkte, es bleibt bei den 15 Bildschirmpunkten. Der Deckel wirkt nur
237 // nach oben - sonst haette die Deckelung die Bedienung verschlechtert.
238 const { wurzel, aufraeumen } = baueAnsicht(planMit(200, 2000));
239 try {
240 zeichnePunkt(wurzel, 130, 212);
241 expect(wurzel.textContent).toContain('Beginnt an der Haltlinie');
242 } finally {
243 aufraeumen();
244 }
245 });
246 });