import { describe, expect, it } from 'vitest'; import { ANLAGENART_GRENZEN, OEPNV_UEBERGANGSZEIT_STAFFEL, RILSA_DEFAULTS, RILSA_FURT, RILSA_KOORDINIERUNG, RILSA_PHASENUEBERGANG, UEBERFAHR_RAEUMZEIT_ZUSCHLAG, } from '@/domain/rilsa/constants'; import { yellowTime } from '@/domain/rilsa/signalTimes'; import { computeIntergreen } from '@/domain/rilsa/intergreen'; import { computeCoordination } from '@/domain/rilsa/coordination'; import { QUELLEN } from '@/domain/rilsa/quellen'; /** * Der Abgleich an der GEKAUFTEN RiLSA 2015 (10.09.2026, Fassung 5.27.0). * * Bis dahin hatte dieses Programm das Regelwerk nie gesehen. Es stuetzte sich * auf das FGSV-Aenderungsblatt vom 29.07.2015 - acht Seiten, die nur die * Aenderungen 2010 -> 2015 auflisten -, auf behoerdliche Leitfaeden und auf * Umkehrschluesse aus Zwischenzeitentafeln. Das trug erstaunlich weit: Der * Kern stimmt Zahl fuer Zahl. Was nicht trug, steht hier. * * DIESE DATEI BEWACHT DIE BERICHTIGUNGEN, nicht den Kern - der ist anderswo * bewacht. Jeder Fall nennt, was bis 5.26.0 galt; sonst liest sich die Zeile * wie eine beliebige Zusicherung, und beim naechsten Zweifel setzt jemand den * alten Wert zurueck. */ const OHNE_EINFAHRWEG = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0 } as const; describe('Untergrenze der Ueberfahr- und Raeumzeit (Abschnitt 2.5.2)', () => { /* * "Fuer das Ueberfahren und Raeumen geradeaus fahrender Kraftfahrzeuge * (Fall 1) oder abbiegender Kraftfahrzeuge (Fall 2) ist einzuhalten: * tue + tr >= tG + 1". Bis 5.26.0 war diese Schranke NICHT eingebaut, und * der Quelltext begruendete das ausfuehrlich - die Regel habe "als * woertliche Quelle nur eine Webseite, die sich im selben Text * widerspricht". */ const geradeaus = (clearingDistance: number, vZul: number) => computeIntergreen( { mode: 'kfz', movement: 'geradeaus', clearingDistance, vehicleClass: 'pkw', vZul }, OHNE_EINFAHRWEG, ); it('setzt den Zuschlag auf die eine Sekunde des Regelwerks', () => { // Nicht auf die Rot-Gelb-Zeit: Die ist einstellbar (bis 2 s), die RiLSA // schreibt die Zahl 1. expect(UEBERFAHR_RAEUMZEIT_ZUSCHLAG).toBe(1); }); it('hebt die Summe an, wo der Raeumweg kurz ist', () => { // 50 km/h: tG = 3 s, Untergrenze 4 s. Raeumweg 0 m + 6 m Pkw = 6 m, // tr = 0,6 s, tue = 3 s (Gelbzeitkopplung) -> 3,6 s. const kurz = geradeaus(0, 50); expect(kurz.crossingTime).toBe(3); expect(kurz.clearingTime).toBe(0.6); expect(kurz.ueberfahrRaeumzeitUntergrenze).toBe(4); expect(kurz.intergreen).toBe(4); }); it('laesst die Rechnung in Ruhe, sobald die Summe die Untergrenze erreicht', () => { // 4 m Grundraeumweg + 6 m = 10 m, tr = 1,0 s, Summe genau 4,0 s. Die // Toleranz gegen Gleitkommarauschen darf hier nicht anschlagen. const genau = geradeaus(4, 50); expect(genau.ueberfahrRaeumzeitUntergrenze).toBeNull(); expect(genau.intergreen).toBe(4); expect(genau.notes.some((n) => n.code === 'ueberfahr-raeumzeit-untergrenze')).toBe(false); }); it('waechst mit der Gelbzeit, weil die Untergrenze an tG haengt', () => { // Dieselbe Geometrie bei 70 km/h: tG = 5 s, Untergrenze 6 s. const schnell = geradeaus(0, 70); expect(schnell.ueberfahrRaeumzeitUntergrenze).toBe(6); expect(schnell.intergreen).toBe(6); }); it('zieht die Einfahrzeit auch von der angehobenen Summe ab', () => { /* * Die Untergrenze gilt der Ueberfahr- und Raeumzeit, nicht der * Zwischenzeit: tz = (tue + tr) - te. Wer sie auf tz legte, verlaengerte * jede Beziehung mit Einfahrweg unzulaessig. */ const mitEinfahrweg = computeIntergreen( { mode: 'kfz', movement: 'geradeaus', clearingDistance: 0, vehicleClass: 'pkw', vZul: 50 }, { mode: 'kfz', movement: 'geradeaus', enteringDistance: 11.1 }, ); expect(mitEinfahrweg.ueberfahrRaeumzeitUntergrenze).toBe(4); // 4 s - 1 s Einfahrzeit = 3 s. expect(mitEinfahrweg.enteringTime).toBe(1); expect(mitEinfahrweg.raw).toBe(3); expect(mitEinfahrweg.intergreen).toBe(3); }); }); describe('Uebergangszeit gesondert signalisierter OePNV-Fahrzeuge (Abschnitt 2.4)', () => { /* * Bis 5.26.0 lieferte yellowTime fuer OePNV-Gruppen 0 s mit dem Vermerk * "Sondersignale (Balkensignale) kennen kein Gelb". Sie kennen kein GELB, * aber ein Uebergangssignal nach Par. 51 BOStrab, und die RiLSA staffelt * seine Dauer nach der betrieblich zugelassenen Hoechstgeschwindigkeit. */ it('staffelt sie 4/5/6/7/8 s nach der Hoechstgeschwindigkeit', () => { expect(OEPNV_UEBERGANGSZEIT_STAFFEL.map((s) => [s.upToKmh, s.uebergang])).toEqual([ [30, 4], [40, 5], [50, 6], [60, 7], [70, 8], ]); }); it.each([ [20, 4], [30, 4], [31, 5], [40, 5], [50, 6], [60, 7], [70, 8], ])('gibt bei %s km/h %s s', (vZul, erwartet) => { expect(yellowTime('oepnv', vZul)).toBe(erwartet); }); it('nimmt oberhalb der Staffel und ohne Vmax die oberste Stufe', () => { // Dieselbe Regel wie bei der Ueberfahrzeit-Staffel: die laengere Zeit. expect(yellowTime('oepnv', 80)).toBe(8); expect(yellowTime('oepnv', 0)).toBe(8); }); it('laesst das Rot-Gelb der Sondersignale bei null', () => { // Das Uebergangssignal gibt es nur beim Wechsel von Frei auf Gesperrt. expect(RILSA_DEFAULTS.minGreen.oepnv).toBeGreaterThan(0); expect(yellowTime('fuss', 50)).toBe(0); }); }); describe('Gelbzeit des gesondert signalisierten Radverkehrs (Abschnitt 2.4)', () => { it('betraegt 2 s und nicht mehr 3 s', () => { // Die RiLSA nennt fuer gesondert signalisierten Radverkehr einheitlich // 2 s Gelb und 1 s Rot/Gelb. Hier standen bis 5.26.0 drei Sekunden. expect(RILSA_DEFAULTS.yellowRad).toBe(2); expect(yellowTime('rad', 50)).toBe(2); }); it('laesst die Ueberfahrzeit des Radverkehrs unberuehrt', () => { // Fall 5 setzt tue = 1 s an, unabhaengig von der Gelbzeit. Wer beides // koppelte, verkuerzte jede Radbeziehung um eine Sekunde. expect(RILSA_DEFAULTS.crossingTime.radDefault).toBe(1); const rad = computeIntergreen( { mode: 'rad', movement: 'geradeaus', clearingDistance: 20, vehicleClass: 'rad', vZul: 50 }, OHNE_EINFAHRWEG, ); expect(rad.crossingTime).toBe(1); }); }); describe('Umlaufzeit und Auslastungsgrad (Abschnitt 2.6)', () => { it('beginnt den Regelbereich am Knotenpunkt bei 30 s', () => { // "Als Richtwerte fuer die Umlaufzeit gelten: minimal 30 s, maximal // 90 (120) s." Bis 5.26.0 begann der Bereich bei 60 s - einer Zahl dieses // Programms -, und jeder Umlauf darunter wurde beanstandet. expect(RILSA_DEFAULTS.cycleTime.recommendedMin).toBe(30); expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMin).toBe(30); expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMax).toBe(90); // Und die Zeile sagt es auch: gesichert, mit Fundstelle. expect(ANLAGENART_GRENZEN.knotenpunkt.herkunft.regelbereich.pruefstand).toBe('gesichert'); }); it('haelt beide Auslastungsgrade im Band 0,80 bis 0,90', () => { const { targetDegreeOfSaturation, maxDegreeOfSaturation } = RILSA_DEFAULTS.capacity; for (const wert of [targetDegreeOfSaturation, maxDegreeOfSaturation]) { expect(wert).toBeGreaterThanOrEqual(0.8); expect(wert).toBeLessThanOrEqual(0.9); } // Der Hoechstwert lag bis 5.26.0 bei 0,95 und damit ausserhalb. expect(maxDegreeOfSaturation).toBe(0.9); expect(targetDegreeOfSaturation).toBeLessThan(maxDegreeOfSaturation); }); }); describe('Koordinierung (Abschnitt 4.4.4)', () => { const knoten = (name: string, distanceToPrevious: number) => ({ id: name, name, distanceToPrevious, greenStart: 0, greenDuration: 30, }); it('kennt die relative Regel und den Wirkbereich', () => { expect(RILSA_KOORDINIERUNG.progressionsanteilVon).toBe(0.9); expect(RILSA_KOORDINIERUNG.progressionsanteilBis).toBe(1); expect(RILSA_KOORDINIERUNG.wirkbereichRegel).toBe(750); expect(RILSA_KOORDINIERUNG.wirkbereichGuenstig).toBe(1000); expect(RILSA_KOORDINIERUNG.auslastungsgradGrenze).toBe(0.85); }); it('meldet eine Progressionsgeschwindigkeit ausserhalb von 90 bis 100 % der Vzul', () => { // 50 km/h bei Vzul 70: unter 63 km/h und damit ausserhalb. Ohne die // uebergebene Vzul gibt es die Aussage nicht - das Programm erfindet keine. const mitVzul = computeCoordination( [knoten('A', 0), knoten('B', 300)], 90, 50, RILSA_DEFAULTS, 70, ); expect( mitVzul.notes.some((n) => n.code === 'progressionsgeschwindigkeit-ausserhalb-rilsa'), ).toBe(true); const ohneVzul = computeCoordination([knoten('A', 0), knoten('B', 300)], 90, 50); expect( ohneVzul.notes.some((n) => n.code === 'progressionsgeschwindigkeit-ausserhalb-rilsa'), ).toBe(false); }); it('schweigt innerhalb des Bandes', () => { // 45 km/h bei Vzul 50: genau 90 %, die untere Kante gehoert dazu. const ergebnis = computeCoordination( [knoten('A', 0), knoten('B', 300)], 90, 45, RILSA_DEFAULTS, 50, ); expect( ergebnis.notes.some((n) => n.code === 'progressionsgeschwindigkeit-ausserhalb-rilsa'), ).toBe(false); }); it('stuft den Abstand zwischen den Knotenpunkten nach dem Wirkbereich ein', () => { const knapp = computeCoordination([knoten('A', 0), knoten('B', 800)], 90, 50); expect(knapp.notes.find((n) => n.code === 'abstand-ueber-regelwirkbereich')?.severity).toBe( 'hinweis', ); expect(knapp.notes.some((n) => n.code === 'abstand-ueber-wirkbereich')).toBe(false); const weit = computeCoordination([knoten('A', 0), knoten('B', 1200)], 90, 50); expect(weit.notes.find((n) => n.code === 'abstand-ueber-wirkbereich')?.severity).toBe( 'warnung', ); const nah = computeCoordination([knoten('A', 0), knoten('B', 400)], 90, 50); expect(nah.notes.some((n) => n.code.startsWith('abstand-ueber'))).toBe(false); }); }); describe('Kennwerte ohne Rechenwirkung - benannt statt verschwiegen', () => { /* * Furtbreiten (Abschnitt 3.6) und die Zeiten der Phasenuebergaenge * (Abschnitt 2.7) gehen in keine Zahl ein: Das Programm fuehrt keine * Furtbreite und bildet die bedingt vertraegliche Fuehrung nicht ab. Sie * stehen trotzdem als Konstanten da - dieselbe Begruendung wie bei * HBS_STANDARDBEDINGUNGEN: Wer die Unterlage prueft, muss lesen koennen, * was das Programm NICHT prueft. */ it('haelt die Furtbreiten der RiLSA fest', () => { expect(RILSA_FURT.regelbreite).toBe(4); expect(RILSA_FURT.mindestbreite).toBe(3); expect(RILSA_FURT.zweiterSignalgeberAb).toBe(8); }); it('haelt Mindestsperrzeit, Zeitvorsprung und verzoegerten Beginn fest', () => { expect(RILSA_PHASENUEBERGANG.mindestsperrzeit).toBe(1); expect(RILSA_PHASENUEBERGANG.zeitvorsprungVon).toBe(1); expect(RILSA_PHASENUEBERGANG.zeitvorsprungBis).toBe(2); expect(RILSA_PHASENUEBERGANG.verzoegerterBeginnVon).toBe(2); expect(RILSA_PHASENUEBERGANG.verzoegerterBeginnBis).toBe(4); }); }); describe('Fundstellen - genannt, wo das Original vorliegt', () => { it('haengt an jede RiLSA-Quelle eine am Original nachgeschlagene Nummer', () => { const rilsa = Object.entries(QUELLEN).filter(([, q]) => q.regelwerk.includes('RiLSA 2015')); expect(rilsa.length).toBeGreaterThanOrEqual(6); for (const [schluessel, quelle] of rilsa) { expect(quelle.fundstelle, schluessel).toMatch(/^Abschnitt\s+\d/); } }); it('laesst HBS, RSA und VwV-StVO ohne Nummer', () => { // Diese Werke liegen dem Programm nicht vor. Eine geratene Nummer schickt // den Pruefer an die falsche Stelle - dieselbe Lehre wie Befund D4. for (const schluessel of ['hbs-kapazitaet', 'rsa-arbeitsstellen', 'vwv-stvo'] as const) { expect(QUELLEN[schluessel].fundstelle, schluessel).toBeUndefined(); } }); });