import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import { ENGER_RADIUS_LABEL, RILSA_DEFAULTS, RILSA_ENGSTELLE, SAFETY_FLOORS, UEBERFAHRZEIT_ANSATZ_LABELS, } from '@/domain/rilsa/constants'; import { regelUeberfahrzeit } from '@/domain/rilsa/intergreen'; import { QUELLEN } from '@/domain/rilsa/quellen'; import { resolveRilsaSettings } from '@/domain/rilsa/settings'; import type { UeberfahrzeitAnsatz } from '@/domain/rilsa/types'; import { ueberfahrzeitAnsatzSatz } from '@/services/export/bewertung'; import type { ViewDefinition } from '@/ui/shell'; import { conflictsView } from '@/ui/views/conflictsView'; /** * Was das Konfliktfenster ueber die beiden Ueberfahrzeit-Ansaetze und den * engen Innenradius sagt (Fassung 5.5.0, Befunde F4, F5 und B2). * * Gegen den Stand vom 15.08.2026 schlugen fehl: * * F4 Das Fenster nannte die Ueberfahrzeit unbedingt "Regelwert" - auch beim * Ansatz "feste Ueberfahrzeit", dessen Werte quellen.ts damals als * "deshalb kein Regelwerkswert" fuehrte. Drei Felder darueber war genau * das fuer die Raeumgeschwindigkeit bereits behoben. * F4 Die Erlaeuterungsspalte der Zeile "Ueberfahrzeit tue" blieb leer, * waehrend der Ausdruck an derselben Stelle eine Herleitung nennt. * F5 Die Herleitung des Kfz-Ansatzes darf nicht neben einer Fussgaenger- oder * OePNV-Ueberfahrzeit stehen; dort gelten andere Regeln. * B2 Steht neben dem Merkmal "enger Innenradius" eine Raeumgeschwindigkeit * von Hand, hat sie Vorrang. Das Fenster behauptete das Gegenteil. * * UMGEDREHT MIT FASSUNG 5.43.0 (Befund 17): Die Faelle zu F4 * schrieben bis dahin den Stand vor 5.27.0 fest - "Regelwert" beim * Gelbzeitansatz, keiner beim festen. Seit dem Abgleich an der gekauften RiLSA * 2015 ist es umgekehrt: Der feste Ansatz ist der des Regelwerks (Abschnitt * 2.5.2, Faelle 1 und 2), die Kopplung an die Gelbzeit der Ansatz dieses * Programms. Beim Tausch der Beschriftungen in constants.ts blieb das Fenster * stehen und nannte beim Abbieger "Regelwert 3 s", wo die RiLSA 2 s setzt. Und * die Beschriftung des Gelbzeitansatzes sagte "länger als der Regelwert", * obwohl beide bei 50 km/h geradeaus 3 s ergeben. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); function zeichne( view: ViewDefinition, project: Project, ): { wurzel: HTMLElement; aufraeumen: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const abmelden = view.render(wurzel, { store: new ProjectStore(project), refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, aufraeumen: () => { abmelden(); wurzel.remove(); }, }; } /** Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf. */ function beschriftungstext(label: HTMLLabelElement): string { return [...label.childNodes] .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf'))) .map((knoten) => knoten.textContent ?? '') .join('') .trim(); } function feldZu(wurzel: ParentNode, beschriftung: string): HTMLElement | null { const label = [...wurzel.querySelectorAll('label')].find( (l) => beschriftungstext(l) === beschriftung, ); const id = label?.getAttribute('for'); if (id === undefined || id === null || id === '') return null; return wurzel.querySelector(`[id="${id}"]`); } function hinweisZu(wurzel: ParentNode, feld: HTMLElement): string { const id = feld.getAttribute('aria-describedby'); if (id === null) return ''; return id .split(' ') .map((teil) => wurzel.querySelector(`[id="${teil}"]`)?.textContent ?? '') .join(' '); } /** Erlaeuterungsspalte einer Zeile des Rechenwegs. */ function rechenwegZeile(fenster: ParentNode, bezeichnung: string): string { const zeile = [...fenster.querySelectorAll('tbody tr')].find( (tr) => tr.children[0]?.textContent?.trim() === bezeichnung, ); if (!zeile) throw new Error(`Zeile "${bezeichnung}" fehlt im Rechenweg`); return zeile.children[2]?.textContent?.trim() ?? ''; } function oeffneBeziehung(wurzel: HTMLElement, project: Project, fromName: string): ParentNode { const von = project.signalGroups.find((g) => g.name === fromName)!; const konflikt = project.conflicts.find((c) => c.fromId === von.id)!; const zeile = project.signalGroups.findIndex((g) => g.id === konflikt.fromId); const spalte = project.signalGroups.findIndex((g) => g.id === konflikt.toId); const zellen = [...wurzel.querySelectorAll('.matrix-zelle')]; const n = project.signalGroups.length; const versatz = spalte > zeile ? spalte - 1 : spalte; zellen[zeile * (n - 1) + versatz]?.click(); const fenster = document.querySelector('.dialog'); expect(fenster, `Das Fenster zu ${fromName} ist nicht offen`).not.toBeNull(); return fenster as ParentNode; } /** Knotenpunkt, dessen erste Signalgruppe rechts abbiegt. */ function mitAbbieger(aenderung: Partial = {}): Project { const basis = createStandardIntersectionProject('Prüffall', FESTES_DATUM); const erste = basis.signalGroups[0]!; return { ...basis, signalGroups: basis.signalGroups.map((g) => g.id === erste.id ? { ...g, movement: 'rechts' as const } : g, ), conflicts: basis.conflicts.map((c) => (c.fromId === erste.id ? { ...c, ...aenderung } : c)), }; } /** * Derselbe Abbieger mit gewaehltem Ansatz und - wo angegeben - eigenen * Vorgaben fuer die festen Ueberfahrzeiten. */ function mitAnsatz( ansatz: UeberfahrzeitAnsatz, aenderung: Partial = {}, rilsa: Project['settings']['rilsa'] = {}, ): Project { const basis = mitAbbieger(aenderung); return { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: ansatz, rilsa } }; } /** Zwei Kfz-Stroeme ueber eine einstreifige Engstelle (RiLSA 2015, Abschnitt 5.2). */ function baustelle(ansatz: UeberfahrzeitAnsatz): Project { const basis = createEmptyProject('Engstelle', FESTES_DATUM, 'einstreifig'); const a = createSignalGroup({ name: 'K1', mode: 'kfz', vZul: 50, index: 0 }); const b = createSignalGroup({ name: 'K2', mode: 'kfz', vZul: 50, index: 1 }); const p1 = createPhase('Seite A', [a.id]); const p2 = createPhase('Seite B', [b.id]); return { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: ansatz }, signalGroups: [a, b], conflicts: [ { ...createConflict(a.id, b.id), clearingDistance: 44, enteringDistance: 0 }, { ...createConflict(b.id, a.id), clearingDistance: 44, enteringDistance: 0 }, ], phases: [p1, p2], program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, }; } /** Hinweis unter dem Feld "Überfahrzeit" der Beziehung, die `fromName` raeumt. */ function ueberfahrzeitHinweis(project: Project, fromName: string): string { const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, fromName); const feld = feldZu(fenster, 'Überfahrzeit'); expect(feld, 'Das Feld "Überfahrzeit" fehlt im Fenster').not.toBeNull(); return hinweisZu(fenster, feld!); } finally { aufraeumen(); } } afterEach(() => { document.body.replaceChildren(); }); describe('F4 - "Regelwert" nur, wo einer steht', () => { it('nennt die feste Ueberfahrzeit Regelwert, solange sie der RiLSA entspricht', () => { const hinweis = ueberfahrzeitHinweis(mitAnsatz('fest'), 'K1'); expect(hinweis).toContain(`Regelwert ${RILSA_DEFAULTS.crossingTime.kfzAbbiegend} s`); expect(hinweis).not.toContain('Praxisansatz'); }); it('nennt die Gelbzeit beim Abbieger nicht Regelwert', () => { // Der Fall aus dem Befund: Die Gelbzeit ist 3 s, die RiLSA setzt 2 s. const hinweis = ueberfahrzeitHinweis(mitAnsatz('gelbzeit'), 'K1'); expect(hinweis).toContain('Angesetzt 3 s – Gelbzeit (Ansatz dieses Programms)'); expect(hinweis).not.toContain('Regelwert'); }); it('nennt eine abweichende feste Vorgabe nicht Regelwert', () => { const hinweis = ueberfahrzeitHinweis( mitAnsatz('fest', {}, { crossingTimeKfzAbbiegend: 4 }), 'K1', ); expect(hinweis).toContain('Angesetzt 4 s – Vorgabe dieses Plans'); expect(hinweis).not.toContain('Regelwert'); }); it('nennt bei Handeingabe im Gelbzeitansatz die Gelbzeit nicht Regelwert', () => { const hinweis = ueberfahrzeitHinweis(mitAnsatz('gelbzeit', { crossingTimeOverride: 6 }), 'K1'); expect(hinweis).toContain('Angesetzt 6 s – von Hand eingetragen; ohne diesen Eintrag 3 s'); expect(hinweis).not.toContain('Regelwert'); }); it('nennt bei Handeingabe im festen Ansatz den Regelwert der RiLSA', () => { const hinweis = ueberfahrzeitHinweis(mitAnsatz('fest', { crossingTimeOverride: 4 }), 'K1'); expect(hinweis).toContain( `Angesetzt 4 s – von Hand eingetragen; Regelwert ${RILSA_DEFAULTS.crossingTime.kfzAbbiegend} s`, ); }); it('nennt bei Handeingabe und abweichender fester Vorgabe keinen Regelwert', () => { // Gegenprobe zum Fall darueber: Die Vorgabe dieses Plans ist kein Regelwert. const hinweis = ueberfahrzeitHinweis( mitAnsatz('fest', { crossingTimeOverride: 6 }, { crossingTimeKfzAbbiegend: 4 }), 'K1', ); expect(hinweis).toContain('ohne diesen Eintrag 4 s'); expect(hinweis).not.toContain('Regelwert'); }); it('nennt die Ueberfahrzeit der Engstelle beim festen Ansatz Regelwert', () => { // Abschnitt 5.2.2 setzt 4 s unabhaengig vom Ansatz; das Fenster nannte sie // beim festen Ansatz "feste Überfahrzeit (Praxisansatz ...)". const project = baustelle('fest'); const hinweis = ueberfahrzeitHinweis(project, 'K1'); expect(hinweis).toContain(`Regelwert ${RILSA_ENGSTELLE.ueberfahrzeit} s`); expect(hinweis).not.toContain('Überfahrzeit'); }); it('nennt die Ueberfahrzeit der Engstelle beim Gelbzeitansatz Regelwert', () => { // Gegenprobe: Der Tausch darf die 4 s der Engstelle nicht zur Gelbzeit machen. const hinweis = ueberfahrzeitHinweis(baustelle('gelbzeit'), 'K1'); expect(hinweis).toContain(`Regelwert ${RILSA_ENGSTELLE.ueberfahrzeit} s`); expect(hinweis).not.toContain('Gelbzeit'); }); it('nennt die Ueberfahrzeit der Fussgaenger bei beiden Ansaetzen Regelwert', () => { // Gegenprobe: Der Ansatz erreicht nur den Kraftfahrzeugverkehr. for (const ansatz of ['gelbzeit', 'fest'] as const) { const basis = createStandardIntersectionProject('Fußgänger', FESTES_DATUM); const project: Project = { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: ansatz }, }; const fuss = project.signalGroups.find((g) => g.mode === 'fuss'); expect(fuss, 'Die Vorlage führt keine Fußgängergruppe').toBeDefined(); expect(ueberfahrzeitHinweis(project, fuss!.name), ansatz).toContain( `Regelwert ${RILSA_DEFAULTS.crossingTime.fuss} s`, ); } }); it('erklaert die Zeile "Ueberfahrzeit tue" im Rechenweg', () => { const basis = mitAbbieger(); const project: Project = { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' }, }; const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, 'K1'); expect(rechenwegZeile(fenster, 'Überfahrzeit tü')).toBe(UEBERFAHRZEIT_ANSATZ_LABELS.fest); } finally { aufraeumen(); } }); it('nennt eine von Hand gesetzte Ueberfahrzeit als solche', () => { const project = mitAbbieger({ crossingTimeOverride: 4 }); const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, 'K1'); expect(rechenwegZeile(fenster, 'Überfahrzeit tü')).toContain('von Hand'); } finally { aufraeumen(); } }); }); describe('F5 - die Herleitung der Ueberfahrzeit gilt je Verkehrsart', () => { it('nennt bei Fussgaengern nicht den Kfz-Ansatz', () => { const basis = createStandardIntersectionProject('Fußgänger', FESTES_DATUM); const project: Project = { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' }, }; const fuss = project.signalGroups.find((g) => g.mode === 'fuss'); expect(fuss, 'Die Vorlage führt keine Fußgängergruppe').toBeDefined(); const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, fuss!.name); const erlaeuterung = rechenwegZeile(fenster, 'Überfahrzeit tü'); expect(erlaeuterung).not.toContain('feste Überfahrzeit'); expect(erlaeuterung).toContain('Fußgänger'); } finally { aufraeumen(); } }); }); describe('B2 - das Fenster zeigt den Vorrang der Handeingabe', () => { it('beschriftet die Raeumgeschwindigkeit nicht mehr mit dem Merkmal', () => { const project = mitAbbieger({ engerRadius: true, clearingSpeedOverride: 9 }); const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, 'K1'); const hinweis = hinweisZu(fenster, feldZu(fenster, 'Räumgeschwindigkeit')!); expect(hinweis).not.toContain('enger Innenradius'); } finally { aufraeumen(); } }); it('sagt am Kaestchen, dass es ohne Wirkung bleibt', () => { const project = mitAbbieger({ engerRadius: true, clearingSpeedOverride: 9 }); const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, 'K1'); const hinweis = hinweisZu(fenster, feldZu(fenster, ENGER_RADIUS_LABEL)!); expect(hinweis).toContain('Ohne Wirkung'); expect(hinweis).toContain('Vorrang'); expect(hinweis).not.toContain('die Zwischenzeit fällt länger aus'); } finally { aufraeumen(); } }); it('zeigt die Warnung im Rechenweg der Beziehung', () => { const project = mitAbbieger({ engerRadius: true, clearingSpeedOverride: 9 }); const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, 'K1'); const balken = [...fenster.querySelectorAll('.hinweisbalken-warnung')].map( (b) => b.textContent ?? '', ); expect(balken.some((t) => t.includes('Vorrang'))).toBe(true); } finally { aufraeumen(); } }); }); describe('Befund 17 - der Gelbzeitansatz heisst nur, was bei jeder Vorgabe stimmt', () => { /* * "Länger als der Regelwert" stimmte nicht: Bei 50 km/h geradeaus ergeben * beide Ansaetze 3 s, und die Gelbzeitstaffel ist einstellbar. Immer wahr ist * "nie kürzer", weil keine Gelbzeit unter SAFETY_FLOORS.yellowKfz liegt. Eine * Tempogrenze ("oberhalb von 50 km/h länger") gehoert deshalb nicht in die * Beschriftung. */ it('beschriftet den Gelbzeitansatz mit "nie kürzer als der Regelwert"', () => { expect(UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit).toContain('nie kürzer als der Regelwert'); expect(UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit).not.toContain('länger als der Regelwert'); }); it('haelt "nie kürzer" auch an der kleinsten zulaessigen Gelbzeitstaffel', () => { // Die Grundlage der Beschriftung, nicht ihr Wortlaut: Faellt die // Sicherheitsschranke unter einen Regelwert, ist der Satz falsch. const { defaults } = resolveRilsaSettings({ yellowKfzUpTo50: 0, yellowKfzUpTo60: 0, yellowKfzAbove60: 0, }); expect(defaults.yellowBySpeed.map((stufe) => stufe.yellow)).toEqual([ SAFETY_FLOORS.yellowKfz, SAFETY_FLOORS.yellowKfz, SAFETY_FLOORS.yellowKfz, ]); for (const vZul of [30, 50, 60, 70, 100]) { for (const movement of ['geradeaus', 'rechts', 'links'] as const) { const gelbzeit = regelUeberfahrzeit( { mode: 'kfz', movement, vZul, ueberfahrzeitAnsatz: 'gelbzeit' }, defaults, ); const regelwert = regelUeberfahrzeit({ mode: 'kfz', movement, vZul, ueberfahrzeitAnsatz: 'fest', }); expect(gelbzeit, `${movement}, ${vZul} km/h`).toBeGreaterThanOrEqual(regelwert); } } }); it('sagt dasselbe im Fundstellenverzeichnis', () => { const hinweis = QUELLEN['rilsa-zwischenzeiten'].hinweis ?? ''; expect(hinweis).toContain('„Überfahrzeit = Gelbzeit" ist nie kürzer als der Regelwert'); expect(hinweis).not.toContain('ist länger als der Regelwert'); }); it('sagt es beim festen Ansatz in der Planunterlage ebenso', () => { const satz = ueberfahrzeitAnsatzSatz('fest'); expect(satz).toContain('die Kopplung an die Gelbzeit – rechnet nie kürzer'); expect(satz).not.toContain('rechnet länger'); }); }); describe('Nachtrag zu Befund 17 - "nie kürzer" gilt gegen die RiLSA, nicht gegen jede Vorgabe', () => { /* * Die Planunterlage sagte beim festen Ansatz unbedingt "Die Werte sind die * Regelwerte der RiLSA 2015 ...; der Regelansatz dieses Programms ... rechnet * nie kürzer als diese" - auch dort, wo der Plan andere feste Werte traegt. * Mit 4 s abbiegend ist die Gelbzeit bei 50 km/h 3 s und damit KUERZER als * "diese". Und das Feld "Überfahrzeit" zeigte leer "Regelwert", wo der * Hinweis darunter gerade keinen mehr nennt. */ const rilsaFeste = `${RILSA_DEFAULTS.crossingTime.kfzGeradeaus} s geradeaus und ` + `${RILSA_DEFAULTS.crossingTime.kfzAbbiegend} s abbiegend`; it('schreibt abweichende feste Werte in der Planunterlage nicht der RiLSA zu', () => { const { defaults } = resolveRilsaSettings({ crossingTimeKfzAbbiegend: 4 }); // Der Grund des Befunds, nicht sein Wortlaut: Gegen DIESE Vorgabe faellt // die Gelbzeitkopplung kuerzer aus. expect( regelUeberfahrzeit( { mode: 'kfz', movement: 'rechts', vZul: 50, ueberfahrzeitAnsatz: 'gelbzeit' }, defaults, ), ).toBeLessThan(defaults.crossingTime.kfzAbbiegend); const satz = ueberfahrzeitAnsatzSatz('fest', defaults); expect(satz).not.toContain('Die Werte sind die Regelwerte der RiLSA 2015'); expect(satz).not.toContain('nie kürzer als diese'); expect(satz).toContain('rechnet nie kürzer als die Regelwerte der RiLSA 2015'); expect(satz).toContain(`nicht die ${rilsaFeste} der RiLSA 2015`); }); it('widerspricht sich bei abweichenden festen Werten nicht im selben Absatz', () => { const { defaults } = resolveRilsaSettings({ crossingTimeKfzGeradeaus: 6, crossingTimeKfzAbbiegend: 6, }); const satz = ueberfahrzeitAnsatzSatz('fest', defaults); expect(satz).toContain('weichen dann allerdings von den Regelwerten der RiLSA 2015 ab'); expect(satz).not.toContain('Die Werte sind die Regelwerte der RiLSA 2015'); }); it('laesst den Satz bei den Werten der RiLSA stehen', () => { // Gegenprobe: Dort ist "diese" richtig. const satz = ueberfahrzeitAnsatzSatz('fest', resolveRilsaSettings({}).defaults); expect(satz).toContain('Die Werte sind die Regelwerte der RiLSA 2015'); expect(satz).toContain('die Kopplung an die Gelbzeit – rechnet nie kürzer als diese.'); }); /** Platzhalter und Hinweis des Feldes "Überfahrzeit" der Beziehung, die `fromName` raeumt. */ function ueberfahrzeitFeld( project: Project, fromName: string, ): { platzhalter: string; hinweis: string } { const { wurzel, aufraeumen } = zeichne(conflictsView, project); try { const fenster = oeffneBeziehung(wurzel, project, fromName); const feld = feldZu(fenster, 'Überfahrzeit'); expect(feld, 'Das Feld "Überfahrzeit" fehlt im Fenster').toBeInstanceOf(HTMLInputElement); return { platzhalter: (feld as HTMLInputElement).placeholder, hinweis: hinweisZu(fenster, feld!), }; } finally { aufraeumen(); } } it('schreibt beim Gelbzeitansatz nicht "Regelwert" in das leere Feld', () => { expect(ueberfahrzeitFeld(mitAnsatz('gelbzeit'), 'K1').platzhalter).toBe('Gelbzeit'); }); it('schreibt bei abweichender fester Vorgabe nicht "Regelwert" in das leere Feld', () => { const project = mitAnsatz('fest', {}, { crossingTimeKfzAbbiegend: 4 }); expect(ueberfahrzeitFeld(project, 'K1').platzhalter).toBe('Vorgabe dieses Plans'); }); it('laesst "Regelwert" im Feld, wo einer angesetzt ist', () => { // Gegenproben: fester Ansatz mit den Werten der RiLSA, Engstelle bei beiden // Ansaetzen. expect(ueberfahrzeitFeld(mitAnsatz('fest'), 'K1').platzhalter).toBe('Regelwert'); for (const ansatz of ['gelbzeit', 'fest'] as const) { expect(ueberfahrzeitFeld(baustelle(ansatz), 'K1').platzhalter, ansatz).toBe('Regelwert'); } }); it('nennt im leeren Feld dasselbe wie im Hinweis darunter', () => { const faelle: readonly [string, Project][] = [ ['Gelbzeit', mitAnsatz('gelbzeit')], ['fest', mitAnsatz('fest')], ['fest, abweichend', mitAnsatz('fest', {}, { crossingTimeKfzAbbiegend: 4 })], ['Engstelle, Gelbzeit', baustelle('gelbzeit')], ['Engstelle, fest', baustelle('fest')], ]; for (const [name, project] of faelle) { const { platzhalter, hinweis } = ueberfahrzeitFeld(project, 'K1'); expect(hinweis, name).toContain(platzhalter); } }); });