lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain berichtWidersprueche.test.ts
| 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 | }); |