import { describe, expect, it } from 'vitest'; import { buildSignalPlan, intergreenKey, type SignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { defaultVehicleClass } from '@/domain/model/factory'; import { ASSISTENT_VORGABE, projektAusAngaben, type Anlagenform } from '@/ui/assistent'; import type { Project } from '@/domain/model/project'; import type { ValidationFinding } from '@/domain/validation'; /** * Zwei Ecken der Variationsmatrix, die bisher leer standen. * * DIE LAGE, GEMESSEN: Der Fachkern ist dicht bewacht, aber fast alles davon an * EINER Achse - dem Knotenpunkt. Das Bewertungsverfahren HCM wurde in keinem * Test an einer anderen Anlagenart GERECHNET, und eine OePNV-Signalgruppe * stand in keinem Projekt, das kein Knotenpunkt war. (Beide Kombinationen * kommen in `tests/services/projektdatei.test.ts` vor - dort aber nur als * Rundreise durch Datei und Einlesen, ohne Plan, Bericht oder Ausdruck.) * * Erreichbar sind beide Einstellungen ueberall: Das Auswahlfeld * "Bewertungsverfahren" steht in der Projektansicht ohne jeden Bezug zur * Anlagenart, und die Schaltflaeche "+ ÖPNV" steht in der * Signalgruppentabelle jeder Anlage. * * Was hier geprueft wird, ist deshalb nicht "rechnet es ueberhaupt" - das tut * es -, sondern WAS dabei herauskommt. In beiden Faellen ist das Ergebnis * bemerkenswert genug, um es festzuhalten. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); /** Das Projekt, das der gefuehrte Einstieg mit seinen Vorgaben anlegt. */ function ausDemAssistenten( form: Anlagenform, teil: Partial = {}, ): Project { return projektAusAngaben({ ...ASSISTENT_VORGABE, form, ...teil }, 'Pruefstueck', STICHTAG); } function mitVerfahren(project: Project, verfahren: 'HBS' | 'HCM'): Project { return { ...project, settings: { ...project.settings, serviceLevelScale: verfahren } }; } /** Plan und Bericht in einem Zug - wie die Oberflaeche sie bildet. */ function gerechnet(project: Project): { readonly plan: SignalPlan; readonly regeln: readonly string[]; /** Nur die Befunde zur Leistungsfaehigkeit, mit dem Ziel dahinter. */ readonly bewertung: readonly string[]; } { const plan = buildSignalPlan(project); const bericht = validateProject(project, plan, STICHTAG); return { plan, regeln: bericht.findings.map((f) => f.rule).sort(), bewertung: bericht.findings .filter((f) => f.rule.startsWith('leistungsfaehigkeit.')) .map((f) => `${f.rule.replace('leistungsfaehigkeit.', '')}@${f.target?.label ?? '-'}`) .sort(), }; } /** Die Signalgruppe dieses Namens im gerechneten Plan. */ function gruppe(plan: SignalPlan, name: string): SignalPlan['groups'][number] { const treffer = plan.groups.find((g) => g.name === name); expect(treffer, `keine Signalgruppe ${name} im Plan`).toBeDefined(); return treffer!; } /** Der Befund dieser Regel im Pruefbericht - hoechstens einer je Regel hier. */ function leistungsbefund(project: Project, regel: string): ValidationFinding | undefined { const plan = buildSignalPlan(project); return validateProject(project, plan, STICHTAG).findings.find((f) => f.rule === regel); } /** Die Zwischenzeit, in der die eine Gruppe raeumt und die andere einfaehrt. */ function zwischenzeit(plan: SignalPlan, raeumt: string, faehrtEin: string): number { const schluessel = intergreenKey(gruppe(plan, raeumt).groupId, gruppe(plan, faehrtEin).groupId); const eintrag = plan.intergreens.get(schluessel); expect(eintrag, `keine Zwischenzeit: ${raeumt} raeumt, ${faehrtEin} faehrt ein`).toBeDefined(); return eintrag!.value; } describe('Fussgaengerschutzanlage mit dem Bewertungsverfahren HCM', () => { /* * ZWEI SCHRANKEN TREFFEN AUFEINANDER, und die vier Faelle hier trennen sie: * die Anlagenart, die an einer Fussgaengerschutzanlage die Kfz-Leistungs- * faehigkeit ausser Betracht laesst, und das Verfahren HCM, das fuer * Fussgaenger und Radverkehr keine Stufentafel kennt. Jede fuer sich ist * bewacht; was sie ZUSAMMEN ergeben, stand nirgends. * * Aufgebaut als Vierfeldertafel - Knotenpunkt gegen Fussgaengerschutzanlage, * HBS gegen HCM -, weil sich nur so zeigen laesst, welche Schranke welchen * Befund wegnimmt. Ein einzelner Fall koennte beide Wirkungen nicht * auseinanderhalten. * * DIE ANLAGE IST BEWUSST UEBERLASTET. Die Vorgabe des gefuehrten Einstiegs * traegt gar keine Verkehrsstaerke - ohne sie gaebe es an der Fahrbahn * ueberhaupt nichts zu bewerten, und ein Fall, der dann "es bleibt nichts" * feststellt, haette nur die fehlende Eingabe gemessen und keine der beiden * Schranken. Erst 6000 Fz/h auf den zwei Fahrstreifen bringen den * Auslastungsgrad auf 1,79 und beide Bewertungen ueberhaupt in Reichweite. */ const belastet = ausDemAssistenten('fussgaengerschutzanlage', { verkehrsstaerke: 6000 }); /** Dieselbe Planung, nur als Knotenpunkt beurteilt. */ const alsKnotenpunkt: Project = { ...belastet, anlagenart: 'knotenpunkt' }; it('meldet am Knotenpunkt beides: die ueberlastete Fahrbahn und beide Stufen', () => { // Der Vergleichsfall. Ohne ihn waere von den drei Faellen darunter nicht // zu unterscheiden, was die Anlagenart wegnimmt und was ohnehin fehlte. expect(gerechnet(mitVerfahren(alsKnotenpunkt, 'HBS')).bewertung).toEqual([ 'qualitaetsstufe@F1', 'qualitaetsstufe@K1', 'ueberlastet@K1', ]); }); it('nimmt die Anlagenart der Fahrbahn ihre Bewertung', () => { /* * Die erste Schranke, fuer sich. An einer Fussgaengerschutzanlage ist * nicht die Leistungsfaehigkeit der Fahrbahn das Mass, sondern die * Wartezeit der Querenden - der Bericht schweigt dort zum Auslastungsgrad * und zur Kfz-Stufe. GERECHNET wird beides trotzdem: Der Plan fuehrt fuer * K1 denselben Auslastungsgrad und dieselbe Stufe F wie am Knotenpunkt. * Weggenommen ist nur die Meldung. */ const { plan, bewertung } = gerechnet(mitVerfahren(belastet, 'HBS')); expect(gruppe(plan, 'K1').delay?.serviceLevel?.grade, 'gerechnet wird sie doch').toBe('F'); expect(bewertung).toEqual(['qualitaetsstufe@F1']); }); it('nimmt das Verfahren HCM der Furt ihre Stufe', () => { /* * Die zweite Schranke, fuer sich - am Knotenpunkt, wo die erste nicht * greift. Das HCM hat fuer Fussgaenger und Radverkehr keine Stufentafel; * die Wartezeit selbst bleibt, was sie war. "Nicht bewertet" ist keine * schlechte Bewertung, und genau das sagt der Bericht auch. */ const { plan, bewertung } = gerechnet(mitVerfahren(alsKnotenpunkt, 'HCM')); const furt = gruppe(plan, 'F1'); expect(furt.delay?.maximumDelay, 'die Wartezeit selbst aendert sich nicht').toBe(115); expect(furt.delay?.serviceLevel ?? null, 'das HCM hat fuer Furten keine Tafel').toBeNull(); expect(bewertung).toEqual([ 'fuss-rad-nicht-bewertet@Bewertungsverfahren', 'qualitaetsstufe@K1', 'ueberlastet@K1', ]); }); it('zusammen bleibt an der Fussgaengerschutzanlage keine Bewertung uebrig', () => { /* * DER FALL, DEN NIEMAND GERECHNET HATTE. Jede der beiden Schranken ist * fuer sich richtig und fuer sich bewacht. Zusammen ergeben sie etwas * Drittes: An einer Anlage, deren Zweck die Querung ist, steht bei einer * zu 179 % ausgelasteten Fahrbahn und 115 s Wartezeit an der Furt keine * einzige Bewertung mehr. * * Bis 5.20.0 stand dort nur der allgemeine Hinweis, die Furt bekomme nach * HCM keine Stufe - und der verschwieg die andere Haelfte. Seither sagt * die Regel `leistungsfaehigkeit.verfahren-ohne-bewertung`, dass gar * nichts mehr bewertet wird, und sie ersetzt den allgemeinen Hinweis: * zwei Meldungen zu derselben Sache waeren eine zu viel. */ const { plan, bewertung } = gerechnet(mitVerfahren(belastet, 'HCM')); expect( gruppe(plan, 'K1').capacity?.degreeOfSaturation, 'die Fahrbahn ist ueberlastet', ).toBeGreaterThan(1); expect(gruppe(plan, 'F1').delay?.maximumDelay, 'die Furt wartet lange').toBe(115); expect(bewertung).toEqual(['verfahren-ohne-bewertung@Bewertungsverfahren']); }); it('nennt die Meldung beide Haelften und den Weg heraus', () => { const befund = leistungsbefund( mitVerfahren(belastet, 'HCM'), 'leistungsfaehigkeit.verfahren-ohne-bewertung', ); expect(befund, 'die Regel schweigt').toBeDefined(); expect(befund?.severity, 'eine Unterlage ganz ohne Bewertung ist mehr als ein Hinweis').toBe( 'warnung', ); expect(befund?.message, 'die Anlagenartschranke fehlt').toContain('Fahrbahn'); expect(befund?.message, 'die Verfahrensschranke fehlt').toContain('Furt'); expect(befund?.suggestion, 'der Weg heraus fehlt').toContain('HBS'); }); it('schweigt sie, wo eine der beiden Schranken fehlt', () => { // Nach HBS bewertet die Anlage ihre Furt - dann bleibt etwas uebrig. expect( leistungsbefund( mitVerfahren(belastet, 'HBS'), 'leistungsfaehigkeit.verfahren-ohne-bewertung', ), 'nach HBS meldet sie trotzdem', ).toBeUndefined(); // Und am Knotenpunkt bleibt unter HCM die Fahrbahn bewertet. expect( leistungsbefund( mitVerfahren(alsKnotenpunkt, 'HCM'), 'leistungsfaehigkeit.verfahren-ohne-bewertung', ), 'am Knotenpunkt meldet sie', ).toBeUndefined(); expect( leistungsbefund( mitVerfahren(alsKnotenpunkt, 'HCM'), 'leistungsfaehigkeit.fuss-rad-nicht-bewertet', ), 'der allgemeine Hinweis ist am Knotenpunkt verschwunden', ).toBeDefined(); }); }); describe('Einstreifige Verkehrsfuehrung mit dem Bewertungsverfahren HCM', () => { /* * Die ergiebigere der beiden Kombinationen: Hier greift die Ausblendung der * Fussgaengerschutzanlage nicht, beide Signalgruppen sind Kfz-Stroeme, und * der Verfahrenswechsel schlaegt bis in die Stufe durch. * * Der Hebel ist die Abflusszeit tA: Nach HBS 2015 ist sie die Freigabezeit * zuzueglich einer Sekunde je Freigabezeitfenster - der Abfluss laeuft in * die Gelbzeit hinein -, nach HCM die Freigabezeit selbst. Die Kapazitaet * C = qS * tA/tU faellt damit unter HCM, und der Auslastungsgrad steigt. */ const engstelle = ausDemAssistenten('einstreifig', { verkehrsstaerke: 400 }); it('rechnet unter HCM eine kleinere Kapazitaet und einen hoeheren Auslastungsgrad', () => { const hbs = gruppe(gerechnet(mitVerfahren(engstelle, 'HBS')).plan, 'K1').capacity; const hcm = gruppe(gerechnet(mitVerfahren(engstelle, 'HCM')).plan, 'K1').capacity; expect(hbs, 'ohne Kapazitaet sagt der Vergleich nichts').not.toBeNull(); expect(hcm, 'ohne Kapazitaet sagt der Vergleich nichts').not.toBeNull(); expect(hbs!.verfahren).toBe('HBS'); expect(hcm!.verfahren).toBe('HCM'); expect(hcm!.abflusszeit, 'der Abflusszuschlag des HBS steht noch in der Rechnung').toBeLessThan( hbs!.abflusszeit, ); expect(hcm!.capacity).toBeLessThan(hbs!.capacity); expect(hcm!.degreeOfSaturation!).toBeGreaterThan(hbs!.degreeOfSaturation!); }); it('gibt derselben Planung je nach Verfahren eine andere Qualitaetsstufe', () => { /* * DIE ZAHL, DIE DEN FALL TRAEGT. Derselbe Plan, dieselbe Verkehrsstaerke, * dieselbe Engstelle - und die Stufe springt von B auf C, allein weil in * der Projektansicht ein anderes Verfahren steht. Wer eine Planunterlage * nach der Stufe beurteilt, muss wissen, wonach sie gebildet wurde; das * Feld heisst deshalb seit Fassung 5.4.0 "Bewertungsverfahren" und * nicht mehr "Bewertungsskala". */ expect( gruppe(gerechnet(mitVerfahren(engstelle, 'HBS')).plan, 'K1').delay?.serviceLevel?.grade, ).toBe('B'); expect( gruppe(gerechnet(mitVerfahren(engstelle, 'HCM')).plan, 'K1').delay?.serviceLevel?.grade, ).toBe('C'); }); }); describe('OePNV in einer einstreifigen Verkehrsfuehrung', () => { /* * DIE GEFAEHRLICHE RICHTUNG - GEMESSEN UND FESTGEHALTEN, NICHT BEHOBEN. * * Ein Bus faehrt durch die Baustelle. Wer dafuer in der Signalgruppen- * tabelle die Verkehrsart einer Richtung auf "ÖPNV" stellt - die * Schaltflaeche steht dort, nichts hindert daran -, bekommt eine KUERZERE * Zwischenzeit als fuer dieselbe Richtung als Kfz-Strom. Der Grund steht im * Regelwerk und ist fuer sich richtig: Die Raeumgeschwindigkeit des OePNV * ist Vmax/3,6 - bei 50 km/h also 13,89 m/s. Der Kraftfahrzeugverkehr raeumt * an einer Engstelle seit 5.27.0 mit der Staffel des Abschnitts 5.2, bei * 50 km/h also mit 40 km/h = 11,11 m/s (bis 5.26.0 mit festen 10,0 m/s). * Der raeumende OePNV-Strom ist rechnerisch schneller aus der Engstelle * heraus, also darf die Gegenrichtung frueher einfahren. * * Die Voraussetzung dieses Ansatzes prueft das Programm nirgends nach: Er * meint den gesondert signalisierten OePNV, der den Konfliktbereich mit * seiner Hoechstgeschwindigkeit durchfaehrt. In einer einstreifigen * Verkehrsfuehrung faehrt der Bus auf demselben Fahrstreifen wie alles * andere, hinter demselben Baustellenverkehr, und raeumt gerade nicht mit * Vmax. Bei der Vorgabe des Assistenten - 120 m Engstelle, 50 km/h - fallen * beide Verfahren seit 5.27.0 zufaellig auf dieselbe Sekunde; bei 400 m * treten sie um fuenf Sekunden auseinander. * * DIE RECHNUNG BLEIBT, DER BERICHT SAGT ES JETZT. Der Kennwert wird nicht * angetastet - er ist der Regelwert, und ihn zu aendern hiesse, das Regelwerk * zu korrigieren. Was sich geaendert hat, ist das Schweigen daneben: Die * Regel `einstreifig.oepnv-raeumgeschwindigkeit` nennt an dieser Anlagenart * die Voraussetzung des Ansatzes und die beiden Geschwindigkeiten, die * auseinanderfallen. Die Faelle unten halten beides gegeneinander - was * gerechnet wird und was daraufhin im Bericht steht. */ const alsKfz = ausDemAssistenten('einstreifig'); /** Dieselbe Anlage, eine Richtung als OePNV - so, wie es die Tabelle tut. */ function mitBus(project: Project): Project { return { ...project, signalGroups: project.signalGroups.map((g) => g.name === 'K1' ? { ...g, mode: 'oepnv' as const, vehicleClass: defaultVehicleClass('oepnv') } : g, ), }; } it('verkuerzt die Zwischenzeit, statt sie zu verlaengern', () => { const ohne = gerechnet(alsKfz).plan; const mit = gerechnet(mitBus(alsKfz)).plan; /* * NACHGEZOGEN MIT 5.27.0: Bei der Vorgabe des Assistenten - 120 m, 50 km/h * - fallen beide Verfahren auf dieselbe Sekunde. Kfz nach Abschnitt 5.2: * 4 + 120/40 * 3,6 = 14,8 -> 15 s. OePNV nach Abschnitt 2: 5 s * Ueberfahrzeit + 126 m / 13,89 m/s = 14,07 -> 15 s. Bis 5.26.0 stand der * Kfz-Wert bei 16 s und der Unterschied betrug eine Sekunde. */ expect(zwischenzeit(ohne, 'K1', 'K2'), 'Kfz raeumt 120 m mit 40 km/h, tue 4 s').toBe(15); expect(zwischenzeit(mit, 'K1', 'K2'), 'der Bus raeumt nach Abschnitt 2').toBe(15); // Die Gegenrichtung bleibt, wo sie war - sie raeumt weiter als Kfz. Der // Einfahrweg ist 0, die Verkehrsart des EINFAHRENDEN Stroms also ohne // Wirkung; nur der raeumende zaehlt. expect(zwischenzeit(mit, 'K2', 'K1'), 'die Gegenrichtung hat sich mitverschoben').toBe( zwischenzeit(ohne, 'K2', 'K1'), ); }); it('traegt die Verkuerzung an einer laengeren Engstelle offen zutage', () => { /* * DER FALL, DEN DIE 120 m VERDECKEN. Bei 400 m Engstelle rechnet der * Kraftfahrzeugverkehr nach Abschnitt 5.2 mit 4 + 400/40 * 3,6 = 40 s, der * OePNV nach Abschnitt 2 mit 5 + 406/13,89 = 34,2 -> 35 s. Fuenf Sekunden * kuerzer, allein weil in der Signalgruppentabelle eine Verkehrsart * umgestellt wurde. */ const lang = ausDemAssistenten('einstreifig', { engstellenlaenge: 400 }); expect(zwischenzeit(gerechnet(lang).plan, 'K1', 'K2')).toBe(40); expect(zwischenzeit(gerechnet(mitBus(lang)).plan, 'K1', 'K2')).toBe(35); }); it('meldet der Pruefbericht die Voraussetzung des Ansatzes', () => { /* * DER BEFUND, DER DIESE REGEL AUSGELOEST HAT: Bis 5.20.0 war die * Befundliste mit und ohne OePNV-Gruppe ZEICHENGLEICH. Wer die Verkehrsart * umstellte, verkuerzte eine Zwischenzeit und sah am Bericht nichts davon. * * Gemeldet wird nicht der Kennwert - der ist richtig -, sondern seine * Voraussetzung: Der Ansatz vr = Vmax/3,6 gilt dem GESONDERT * signalisierten OePNV. Ob der Bus hier auf eigenem Gleiskoerper faehrt, * kann das Programm nicht wissen; an einer einstreifigen Verkehrsfuehrung * kann es die Frage aber stellen, denn dort gibt es nur einen Fahrstreifen. */ const befund = leistungsbefund(mitBus(alsKfz), 'einstreifig.oepnv-raeumgeschwindigkeit'); expect(befund, 'die Regel schweigt').toBeDefined(); expect(befund?.severity, 'die verkuerzte Zwischenzeit ist mehr als ein Hinweis').toBe( 'warnung', ); expect(befund?.target?.label, 'der Befund zeigt nicht auf die Signalgruppe').toBe('K1'); // Beide Geschwindigkeiten stehen darin - sonst muesste der Anwender // nachschlagen, worin der Unterschied besteht. expect(befund?.message, 'die Raeumgeschwindigkeit des OePNV fehlt').toContain('13,9 m/s'); expect(befund?.message, 'die des Kfz fehlt').toContain('10,0 m/s'); expect(befund?.message, 'die Voraussetzung des Ansatzes fehlt').toContain( 'gesondert signalisiert', ); }); it('schweigt sie, wo ihre Voraussetzung nicht in Frage steht', () => { /* * Drei Gegenproben, und jede einzeln noetig - ohne sie waere eine Regel * haltbar, die einfach immer meldet. */ expect( leistungsbefund(alsKfz, 'einstreifig.oepnv-raeumgeschwindigkeit'), 'ohne OePNV-Gruppe meldet sie trotzdem', ).toBeUndefined(); // Am Knotenpunkt kann der OePNV sehr wohl gesondert signalisiert sein - // ein eigener Gleiskoerper oder ein Sonderfahrstreifen ist dort der // Regelfall. Die Frage stellt sich nur, wo es einen Fahrstreifen gibt. const knoten: Project = { ...mitBus(alsKfz), anlagenart: 'knotenpunkt' }; expect( leistungsbefund(knoten, 'einstreifig.oepnv-raeumgeschwindigkeit'), 'am Knotenpunkt meldet sie', ).toBeUndefined(); /* * Und unterhalb von 36 km/h raeumt der OePNV LANGSAMER als der Kfz-Strom * (10,0 m/s): Die Zwischenzeit wird dann laenger, nicht kuerzer - die * sichere Richtung. Gemessen bei 30 km/h: 8,3 m/s. */ const langsam = ausDemAssistenten('einstreifig', { vZul: 30 }); expect( leistungsbefund(mitBus(langsam), 'einstreifig.oepnv-raeumgeschwindigkeit'), 'auch in der sicheren Richtung meldet sie', ).toBeUndefined(); }); it('nimmt der Richtung das Rot-Gelb und tauscht Gelb gegen das Uebergangssignal', () => { /* * Die zweite Folge desselben Handgriffs, und sie faellt an einer * Baustellenampel mit gewoehnlichen Dreikammersignalen sofort auf: Ein * OePNV-Signal ist ein Sondersignal und hat kein Rot-Gelb. * * NACHGEZOGEN MIT 5.27.0: Bis dahin erwartete dieser Fall auch beim Gelb * eine Null. Ein Sondersignal zeigt kein GELB, wohl aber ein * Uebergangssignal nach Par. 51 BOStrab; die RiLSA 2015 staffelt seine * Dauer in Abschnitt 2.4 nach der betrieblich zugelassenen * Hoechstgeschwindigkeit - bei 50 km/h sind es 6 s. Die Zahl steht in * `times.yellow`, weil dort die Uebergangszeit vor Rot gefuehrt wird. */ const vorher = gruppe(gerechnet(alsKfz).plan, 'K1').times; expect([vorher.yellow, vorher.redYellow], 'als Kfz-Strom Gelb und Rot-Gelb').toEqual([3, 1]); const bus = gruppe(gerechnet(mitBus(alsKfz)).plan, 'K1').times; expect([bus.yellow, bus.redYellow], 'Uebergangssignal ja, Rot-Gelb nein').toEqual([6, 0]); }); });