import { describe, expect, it } from 'vitest'; import type { Project } from '@/domain/model/project'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { computeIntergreen } from '@/domain/rilsa/intergreen'; import { QUELLEN } from '@/domain/rilsa/quellen'; import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types'; import type { Meters } from '@/domain/units'; import { validateProject } from '@/domain/validation/engine'; import { ASSISTENT_VORGABE, FURT_MINDESTWEGE, assistentZusammenfassung, furtAnnahmeText, furtWegeAnnahme, projektAusAngaben, } from '@/ui/assistent'; import { HILFE } from '@/ui/help/texte'; /** * Der Einfahrweg, den der gefuehrte Einstieg einer Fussgaengerfurt gibt. * * VORGESCHICHTE (Befund 9): Der Einstieg hob den halben Kfz-Einfahrweg auf eine * Untergrenze von 3 m an. In tz = tue + tr - te wird die Einfahrzeit ABGEZOGEN; * die Furt ist an dieser Stelle der einfahrende Strom, ein groesserer * Einfahrweg verkuerzt also die Zwischenzeit ZUM Fussgaenger. Die Untergrenze * fiel weg, die Halbierung blieb. * * JETZT (Fassung 5.43.0): Auch die Halbierung faellt weg - der Einfahrweg ist * 0 m. Nach RiLSA 2015 (Abschnitt 2.5.3) ist die Einfahrzeit des Fussgaengers * 0 s, wenn die Konfliktflaeche am Fahrbahnrand beginnt; mit 1,5 m/s wird nur * gerechnet, wenn der raeumende Verkehr den Randfahrstreifen nicht benutzen * darf. Die Arme des KNOTENPUNKTS sind einstreifig (`createArm(..., 1, ...)`), * und jede Kfz-Gruppe darin fuehrt einen Fahrstreifen (`lanes ?? 1`) - auch die * eigene Linksabbiegergruppe. Der Kfz-Strom darf den Randfahrstreifen also * benutzen, und die Furt beginnt an der Bordkante unmittelbar daneben. * Dieselbe Rechnung setzen seit 5.43.0 die Vermessung aus dem Lageplan * (`vermesseWege`) und die Vorlage (`factory.ts`) an; die * Fussgaengerschutzanlage des Einstiegs tat es schon immer - sie legt zwar zwei * Fahrstreifen an, und beide sind benutzbar. * * DIE GRENZE DIESER AUSSAGE (Stand 17.09.2026): Der Knotenpunkt war * die letzte Stelle, die einen Plan NEU ANLEGT oder VERMISST und dabei einem * Fussgaenger einen Einfahrweg annahm. Nicht die letzte im Programm: Beim * Einlesen eines 4.x-Projekts bekommt jede Beziehung den global hinterlegten * Einfahrweg der Altdatei, auch eine einfahrende Furt (`migrate.ts`; fehlt er, * sind es 10 m). Hier stand die Aussage bis dahin ohne diese Grenze. * * WO 0 m eine VERSCHAERFUNG waere: vor einem Strom, der den Randfahrstreifen * nicht benutzen darf - eine mehrstreifige Zufahrt mit eigenem * Abbiegestreifen, wie in der mitgelieferten Beispieldatei. Die Richtung * stimmt auch dort (laengere Zwischenzeit), aber es ist dann eine Annahme * dieses Programms und keine Umsetzung der RiLSA. Ebenso verfaehrt die * Vermessung aus dem Lageplan (siehe `vermessung.ts`). AM KNOTENPUNKT DES * ASSISTENTEN kommt der Fall nicht vor; bis zum 17.09.2026 * stand hier seine eigene Linksabbiegergruppe als Beispiel, und die fuehrt im * Randfahrstreifen ihrer Zufahrt. */ const FURT_MINDESTWEGE_FELDER = FURT_MINDESTWEGE as Readonly>; const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); /** Zwischenzeit vom raeumenden Kfz-Strom zur einfahrenden Furt. */ function zwischenzeitZurFurt(einfahrwegFurt: Meters, raeumwegKfz: Meters): number { const raeumend: ClearingStream = { mode: 'kfz', movement: 'geradeaus', clearingDistance: raeumwegKfz, vehicleClass: 'pkw', vZul: 50, }; const einfahrend: EnteringStream = { mode: 'fuss', movement: 'geradeaus', enteringDistance: einfahrwegFurt, }; return computeIntergreen(raeumend, einfahrend).intergreen; } /** * Der Altstand am fertigen Projekt nachgebaut: Jede einfahrende Furt bekommt * den halben Kfz-Einfahrweg von 4 m zurueck, alles andere bleibt. * * Nur so lassen sich die Wirkungen auf den GERECHNETEN Plan nebeneinander * messen - die Funktion `furtWegeAnnahme` nimmt den Kfz-Einfahrweg nicht mehr * entgegen, und ueber die Angaben ist der Altstand deshalb nicht mehr * erreichbar. */ function altstandMitEinfahrweg(projekt: Project, se: Meters): Project { const furten = new Set(projekt.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id)); return { ...projekt, conflicts: projekt.conflicts.map((c) => furten.has(c.toId) ? { ...c, enteringDistance: se } : c, ), }; } /** Zwischenzeiten eines Plans als "K1->F1" auf Sekunden. */ function zwischenzeiten(projekt: Project): ReadonlyMap { const name = new Map(projekt.signalGroups.map((g) => [g.id, g.name])); return new Map( [...buildSignalPlan(projekt).intergreens.values()].map((z) => [ `${name.get(z.fromId) ?? '?'}->${name.get(z.toId) ?? '?'}`, z.value, ]), ); } describe('Assistent: kein angenommener Einfahrweg fuer Fussgaenger (5.43.0)', () => { it('setzt den Einfahrweg der Furt auf 0 m, gleich welcher Kfz-Einfahrweg eingetragen ist', () => { // Am angelegten Projekt und nicht an der Funktion: Der Kfz-Einfahrweg geht // dort ueberhaupt nicht mehr ein (`furtWegeAnnahme` nimmt ihn nicht mehr // entgegen), und genau das ist die Aussage. for (const einfahrweg of [1, 2, 3, 4, 5, 6, 8, 12, 20]) { const projekt = projektAusAngaben({ ...ASSISTENT_VORGABE, einfahrweg }, 'F', FESTES_DATUM); const furten = new Set( projekt.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id), ); const einfahrendeFurt = projekt.conflicts.filter((c) => furten.has(c.toId)); expect(einfahrendeFurt.length, `Kfz-Einfahrweg ${einfahrweg} m`).toBeGreaterThan(0); for (const c of einfahrendeFurt) { expect(c.enteringDistance, `Kfz-Einfahrweg ${einfahrweg} m`).toBe(0); } } }); it('laesst den Raeumweg der Furt unveraendert - dort ist die Untergrenze die sichere Seite', () => { expect(furtWegeAnnahme({ raeumweg: 22 })).toEqual({ raeumweg: 11, einfahrweg: 0 }); expect(furtWegeAnnahme({ raeumweg: 8 })).toEqual({ raeumweg: FURT_MINDESTWEGE.raeumweg, einfahrweg: 0, }); expect(furtWegeAnnahme({ raeumweg: 4 })).toEqual({ raeumweg: FURT_MINDESTWEGE.raeumweg, einfahrweg: 0, }); }); it('fuehrt in FURT_MINDESTWEGE weiterhin nur eine Untergrenze fuer den Raeumweg', () => { expect(FURT_MINDESTWEGE.raeumweg).toBe(6); expect(Object.keys(FURT_MINDESTWEGE_FELDER)).toEqual(['raeumweg']); }); it('verlaengert dadurch die Zwischenzeit zum Fussgaenger um zwei ganze Sekunden', () => { /* * Die Zahlen des Assistenten (Vorgabe): Raeumweg 22 m, Kfz geradeaus, * vr 10 m/s, lFZ 6 m, tue = Gelbzeit 3 s bei 50 km/h, Furt als * einfahrender Strom mit ve = 1,5 m/s. * * bisher (4 m): 3 + (22 + 6)/10 - 4/1,5 = 5,80 - 2,67 = 3,13 s -> 4 s * jetzt (0 m): 3 + (22 + 6)/10 - 0 = 5,80 s -> 6 s * * Die Untergrenze tue + tr >= tG + 1 s (RiLSA 2015, Abschnitt 2.5.2) * greift in beiden Faellen nicht: 5,80 s >= 4 s. */ const furt = furtWegeAnnahme(ASSISTENT_VORGABE); expect(furt.einfahrweg).toBe(0); expect(zwischenzeitZurFurt(furt.einfahrweg, ASSISTENT_VORGABE.raeumweg)).toBe(6); expect(zwischenzeitZurFurt(4, ASSISTENT_VORGABE.raeumweg)).toBe(4); }); it('traegt die 0 in jede Konfliktbeziehung, in der eine Furt einfaehrt', () => { const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'F', FESTES_DATUM); const furten = new Set(projekt.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id)); expect(furten.size).toBe(4); const einfahrendeFurt = projekt.conflicts.filter((c) => furten.has(c.toId)); expect(einfahrendeFurt.length).toBeGreaterThan(0); for (const c of einfahrendeFurt) { expect(c.enteringDistance).toBe(0); expect(c.note).toContain('Einfahrweg 0 m'); } // Die Kfz-Beziehungen behalten den eingetragenen Einfahrweg: Die Regel der // RiLSA gilt dem einfahrenden FUSSGAENGER, nicht jedem Strom. for (const c of projekt.conflicts) { if (furten.has(c.toId) || furten.has(c.fromId)) continue; expect(c.enteringDistance).toBe(ASSISTENT_VORGABE.einfahrweg); expect(c.clearingDistance).toBe(ASSISTENT_VORGABE.raeumweg); } }); it('steht die laengere Zwischenzeit auch im gerechneten Plan der angelegten Kreuzung', () => { // Nicht die Formel, sondern der Plan, den der Anwender sieht: K1 raeumt, // eine Furt faehrt ein - 6 s statt der 4 s des Altstands. const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'F', FESTES_DATUM); const plan = buildSignalPlan(projekt); const k1 = projekt.signalGroups.find((g) => g.name === 'K1')!; const furtIds = new Set(projekt.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id)); const vonK1ZurFurt = [...plan.intergreens.values()].filter( (z) => z.fromId === k1.id && furtIds.has(z.toId), ); expect(vonK1ZurFurt.length).toBeGreaterThan(0); for (const z of vonK1ZurFurt) expect(z.value).toBe(6); }); it('verlaengert die Zwischenzeit von Furt zu Furt sogar um drei Sekunden', () => { /* * DIE GROESSERE DER BEIDEN WIRKUNGEN, und bis zum 17.09.2026 nannte sie * kein Text: Raeumt eine Furt und faehrt eine andere ein, ist tue = 0 s * (Fussgaenger, RiLSA 2015 Fall 6) und tr = 11 m / 1,2 m/s = 9,17 s. Der * alte Einfahrweg von 4 m zog 4 / 1,5 = 2,67 s ab, macht 6,50 s -> 7 s; * ohne ihn bleiben 9,17 s -> 10 s. * * Unveraendert bleiben die beiden Beziehungen ohne einfahrende Furt: Kfz vor * Kfz 6 s und Furt vor Kfz 9 s. */ const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'F', FESTES_DATUM); const neu = zwischenzeiten(projekt); const alt = zwischenzeiten(altstandMitEinfahrweg(projekt, 4)); expect(neu.get('F1->F3')).toBe(10); expect(alt.get('F1->F3')).toBe(7); expect(neu.get('K1->F1')).toBe(6); expect(alt.get('K1->F1')).toBe(4); expect(neu.get('F1->K1')).toBe(9); expect(alt.get('F1->K1')).toBe(9); expect(neu.get('K1->K3')).toBe(6); expect(alt.get('K1->K3')).toBe(6); // Keine einzige Zwischenzeit wird kuerzer - die Richtung der ganzen // Aenderung, an allen 32 Beziehungen der Kreuzung geprueft. expect(neu.size).toBe(32); for (const [paar, wert] of neu) { expect(wert, `Beziehung ${paar}`).toBeGreaterThanOrEqual(alt.get(paar) ?? 0); } }); it('macht den Umlauf laenger und meldet die ungerade Sekunde im Pruefbericht', () => { /* * Die Folge der laengeren Zwischenzeiten steht im Plan, den der Anfaenger * als Ersten sieht: Der Umlauf der Vorgabekreuzung steigt von 40 s auf * 45 s, und die Freigabezeitverteilung laesst eine ungerade Sekunde uebrig. * Nach dem Grundsatz des Projekts gehoert die in den Pruefbericht, und dort * steht sie - ohne einen neuen Fehler. */ const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'F', FESTES_DATUM); const alt = altstandMitEinfahrweg(projekt, 4); const planNeu = buildSignalPlan(projekt); const planAlt = buildSignalPlan(alt); expect(planNeu.cycleTime).toBe(45); expect(planAlt.cycleTime).toBe(40); const berichtNeu = validateProject(projekt, planNeu, FESTES_DATUM); const berichtAlt = validateProject(alt, planAlt, FESTES_DATUM); const regeln = (bericht: ReturnType): readonly string[] => bericht.findings.map((b) => b.rule); expect(regeln(berichtNeu)).toContain('signalplan.freigabezeit-restsekunde'); expect(regeln(berichtAlt)).not.toContain('signalplan.freigabezeit-restsekunde'); expect(berichtNeu.errorCount).toBe(0); expect(berichtNeu.errorCount).toBe(berichtAlt.errorCount); expect(berichtNeu.warningCount).toBe(berichtAlt.warningCount); }); it('legt jeden Arm und jede Kfz-Gruppe einstreifig an - darauf beruht die 0', () => { /* * Die Begruendung des Einfahrwegs 0 m haengt daran, dass der raeumende * Kfz-Strom den Fahrstreifen am Fahrbahnrand benutzen darf. Beim * Knotenpunkt dieses Assistenten ist das der einzige Fahrstreifen, den es * gibt - auch fuer die eigene Linksabbiegergruppe, die keinen eigenen * Abbiegestreifen bekommt. Bis zum 17.09.2026 nannten * Kommentar und Unterlagen sie als Fall der Verschaerfung; belegt ist das * hier nicht. */ const projekt = projektAusAngaben( { ...ASSISTENT_VORGABE, linksabbieger: true }, 'F', FESTES_DATUM, ); for (const arm of projekt.intersection.arms) { expect(arm.lanes, `Arm ${arm.name}`).toBe(1); } const linksabbieger = projekt.signalGroups.filter((g) => g.movement === 'links'); expect(linksabbieger.length).toBeGreaterThan(0); for (const g of projekt.signalGroups) { expect(g.lanes, `Signalgruppe ${g.name}`).toBe(1); } }); }); /** * DER RAEUMWEG DER FURT bleibt der halbe Kfz-Raeumweg (mindestens 6 m) - ein * Faustwert des Assistenten, kein Regelwerkswert. Er wirkt an ZWEI Stellen, * und dieser Block haelt beide fest, weil der angezeigte Satz sie zusagt. */ describe('Assistent: der angenommene Furt-Raeumweg wirkt doppelt', () => { it('ist zugleich die Furtlaenge der Mindestfreigabezeit', () => { /* * `furtlaengeDerGruppe` (plan/signalPlan.ts) nimmt den laengsten Raeumweg * der Beziehungen, in denen die Gruppe raeumt - beim Assistenten also * genau die Annahme. Bei der Vorgabe 22 m sind das 11,0 m; mit * Zusatzeinrichtung fuer Blinde und Sehbehinderte waeren 11/1,2 = 9,17 s * -> 10 s massgebend, ohne sie 11/2/1,2 = 4,58 s -> 5 s und damit der * Regelwert. Ein zu kurz angenommener Raeumweg verkuerzt also nicht nur * die Zwischenzeit, sondern auch die Freigabezeit des Fussgaengers. */ const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'F', FESTES_DATUM); const plan = buildSignalPlan(projekt); const furt = plan.groups.find((g) => g.mode === 'fuss')!; expect(furt.mindestfreigabezeit.furt?.furtlaenge).toBe( furtWegeAnnahme(ASSISTENT_VORGABE).raeumweg, ); expect(furt.mindestfreigabezeit.furt?.furtlaenge).toBe(11); }); it('und der Anwender erfaehrt beide Wirkungen', () => { // Die Messung oben ist nur dann eine Auskunft, wenn sie irgendwo steht: // im Satz des Assistenten und in der Hilfe zum Raeumweg. expect(furtAnnahmeText(ASSISTENT_VORGABE)).toContain( 'aus ihm folgt auch die Mindestfreigabezeit', ); expect(HILFE.raeumweg.woher ?? '').toContain('zugleich die Furtlänge'); expect(HILFE.raeumweg.woher ?? '').toContain('verkürzt deshalb beides'); }); }); describe('Wortlaut: die sichtbaren Texte nennen den Wert, der angesetzt wird', () => { it('nennt der Satz des Assistenten den Einfahrweg 0 m und seinen Grund', () => { const satz = furtAnnahmeText({ raeumweg: 22 }); expect(satz).toContain('halber Räumweg: 11,0 m'); expect(satz).toContain('mindestens 6 m'); expect(satz).toContain('Einfahrweg 0 m'); expect(satz).toContain('Fahrbahnrand'); expect(satz).toContain('Annahme, im Lageplan prüfen'); // Die beiden ueberholten Staende duerfen nicht zurueckkehren. expect(satz).not.toContain('halber Einfahrweg'); expect(satz).not.toContain('mindestens 3 m'); }); it('sagt derselbe Satz, dass der Raeumweg die ganze Furt abdecken muss', () => { const satz = furtAnnahmeText({ raeumweg: 22 }); expect(satz).toContain('Bordkante zu Bordkante'); expect(satz).toContain('Mittelinsel'); }); it('steht er unveraendert in der Zusammenfassung des letzten Schritts', () => { const text = assistentZusammenfassung({ ...ASSISTENT_VORGABE, fussgaenger: true }).join(' '); expect(text).toContain(furtAnnahmeText(ASSISTENT_VORGABE)); }); it('nennt die Bemerkung der Konfliktbeziehung dieselbe Zahl', () => { const projekt = projektAusAngaben( { ...ASSISTENT_VORGABE, raeumweg: 8, einfahrweg: 4 }, 'F', FESTES_DATUM, ); const furten = new Set(projekt.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id)); const beziehung = projekt.conflicts.find((c) => furten.has(c.toId))!; expect(beziehung.enteringDistance).toBe(0); expect(beziehung.note).toContain('Einfahrweg 0 m (Fahrbahnrand'); expect(beziehung.note).not.toContain('halber Einfahrweg'); }); it('sagt die Bemerkung je Beziehung nur, was an IHR angesetzt wurde', () => { /* * Stand 17.09.2026: Bis dahin stand an jeder Beziehung mit * Furtbeteiligung derselbe Satz mit beiden Zahlen. An "Furt raeumt, Kfz * faehrt ein" nannte er "Einfahrweg 0 m", waehrend dort der Kfz-Einfahrweg * von 8 m gespeichert ist, und an "Kfz raeumt, Furt faehrt ein" den halben * Raeumweg von 11,0 m, waehrend dort der ganze Kfz-Raeumweg von 22 m steht. * Eine Bemerkung, die eine Zahl nennt, die an dieser Beziehung nicht gilt, * ist keine Auskunft. */ const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'F', FESTES_DATUM); const furten = new Set(projekt.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id)); let raeumendeFurt = 0; let einfahrendeFurt = 0; let furtVorFurt = 0; for (const c of projekt.conflicts) { const raeumt = furten.has(c.fromId); const faehrtEin = furten.has(c.toId); const note = c.note ?? ''; if (raeumt && !faehrtEin) { raeumendeFurt += 1; expect(c.clearingDistance).toBe(11); expect(c.enteringDistance).toBe(ASSISTENT_VORGABE.einfahrweg); expect(note).toContain('Furt räumt: halber Räumweg (11,0 m)'); expect(note, 'die Bemerkung nennt einen Einfahrweg, der hier nicht gilt').not.toContain( 'Einfahrweg 0 m', ); } if (faehrtEin && !raeumt) { einfahrendeFurt += 1; expect(c.clearingDistance).toBe(ASSISTENT_VORGABE.raeumweg); expect(c.enteringDistance).toBe(0); expect(note).toContain('Furt fährt ein: Einfahrweg 0 m (Fahrbahnrand'); expect(note, 'die Bemerkung nennt einen Räumweg, der hier nicht gilt').not.toContain( 'halber Räumweg', ); } if (raeumt && faehrtEin) { furtVorFurt += 1; expect(note).toContain('Furt räumt: halber Räumweg (11,0 m)'); expect(note).toContain('Furt fährt ein: Einfahrweg 0 m (Fahrbahnrand'); } expect(note).toContain('im Lageplan prüfen'); } // Alle drei Faelle kommen an der Vorgabekreuzung wirklich vor. expect([raeumendeFurt, einfahrendeFurt, furtVorFurt].every((n) => n > 0)).toBe(true); }); it('nennt die Hilfe zum Raeumweg den neuen Ansatz', () => { const woher = HILFE.raeumweg.woher ?? ''; expect(woher).toContain('halben Räumweg'); expect(woher).toContain('mindestens 6 m'); expect(woher).toContain('Einfahrweg 0 m'); expect(woher).not.toContain('halben Einfahrweg'); expect(woher).not.toContain('ohne Untergrenze'); }); it('nennt die Hilfe zur Mittelinsel den uebernommenen Einfahrweg', () => { const woher = HILFE.mittelinsel.woher ?? ''; expect(woher).toContain('0 m'); expect(woher).toContain('nicht benutzen darf'); }); it('bleibt bei der Aussage der Hilfe zum Einfahrweg', () => { // Die Regel, gegen die der Assistent verstiess - sie stand schon da. expect(HILFE.einfahrweg.wennFalsch ?? '').toContain( 'Ein zu langer Einfahrweg verkürzt die Zwischenzeit', ); }); it('nennt die Fundstelle BEIDE Wirkungen und nicht nur die kleinere', () => { // Bis zum 17.09.2026 nannten die Texte allein "4 s statt 6 s". // Die groessere Verkuerzung lag bei drei Sekunden (Furt vor Furt), und ein // Text, der nur die kleinere nennt, laesst den Leser zu guenstig rechnen. const hinweis = QUELLEN['rilsa-zwischenzeiten'].hinweis ?? ''; expect(hinweis).toContain('4 s statt 6 s'); expect(hinweis).toContain('7 s statt 10 s'); }); });