lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { HILFE, glossarEintraege, glossarSuche, istHilfeSchluessel } from '@/ui/help/texte'; |
| 3 | import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; |
| 4 | import { naechsterSchritt } from '@/ui/naechsterSchritt'; |
| 5 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 6 | import { validateProject } from '@/domain/validation'; |
| 7 | import { isGreenAt } from '@/domain/plan/aspects'; |
| 8 | import { createEmptyProject } from '@/domain/model/factory'; |
| 9 | import type { Project } from '@/domain/model/project'; |
| 10 | import type { AppState } from '@/app/store'; |
| 11 | |
| 12 | const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); |
| 13 | |
| 14 | function zustand(project: Project): AppState { |
| 15 | const plan = buildSignalPlan(project); |
| 16 | return { |
| 17 | project, |
| 18 | plan, |
| 19 | report: validateProject(project, plan, FIXED_DATE), |
| 20 | dirty: false, |
| 21 | filePath: null, |
| 22 | canUndo: false, |
| 23 | canRedo: false, |
| 24 | undoLabel: null, |
| 25 | redoLabel: null, |
| 26 | }; |
| 27 | } |
| 28 | |
| 29 | describe('Fachliche Hilfe', () => { |
| 30 | it('erklaert jeden Begriff vollstaendig', () => { |
| 31 | for (const [schluessel, eintrag] of Object.entries(HILFE)) { |
| 32 | expect(eintrag.titel, schluessel).not.toBe(''); |
| 33 | expect(eintrag.kurz, schluessel).not.toBe(''); |
| 34 | // Der Kurztext soll ein Satz sein, kein Absatz. |
| 35 | expect(eintrag.kurz.length, schluessel).toBeLessThan(140); |
| 36 | expect(eintrag.wasIstDas.length, schluessel).toBeGreaterThan(60); |
| 37 | } |
| 38 | }); |
| 39 | |
| 40 | it('loest alle Querverweise auf', () => { |
| 41 | for (const [schluessel, eintrag] of Object.entries(HILFE)) { |
| 42 | for (const verweis of eintrag.siehe ?? []) { |
| 43 | expect(istHilfeSchluessel(verweis), `${schluessel} verweist auf ${verweis}`).toBe(true); |
| 44 | } |
| 45 | } |
| 46 | }); |
| 47 | |
| 48 | it('verweist nicht auf sich selbst', () => { |
| 49 | for (const [schluessel, eintrag] of Object.entries(HILFE)) { |
| 50 | expect(eintrag.siehe ?? [], schluessel).not.toContain(schluessel); |
| 51 | } |
| 52 | }); |
| 53 | |
| 54 | it('sagt bei sicherheitsrelevanten Groessen, was eine falsche Angabe bewirkt', () => { |
| 55 | const kritisch = [ |
| 56 | 'zwischenzeit', |
| 57 | 'raeumweg', |
| 58 | 'einfahrweg', |
| 59 | 'verkehrsart', |
| 60 | 'fahrbeziehung', |
| 61 | 'gelbzeit', |
| 62 | 'konfliktbeziehung', |
| 63 | 'phase', |
| 64 | ] as const; |
| 65 | for (const schluessel of kritisch) { |
| 66 | const eintrag = HILFE[schluessel]; |
| 67 | expect(eintrag.wennFalsch, schluessel).toBeDefined(); |
| 68 | expect(eintrag.wennFalsch.length, schluessel).toBeGreaterThan(40); |
| 69 | } |
| 70 | }); |
| 71 | |
| 72 | it('erklaert bei Eingabefeldern, woher der Wert kommt', () => { |
| 73 | // 'bewertungsverfahren' und 'instationaritaetsfaktor' kamen mit |
| 74 | // Fassung 5.4.0 (Befund B2) hinzu: Beide sind Einstellungen, die |
| 75 | // in Kapazitaet und Wartezeit eingehen - wer sie setzt, muss wissen, woher |
| 76 | // der Wert kommt (Zaehldaten bzw. FGSV-Dialog zum HBS 2015). |
| 77 | const eingabefelder = [ |
| 78 | 'raeumweg', |
| 79 | 'einfahrweg', |
| 80 | 'verkehrsstaerke', |
| 81 | 'zufahrt', |
| 82 | 'fahrstreifen', |
| 83 | 'bewertungsverfahren', |
| 84 | 'instationaritaetsfaktor', |
| 85 | ] as const; |
| 86 | for (const schluessel of eingabefelder) { |
| 87 | // toBeDefined() allein liesse eine leere Zeichenkette durch - der Test |
| 88 | // darueber prueft an derselben Struktur bereits eine Mindestlaenge. |
| 89 | const woher = HILFE[schluessel].woher; |
| 90 | expect(woher, schluessel).toBeDefined(); |
| 91 | expect(woher.trim().length, schluessel).toBeGreaterThan(40); |
| 92 | } |
| 93 | }); |
| 94 | |
| 95 | it('fuehrt die Fachbegriffe im Glossar, Bedienfelder nicht', () => { |
| 96 | const schluessel = glossarEintraege().map((e) => e.schluessel); |
| 97 | expect(schluessel).toContain('zwischenzeit'); |
| 98 | expect(schluessel).toContain('zufahrt'); |
| 99 | expect(schluessel).not.toContain('manuelleZwischenzeit'); |
| 100 | }); |
| 101 | |
| 102 | it('findet Begriffe ueber die Suche', () => { |
| 103 | expect(glossarSuche('Räumweg').map((e) => e.schluessel)).toContain('raeumweg'); |
| 104 | expect(glossarSuche('Haltlinie').length).toBeGreaterThan(0); |
| 105 | expect(glossarSuche('xyzabc')).toHaveLength(0); |
| 106 | expect(glossarSuche('').length).toBe(glossarEintraege().length); |
| 107 | }); |
| 108 | }); |
| 109 | |
| 110 | /** |
| 111 | * Befund D2: Das Programm rechnet jede Furt als durchgehende Furt; eine |
| 112 | * progressive (zweistufige) Signalisierung der Furtabschnitte bildet es nicht |
| 113 | * ab. Das ist die konservative Seite - und trotzdem eine Grenze des |
| 114 | * Leistungsumfangs, die in der Hilfe stehen muss. Gegen den Altstand schlaegt |
| 115 | * jeder Fall fehl: Dort gab es das Wort "Mittelinsel" in der Hilfe nicht. |
| 116 | */ |
| 117 | describe('Furt mit Mittelinsel (Befund D2)', () => { |
| 118 | it('erklaert, was gerechnet wird und was nicht', () => { |
| 119 | const eintrag = HILFE.mittelinsel; |
| 120 | expect(eintrag.wasIstDas).toContain('durchgehende Furt'); |
| 121 | expect(eintrag.wasIstDas).toContain('von Bordkante zu Bordkante'); |
| 122 | // Beides gehoert zusammen: die konservative Richtung UND die Luecke. |
| 123 | expect(eintrag.wasIstDas).toContain('sicheren Seite'); |
| 124 | expect(eintrag.wasIstDas).toContain('Nicht abgebildet ist die progressive Signalisierung'); |
| 125 | expect(eintrag.wasIstDas).toContain( |
| 126 | 'zeitliche Abstimmung zwischen ihnen ist außerhalb dieses Programms zu planen', |
| 127 | ); |
| 128 | // Seit 5.38.0 prueft der Bericht EINEN Punkt davon; der Eintrag darf das |
| 129 | // nicht laenger pauschal verneinen. |
| 130 | expect(eintrag.wasIstDas).toContain('warnt der Prüfbericht'); |
| 131 | }); |
| 132 | |
| 133 | it('sagt, was eine falsche Annahme kostet', () => { |
| 134 | // Der teure Irrtum ist nicht "zu lange Freigabe", sondern der halbierte |
| 135 | // Raeumweg: Er verkuerzt die Zwischenzeit fuer einen Querenden, der nach |
| 136 | // diesem Plan in einem Zug durchgehen darf. |
| 137 | expect(HILFE.mittelinsel.wennFalsch).toBeDefined(); |
| 138 | expect(HILFE.mittelinsel.wennFalsch).toContain('Sicherheitsrelevant'); |
| 139 | expect(HILFE.mittelinsel.wennFalsch).toContain('Zwischenzeit wird zu kurz'); |
| 140 | }); |
| 141 | |
| 142 | it('steht im Glossar und ist ueber die Suche zu finden', () => { |
| 143 | expect(glossarEintraege().map((e) => e.schluessel)).toContain('mittelinsel'); |
| 144 | expect(glossarSuche('Mittelinsel').map((e) => e.schluessel)).toContain('mittelinsel'); |
| 145 | }); |
| 146 | |
| 147 | it('haengt an den Eintraegen, an denen die Furtmasse entstehen', () => { |
| 148 | // Wer den Raeumweg misst oder die Mindestfreigabezeit liest, muss auf den |
| 149 | // Eintrag stossen - sonst findet ihn nur, wer ihn schon kennt. |
| 150 | for (const schluessel of ['raeumweg', 'mindestfreigabezeit', 'blindenzusatz'] as const) { |
| 151 | expect(HILFE[schluessel].siehe ?? [], schluessel).toContain('mittelinsel'); |
| 152 | } |
| 153 | expect(HILFE.raeumweg.woher).toContain( |
| 154 | 'Auch bei einer Mittelinsel wird über die ganze Breite gemessen', |
| 155 | ); |
| 156 | expect(HILFE.mindestfreigabezeit.wasIstDas).toContain('mit ihrer ganzen Länge anzusetzen'); |
| 157 | }); |
| 158 | }); |
| 159 | |
| 160 | describe('Gefuehrter Einstieg', () => { |
| 161 | it('erzeugt aus den Vorgaben einen schaltbaren Plan ohne Fehler', () => { |
| 162 | const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'Assistent', FIXED_DATE); |
| 163 | const plan = buildSignalPlan(projekt); |
| 164 | const bericht = validateProject(projekt, plan, FIXED_DATE); |
| 165 | |
| 166 | expect(plan.feasible).toBe(true); |
| 167 | expect(plan.cycleTime).toBeGreaterThan(0); |
| 168 | const fachfehler = bericht.findings.filter( |
| 169 | (f) => f.severity === 'fehler' && f.category !== 'projektdaten', |
| 170 | ); |
| 171 | expect(fachfehler, fachfehler.map((f) => `${f.rule}: ${f.message}`).join('\n')).toHaveLength(0); |
| 172 | }); |
| 173 | |
| 174 | it('gibt feindliche Signalgruppen nie gleichzeitig frei', () => { |
| 175 | const projekt = projektAusAngaben( |
| 176 | { ...ASSISTENT_VORGABE, rad: true, linksabbieger: true }, |
| 177 | 'Assistent', |
| 178 | FIXED_DATE, |
| 179 | ); |
| 180 | const plan = buildSignalPlan(projekt); |
| 181 | |
| 182 | for (const konflikt of projekt.conflicts) { |
| 183 | const a = plan.groups.find((g) => g.groupId === konflikt.fromId)!; |
| 184 | const b = plan.groups.find((g) => g.groupId === konflikt.toId)!; |
| 185 | for (let t = 0; t < plan.cycleTime; t += 0.5) { |
| 186 | expect( |
| 187 | isGreenAt(a, t, plan.cycleTime) && isGreenAt(b, t, plan.cycleTime), |
| 188 | `${a.name} und ${b.name} bei t=${t}s`, |
| 189 | ).toBe(false); |
| 190 | } |
| 191 | } |
| 192 | }); |
| 193 | |
| 194 | it('legt fuer die Einmuendung drei Zufahrten an', () => { |
| 195 | const projekt = projektAusAngaben( |
| 196 | { ...ASSISTENT_VORGABE, form: 'einmuendung' }, |
| 197 | 'Einmuendung', |
| 198 | FIXED_DATE, |
| 199 | ); |
| 200 | expect(projekt.intersection.arms).toHaveLength(3); |
| 201 | expect(buildSignalPlan(projekt).feasible).toBe(true); |
| 202 | }); |
| 203 | |
| 204 | it('legt Fussgaengerfurten nur auf Wunsch an', () => { |
| 205 | const mit = projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE); |
| 206 | const ohne = projektAusAngaben({ ...ASSISTENT_VORGABE, fussgaenger: false }, 'x', FIXED_DATE); |
| 207 | expect(mit.signalGroups.some((g) => g.mode === 'fuss')).toBe(true); |
| 208 | expect(ohne.signalGroups.some((g) => g.mode === 'fuss')).toBe(false); |
| 209 | }); |
| 210 | |
| 211 | it('uebernimmt die Verkehrsstaerke nur, wenn sie angegeben ist', () => { |
| 212 | const ohne = projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE); |
| 213 | const mit = projektAusAngaben({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }, 'x', FIXED_DATE); |
| 214 | expect(ohne.demands).toHaveLength(0); |
| 215 | expect(mit.demands.length).toBeGreaterThan(0); |
| 216 | expect(mit.demands.every((d) => d.volume === 400)).toBe(true); |
| 217 | }); |
| 218 | |
| 219 | it('vergibt eindeutige Bezeichnungen', () => { |
| 220 | const projekt = projektAusAngaben( |
| 221 | { ...ASSISTENT_VORGABE, rad: true, linksabbieger: true }, |
| 222 | 'x', |
| 223 | FIXED_DATE, |
| 224 | ); |
| 225 | const namen = projekt.signalGroups.map((g) => g.name); |
| 226 | expect(new Set(namen).size).toBe(namen.length); |
| 227 | }); |
| 228 | }); |
| 229 | |
| 230 | describe('Naechster Arbeitsschritt', () => { |
| 231 | it('fuehrt vom leeren Projekt bis zur Ausgabe', () => { |
| 232 | const leer = naechsterSchritt(zustand(createEmptyProject('Leer', FIXED_DATE))); |
| 233 | expect(leer.ansicht).toBe('signalgruppen'); |
| 234 | expect(leer.text).toContain('Signalgruppen'); |
| 235 | |
| 236 | const fertig = zustand({ |
| 237 | ...projektAusAngaben({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }, 'x', FIXED_DATE), |
| 238 | meta: { |
| 239 | ...createEmptyProject('x', FIXED_DATE).meta, |
| 240 | name: 'Vollstaendig', |
| 241 | projectNumber: 'VP-1', |
| 242 | client: 'Stadt', |
| 243 | planner: 'Willerding', |
| 244 | location: 'Hauptstrasse', |
| 245 | }, |
| 246 | }); |
| 247 | const schritt = naechsterSchritt(fertig); |
| 248 | // Kein Fehler mehr offen - es geht nur noch um Warnungen oder die Ausgabe. |
| 249 | expect(schritt.dringlichkeit).not.toBe('fehler'); |
| 250 | }); |
| 251 | |
| 252 | it('nennt fehlende Raeumwege vor allem anderen', () => { |
| 253 | const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE); |
| 254 | const ohneWege: Project = { |
| 255 | ...projekt, |
| 256 | conflicts: projekt.conflicts.map((c) => ({ ...c, clearingDistance: 0 })), |
| 257 | }; |
| 258 | const schritt = naechsterSchritt(zustand(ohneWege)); |
| 259 | expect(schritt.ansicht).toBe('zwischenzeiten'); |
| 260 | expect(schritt.text).toContain('Räumwege'); |
| 261 | expect(schritt.dringlichkeit).toBe('fehler'); |
| 262 | }); |
| 263 | |
| 264 | it('gibt zu jedem Schritt eine fachliche Begruendung', () => { |
| 265 | for (const projekt of [ |
| 266 | createEmptyProject('Leer', FIXED_DATE), |
| 267 | projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE), |
| 268 | ]) { |
| 269 | const schritt = naechsterSchritt(zustand(projekt)); |
| 270 | expect(schritt.grund.length).toBeGreaterThan(30); |
| 271 | expect(schritt.ziel).not.toBe(''); |
| 272 | } |
| 273 | }); |
| 274 | }); |