import { describe, expect, it } from 'vitest'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { engstellenFolgen, umlaufBeiFreigabe } from '@/domain/rilsa/engstelle'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { QUELLEN, type QuellenSchluessel } from '@/domain/rilsa/quellen'; import { resolveRilsaSettings, settingFields, wirkungslosigkeit } from '@/domain/rilsa/settings'; import { wirkungsvermerk } from '@/services/export/bewertung'; import { validateProject } from '@/domain/validation'; import type { Project } from '@/domain/model/project'; /** * Kennwerte, die einstellbar sind und in keine Zahl eingehen (Befund * C20) - und die Kennwerte der Engstellen-Vorschau, die in * eine eingehen sollen (Befund D1). * * C20: Die drei Progressionsgeschwindigkeiten der Koordinierung standen in der * Vorgabenverwaltung unter dem Kopfsatz "Diese Kennwerte gehen unmittelbar in * jede Berechnung ein", und der Pruefbericht verlangte fuer eine Abweichung * eine fachliche Begruendung. coordination.ts wird von keiner Ansicht und * keiner Rechnung aufgerufen; quellen.ts sagt das seit jeher. Eine Begruendung * fuer eine Zahl zu verlangen, die in kein Ergebnis eingeht, ist eine leere * Forderung - und sie lenkt von den Abweichungen ab, die wirklich zu * begruenden sind. * * D1: engstellenFolgen rechnete fest mit RILSA_DEFAULTS. Wer die * Raeumgeschwindigkeit angepasst hatte, bekam in Assistent und Ausdruck eine * Zwischenzeit, die im Plan daneben nicht vorkommt - und im Ausdruck ist genau * diese Zahl die Begruendung der Umlaufzeitobergrenze. * * Gegen den Altstand schlagen diese Faelle fehl: `wirkungslosigkeit` gab es * nicht, der Pruefbericht kannte als wirkungslosen Kennwert allein den * Instationaritaetsfaktor, und engstellenFolgen nahm keinen dritten Parameter * entgegen (die Vorschau blieb bei jeder Vorgabe dieselbe). */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); /** Alle Herkuenfte, die an einem Vorgabenfeld haengen. */ const HERKUENFTE = [...new Set(settingFields().map((f) => f.quellenSchluessel))]; /* * EIN Grundprojekt fuer alle Faelle: createStandardIntersectionProject vergibt * bei jedem Aufruf neue Kennungen. Zwei getrennt erzeugte Projekte * unterscheiden sich in jeder Signalgruppen- und Phasenkennung - der Vergleich * zweier Plaene weiter unten faende dann einen Unterschied, der mit der * Vorgabe nichts zu tun hat. */ const GRUNDPROJEKT = createStandardIntersectionProject('Wirkung', FESTES_DATUM); function projektMitVorgabe( vorgaben: Record, verfahren: 'HBS' | 'HCM' = 'HBS', ): Project { return { ...GRUNDPROJEKT, settings: { ...GRUNDPROJEKT.settings, serviceLevelScale: verfahren, rilsa: vorgaben }, }; } function befundZu(project: Project, key: string) { const bericht = validateProject(project, buildSignalPlan(project), FESTES_DATUM); return bericht.findings.find((f) => f.rule === `vorgaben.abweichung.${key}`); } describe('C20 - Auskunft des Fachkerns: welcher Kennwert wirkt nicht', () => { it('nennt die Koordinierung wirkungslos, solange kein Straßenzug erfasst ist', () => { /* * SEIT 5.35.0 HAENGT DER VERMERK AM PROJEKT, NICHT AN DER FASSUNG. Bis * dahin lautete er "ohne Wirkung in dieser Fassung", und das war richtig: * Die Koordinierung wurde ueberhaupt nicht gerechnet. Seit es die Ansicht * "Koordinierung" gibt, waere derselbe Satz eine falsche Auskunft ueber * eine wirkende Zahl - genau der Fehler, den Befund C20 in der * Gegenrichtung geruegt hat. */ for (const verfahren of ['HBS', 'HCM'] as const) { const auskunft = wirkungslosigkeit('rilsa-koordinierung', verfahren, { koordinierungErfasst: false, }); expect(auskunft, `Koordinierung bei ${verfahren}`).not.toBeNull(); expect(auskunft!.vermerk).toBe('ohne Wirkung: keine Koordinierung erfasst'); expect(auskunft!.begruendung).toContain('kein Straßenzug erfasst'); // Der Satz, der an die Stelle der Begruendungspflicht tritt. expect(auskunft!.zutun).toContain('Setzen Sie den Wert zurück'); expect(auskunft!.zutun).not.toContain('zu begründen'); } }); it('erklärt die Koordinierung für wirksam, sobald ein Straßenzug erfasst ist', () => { // Die Gegenprobe zum Fall darueber, und der eigentliche Gewinn der Koordinierungskette: // Mit erfasstem Strassenzug gehen die Kennwerte in Reisezeit, Versatz und // Bandbreite ein - der Vermerk muss dann verschwinden. for (const verfahren of ['HBS', 'HCM'] as const) { expect( wirkungslosigkeit('rilsa-koordinierung', verfahren, { koordinierungErfasst: true }), `Koordinierung bei ${verfahren}`, ).toBeNull(); } }); it('fällt ohne Angabe auf "der Wert wirkt" zurück', () => { // Die Grundregel des Kontexts: Wer die Lage nicht kennt, bekommt eine // Begruendungspflicht zu viel statt einer Wirkungsbehauptung zu wenig. expect(wirkungslosigkeit('rilsa-koordinierung', 'HBS')).toBeNull(); }); it('bleibt beim Instationaritaetsfaktor bei der Auskunft aus P3: nur ohne HBS', () => { expect(wirkungslosigkeit('hbs-wartezeit', 'HBS')).toBeNull(); const auskunft = wirkungslosigkeit('hbs-wartezeit', 'HCM'); expect(auskunft).not.toBeNull(); expect(auskunft!.vermerk).toBe('ohne Wirkung: Bewertungsverfahren HCM'); }); it('erklaert jeden anderen Kennwert fuer wirksam - auch die, die kein Regelwerkswert sind', () => { // Die Koordinierung steht NICHT mehr in dieser Liste: Ohne Kontext gilt // sie als wirksam, wie jeder andere Kennwert auch. const wirkungslos: QuellenSchluessel[] = ['hbs-wartezeit']; for (const schluessel of HERKUENFTE) { if (wirkungslos.includes(schluessel)) continue; for (const verfahren of ['HBS', 'HCM'] as const) { expect( wirkungslosigkeit(schluessel, verfahren), `${schluessel} (${verfahren}) darf nicht als wirkungslos gelten`, ).toBeNull(); } } }); it('haengt die Koordinierung wirklich an der Quelle, die ihre Wirkungslosigkeit schon sagt', () => { const felder = settingFields().filter((f) => f.quellenSchluessel === 'rilsa-koordinierung'); expect(felder.map((f) => f.key)).toEqual([ 'coordinationMinSpeed', 'coordinationMaxSpeed', 'coordinationDefaultSpeed', ]); // Vorbedingung des Befundes: Das Fundstellenverzeichnis sagt es laengst, // die Vorgabenansicht und der Pruefbericht sagten das Gegenteil. // Das Fundstellenverzeichnis sagt seit 5.35.0 BEIDES: wo die Kennwerte // wirken und wo nicht. Eine Verwendung zu verschweigen, die es gibt, laesst // den Pruefer eine wirkende Zahl fuer folgenlos halten. expect(QUELLEN['rilsa-koordinierung'].verwendung).toContain( 'Sie wirken in der Ansicht „Koordinierung"', ); expect(QUELLEN['rilsa-koordinierung'].verwendung).toContain( 'in den Signalzeitenplan dieses Knotenpunkts gehen sie nicht ein', ); }); }); describe('C20 - Beleg: eine geaenderte Progressionsgeschwindigkeit aendert kein Ergebnis', () => { /** * Der Nachweis, dass der Vermerk nicht selbst eine Behauptung ist: Zwei * Plaene, die sich allein in der Progressionsgeschwindigkeit unterscheiden, * ergeben dieselben Zahlen. Verglichen wird alles AUSSER `defaults` - der * Vorgabensatz ist die Eingabe, nicht das Ergebnis, und in ihm steht der * geaenderte Wert selbstverstaendlich drin. */ it('liefert denselben Signalzeitenplan wie ohne Vorgabe', () => { const ohne = buildSignalPlan(projektMitVorgabe({})); const mit = buildSignalPlan(projektMitVorgabe({ coordinationDefaultSpeed: 70 })); const ergebnis = (plan: ReturnType): string => JSON.stringify({ ...plan, defaults: null }); expect(ergebnis(mit)).toBe(ergebnis(ohne)); // Und der Wert ist wirklich angekommen - sonst waere die Gleichheit oben // nur der Beweis, dass die Vorgabe verworfen wurde. expect(mit.defaults.coordination.defaultSpeed).toBe(70); expect(ohne.defaults.coordination.defaultSpeed).toBe(RILSA_DEFAULTS.coordination.defaultSpeed); }); }); describe('C20 - Pruefbericht verlangt keine Begruendung fuer eine wirkungslose Abweichung', () => { it('meldet die Abweichung, nennt aber den Grund der Wirkungslosigkeit statt der Begruendungspflicht', () => { const befund = befundZu( projektMitVorgabe({ coordinationDefaultSpeed: 70 }), 'coordinationDefaultSpeed', ); expect(befund, 'Die Abweichung wird nicht verschwiegen').toBeDefined(); expect(befund!.title).toContain('Progressionsgeschwindigkeit'); expect(befund!.message).toContain('Angesetzt: 70 km/h'); expect(befund!.message).toContain('Koordinierung'); expect(befund!.message).toContain('kein Straßenzug erfasst'); // Der Kern des Befundes: kein "zu begruenden" mehr. expect(befund!.suggestion).not.toContain('zu begründen'); expect(befund!.suggestion).toContain('Setzen Sie den Wert zurück'); // Die Fundstelle bleibt die Quelle des Kennwerts. expect(befund!.reference).toContain('Koordinierung benachbarter Lichtsignalanlagen'); }); it('verlangt fuer eine WIRKSAME Abweichung weiterhin die fachliche Begruendung', () => { const befund = befundZu( projektMitVorgabe({ clearingSpeedKfzStraight: 8 }), 'clearingSpeedKfzStraight', ); expect(befund).toBeDefined(); expect(befund!.suggestion).toBe('Die Abweichung ist in den Planunterlagen zu begründen.'); expect(befund!.message).not.toContain('ohne Wirkung'); }); it('behandelt den Instationaritaetsfaktor unveraendert wie seit P3', () => { const hcm = befundZu( projektMitVorgabe({ instationaritaetsfaktor: 1.3 }, 'HCM'), 'instationaritaetsfaktor', ); expect(hcm!.suggestion).toContain('Bewertungsverfahren'); expect(hcm!.suggestion).not.toContain('zu begründen'); const hbs = befundZu( projektMitVorgabe({ instationaritaetsfaktor: 1.3 }, 'HBS'), 'instationaritaetsfaktor', ); expect(hbs!.suggestion).toBe('Die Abweichung ist in den Planunterlagen zu begründen.'); }); it('nimmt die Abweichung NICHT aus der Liste heraus - sie bleibt sichtbar', () => { const { deviations } = resolveRilsaSettings( { coordinationDefaultSpeed: 70 }, undefined, 'knotenpunkt', ); expect(deviations.map((d) => d.key)).toContain('coordinationDefaultSpeed'); }); }); describe('C20 - Vermerk im Fundstellenverzeichnis', () => { it('haengt den Vermerk an die Koordinierung, solange kein Strassenzug erfasst ist', () => { for (const verfahren of ['HBS', 'HCM'] as const) { expect( wirkungsvermerk('rilsa-koordinierung', verfahren, { koordinierungErfasst: false }), ).toBe(' (ohne Wirkung: keine Koordinierung erfasst)'); expect( wirkungsvermerk('rilsa-koordinierung', verfahren, { koordinierungErfasst: true }), ).toBe(''); } }); it('laesst den Vermerk fuer wirksame Herkuenfte leer', () => { expect(wirkungsvermerk('rilsa-zwischenzeiten', 'HBS')).toBe(''); expect(wirkungsvermerk('hbs-wartezeit', 'HBS')).toBe(''); expect(wirkungsvermerk('hbs-wartezeit', 'HCM')).toBe( ' (ohne Wirkung: Bewertungsverfahren HCM)', ); }); }); describe('D1 - Engstellen-Vorschau rechnet mit den uebergebenen Kennwerten', () => { /** Vorgabensatz mit einer geaenderten Raeumgeschwindigkeit Kfz geradeaus. */ const mitRaeumgeschwindigkeit = (wert: number) => resolveRilsaSettings({ clearingSpeedKfzStraight: wert }, undefined, 'einstreifig').defaults; it('verlaengert die Zwischenzeit, wenn langsamer geraeumt wird', () => { /* * NACHGEZOGEN MIT 5.27.0: Die Engstelle rechnet nach RiLSA 2015, * Abschnitt 5.2.2, und dort steht die Raeumgeschwindigkeit im Regelwerk - * 40 km/h bei zulaessig 50 km/h. Der Vorgabenwert der Knotenpunktrechnung * (10,0 m/s) wirkt dort nicht mehr; eine ABWEICHENDE Vorgabe wirkt weiter, * aber nur nach unten. Das ist die Aussage, die D1 verlangt: Was der * Bearbeiter selbst gesetzt hat, muss sich in der Vorschau wiederfinden. */ const laenge = 400; // Regelwert: 4 + 400/40 * 3,6 = 40 s (bis 5.26.0: 3 + 406/10 = 44 s). expect(engstellenFolgen(laenge, 50).zwischenzeit).toBe(40); // Abweichende Vorgabe 8 m/s - langsamer als die 11,11 m/s der Staffel: // 4 + 400/8 = 54 s. const langsam = mitRaeumgeschwindigkeit(8); expect(langsam.clearingSpeed.kfzStraight, 'Vorbedingung: die Vorgabe gilt').toBe(8); expect(engstellenFolgen(laenge, 50, langsam).zwischenzeit).toBe(54); }); it('laesst eine schnellere Vorgabe an der Engstelle nicht durch', () => { // 12 m/s liegen ueber den 11,11 m/s der Staffel; sie wuerden die // Zwischenzeit gegenueber dem Regelwerk verkuerzen und werden verworfen. const schnell = mitRaeumgeschwindigkeit(12); expect(engstellenFolgen(400, 50, schnell).zwischenzeit).toBe(40); }); it('schlaegt bis in Umlauf und Wartezeit durch', () => { const langsam = mitRaeumgeschwindigkeit(8); const regel = engstellenFolgen(400, 50); const vorgabe = engstellenFolgen(400, 50, langsam); // Umlauf = 2 x Zwischenzeit + 2 x Mindestfreigabe (5 s). expect(regel.umlaufzeit).toBe(2 * 40 + 2 * RILSA_DEFAULTS.minGreen.kfz); expect(vorgabe.umlaufzeit).toBe(2 * 54 + 2 * RILSA_DEFAULTS.minGreen.kfz); // Wartezeit = Umlauf abzueglich der eigenen Freigabezeit. expect(vorgabe.wartezeit).toBe(2 * 54 + RILSA_DEFAULTS.minGreen.kfz); expect(vorgabe.wartezeit).toBeGreaterThan(regel.wartezeit); }); it('rechnet auch den Umlauf bei brauchbarer Freigabezeit mit den Vorgaben', () => { const langsam = mitRaeumgeschwindigkeit(8); expect(umlaufBeiFreigabe(400, 50, 20)).toBe(2 * 40 + 2 * 20); expect(umlaufBeiFreigabe(400, 50, 20, langsam)).toBe(2 * 54 + 2 * 20); }); it('bleibt ohne Angabe bei den Regelwerten - alte Aufrufe rechnen unveraendert', () => { expect(engstellenFolgen(400, 50, RILSA_DEFAULTS).zwischenzeit).toBe( engstellenFolgen(400, 50).zwischenzeit, ); expect(umlaufBeiFreigabe(400, 50, 20, RILSA_DEFAULTS)).toBe(umlaufBeiFreigabe(400, 50, 20)); }); it('hebt die Stufe an, wenn die Vorgabe den Umlauf ueber die Schranke der Anlagenart treibt', () => { // 1000 m mit dem Regelwert: 94 s Zwischenzeit, 193 s Wartezeit - ueber der // Warnschwelle von 180 s, unter der Beanstandungsschwelle von 300 s. Bis // 5.26.0 genuegten dafuer 900 m; das Verfahren des Abschnitts 5.2 raeumt // bei zulaessig 50 km/h schneller. expect(engstellenFolgen(1000, 50).zwischenzeit).toBe(94); expect(engstellenFolgen(1000, 50).stufe).toBe('warnung'); // Mit 3 m/s: 304 s Zwischenzeit, 618 s Umlauf - ueber den 300 s, die bei // einstreifiger Fuehrung zulaessig sind. expect(engstellenFolgen(900, 50, mitRaeumgeschwindigkeit(3)).stufe).toBe('fehler'); }); it('nimmt die Schwellen aus der Anlagenart, nicht aus dem Vorgabensatz', () => { /* * Wartezeitwarnung, Wartezeitfehler und die 300 s Umlauf sind Schwellen * dieses Programms (ANLAGENART_GRENZEN, Befund B8) und keine anpassbaren * Vorgaben. Eine kleinere "Groesste Umlaufzeit" darf die Vorschau deshalb * nicht umstufen - sonst liesse sich die Warnung, die vom Verzicht auf die * Ampel abraet, ueber die Vorgabenverwaltung verstellen. */ const kleinererUmlauf = resolveRilsaSettings( { cycleTimeMax: 150 }, undefined, 'einstreifig', ).defaults; expect(kleinererUmlauf.cycleTime.max, 'Vorbedingung: die Vorgabe gilt').toBe(150); const folgen = engstellenFolgen(1000, 50, kleinererUmlauf); expect(folgen.umlaufzeit).toBe(198); expect(folgen.stufe).toBe('warnung'); }); });