lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests domain notizUeberschriften.test.ts

12,9 KB Rohdatei
tests/domain/notizUeberschriften.test.ts — 297 Zeilen
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 });