lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain notizUeberschriften.test.ts
| 1 | import { readFileSync } from 'node:fs'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import { validateProject } from '@/domain/validation/engine'; |
| 4 | import { buildSignalPlan, type PlanNote, type SignalPlan } from '@/domain/plan/signalPlan'; |
| 5 | import { |
| 6 | createConflict, |
| 7 | createPhase, |
| 8 | createSignalGroup, |
| 9 | createStandardIntersectionProject, |
| 10 | } from '@/domain/model/factory'; |
| 11 | import type { Project } from '@/domain/model/project'; |
| 12 | |
| 13 | /* |
| 14 | * Fassung 5.9.0 - Ueberschrift, naechster Schritt, Sachgebiet |
| 15 | * und Fundstelle der Plan-Notizen im Pruefbericht (Befunde 13 und 38). |
| 16 | * |
| 17 | * Der Bericht loest den Notizcode ueber vier Tabellen auf. Wo ein Code in |
| 18 | * keiner steht, druckt er den Sammeltitel "Hinweis aus dem Planaufbau", den |
| 19 | * Ersatzrat "Prüfen Sie die zugrunde liegenden Eingaben.", das Sammelgebiet |
| 20 | * "Signalzeitenplan" und die Sammelfundstelle "RiLSA 2015" - eine Warnung, die |
| 21 | * sich selbst Hinweis nennt, in der falschen Karte, mit einem Regelwerksbeleg, |
| 22 | * den es fuer den genannten Wert nicht gibt. |
| 23 | */ |
| 24 | |
| 25 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 26 | const SAMMELTITEL = 'Hinweis aus dem Planaufbau'; |
| 27 | const ERSATZRAT = 'Prüfen Sie die zugrunde liegenden Eingaben.'; |
| 28 | |
| 29 | function bericht(project: Project) { |
| 30 | return validateProject(project, buildSignalPlan(project), DATUM); |
| 31 | } |
| 32 | |
| 33 | describe('Befund 13 - Die Handkuerzung der Zwischenzeit traegt eine eigene Ueberschrift', () => { |
| 34 | /** K1 nach K3 mit einer von Hand auf 1 s gekuerzten Zwischenzeit. */ |
| 35 | function handgekuerzt(): Project { |
| 36 | const roh = createStandardIntersectionProject('Handkürzung', DATUM); |
| 37 | const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; |
| 38 | const k3 = roh.signalGroups.find((g) => g.name === 'K3')!; |
| 39 | return { |
| 40 | ...roh, |
| 41 | conflicts: roh.conflicts.map((c) => |
| 42 | c.fromId === k1.id && c.toId === k3.id ? { ...c, manualIntergreen: 1 } : c, |
| 43 | ), |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | it('nennt die von Hand unterschrittene Zwischenzeit beim Namen', () => { |
| 48 | // Die gefaehrliche Richtung: eingetragen ist weniger, als gerechnet wird. |
| 49 | // Wer die Zeile ueberfliegt, liest sonst "Hinweis aus dem Planaufbau" und |
| 50 | // als naechsten Schritt "Prüfen Sie die zugrunde liegenden Eingaben." |
| 51 | const befund = bericht(handgekuerzt()).findings.find( |
| 52 | (f) => f.rule === 'signalplan.zwischenzeit-vorgabe-unter-rechenwert', |
| 53 | ); |
| 54 | expect(befund?.severity).toBe('warnung'); |
| 55 | expect(befund?.category).toBe('zwischenzeiten'); |
| 56 | expect(befund?.title).not.toBe(SAMMELTITEL); |
| 57 | expect(befund?.suggestion).not.toBe(ERSATZRAT); |
| 58 | }); |
| 59 | }); |
| 60 | |
| 61 | describe('Befund 38 - Meldungen der Zwischenzeitrechnung ohne Praefixtreffer', () => { |
| 62 | /** |
| 63 | * Ein Knotenpunkt mit einer OePNV-Gruppe ohne zulaessige |
| 64 | * Hoechstgeschwindigkeit. Erreichbar ueber den Altdatei-Pfad, der die |
| 65 | * Geschwindigkeit ungeprueft uebernimmt. |
| 66 | */ |
| 67 | function oepnvOhneVmax(): Project { |
| 68 | const roh = createStandardIntersectionProject('ÖPNV', DATUM); |
| 69 | const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; |
| 70 | const bahn = createSignalGroup({ name: 'B1', mode: 'oepnv', index: 6 }); |
| 71 | const ohneVmax = { ...bahn, vZul: 0 }; |
| 72 | const phase3 = createPhase('Phase 3 - ÖPNV', [ohneVmax.id]); |
| 73 | return { |
| 74 | ...roh, |
| 75 | signalGroups: [...roh.signalGroups, ohneVmax], |
| 76 | phases: [...roh.phases, phase3], |
| 77 | program: { ...roh.program, phaseOrder: [...roh.program.phaseOrder, phase3.id] }, |
| 78 | conflicts: [ |
| 79 | ...roh.conflicts, |
| 80 | { ...createConflict(ohneVmax.id, k1.id), clearingDistance: 20, enteringDistance: 8 }, |
| 81 | ], |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | it('fuehrt die fehlende OePNV-Hoechstgeschwindigkeit unter Zwischenzeiten', () => { |
| 86 | const befund = bericht(oepnvOhneVmax()).findings.find( |
| 87 | (f) => f.rule === 'signalplan.oepnv-vmax-fehlt', |
| 88 | ); |
| 89 | expect(befund?.severity).toBe('warnung'); |
| 90 | expect(befund?.category).toBe('zwischenzeiten'); |
| 91 | expect(befund?.title).not.toBe(SAMMELTITEL); |
| 92 | expect(befund?.suggestion).not.toBe(ERSATZRAT); |
| 93 | }); |
| 94 | |
| 95 | it('schreibt den Rueckfallwert ohne Vmax nicht der RiLSA 2015 zu', () => { |
| 96 | // Der Rueckfallwert ist ein Programmansatz. Eine erfundene Fundstelle |
| 97 | // waere schlimmer als keine - der Bericht geht in die Planunterlage. |
| 98 | const befund = bericht(oepnvOhneVmax()).findings.find( |
| 99 | (f) => f.rule === 'signalplan.oepnv-vmax-fehlt', |
| 100 | ); |
| 101 | expect(befund?.reference).not.toBe('RiLSA 2015'); |
| 102 | }); |
| 103 | }); |
| 104 | |
| 105 | /* |
| 106 | * Waechter ueber alle Notizcodes, die der Planaufbau erzeugen kann. |
| 107 | * |
| 108 | * Ohne ihn wandert der Befund beim naechsten neuen Code sofort zurueck: Wer |
| 109 | * eine Notiz hinzufuegt und die vier Tabellen in engine.ts nicht ergaenzt, |
| 110 | * bekommt keinen roten Test, sondern eine Zeile mit Sammeltitel und Ersatzrat |
| 111 | * im gedruckten Bericht. |
| 112 | * |
| 113 | * Gelesen werden die Dateien, deren Notizen in `plan.notes` gelangen. Die |
| 114 | * Meldungen der Vorgabenpruefung (Praefix "vorgabe-") bleiben aussen vor: Sie |
| 115 | * fuehrt checkSettings mit eigener Kategorie, und die Notizschleife |
| 116 | * ueberspringt sie. |
| 117 | */ |
| 118 | const NOTIZQUELLEN: readonly { |
| 119 | readonly datei: string; |
| 120 | readonly ohneHinweise: boolean; |
| 121 | /** Meldungen der Zwischenzeitrechnung - sie gehoeren unter "Zwischenzeiten". */ |
| 122 | readonly ausZwischenzeitrechnung: boolean; |
| 123 | }[] = [ |
| 124 | // Die Hinweise aus computeIntergreen verwirft der Planaufbau, bevor sie in |
| 125 | // plan.notes gelangen (signalPlan.ts, Filter auf severity 'hinweis'). |
| 126 | { |
| 127 | datei: 'src/domain/rilsa/intergreen.ts', |
| 128 | ohneHinweise: true, |
| 129 | ausZwischenzeitrechnung: true, |
| 130 | }, |
| 131 | { datei: 'src/domain/plan/signalPlan.ts', ohneHinweise: false, ausZwischenzeitrechnung: false }, |
| 132 | { datei: 'src/domain/rilsa/cycle.ts', ohneHinweise: false, ausZwischenzeitrechnung: false }, |
| 133 | { datei: 'src/domain/rilsa/greenSplit.ts', ohneHinweise: false, ausZwischenzeitrechnung: false }, |
| 134 | ]; |
| 135 | |
| 136 | /** Eine gefundene Notiz samt der Herkunft, die ihr Sachgebiet bestimmt. */ |
| 137 | interface Notizfund { |
| 138 | readonly note: PlanNote; |
| 139 | readonly ausZwischenzeitrechnung: boolean; |
| 140 | } |
| 141 | |
| 142 | function notizcodes(): readonly Notizfund[] { |
| 143 | const gefunden = new Map<string, Notizfund>(); |
| 144 | for (const quelle of NOTIZQUELLEN) { |
| 145 | const text = readFileSync(quelle.datei, 'utf8'); |
| 146 | const treffer = text.matchAll(/severity: '(fehler|warnung|hinweis)',\s*code: '([^']+)'/g); |
| 147 | for (const [, severity, code] of treffer) { |
| 148 | if (severity === undefined || code === undefined) continue; |
| 149 | if (code.startsWith('vorgabe-')) continue; |
| 150 | if (quelle.ohneHinweise && severity === 'hinweis') continue; |
| 151 | // Derselbe Code kann an mehreren Stellen mit verschiedener Schwere |
| 152 | // entstehen; die schwerere Fassung ist die massgebende. |
| 153 | const bisher = gefunden.get(code); |
| 154 | if (bisher !== undefined && bisher.note.severity !== 'hinweis') continue; |
| 155 | gefunden.set(code, { |
| 156 | note: { |
| 157 | severity: severity as PlanNote['severity'], |
| 158 | code, |
| 159 | message: `Prüftext zu ${code}`, |
| 160 | }, |
| 161 | ausZwischenzeitrechnung: quelle.ausZwischenzeitrechnung, |
| 162 | }); |
| 163 | } |
| 164 | } |
| 165 | return [...gefunden.values()]; |
| 166 | } |
| 167 | |
| 168 | /** Derselbe Plan, aber mit einer untergeschobenen Notiz. */ |
| 169 | function planMitNotiz(plan: SignalPlan, note: PlanNote): SignalPlan { |
| 170 | return { ...plan, notes: [note] }; |
| 171 | } |
| 172 | |
| 173 | describe('Waechter - keine Plan-Notiz faellt auf Sammeltitel oder Ersatzrat', () => { |
| 174 | const project = createStandardIntersectionProject('Wächter', DATUM); |
| 175 | const plan = buildSignalPlan(project); |
| 176 | const codes = notizcodes(); |
| 177 | |
| 178 | it('findet die Notizcodes des Planaufbaus', () => { |
| 179 | // Faellt die Suche leer aus, bewacht der Waechter nichts mehr - etwa |
| 180 | // nachdem eine Datei umbenannt wurde. |
| 181 | expect(codes.length).toBeGreaterThan(20); |
| 182 | const namen = codes.map((n) => n.note.code); |
| 183 | expect(namen).toContain('zwischenzeit-vorgabe-unter-rechenwert'); |
| 184 | expect(namen).toContain('oepnv-vmax-fehlt'); |
| 185 | expect(namen).toContain('oepnv-vmax-ueber-70'); |
| 186 | expect(namen).toContain('enger-radius-schneller-als-regelfall'); |
| 187 | expect(namen).toContain('abbiegend-schneller-als-geradeaus'); |
| 188 | expect(namen).toContain('erhoehter-zeitbedarf-schneller-als-regelfall'); |
| 189 | }); |
| 190 | |
| 191 | it.each(codes.map((n) => [n.note.code, n] as const))( |
| 192 | '%s traegt eine eigene Ueberschrift, einen eigenen naechsten Schritt und das richtige Sachgebiet', |
| 193 | (_code, fund) => { |
| 194 | const ergebnis = validateProject(project, planMitNotiz(plan, fund.note), DATUM); |
| 195 | const befund = ergebnis.findings.find((f) => f.rule === `signalplan.${fund.note.code}`); |
| 196 | // Kein Befund heisst: Der Sachverhalt hat im Bericht eine eigene Regel, |
| 197 | // und die Notizschleife ueberspringt ihn. Auch das ist in Ordnung. |
| 198 | if (befund === undefined) return; |
| 199 | expect(befund.title).not.toBe(SAMMELTITEL); |
| 200 | expect(befund.suggestion).not.toBe(ERSATZRAT); |
| 201 | // Jede Meldung aus computeIntergreen traegt bereits das Vorwort |
| 202 | // "Zwischenzeit A nach B:". Unter "Signalzeitenplan" gefuehrt, sucht |
| 203 | // der Anwender sie in der falschen Karte des Berichts. |
| 204 | if (fund.ausZwischenzeitrechnung) expect(befund.category).toBe('zwischenzeiten'); |
| 205 | }, |
| 206 | ); |
| 207 | }); |
| 208 | |
| 209 | /* |
| 210 | * Fundstelle der beiden Meldungen, deren Zahl NICHT aus der RiLSA stammt |
| 211 | * (Befund 38). Der Pruefbericht geht in die Planunterlage; eine erfundene |
| 212 | * Fundstelle ist schlimmer als keine. |
| 213 | */ |
| 214 | describe('Befund 38 - Fundstelle nennt die wirkliche Herkunft', () => { |
| 215 | const project = createStandardIntersectionProject('Fundstelle', DATUM); |
| 216 | const plan = buildSignalPlan(project); |
| 217 | |
| 218 | function befundZu(code: string) { |
| 219 | const note: PlanNote = { severity: 'fehler', code, message: `Prüftext zu ${code}` }; |
| 220 | return validateProject(project, planMitNotiz(plan, note), DATUM).findings.find( |
| 221 | (f) => f.rule === `signalplan.${code}`, |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | it('fuehrt den Sonderwert bei engem Innenradius als Praxisansatz, nicht als RiLSA 2015', () => { |
| 226 | // Die Engine sagt an ihrer eigenen Meldung zur selben Sache, in der RiLSA |
| 227 | // 2015 sei ein Sonderwert fuer enge Innenradien nicht nachweisbar. Zwei |
| 228 | // Zeilen desselben Berichts nannten dafuer zwei verschiedene Herkuenfte. |
| 229 | const befund = befundZu('enger-radius-schneller-als-regelfall'); |
| 230 | expect(befund).toBeDefined(); |
| 231 | expect(befund?.category).toBe('zwischenzeiten'); |
| 232 | expect(befund?.reference).not.toBe('RiLSA 2015'); |
| 233 | // Seit 5.27.0 nennt der Rueckfall die RiLSA-Fundstelle - der Sonderwert |
| 234 | // steht dort (Abschnitt 2.5.2, Fall 2). Der eigene Zweig bleibt, weil er |
| 235 | // das Sachgebiet mitliefert statt eines pauschalen "RiLSA 2015". |
| 236 | expect(befund?.reference).toContain('Räumgeschwindigkeit bei engem Innenradius'); |
| 237 | }); |
| 238 | |
| 239 | it('fuehrt die 70-km/h-Grenze des OePNV unter der VwV-StVO', () => { |
| 240 | // Die Grenze steht in der Verwaltungsvorschrift zu Paragraph 37, nicht in |
| 241 | // der RiLSA - der Meldungstext nennt sie selbst so. |
| 242 | const befund = befundZu('oepnv-vmax-ueber-70'); |
| 243 | expect(befund).toBeDefined(); |
| 244 | expect(befund?.category).toBe('zwischenzeiten'); |
| 245 | expect(befund?.reference).not.toBe('RiLSA 2015'); |
| 246 | expect(befund?.reference).toContain('VwV-StVO'); |
| 247 | }); |
| 248 | }); |
| 249 | |
| 250 | /* |
| 251 | * Ueberschrift und naechster Schritt duerfen nicht behaupten, was der |
| 252 | * Meldungstext derselben Zeile bestreitet (Nachtrag zu den Befunden 13 |
| 253 | * und 38). Alle drei Meldungen enden mit "Angesetzt wird der kleinere Wert": |
| 254 | * Die Kappung in clearingSpeedFor verhindert die Verkuerzung gerade, sie |
| 255 | * tritt nicht ein. |
| 256 | */ |
| 257 | describe('Befunde 13 und 38 - Kein Titel behauptet eine eingetretene Verkuerzung', () => { |
| 258 | const project = createStandardIntersectionProject('Kappung', DATUM); |
| 259 | const plan = buildSignalPlan(project); |
| 260 | |
| 261 | const GEKAPPTE_WIDERSPRUECHE = [ |
| 262 | 'enger-radius-schneller-als-regelfall', |
| 263 | 'abbiegend-schneller-als-geradeaus', |
| 264 | 'erhoehter-zeitbedarf-schneller-als-regelfall', |
| 265 | ] as const; |
| 266 | |
| 267 | it.each(GEKAPPTE_WIDERSPRUECHE)('%s: Ueberschrift bleibt bei der Ursache', (code) => { |
| 268 | const note: PlanNote = { severity: 'fehler', code, message: `Prüftext zu ${code}` }; |
| 269 | const befund = validateProject(project, planMitNotiz(plan, note), DATUM).findings.find( |
| 270 | (f) => f.rule === `signalplan.${code}`, |
| 271 | ); |
| 272 | expect(befund).toBeDefined(); |
| 273 | expect(befund?.title).not.toBe(SAMMELTITEL); |
| 274 | // "verkürzte die Zwischenzeit" und "räumt schneller" sagen, es sei |
| 275 | // geschehen. Es ist nicht geschehen - der Bericht widerspraeche sich in |
| 276 | // derselben Zeile. |
| 277 | expect(befund?.title).not.toMatch(/verkürzt/); |
| 278 | expect(befund?.title).not.toMatch(/räumt schneller/); |
| 279 | }); |
| 280 | |
| 281 | it('haelt den naechsten Schritt zum engen Innenradius im Konjunktiv der Vorgabenverwaltung', () => { |
| 282 | // Wortlaut wie in der Feldbegruendung von settings.ts: "sonst verkürzte |
| 283 | // das Merkmal die Zwischenzeit". Der Indikativ "verkürzt" behauptete eine |
| 284 | // Wirkung, die die Kappung gerade abwendet. |
| 285 | const note: PlanNote = { |
| 286 | severity: 'fehler', |
| 287 | code: 'enger-radius-schneller-als-regelfall', |
| 288 | message: 'Prüftext', |
| 289 | }; |
| 290 | const befund = validateProject(project, planMitNotiz(plan, note), DATUM).findings.find( |
| 291 | (f) => f.rule === 'signalplan.enger-radius-schneller-als-regelfall', |
| 292 | ); |
| 293 | expect(befund).toBeDefined(); |
| 294 | expect(befund?.suggestion).not.toContain('sonst verkürzt das Merkmal'); |
| 295 | expect(befund?.suggestion).toContain('sonst verkürzte das Merkmal die Zwischenzeit'); |
| 296 | }); |
| 297 | }); |