lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui lageplanGenauigkeit.test.ts
| 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 | }); |