lsa-planer

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

/ tests domain berichtWidersprueche.test.ts

10,9 KB Rohdatei
tests/domain/berichtWidersprueche.test.ts — 248 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { validateProject } from '@/domain/validation/engine';
3 import { buildSignalPlan } from '@/domain/plan/signalPlan';
4 import { createPhase, createStandardIntersectionProject } from '@/domain/model/factory';
5 import { ANLAGENART_KURZ } from '@/domain/rilsa/constants';
6 import type { Anlagenart, Project } from '@/domain/model/project';
7
8 /*
9 * Stellen, an denen der Pruefbericht dem Bearbeiter etwas Falsches sagte.
10 * Alle waren Wortlaut, keine Rechnung - und gerade deshalb schwer zu
11 * bemerken: Die Zahlen stimmten, der Satz daneben nicht.
12 *
13 * Die letzte Gruppe kam mit Fassung 5.4.0 (Befunde B1, B2, B4)
14 * hinzu: Die Stufenmeldung schrieb "nach HBS", waehrend nach HCM gerechnet
15 * wurde, und nannte nicht, woran die Stufe haengt.
16 */
17
18 const STICHTAG = new Date('2026-01-01T00:00:00Z');
19
20 function vollstaendig(name: string): Project {
21 const base = createStandardIntersectionProject(name, STICHTAG);
22 return {
23 ...base,
24 meta: {
25 ...base.meta,
26 projectNumber: 'VP-2026-001',
27 client: 'Stadt Musterhausen',
28 planner: 'O. Willerding',
29 location: name,
30 },
31 };
32 }
33
34 describe('Uebergang ohne Zwischenzeit: erfasst oder nicht erfasst', () => {
35 /*
36 * Die Regel prueft transition.duration === 0 und schloss daraus, es sei keine
37 * Beziehung erfasst. Eine Dauer von 0 s entsteht aber auch, wenn ALLE
38 * Beziehungen erfasst sind und rechnerisch 0 s ergeben. Der Bericht forderte
39 * dann, Beziehungen zu erfassen, die alle bereits erfasst waren, und sperrte
40 * dabei den Export. (Hier stand bis 5.42.1 "der Regelfall bei langen
41 * Furten" - das war der Einfahrweg des Fussgaengers ab der falschen
42 * Bordkante; siehe tests/domain/furtrichtung.test.ts.)
43 */
44 function alleNull(): Project {
45 const roh = vollstaendig('Nullzwischenzeiten');
46 return {
47 ...roh,
48 // Kurzer Konfliktbereich, weit zurueckliegende Haltlinien: Die
49 // Einfahrzeit erreicht die Raeum- und Ueberfahrzeit, tz wird 0.
50 conflicts: roh.conflicts.map((c) => ({
51 ...c,
52 clearingDistance: 3,
53 enteringDistance: 60,
54 })),
55 };
56 }
57
58 it('schweigt, wenn die Beziehungen erfasst sind und rechnerisch 0 s ergeben', () => {
59 const projekt = alleNull();
60 const plan = buildSignalPlan(projekt);
61 const bericht = validateProject(projekt, plan, STICHTAG);
62
63 // Voraussetzung des Aufbaus: Es gibt Uebergaenge ohne Dauer.
64 expect(plan.transitions.some((t) => t.duration === 0)).toBe(true);
65 expect([...plan.intergreens.values()].every((i) => i.value === 0)).toBe(true);
66
67 expect(
68 bericht.findings.some((f) => f.rule === 'zwischenzeiten.uebergang-ungesichert'),
69 'Die Beziehungen sind erfasst - die Meldung behauptete das Gegenteil',
70 ).toBe(false);
71 // Hinsehen muss der Bearbeiter trotzdem; dafuer steht die Meldung je
72 // Beziehung, und die ist eine Warnung.
73 const null0 = bericht.findings.filter((f) => f.rule === 'zwischenzeiten.null');
74 expect(null0.length).toBeGreaterThan(0);
75 expect(null0.every((f) => f.severity === 'warnung')).toBe(true);
76 });
77
78 it('meldet weiterhin, wenn zwischen den wechselnden Gruppen gar nichts erfasst ist', () => {
79 const roh = vollstaendig('Ohne Beziehungen');
80 const projekt: Project = { ...roh, conflicts: [] };
81 const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG);
82
83 const befund = bericht.findings.find((f) => f.rule === 'zwischenzeiten.uebergang-ungesichert');
84 expect(befund).toBeDefined();
85 expect(befund!.severity).toBe('fehler');
86 expect(bericht.exportBlocked).toBe(true);
87 });
88
89 it('meldet denselben Uebergang nicht zweimal mit verschiedener Schwere', () => {
90 for (const projekt of [alleNull(), { ...vollstaendig('Leer'), conflicts: [] }]) {
91 const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG);
92 // Die zweite Meldung aus dem Planaufbau ist entfallen; sie sagte zum
93 // selben Uebergang etwas anderes und wog anders.
94 expect(
95 bericht.findings.some((f) => f.rule === 'signalplan.uebergang-ohne-zwischenzeit'),
96 ).toBe(false);
97 }
98 });
99 });
100
101 describe('Feindliche Paare werden einmal gezaehlt', () => {
102 it('meldet je Paar genau einen Fehler, nicht zwei', () => {
103 const roh = vollstaendig('Doppelzaehlung');
104 const alle = createPhase(
105 'Alles gemeinsam',
106 roh.signalGroups.map((g) => g.id),
107 );
108 const projekt: Project = {
109 ...roh,
110 phases: [alle],
111 program: { ...roh.program, phaseOrder: [alle.id] },
112 };
113 const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG);
114
115 const paare = new Set(projekt.conflicts.map((c) => [c.fromId, c.toId].sort().join('|')));
116 const feindlich = bericht.findings.filter(
117 (f) => f.rule === 'phasen.feindlich-in-phase' || f.rule === 'signalplan.feindliche-freigabe',
118 );
119
120 // Acht Paare seit 5.43.0: Die Vorlage erfasst jede Furt auch gegen den
121 // Strom, der sie beim Ausfahren quert (K2/F2, K4/F1). Bis 5.42.1 waren es
122 // sechs. Die Aussage bleibt: je Paar ein Fehler, nicht zwei.
123 expect(paare.size).toBe(8);
124 expect(feindlich, 'acht Konflikte duerfen nicht sechzehn Fehler ergeben').toHaveLength(8);
125 // Jede Meldung nennt ihr Paar; achtmal derselbe Titel waere im Bericht
126 // nicht auseinanderzuhalten.
127 expect(new Set(feindlich.map((f) => f.title)).size).toBe(8);
128 });
129 });
130
131 describe('Anlagenart im Wortlaut der Uebersaettigung', () => {
132 function uebersaettigt(anlagenart: Anlagenart): Project {
133 const roh = vollstaendig('Uebersaettigt');
134 return {
135 ...roh,
136 anlagenart,
137 // Verkehrsstaerken weit ueber der Kapazitaet.
138 demands: roh.signalGroups
139 .filter((g) => g.mode === 'kfz')
140 .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })),
141 };
142 }
143
144 it('nennt bei einer Baustellenampel keinen Knotenpunkt', () => {
145 const bericht = validateProject(uebersaettigt('einstreifig'), undefined, STICHTAG);
146 const befund = bericht.findings.find((f) => f.rule === 'signalplan.uebersaettigt');
147 expect(befund, 'Der Aufbau muss die Uebersaettigung ausloesen').toBeDefined();
148
149 // Ueberschrift und Fliesstext duerfen sich nicht widersprechen.
150 expect(befund!.title).toContain(ANLAGENART_KURZ.einstreifig);
151 expect(befund!.message).not.toContain('Knotenpunkt');
152 // "Fahrstreifen ergaenzen" ist bei einstreifiger Fuehrung kein Rat.
153 expect(befund!.suggestion).not.toContain('ergänzen Sie Fahrstreifen');
154 });
155
156 it('haelt beim Knotenpunkt am gewohnten Rat fest', () => {
157 const bericht = validateProject(uebersaettigt('knotenpunkt'), undefined, STICHTAG);
158 const befund = bericht.findings.find((f) => f.rule === 'signalplan.uebersaettigt');
159 expect(befund).toBeDefined();
160 expect(befund!.title).toContain('Knotenpunkt');
161 expect(befund!.suggestion).toContain('Fahrstreifen');
162 });
163
164 it('gibt fuer jede Anlagenart einen eigenen Rat', () => {
165 const raete = (['knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig'] as const).map(
166 (art) =>
167 validateProject(uebersaettigt(art), undefined, STICHTAG).findings.find(
168 (f) => f.rule === 'signalplan.uebersaettigt',
169 )?.suggestion ?? '',
170 );
171 expect(raete.every((r) => r !== '')).toBe(true);
172 expect(new Set(raete).size, 'drei Anlagenarten, drei Raete').toBe(3);
173 });
174 });
175
176 describe('Verfahren und Kriterium in der Stufenmeldung', () => {
177 /*
178 * Vor Fassung 5.4.0 hiess es "nach HBS" oder "nach HCM", je
179 * nachdem, welche Tafel eingestellt war - gerechnet wurde immer dieselbe
180 * HCM-Formel (Befund B2). Und die Stufe F fuer Kfz haengt nach HBS 2015 nicht
181 * an der Wartezeit, sondern an q > C (Befund B1): "Stufe F, mittlere
182 * Wartezeit 45 s" neben einer Tafel, die bei 45 s C ausweist, ist ein
183 * Widerspruch im selben Dokument. Deshalb muss jede Stufenmeldung sagen,
184 * welches Verfahren gerechnet wurde und woran gemessen wurde.
185 */
186 function hochbelastet(scale: 'HBS' | 'HCM'): Project {
187 const roh = vollstaendig('Hochbelastet');
188 return {
189 ...roh,
190 settings: { ...roh.settings, serviceLevelScale: scale },
191 // K1 weit ueber der Kapazitaet, K2 hoch belastet, die Furten mit langer
192 // Sperrzeit: alle drei Kriterien kommen im selben Bericht vor.
193 demands: roh.demands.map((d, i) => ({ ...d, volume: i === 0 ? 2500 : 900 })),
194 };
195 }
196
197 const KRITERIEN = ['mittlere Wartezeit', 'maximale Wartezeit', 'überlastet (q > C)'];
198
199 it('nennt nach HBS an jeder Stufenmeldung "HBS 2015" und genau ein Kriterium', () => {
200 const bericht = validateProject(hochbelastet('HBS'), undefined, STICHTAG);
201 const stufen = bericht.findings.filter((f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe');
202 expect(stufen.length, 'der Aufbau muss Stufenmeldungen erzeugen').toBeGreaterThan(1);
203 // Kfz und Furt sind beide dabei - zwei verschiedene Kriterien.
204 expect(stufen.some((f) => f.message.includes('maximale Wartezeit'))).toBe(true);
205 expect(stufen.some((f) => f.message.includes('überlastet (q > C)'))).toBe(true);
206 for (const f of stufen) {
207 expect(f.message, f.message).toContain('HBS 2015');
208 expect(f.message).not.toContain('HCM');
209 expect(f.reference).toBe('HBS 2015 - Qualitätsstufen des Verkehrsablaufs');
210 const genannt = KRITERIEN.filter((k) => f.message.includes(k));
211 expect(genannt, `genau ein Kriterium: ${f.message}`).toHaveLength(1);
212 }
213 });
214
215 it('behauptet nach HCM nirgends "HBS 2015"', () => {
216 const bericht = validateProject(hochbelastet('HCM'), undefined, STICHTAG);
217 const leistung = bericht.findings.filter((f) => f.category === 'leistungsfaehigkeit');
218 const stufen = leistung.filter((f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe');
219 expect(stufen.length).toBeGreaterThan(0);
220 for (const f of stufen) {
221 expect(f.message).toContain('HCM');
222 expect(f.message).not.toContain('HBS');
223 expect(f.reference).toBe('HCM - Level of Service');
224 }
225 // Die Furten bekommen nach HCM keine Stufe - und keine Stufenmeldung, die
226 // eine behauptet. Stattdessen sagt der Bericht einmal, dass sie nicht
227 // bewertet werden.
228 expect(stufen.some((f) => f.message.includes('maximale Wartezeit'))).toBe(false);
229 expect(
230 leistung.filter((f) => f.rule === 'leistungsfaehigkeit.fuss-rad-nicht-bewertet'),
231 ).toHaveLength(1);
232 });
233
234 it('haengt die Stufe F fuer Kfz an q > C und nennt beide Zahlen', () => {
235 const projekt = hochbelastet('HBS');
236 const plan = buildSignalPlan(projekt);
237 const bericht = validateProject(projekt, plan, STICHTAG);
238 const k1 = plan.groups.find((g) => g.name === 'K1')!;
239 const befund = bericht.findings.find(
240 (f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe' && f.target?.label === 'K1',
241 );
242 expect(befund).toBeDefined();
243 expect(befund!.title).toBe('Qualitätsstufe F für "K1"');
244 expect(befund!.message).toContain('2500 Fz/h');
245 expect(befund!.message).toContain(`${k1.capacity!.capacity} Fz/h`);
246 expect(befund!.message).toContain('unabhängig von der Wartezeit');
247 });
248 });