lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain fundstellenMeldungstexte.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 3 | import { validateProject } from '@/domain/validation/engine'; |
| 4 | import { abbiegerfaktorBefunde } from '@/domain/validation/rules'; |
| 5 | import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants'; |
| 6 | import { |
| 7 | createConflict, |
| 8 | createEmptyProject, |
| 9 | createPhase, |
| 10 | createSignalGroup, |
| 11 | createStandardIntersectionProject, |
| 12 | } from '@/domain/model/factory'; |
| 13 | import type { Anlagenart, Project, SignalGroup } from '@/domain/model/project'; |
| 14 | |
| 15 | /* |
| 16 | * Fundstellen und Meldungstexte. |
| 17 | * |
| 18 | * Befund 3: Die Fundstelle der Plan-Notizen kam ueber einen Praefixtreffer |
| 19 | * (`code.startsWith('umlaufzeit')`). Damit trugen Meldungen die Herkunft der |
| 20 | * Umlaufzeitschranke, deren genannter Zahlenwert gerade NICHT diese Schranke |
| 21 | * ist: die Mindestumlaufzeit (Summe aus Mindestfreigabe- und Zwischenzeiten), |
| 22 | * die Uebersaettigung (Y gegen die Grenze des Verfahrens) und die unbrauchbare |
| 23 | * Eingabe. An einer Arbeitsstelle stand an der Uebersaettigung - einem Fehler |
| 24 | * mit Exportsperre - "Schwelle dieses Programms ...; Zahlenwert abzugleichen": |
| 25 | * Der Pruefer las, das Kriterium sei eine mit der Behoerde abzustimmende |
| 26 | * Eigenschwelle. |
| 27 | * |
| 28 | * Befund B-7 und Befund 4: Der Hinweis zum Abbiegerfaktor stand bei genau |
| 29 | * einem Partner im Plural und behauptete "Kraftfahrzeugstroeme anderer |
| 30 | * Zufahrten", ohne zu sagen, dass ueber den Gegenverkehr damit nichts |
| 31 | * festgestellt ist. |
| 32 | */ |
| 33 | |
| 34 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 35 | |
| 36 | /** Der Musterknotenpunkt mit Verkehrsstaerken weit ueber der Kapazitaet. */ |
| 37 | function uebersaettigt(anlagenart: Anlagenart): Project { |
| 38 | const roh = createStandardIntersectionProject('Uebersaettigt', DATUM); |
| 39 | return { |
| 40 | ...roh, |
| 41 | anlagenart, |
| 42 | demands: roh.signalGroups |
| 43 | .filter((g) => g.mode === 'kfz') |
| 44 | .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })), |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | /** Feste Umlaufzeit ueber der Schranke der Anlagenart. */ |
| 49 | function festeUmlaufzeit(anlagenart: Anlagenart, wert: number | null): Project { |
| 50 | const roh = createStandardIntersectionProject('Umlaufzeit', DATUM); |
| 51 | return { |
| 52 | ...roh, |
| 53 | anlagenart, |
| 54 | program: { ...roh.program, method: 'manuell', manualCycleTime: wert }, |
| 55 | }; |
| 56 | } |
| 57 | |
| 58 | function fundstelle(project: Project, regel: string): string { |
| 59 | const plan = buildSignalPlan(project); |
| 60 | const bericht = validateProject(project, plan, DATUM); |
| 61 | const treffer = bericht.findings.find((f) => f.rule === regel); |
| 62 | expect(treffer, `${regel} muss im Bericht stehen`).toBeDefined(); |
| 63 | return treffer!.reference; |
| 64 | } |
| 65 | |
| 66 | describe('Befund 3 - jede Meldung nennt die Herkunft IHRER Zahl', () => { |
| 67 | it('gibt der Uebersaettigung das Kriterium des Verfahrens, nicht die Umlaufzeitschranke', () => { |
| 68 | // Der nachgemessene Fall der Pruefung: einstreifige Fuehrung, 1800 Kfz/h. |
| 69 | const einstreifig = fundstelle(uebersaettigt('einstreifig'), 'signalplan.uebersaettigt'); |
| 70 | expect(einstreifig).not.toContain('Schwelle dieses Programms'); |
| 71 | expect(einstreifig).not.toContain('Zahlenwert abzugleichen'); |
| 72 | // Der Musterknotenpunkt rechnet nach Webster; dort ist Y < 1 die |
| 73 | // Voraussetzung der Formel und nicht eine Schranke der Anlagenart. |
| 74 | expect(einstreifig).toContain('Webster 1958'); |
| 75 | expect(einstreifig).toContain('Y < 1 als Voraussetzung der Formel'); |
| 76 | |
| 77 | const knoten = fundstelle(uebersaettigt('knotenpunkt'), 'signalplan.uebersaettigt'); |
| 78 | expect(knoten).toBe(einstreifig); |
| 79 | // Vor der Fassung 5.4.0 war es an beiden Anlagenarten die Umlaufzeitzeile. |
| 80 | expect(knoten).not.toBe('RiLSA 2015 - Umlaufzeit'); |
| 81 | }); |
| 82 | |
| 83 | it('nennt bei der Mindestumlaufzeit die Groessen, aus denen sie folgt', () => { |
| 84 | const erwartet = |
| 85 | 'RiLSA 2015 - Mindestfreigabezeiten und Zwischenzeiten (daraus folgt die Mindestumlaufzeit)'; |
| 86 | for (const art of ['knotenpunkt', 'einstreifig'] as const) { |
| 87 | // Feste Umlaufzeit gewaehlt, aber keine eingetragen: Angesetzt wird die |
| 88 | // Mindestumlaufzeit, und genau die nennt die Meldung. |
| 89 | expect(fundstelle(festeUmlaufzeit(art, null), 'signalplan.umlaufzeit-fehlt'), art).toBe( |
| 90 | erwartet, |
| 91 | ); |
| 92 | } |
| 93 | }); |
| 94 | |
| 95 | it('behaelt die Herkunft der Schranke dort, wo die Zahl auch die Schranke ist', () => { |
| 96 | // Waechter fuer Befund B9: Die Meldung "Umlaufzeit ueberschreitet den |
| 97 | // Hoechstwert" nennt die Schranke der Anlagenart - sie MUSS deren Herkunft |
| 98 | // tragen. |
| 99 | expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max).toBe(120); |
| 100 | expect(fundstelle(festeUmlaufzeit('knotenpunkt', 200), 'signalplan.umlaufzeit-zu-lang')).toBe( |
| 101 | 'RiLSA 2015 - Umlaufzeit', |
| 102 | ); |
| 103 | |
| 104 | expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.max).toBe(300); |
| 105 | const engstelle = fundstelle( |
| 106 | festeUmlaufzeit('einstreifig', 400), |
| 107 | 'signalplan.umlaufzeit-zu-lang', |
| 108 | ); |
| 109 | expect(engstelle).toContain('Schwelle dieses Programms'); |
| 110 | expect(engstelle).toContain('kein RSA-Wert'); |
| 111 | }); |
| 112 | |
| 113 | it('nennt beim Regelbereich weiterhin dessen eigene Herkunft', () => { |
| 114 | // Waechter fuer Befund B8/B9: Der empfohlene Bereich hat eine eigene |
| 115 | // Herkunft und darf sie ueber die Umstellung nicht verlieren. |
| 116 | const knoten = fundstelle( |
| 117 | festeUmlaufzeit('knotenpunkt', 110), |
| 118 | 'signalplan.umlaufzeit-ausserhalb-regelbereich', |
| 119 | ); |
| 120 | expect(knoten).toBe(ANLAGENART_GRENZEN.knotenpunkt.herkunft.regelbereich.text); |
| 121 | }); |
| 122 | |
| 123 | /* |
| 124 | * UMGESCHRIEBEN - der Fall lief nie durch seinen Rumpf. |
| 125 | * |
| 126 | * Er baute den Musterknotenpunkt mit `manualCycleTime: Number.NaN`, lief |
| 127 | * ueber `bericht.findings` und uebersprang mit |
| 128 | * `if (finding.rule !== 'signalplan.umlaufzeit-ungueltig') continue;` jeden |
| 129 | * Befund. Diese Regel entsteht dort aber gar nicht: resolveCycleTime |
| 130 | * (plan/signalPlan.ts) ersetzt jede unbrauchbare Handvorgabe durch die |
| 131 | * Mindestumlaufzeit und meldet 'umlaufzeit-fehlt'. GEMESSEN: Der Bericht |
| 132 | * jenes Projekts fuehrt 12 Befunde, keinen mit dieser Regel; ein |
| 133 | * `expect.fail()` als erste Zeile des Rumpfes liess alle neun Faelle der |
| 134 | * Datei gruen bleiben. Der Fall war seit jeher gruen und bewachte nichts. |
| 135 | * |
| 136 | * 'umlaufzeit-ungueltig' entsteht, wo die ANGESETZTE Umlaufzeit wirklich |
| 137 | * kein brauchbarer Wert ist: distributeGreenTimes (rilsa/greenSplit.ts) |
| 138 | * meldet sie bei einer Umlaufzeit <= 0 s. Dahin fuehrt eine Phase ohne |
| 139 | * Signalgruppen - ihre Mindestfreigabezeit ist 0 s, es gibt keine |
| 140 | * Uebergaenge, und damit ist auch die Mindestumlaufzeit 0 s, die |
| 141 | * resolveCycleTime fuer die unbrauchbare Handvorgabe ansetzt. |
| 142 | * |
| 143 | * Die Fundstelle des Programms ist in Ordnung: referenceFor |
| 144 | * (validation/engine.ts) faengt den Kode vor jedem Rueckfall ab und |
| 145 | * antwortet mit "Kein Regelwerksbezug ...". Der Fall misst das jetzt. |
| 146 | */ |
| 147 | function umlaufzeitNull(): Project { |
| 148 | const basis = createEmptyProject('Ungueltig', DATUM, 'knotenpunkt'); |
| 149 | const leer = createPhase('Phase 1', []); |
| 150 | return { |
| 151 | ...basis, |
| 152 | phases: [leer], |
| 153 | program: { |
| 154 | ...basis.program, |
| 155 | phaseOrder: [leer.id], |
| 156 | method: 'manuell', |
| 157 | manualCycleTime: Number.NaN, |
| 158 | }, |
| 159 | }; |
| 160 | } |
| 161 | |
| 162 | it('erfindet fuer eine unbrauchbare Umlaufzeit keine Fundstelle', () => { |
| 163 | // Kein Regelwerk sagt etwas ueber einen ungueltigen Zahlenwert. Eine |
| 164 | // erfundene Fundstelle waere hier schlimmer als keine. |
| 165 | const kaputt = umlaufzeitNull(); |
| 166 | // Beleg, dass der Kode ueberhaupt entstehen kann - sonst prueft der Fall |
| 167 | // wieder nichts. |
| 168 | expect(buildSignalPlan(kaputt).cycleTime, 'angesetzte Umlaufzeit').toBe(0); |
| 169 | const gefunden = fundstelle(kaputt, 'signalplan.umlaufzeit-ungueltig'); |
| 170 | /* |
| 171 | * Der Wortlaut steht exakt. Erlaeuterung dazu, kein zweiter Pruefschritt: |
| 172 | * Der Rueckfall von referenceFor (validation/engine.ts) lautet |
| 173 | * "RiLSA 2015" - genau der waere hier die erfundene Fundstelle, und gegen |
| 174 | * ihn haelt bereits dieses `toBe`. |
| 175 | * |
| 176 | * ENTFERNT (Fassung 5.16.0): Darunter stand eine Schleife ueber sieben |
| 177 | * Regelwerksnamen mit `not.toContain`. Sie lief zwar - anders als die |
| 178 | * beiden Schleifen, die mit dem Umschreiben entfielen -, konnte aber nie |
| 179 | * die Ursache eines roten Laufs sein: Haelt das `toBe`, ist `gefunden` |
| 180 | * genau diese eine Zeichenkette und enthaelt keinen der sieben Namen; haelt |
| 181 | * es nicht, bricht der Fall vorher ab. GEMESSEN an der Mutation |
| 182 | * "referenceFor gibt fuer 'umlaufzeit-ungueltig' den Rueckfall 'RiLSA |
| 183 | * 2015 - Umlaufzeit'": Vitest meldete ausschliesslich das `toBe`, die |
| 184 | * Schleife kam nicht zum Zug. Sie hatte damit keine Unterscheidungskraft, |
| 185 | * las sich neben ihrem Kommentar aber wie eine eigenstaendige Pruefung - |
| 186 | * dieselbe Gattung Irrefuehrung, gegen die dieser Fall geschrieben ist. Das |
| 187 | * `toBe` zu einem `toContain` abzuschwaechen, nur damit die Schleife etwas |
| 188 | * zu tun bekommt, waere ein Rueckschritt gewesen. |
| 189 | */ |
| 190 | expect(gefunden).toBe('Kein Regelwerksbezug - die Umlaufzeit ist kein gültiger Zahlenwert'); |
| 191 | }); |
| 192 | |
| 193 | it('setzt fuer eine unbrauchbare Handvorgabe die Mindestumlaufzeit an, nicht den Ungueltig-Kode', () => { |
| 194 | /* |
| 195 | * Wache gegen die Rueckkehr der Blindstelle: Solange dieser Fall gilt, |
| 196 | * fuehrt `manualCycleTime: Number.NaN` am Musterknotenpunkt zu |
| 197 | * 'umlaufzeit-fehlt' - und ein Fall, der dort 'umlaufzeit-ungueltig' |
| 198 | * sucht, laeuft leer. Er haelt zugleich fest, was das Programm mit einer |
| 199 | * unbrauchbaren Zahl tut: Es rechnet nicht mit ihr weiter. |
| 200 | */ |
| 201 | const roh = createStandardIntersectionProject('Ungueltig', DATUM); |
| 202 | const kaputt: Project = { |
| 203 | ...roh, |
| 204 | program: { ...roh.program, method: 'manuell', manualCycleTime: Number.NaN }, |
| 205 | }; |
| 206 | const bericht = validateProject(kaputt, buildSignalPlan(kaputt), DATUM); |
| 207 | const regeln = bericht.findings.map((f) => f.rule); |
| 208 | expect(regeln).toContain('signalplan.umlaufzeit-fehlt'); |
| 209 | expect(regeln).not.toContain('signalplan.umlaufzeit-ungueltig'); |
| 210 | }); |
| 211 | |
| 212 | it('laesst keine Fundstelle leer', () => { |
| 213 | for (const art of ['knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig'] as const) { |
| 214 | const project = uebersaettigt(art); |
| 215 | const bericht = validateProject(project, buildSignalPlan(project), DATUM); |
| 216 | for (const finding of bericht.findings) { |
| 217 | expect(finding.reference, finding.rule).not.toBe(''); |
| 218 | } |
| 219 | } |
| 220 | }); |
| 221 | }); |
| 222 | |
| 223 | /** Linksabbieger plus `anzahl` Kfz-Gruppen anderer Arme in derselben Phase. */ |
| 224 | function linksabbiegerMitPartnern(anzahl: number): Project { |
| 225 | const basis = createEmptyProject('Abbieger', DATUM, 'knotenpunkt'); |
| 226 | const links: SignalGroup = { |
| 227 | ...createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }), |
| 228 | movement: 'links', |
| 229 | armId: 'nord', |
| 230 | }; |
| 231 | const partner = Array.from({ length: anzahl }, (_, i) => ({ |
| 232 | ...createSignalGroup({ name: `P${i + 1}`, mode: 'kfz', index: i + 1 }), |
| 233 | movement: 'rechts' as const, |
| 234 | armId: `arm-${i}`, |
| 235 | })); |
| 236 | const phase = createPhase('Phase 1', [links.id, ...partner.map((p) => p.id)]); |
| 237 | return { |
| 238 | ...basis, |
| 239 | signalGroups: [links, ...partner], |
| 240 | conflicts: partner.map((p) => createConflict(links.id, p.id)), |
| 241 | phases: [phase], |
| 242 | program: { ...basis.program, phaseOrder: [phase.id] }, |
| 243 | demands: [{ signalGroupId: links.id, volume: 400, heavyVehicleShare: 0 }], |
| 244 | }; |
| 245 | } |
| 246 | |
| 247 | describe('Befund B-7 und 4 - Abbiegerfaktor: Numerus und Trefferschaerfe', () => { |
| 248 | it('setzt den Singular, wenn es genau einen Partner gibt', () => { |
| 249 | const befunde = abbiegerfaktorBefunde(linksabbiegerMitPartnern(1)); |
| 250 | expect(befunde).toHaveLength(1); |
| 251 | expect(befunde[0]!.message).toContain('ein Kraftfahrzeugstrom einer anderen Zufahrt'); |
| 252 | expect(befunde[0]!.message).not.toContain('Kraftfahrzeugströme anderer Zufahrten'); |
| 253 | }); |
| 254 | |
| 255 | it('setzt den Plural erst ab zwei Partnern', () => { |
| 256 | const befunde = abbiegerfaktorBefunde(linksabbiegerMitPartnern(2)); |
| 257 | expect(befunde).toHaveLength(1); |
| 258 | expect(befunde[0]!.message).toContain('Kraftfahrzeugströme anderer Zufahrten'); |
| 259 | }); |
| 260 | |
| 261 | it('behauptet keinen Gegenverkehr, den die Regel nicht geprueft hat', () => { |
| 262 | // Ein Rechtseinbieger aus einem anderen Arm ist fuer einen Linksabbieger |
| 263 | // kein Gegenverkehr. Die Regel meldet ihn bewusst weiterhin - der Text |
| 264 | // sagt jetzt aber, was er nicht weiss. |
| 265 | const befunde = abbiegerfaktorBefunde(linksabbiegerMitPartnern(1)); |
| 266 | expect(befunde[0]!.message).toContain('ob darunter der Gegenverkehr ist, zeigt der Lageplan'); |
| 267 | }); |
| 268 | }); |