lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui blindenzusatzUndEngstelle.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 4 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 5 | import { ENGSTELLE_PRAXIS, RSA_EINSTREIFIG, VZUL_OBERGRENZE_LSA } from '@/domain/rilsa/constants'; |
| 6 | import type { Project } from '@/domain/model/project'; |
| 7 | import type { ViewDefinition } from '@/ui/shell'; |
| 8 | import { ASSISTENT_VORGABE, fuelleEngstellenkasten } from '@/ui/assistent'; |
| 9 | import { HILFE } from '@/ui/help/texte'; |
| 10 | import { projectView } from '@/ui/views/projectView'; |
| 11 | import { mindestfreigabeHinweis, signalGroupsView } from '@/ui/views/signalGroupsView'; |
| 12 | |
| 13 | /** |
| 14 | * Oberflaeche zu den Befunden C1 bis C6: |
| 15 | * |
| 16 | * - C1: Das Kontrollkaestchen "Zusatzeinrichtung fuer Blinde und Sehbehinderte" |
| 17 | * gibt es nur fuer Fussgaengergruppen, und der Platzhalter der |
| 18 | * Mindestfreigabezeit nennt den MASSGEBENDEN Wert aus dem Plan - nicht mehr |
| 19 | * nur den Regelwert, waehrend der Plan mit einem hoeheren verteilt. |
| 20 | * - C2/C6: Hilfe und Projektansicht nennen die 70-km/h-Grenze der VwV-StVO. |
| 21 | * - C3/C5: Der Engstellen-Schritt des Assistenten zeigt die Einstufung nach |
| 22 | * Behoerdenpraxis (400/600 m, als solche benannt) und die RSA-Kriterien. |
| 23 | * |
| 24 | * Gegen den Altstand schlagen diese Faelle fehl: Dort gab es weder Kaestchen |
| 25 | * noch Hilfeeintrag, der Platzhalter stand fest auf dem Regelwert, und der |
| 26 | * Assistent schwieg zwischen 400 und rund 835 m. |
| 27 | */ |
| 28 | |
| 29 | const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); |
| 30 | |
| 31 | function zeichne( |
| 32 | view: ViewDefinition, |
| 33 | project: Project, |
| 34 | ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } { |
| 35 | const wurzel = document.createElement('div'); |
| 36 | document.body.append(wurzel); |
| 37 | const store = new ProjectStore(project); |
| 38 | const aufraeumen = view.render(wurzel, { |
| 39 | store, |
| 40 | refresh: () => {}, |
| 41 | navigate: () => {}, |
| 42 | target: null, |
| 43 | }); |
| 44 | return { |
| 45 | wurzel, |
| 46 | store, |
| 47 | aufraeumen: () => { |
| 48 | aufraeumen(); |
| 49 | wurzel.remove(); |
| 50 | }, |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | /** Spaltenindex zu einem sichtbaren Kopftext (ohne Hilfeknopf). */ |
| 55 | function spalte(wurzel: ParentNode, kopf: string): number { |
| 56 | const koepfe = [...wurzel.querySelectorAll('thead th')].map((th) => |
| 57 | [...th.childNodes] |
| 58 | .filter((k) => !(k instanceof Element && k.classList.contains('hilfe-knopf'))) |
| 59 | .map((k) => k.textContent ?? '') |
| 60 | .join('') |
| 61 | .trim(), |
| 62 | ); |
| 63 | const index = koepfe.indexOf(kopf); |
| 64 | if (index < 0) throw new Error(`Keine Spalte "${kopf}" in ${koepfe.join(' | ')}`); |
| 65 | return index; |
| 66 | } |
| 67 | |
| 68 | describe('Signalgruppen: Zusatzeinrichtung fuer Blinde und Sehbehinderte (Befund C1)', () => { |
| 69 | it('bietet das Kaestchen nur fuer Fussgaengergruppen an', () => { |
| 70 | const p = createStandardIntersectionProject('Furt', FESTES_DATUM); |
| 71 | const { wurzel, aufraeumen } = zeichne(signalGroupsView, p); |
| 72 | try { |
| 73 | const s = spalte(wurzel, 'Blindenzusatz'); |
| 74 | for (const gruppe of p.signalGroups) { |
| 75 | const zeile = wurzel.querySelector<HTMLTableRowElement>(`#sg-${gruppe.id}`)!; |
| 76 | const zelle = zeile.children[s] as HTMLElement; |
| 77 | const kaestchen = zelle.querySelector<HTMLInputElement>('input[type="checkbox"]'); |
| 78 | if (gruppe.mode === 'fuss') { |
| 79 | expect(kaestchen, `${gruppe.name}: Kaestchen fehlt`).not.toBeNull(); |
| 80 | expect(kaestchen!.checked).toBe(false); |
| 81 | /* |
| 82 | * Der Name sagt, was der Haken bewirkt - nicht nur "Blindenzusatz" - |
| 83 | * UND welche Furt gemeint ist. Vor der Fassung 5.11.0 (Befund 18) |
| 84 | * stand der ganze Erklaersatz im `aria-label`, und damit hiessen alle |
| 85 | * Furtzeilen gleich. Der Satz ist nicht verschwunden, er ist jetzt |
| 86 | * die vorgelesene Beschreibung des Feldes; geprueft wird er dort, wo |
| 87 | * er steht. |
| 88 | */ |
| 89 | expect(kaestchen!.getAttribute('aria-label')).toContain('Blinde und Sehbehinderte'); |
| 90 | expect(kaestchen!.getAttribute('aria-label')).toContain(gruppe.name); |
| 91 | const beschreibung = wurzel.querySelector( |
| 92 | `[id="${kaestchen!.getAttribute('aria-describedby') ?? ''}"]`, |
| 93 | ); |
| 94 | expect(beschreibung?.textContent).toContain('ganze Furt'); |
| 95 | } else { |
| 96 | expect(kaestchen, `${gruppe.name}: Kaestchen darf nicht da sein`).toBeNull(); |
| 97 | expect(zelle.textContent?.trim()).toBe('–'); |
| 98 | } |
| 99 | } |
| 100 | } finally { |
| 101 | aufraeumen(); |
| 102 | } |
| 103 | }); |
| 104 | |
| 105 | it('schreibt den Haken in die Signalgruppe', () => { |
| 106 | const p = createStandardIntersectionProject('Furt', FESTES_DATUM); |
| 107 | const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, p); |
| 108 | try { |
| 109 | const f1 = p.signalGroups.find((g) => g.name === 'F1')!; |
| 110 | const s = spalte(wurzel, 'Blindenzusatz'); |
| 111 | const zeile = wurzel.querySelector<HTMLTableRowElement>(`#sg-${f1.id}`)!; |
| 112 | const kaestchen = (zeile.children[s] as HTMLElement).querySelector<HTMLInputElement>( |
| 113 | 'input', |
| 114 | )!; |
| 115 | kaestchen.checked = true; |
| 116 | kaestchen.dispatchEvent(new Event('change')); |
| 117 | expect(store.getProject().signalGroups.find((g) => g.id === f1.id)!.blindenzusatz).toBe(true); |
| 118 | // Der Plan rechnet sofort mit der ganzen Furt: 12 m / 1,2 m/s = 10 s. |
| 119 | expect(store.getPlan().groups.find((g) => g.groupId === f1.id)!.times.minGreen).toBe(10); |
| 120 | } finally { |
| 121 | aufraeumen(); |
| 122 | } |
| 123 | }); |
| 124 | |
| 125 | it('nennt im Platzhalter der Mindestfreigabezeit den massgebenden Wert, nicht nur den Regelwert', () => { |
| 126 | const roh = createStandardIntersectionProject('Furt', FESTES_DATUM); |
| 127 | const p: Project = { |
| 128 | ...roh, |
| 129 | signalGroups: roh.signalGroups.map((g) => |
| 130 | g.name === 'F1' ? { ...g, blindenzusatz: true } : g, |
| 131 | ), |
| 132 | }; |
| 133 | const { wurzel, aufraeumen } = zeichne(signalGroupsView, p); |
| 134 | try { |
| 135 | const s = spalte(wurzel, 'Mindestfreigabe'); |
| 136 | const f1 = p.signalGroups.find((g) => g.name === 'F1')!; |
| 137 | const f2 = p.signalGroups.find((g) => g.name === 'F2')!; |
| 138 | const feld = (id: string): HTMLInputElement => |
| 139 | ( |
| 140 | wurzel.querySelector<HTMLTableRowElement>(`#sg-${id}`)!.children[s] as HTMLElement |
| 141 | ).querySelector('input')!; |
| 142 | // F1 mit Zusatz: ganze Furt 12 m / 1,2 m/s = 10 s; F2 ohne: halbe Furt 6 m -> 5 s = Regelwert. |
| 143 | expect(feld(f1.id).placeholder).toBe('10'); |
| 144 | expect(feld(f1.id).title).toContain('Regelwert 5 s'); |
| 145 | expect(feld(f1.id).title).toContain('Maßgebend 10 s aus der Furtlänge'); |
| 146 | expect(feld(f1.id).title).toContain('12,00 m / 1,2 m/s = 10,000 s'); |
| 147 | // "Leer lassen" verspricht hier den Furtbedarf, nicht den Regelwert - |
| 148 | // den gaebe es bei leerem Feld nicht. |
| 149 | expect(feld(f1.id).title).toContain('Leer lassen: es gilt der Furtbedarf 10 s'); |
| 150 | expect(feld(f1.id).title).not.toContain('um den Regelwert zu verwenden'); |
| 151 | expect(feld(f2.id).placeholder).toBe('5'); |
| 152 | expect(feld(f2.id).title).not.toContain('Furtlänge'); |
| 153 | expect(feld(f2.id).title).toContain('Leer lassen, um den Regelwert zu verwenden'); |
| 154 | } finally { |
| 155 | aufraeumen(); |
| 156 | } |
| 157 | }); |
| 158 | |
| 159 | it('formuliert den Kurzhinweis aus dem Rechenweg des Plans', () => { |
| 160 | const roh = createStandardIntersectionProject('Furt', FESTES_DATUM); |
| 161 | const p: Project = { |
| 162 | ...roh, |
| 163 | signalGroups: roh.signalGroups.map((g) => |
| 164 | g.name === 'F1' ? { ...g, reducedMobility: true, blindenzusatz: true } : g, |
| 165 | ), |
| 166 | }; |
| 167 | const plan = buildSignalPlan(p); |
| 168 | const f1 = plan.groups.find((g) => g.name === 'F1')!; |
| 169 | // 12 m / 1,0 m/s = 12 s. |
| 170 | expect(f1.times.minGreen).toBe(12); |
| 171 | const hinweis = mindestfreigabeHinweis(5, f1.mindestfreigabezeit); |
| 172 | expect(hinweis).toContain('ganze Furt'); |
| 173 | expect(hinweis).toContain('Blinde und Sehbehinderte'); |
| 174 | expect(hinweis).toContain('12,00 m / 1,0 m/s = 12,000 s, aufgerundet 12 s'); |
| 175 | expect(hinweis).toContain('Leer lassen: es gilt der Furtbedarf 12 s'); |
| 176 | // Ohne Plan-Eintrag bleibt es beim Regelwert - und ohne Behauptung. |
| 177 | expect(mindestfreigabeHinweis(5, undefined)).toBe( |
| 178 | 'Regelwert 5 s. Leer lassen, um den Regelwert zu verwenden; ein Eintrag gilt nur nach oben.', |
| 179 | ); |
| 180 | }); |
| 181 | |
| 182 | it('nennt bei einer hoeheren Vorgabe den Furtbedarf trotzdem - denn der gilt bei leerem Feld', () => { |
| 183 | // Vorgabe 15 s ueber dem Furtbedarf von 12 s: massgebend die Vorgabe, aber |
| 184 | // "Leer lassen" fuehrt auf 12 s, nicht auf den Regelwert 5 s. |
| 185 | const roh = createStandardIntersectionProject('Furt', FESTES_DATUM); |
| 186 | const p: Project = { |
| 187 | ...roh, |
| 188 | signalGroups: roh.signalGroups.map((g) => |
| 189 | g.name === 'F1' |
| 190 | ? { ...g, reducedMobility: true, blindenzusatz: true, minGreenOverride: 15 } |
| 191 | : g, |
| 192 | ), |
| 193 | }; |
| 194 | const f1 = buildSignalPlan(p).groups.find((g) => g.name === 'F1')!; |
| 195 | expect(f1.mindestfreigabezeit.massgebend).toBe('vorgabe'); |
| 196 | const hinweis = mindestfreigabeHinweis(5, f1.mindestfreigabezeit); |
| 197 | expect(hinweis).toContain('Furtbedarf aus der Furtlänge: ganze Furt'); |
| 198 | expect(hinweis).toContain( |
| 199 | '12,00 m / 1,0 m/s = 12,000 s, aufgerundet 12 s; maßgebend die Vorgabe von 15 s.', |
| 200 | ); |
| 201 | expect(hinweis).toContain('Leer lassen: es gilt der Furtbedarf 12 s'); |
| 202 | expect(hinweis).not.toContain('um den Regelwert zu verwenden'); |
| 203 | }); |
| 204 | }); |
| 205 | |
| 206 | describe('Hilfetexte zu den Befunden C1 bis C6', () => { |
| 207 | it('erklaert die Zusatzeinrichtung fuer Blinde und Sehbehinderte', () => { |
| 208 | const h = HILFE.blindenzusatz; |
| 209 | expect(h.titel).toBe('Zusatzeinrichtung für Blinde und Sehbehinderte'); |
| 210 | expect(h.wasIstDas).toContain('DIN 32981'); |
| 211 | expect(h.wasIstDas).toContain('ganze Furt'); |
| 212 | expect(h.wennFalsch).toContain('Sicherheitsrelevant'); |
| 213 | expect(h.siehe).toContain('mindestfreigabezeit'); |
| 214 | }); |
| 215 | |
| 216 | it('nennt bei der Mindestfreigabezeit die Furtregel mit Rechenweg', () => { |
| 217 | const h = HILFE.mindestfreigabezeit; |
| 218 | expect(h.wasIstDas).toContain('halbe Furt'); |
| 219 | expect(h.wasIstDas).toContain('7 m / 1,2 m/s = 5,83 s'); |
| 220 | expect(h.woher).toContain('ist die Furtlänge unbekannt'); |
| 221 | expect(h.siehe).toContain('blindenzusatz'); |
| 222 | }); |
| 223 | |
| 224 | it('nennt bei der Hoechstgeschwindigkeit die 70-km/h-Grenze der VwV-StVO', () => { |
| 225 | const h = HILFE.vZulKnoten; |
| 226 | expect(h.ueblich).toContain( |
| 227 | `Über ${VZUL_OBERGRENZE_LSA} km/h sollen Lichtsignalanlagen nicht eingerichtet werden`, |
| 228 | ); |
| 229 | expect(h.ueblich).toContain('VwV-StVO'); |
| 230 | expect(h.ueblich).toContain(`${RSA_EINSTREIFIG.vZulRegel} km/h`); |
| 231 | }); |
| 232 | |
| 233 | it('erklaert die Engstellenlaenge mit den Praxisgrenzen als Behoerdenpraxis', () => { |
| 234 | const h = HILFE.engstellenlaenge; |
| 235 | expect(h.ueblich).toContain(`${ENGSTELLE_PRAXIS.hinweisAb} m`); |
| 236 | expect(h.ueblich).toContain(`${ENGSTELLE_PRAXIS.warnungAb} m`); |
| 237 | expect(h.ueblich).toContain('Behördenpraxis'); |
| 238 | expect(h.ueblich).toContain('kein Regelwerkswert'); |
| 239 | expect(h.ueblich).toContain(`${RSA_EINSTREIFIG.kurzeEngstelleBis} m`); |
| 240 | expect(h.wennFalsch).toContain('Sicherheitsrelevant'); |
| 241 | }); |
| 242 | |
| 243 | it('fuehrt die RSA-Anordnungskriterien als Hinweise, nicht als Rechenpruefung', () => { |
| 244 | const h = HILFE.rsaAnordnung; |
| 245 | expect(h.wasIstDas).toContain(`${RSA_EINSTREIFIG.verkehrsstaerkeRichtwert} Kfz/h`); |
| 246 | expect(h.wasIstDas).toContain(`${RSA_EINSTREIFIG.kurzeEngstelleBis} m`); |
| 247 | expect(h.wasIstDas).toContain('Typ D'); |
| 248 | expect(h.wennFalsch).toContain('Keine Rechenwirkung'); |
| 249 | expect(h.woher).toContain('abzugleichen'); |
| 250 | }); |
| 251 | }); |
| 252 | |
| 253 | describe('Assistent: Engstellen-Schritt mit Behoerdenpraxis und RSA-Hinweisen (Befunde C3, C5)', () => { |
| 254 | const kasten = (laenge: number): HTMLElement => { |
| 255 | const node = document.createElement('div'); |
| 256 | fuelleEngstellenkasten(node, { |
| 257 | ...ASSISTENT_VORGABE, |
| 258 | form: 'einstreifig', |
| 259 | engstellenlaenge: laenge, |
| 260 | }); |
| 261 | return node; |
| 262 | }; |
| 263 | |
| 264 | it('schweigt bei 120 m zur Behoerdenpraxis', () => { |
| 265 | const k = kasten(120); |
| 266 | expect(k.textContent).not.toContain('Behördenpraxis'); |
| 267 | expect(k.className).toBe('hinweisbalken'); |
| 268 | }); |
| 269 | |
| 270 | it('weist bei 450 m auf den Abstimmungsbedarf hin - als Behoerdenpraxis, kein Regelwerkswert', () => { |
| 271 | const k = kasten(450); |
| 272 | const praxis = k.querySelector('.assistent-praxishinweis')!; |
| 273 | expect(praxis, 'Absatz zur Behoerdenpraxis fehlt').not.toBeNull(); |
| 274 | expect(praxis.textContent).toContain('Hinweis:'); |
| 275 | expect(praxis.textContent).toContain('450 m lang'); |
| 276 | expect(praxis.textContent).toContain('Behördenpraxis'); |
| 277 | expect(praxis.textContent).toContain('kein Regelwerkswert'); |
| 278 | expect(praxis.textContent).toContain(`bis ${ENGSTELLE_PRAXIS.hinweisAb} m`); |
| 279 | expect(praxis.textContent).toContain('Abstimmung mit der Straßenverkehrsbehörde'); |
| 280 | // Die Wartezeit ist bei 450 m noch unauffaellig - der Kasten bleibt ein Hinweis. |
| 281 | expect(k.className).toBe('hinweisbalken'); |
| 282 | expect(k.querySelector('strong')!.textContent).toBe('Das bewirkt diese Länge'); |
| 283 | }); |
| 284 | |
| 285 | it('warnt bei 650 m, obwohl die Wartezeit noch nicht warnt', () => { |
| 286 | const k = kasten(650); |
| 287 | const praxis = k.querySelector('.assistent-praxishinweis')!; |
| 288 | expect(praxis.textContent).toContain('Warnung:'); |
| 289 | expect(praxis.textContent).toContain( |
| 290 | 'erreicht oder überschreitet auch die Ausnahmegrenze von 600 m', |
| 291 | ); |
| 292 | expect(praxis.textContent).toContain('Ablehnung'); |
| 293 | // Die Behoerdenpraxis hebt die Stufe des Kastens an: Farbe UND Ueberschrift. |
| 294 | expect(k.className).toContain('hinweisbalken-warnung'); |
| 295 | expect(k.querySelector('strong')!.textContent).toContain('Behördenpraxis'); |
| 296 | }); |
| 297 | |
| 298 | it('nennt bei kurzer Engstelle die Regelung ohne Ampel, sonst die drei RSA-Kriterien', () => { |
| 299 | const kurz = kasten(RSA_EINSTREIFIG.kurzeEngstelleBis); |
| 300 | expect(kurz.querySelector('.assistent-rsahinweis')!.textContent).toContain('Zeichen 208/308'); |
| 301 | expect(kurz.querySelector('.assistent-rsahinweis')!.textContent).toContain('nicht zwingend'); |
| 302 | |
| 303 | const lang = kasten(RSA_EINSTREIFIG.kurzeEngstelleBis + 10); |
| 304 | const rsa = lang.querySelector('.assistent-rsahinweis')!.textContent ?? ''; |
| 305 | expect(rsa).not.toContain('nicht zwingend'); |
| 306 | expect(rsa).toContain('Verkehrsstärke'); |
| 307 | expect(rsa).toContain(`${RSA_EINSTREIFIG.verkehrsstaerkeRichtwert} Fz/h`); |
| 308 | expect(rsa).toContain('Sichtverhältnisse'); |
| 309 | }); |
| 310 | }); |
| 311 | |
| 312 | describe('Projekt: Hoechstgeschwindigkeit mit 70-km/h-Hinweis (Befund C2)', () => { |
| 313 | it('nennt am Feld die Soll-Vorschrift der VwV-StVO', () => { |
| 314 | const { wurzel, aufraeumen } = zeichne( |
| 315 | projectView, |
| 316 | createStandardIntersectionProject('V', FESTES_DATUM), |
| 317 | ); |
| 318 | try { |
| 319 | const label = [...wurzel.querySelectorAll('label')].find( |
| 320 | (l) => l.textContent?.trim() === 'Zulässige Höchstgeschwindigkeit', |
| 321 | ); |
| 322 | expect(label, 'Feld fehlt').toBeDefined(); |
| 323 | const feld = wurzel.querySelector<HTMLInputElement>(`#${label!.htmlFor}`)!; |
| 324 | const hinweis = wurzel.querySelector(`#${feld.getAttribute('aria-describedby')}`)!; |
| 325 | expect(hinweis.textContent).toContain(`Über ${VZUL_OBERGRENZE_LSA} km/h`); |
| 326 | expect(hinweis.textContent).toContain('VwV-StVO'); |
| 327 | expect(hinweis.textContent).toContain('Prüfbericht'); |
| 328 | } finally { |
| 329 | aufraeumen(); |
| 330 | } |
| 331 | }); |
| 332 | }); |