import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { leiteAb, uebernimmSignalgruppen, uebernimmWege, wegSchluessel, } from '@/domain/geometrie/ableitung'; import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import { REGELBREITE } from '@/domain/geometrie/vermessung'; import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory'; import { parseProject } from '@/domain/model/schema'; import type { Project } from '@/domain/model/project'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { schwerverkehrsfaktor } from '@/domain/rilsa/capacity'; import { HBS_PKW_GLEICHWERTE, RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { computeIntergreen, engerRadiusWirktAn } from '@/domain/rilsa/intergreen'; import { QUELLEN } from '@/domain/rilsa/quellen'; import { resolveRilsaSettings } from '@/domain/rilsa/settings'; import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types'; import { validateProject } from '@/domain/validation/engine'; import { schwerverkehrSatz } from '@/services/export/bewertung'; /** * Richtung, Groessenordnung und Herleitung der Ersatzbelege - die Befunde B1 * bis B6 und F1 bis F7 der Fassung 5.5.0; die Kennungen in dieser Datei * meinen diese Befunde. * * Alle Faelle hier schlagen gegen den Stand vom 15.08.2026 fehl. Der rote * Faden ist derselbe wie in allen Tests zu den Ersatzbelegen: Keine Ausgabe * darf eine Richtung, eine Groessenordnung oder eine Herleitung behaupten, die * die Rechnung daneben nicht hergibt. * * B1 Der enge Innenradius VERKUERZTE die Zwischenzeit, sobald die * Raeumgeschwindigkeit des Abbiegers unter 5 m/s abgesenkt war - die * Feldschranke misst gegen den Regelwert 7,0 m/s, nicht gegen den * wirksamen Wert. * B2 Eine von Hand eingetragene Raeumgeschwindigkeit gewinnt gegen das * Merkmal. Das stand nirgends, war nirgends geprueft, und drei Ausgaben * behaupteten das Gegenteil. * B3 Ein unlesbares `engerRadius` fiel ohne Meldung auf die unsichere Seite. * B4 Die Uebernahme aus dem Lageplan waehlte den massgebenden Weg nach einer * anderen Rechnung als der Plan. * B5 Fest eingeschriebene Zahlen im Fehlertext ("3 s geradeaus, 2 s * abbiegend"), obwohl beide Werte Vorgabenfelder sind. * F1 "bis zu 2,3 %" war keine Obergrenze - der Zuwachs waechst monoton bis * 5,3 %. * F2 Die groesste unsichere Annahme (keine Aufteilung des Schwerverkehrs) * stand ohne Fehlerrichtung in der gedruckten Unterlage. * F6 Ein Referat war als Zitat ausgegeben. */ const DATUM = new Date('2026-01-01T00:00:00Z'); const UEBERNAHME = new Date('2026-08-15T09:30:00Z'); function raeumenderAbbieger(rest: Partial = {}): ClearingStream { return { mode: 'kfz', movement: 'rechts', clearingDistance: 25, vehicleClass: 'pkw', vZul: 50, ...rest, }; } const EINFAHREND: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0 }; // --- B1: Das Merkmal darf nie verkuerzen ----------------------------------- describe('B1 - enger Innenradius gegen abgesenkte Raeumgeschwindigkeit des Abbiegers', () => { it('kappt die Vorgabe auf die wirksame Raeumgeschwindigkeit des Abbiegers und meldet es', () => { // Beide Werte lagen einzeln in ihren Schranken: 4 m/s (Feld 3 bis 10) und // 5 m/s (Feld 2 bis 7). Zusammen ergaben sie einen "engen Innenradius", // der SCHNELLER raeumt als der Regelfall. const { defaults, notes, deviations } = resolveRilsaSettings({ clearingSpeedKfzTurning: 4, clearingSpeedKfzTurningEngerRadius: 5, }); expect(defaults.clearingSpeed.kfzTurningEngerRadius).toBe(4); const meldung = notes.find((n) => n.code.endsWith('clearingSpeedKfzTurningEngerRadius')); expect(meldung?.severity).toBe('fehler'); expect(meldung?.message).toContain('verkürzte'); // Die Abweichungsliste fuehrt den ANGESETZTEN Wert - sonst druckte der // Ausdruck 5 m/s neben einer Rechnung mit 4 m/s (Befund C21). const abweichung = deviations.find((d) => d.key === 'clearingSpeedKfzTurningEngerRadius'); expect(abweichung?.appliedValue).toBe(4); }); it('laesst die Zwischenzeit mit dem Merkmal nie kuerzer ausfallen als ohne', () => { const { defaults } = resolveRilsaSettings({ clearingSpeedKfzTurning: 4, clearingSpeedKfzTurningEngerRadius: 5, }); const ohne = computeIntergreen(raeumenderAbbieger(), EINFAHREND, defaults).intergreen; const mit = computeIntergreen( raeumenderAbbieger({ engerRadius: true }), EINFAHREND, defaults, ).intergreen; // Gegen den Altstand: ohne 11 s, mit 10 s - das Merkmal verkuerzte um 1 s. expect(ohne).toBe(11); expect(mit).toBeGreaterThanOrEqual(ohne); }); it('meldet den Widerspruch auch, wenn ein Kennwertsatz ihn von Hand mitbringt', () => { // Zweiter Riegel: Wer computeIntergreen mit einem selbst gebauten // Kennwertsatz aufruft, laeuft nicht an resolveRilsaSettings vorbei. const kennwerte = { ...RILSA_DEFAULTS, clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, kfzTurning: 4, kfzTurningEngerRadius: 5, }, }; const ergebnis = computeIntergreen( raeumenderAbbieger({ engerRadius: true }), EINFAHREND, kennwerte, ); expect(ergebnis.clearingSpeed).toBe(4); expect(ergebnis.notes.map((n) => n.code)).toContain('enger-radius-schneller-als-regelfall'); }); it('nennt in der Schrankenbegruendung keine feste Zahl mehr', () => { // Die Schranke gilt gegen den eingetragenen Nachbarwert, nicht gegen 7 m/s. const { notes } = resolveRilsaSettings({ clearingSpeedKfzTurningEngerRadius: 9 }); const meldung = notes.find((n) => n.code.endsWith('clearingSpeedKfzTurningEngerRadius')); expect(meldung?.message).toContain('höchstens 7 m/s'); expect(meldung?.message).not.toContain('ohne dieses Merkmal (7 m/s)'); }); }); // --- B2: Wer gewinnt - Merkmal oder Handeingabe? --------------------------- describe('B2 - Vorrang der eingetragenen Raeumgeschwindigkeit vor dem Merkmal', () => { it('beantwortet die Frage an einer Stelle des Fachkerns', () => { const basis = { mode: 'kfz', movement: 'rechts', engerRadius: true } as const; expect(engerRadiusWirktAn(basis)).toBe(true); expect(engerRadiusWirktAn({ ...basis, clearingSpeedOverride: 9 })).toBe(false); // Ein ungueltiger Eintrag faellt auf den Regelwert zurueck - dann wirkt // das Merkmal weiter, genau wie in resolveClearingSpeed. expect(engerRadiusWirktAn({ ...basis, clearingSpeedOverride: 0 })).toBe(true); expect(engerRadiusWirktAn({ ...basis, movement: 'geradeaus' })).toBe(false); expect(engerRadiusWirktAn({ ...basis, engerRadius: false })).toBe(false); }); it('vermerkt den Vorrang im Rechenweg der Beziehung', () => { const ergebnis = computeIntergreen( raeumenderAbbieger({ engerRadius: true, clearingSpeedOverride: 9 }), EINFAHREND, ); expect(ergebnis.clearingSpeed).toBe(9); const notiz = ergebnis.notes.find((n) => n.code === 'enger-radius-durch-vorgabe-ersetzt'); expect(notiz?.severity).toBe('warnung'); expect(notiz?.message).toContain('Vorrang'); expect(notiz?.message).toContain('KÜRZER'); }); it('meldet im Pruefbericht den Vorrang statt einer Verlaengerung', () => { const roh = createStandardIntersectionProject('Vorrang', DATUM); const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; const f2 = roh.signalGroups.find((g) => g.name === 'F2')!; const project: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => g.id === k1.id ? { ...g, movement: 'rechts' } : g, ), conflicts: roh.conflicts.map((c) => c.fromId === k1.id && c.toId === f2.id ? { ...c, engerRadius: true, clearingSpeedOverride: 9 } : c, ), }; const bericht = validateProject(project, buildSignalPlan(project), DATUM); // Der Hinweis "die Zwischenzeit faellt laenger aus" darf hier NICHT stehen. expect(bericht.findings.map((f) => f.rule)).not.toContain('zwischenzeiten.enger-radius'); const warnung = bericht.findings.find( (f) => f.rule === 'zwischenzeiten.enger-radius-ohne-wirkung', ); expect(warnung?.severity).toBe('warnung'); expect(warnung?.message).toContain('Vorrang'); expect(warnung?.suggestion).toContain('Entfernen Sie eines von beiden'); }); }); // --- B3: Unlesbare Merkmale -------------------------------------------------- describe('B3 - unlesbare Wahrheitswerte fallen nicht mehr stumm auf die unsichere Seite', () => { function mitRohemMerkmal(feld: string, wert: unknown): ReturnType { const basis = createStandardIntersectionProject('Unlesbar', DATUM); const roh = JSON.parse(JSON.stringify(basis)) as Record; const konflikte = roh['conflicts'] as Record[]; konflikte[0]![feld] = wert; return parseProject(roh); } it('meldet ein unlesbares engerRadius', () => { const { project, issues } = mitRohemMerkmal('engerRadius', 'ja'); expect(project.conflicts[0]?.engerRadius).toBe(false); const meldung = issues.find((i) => i.path.endsWith('.engerRadius')); expect(meldung?.severity).toBe('warnung'); expect(meldung?.message).toContain('kürzer'); }); it('meldet ein unlesbares haltVorKnoten', () => { const { issues } = mitRohemMerkmal('haltVorKnoten', 'vielleicht'); expect(issues.find((i) => i.path.endsWith('.haltVorKnoten'))?.severity).toBe('warnung'); }); it('schweigt weiterhin zum FEHLENDEN Feld', () => { // Eine Datei bis Schema 11 fuehrt das Feld nicht. Das ist keine // Unlesbarkeit, sondern die Aussage "nicht gesetzt". const basis = createStandardIntersectionProject('Ohne Feld', DATUM); const { project, issues } = parseProject(JSON.parse(JSON.stringify(basis))); expect(project.conflicts[0]?.engerRadius).toBeUndefined(); expect(issues.filter((i) => i.path.includes('engerRadius'))).toHaveLength(0); }); }); // --- B4: Uebernahme aus dem Lageplan --------------------------------------- describe('B4 - die Uebernahme rechnet mit den Ansaetzen des Projekts', () => { /** Zwei kreuzende Linien; die raeumende biegt ab. */ function lageplanMitAbbieger(): Lageplan { const linie = ( id: string, name: string, movement: Planlinie['movement'], punkte: readonly { x: number; y: number }[], ): Planlinie => ({ id, name, mode: 'kfz', movement, breiteMeter: REGELBREITE.kfz, punkte, haltlinieId: null, startT: 0.5, signalGroupId: null, }); return { arbeitsbereiche: [], signalgeber: [], haltlinien: [], bild: { datenUrl: 'data:image/png;base64,AAAA', breite: 400, hoehe: 400, herkunft: 'Test', geladenAm: DATUM.toISOString(), }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 50, herkunft: 'gemessen', }, linien: [ linie('l-w', 'K1', 'rechts', [ { x: 0, y: 100 }, { x: 200, y: 100 }, ]), linie('l-s', 'K2', 'geradeaus', [ { x: 100, y: 0 }, { x: 100, y: 200 }, ]), ], }; } /** Die uebernommene Zwischenzeit steht in der Bemerkung der Beziehung. */ function uebernommeneZwischenzeit(project: Project): number { const { project: mitGruppen, lageplan } = uebernimmSignalgruppen( project, lageplanMitAbbieger(), ['l-w', 'l-s'], ); const ableitung = leiteAb(mitGruppen, lageplan); const weg = ableitung.wege.find((w) => w.vonLinieId === 'l-w'); expect(weg).toBeDefined(); const ergebnis = uebernimmWege( mitGruppen, lageplan, ableitung, [wegSchluessel(weg!)], UEBERNAHME, ).project; const konflikt = ergebnis.conflicts.find((c) => c.note.includes('maßgebend')); expect(konflikt, 'Keine Beziehung mit Uebernahmevermerk').toBeDefined(); const treffer = /\((\d+) s\)/.exec(konflikt!.note); expect(treffer, `Keine Zwischenzeit im Vermerk: ${konflikt!.note}`).not.toBeNull(); return Number(treffer![1]); } it('rechnet den Vergleich mit dem gewaehlten Ueberfahrzeit-Ansatz', () => { const basis = createEmptyProject('Uebernahme', DATUM); const mitGelbzeit = uebernommeneZwischenzeit(basis); const mitFest = uebernommeneZwischenzeit({ ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' }, }); // Abbiegend: Gelbzeit 3 s gegen feste Ueberfahrzeit 2 s. Gegen den // Altstand sind beide Zahlen gleich - die Uebernahme kannte den Ansatz // nicht und rechnete immer mit der Gelbzeit. expect(mitFest).toBe(mitGelbzeit - 1); }); it('rechnet den Vergleich mit dem Merkmal der vorhandenen Beziehung', () => { const basis = createEmptyProject('Uebernahme Radius', DATUM); const ohne = uebernommeneZwischenzeit(basis); // Dieselbe Uebernahme ein zweites Mal, nachdem die Beziehung das Merkmal // traegt: Der Abbieger raeumt jetzt mit 5,0 statt 7,0 m/s. const { project: mitGruppen, lageplan } = uebernimmSignalgruppen(basis, lageplanMitAbbieger(), [ 'l-w', 'l-s', ]); const ableitung = leiteAb(mitGruppen, lageplan); const weg = ableitung.wege.find((w) => w.vonLinieId === 'l-w')!; const einmal = uebernimmWege( mitGruppen, lageplan, ableitung, [wegSchluessel(weg)], UEBERNAHME, ).project; const mitMerkmal: Project = { ...einmal, conflicts: einmal.conflicts.map((c) => c.note.includes('maßgebend') ? { ...c, engerRadius: true } : c, ), }; const nachher = uebernimmWege( mitMerkmal, lageplan, leiteAb(mitMerkmal, lageplan), [wegSchluessel(weg)], UEBERNAHME, ).project; const treffer = /\((\d+) s\)/.exec( nachher.conflicts.find((c) => c.note.includes('maßgebend'))!.note, ); expect(Number(treffer![1])).toBeGreaterThan(ohne); }); }); // --- B5 und F3: keine Zahl aus dem Satz ------------------------------------ describe('B5/F3 - der Fehlertext zur festen Ueberfahrzeit nennt die eingetragenen Werte', () => { it('widerspricht sich nicht mehr, wenn die festen Werte geaendert sind', () => { const { defaults } = resolveRilsaSettings({ crossingTimeKfzGeradeaus: 5, crossingTimeKfzAbbiegend: 4, }); const ergebnis = computeIntergreen( raeumenderAbbieger({ ueberfahrzeitAnsatz: 'fest', crossingTimeOverride: 1 }), EINFAHREND, defaults, ); const meldung = ergebnis.notes.find((n) => n.code === 'ueberfahrzeit-unter-gelbzeitschranke'); expect(meldung?.severity).toBe('fehler'); expect(meldung?.message).toContain('Regelwert von 4 s'); expect(meldung?.message).toContain('5 s geradeaus, 4 s abbiegend'); // Genau das stand vorher da - und widersprach dem Satzanfang. expect(meldung?.message).not.toContain('(3 s geradeaus, 2 s abbiegend)'); }); }); // --- F1 und F2: Fehlerrichtungen des Schwerverkehrs ------------------------ describe('F1/F2 - die Groessenordnungen der Schwerverkehrsrechnung', () => { it('bestaetigt, dass der Zuwachs gegenueber dem Altstand monoton bis 5,3 % waechst', () => { const zuwachs = (pSV: number): number => 2000 / schwerverkehrsfaktor(pSV) / (2000 / (1 + pSV)) - 1; // Die Zahl, die als Obergrenze gedruckt wurde, ist keine. expect(zuwachs(1)).toBeGreaterThan(0.05); expect(zuwachs(1) * 100).toBeCloseTo(5.263, 3); expect(zuwachs(0.3) * 100).toBeCloseTo(2.362, 3); expect(zuwachs(0.05) * 100).toBeCloseTo(0.478, 3); // Streng monoton wachsend - "bis zu" gilt am oberen Rand, nicht bei 30 %. let vorher = -1; for (let pSV = 0; pSV <= 1.0001; pSV += 0.05) { const wert = zuwachs(Math.min(pSV, 1)); expect(wert).toBeGreaterThan(vorher); vorher = wert; } }); it('haelt die Fehlerrichtung der fehlenden Aufteilung fest', () => { const lastzug = (p: number): number => schwerverkehrsfaktor(0, { lkwUndBus: 0, lkwMitAnhaenger: p }) / schwerverkehrsfaktor(p) - 1; expect(lastzug(0.2) * 100).toBeCloseTo(10.17, 2); expect(lastzug(1) * 100).toBeCloseTo(31.58, 2); expect(HBS_PKW_GLEICHWERTE.lkwMitAnhaenger).toBe(2.5); }); it('druckt Richtung und Groessenordnung in der Planunterlage', () => { const satz = schwerverkehrSatz(); expect(satz).toContain('ZU GROSS'); expect(satz).toContain('10,2 %'); expect(satz).toContain('31,6 %'); expect(satz).toContain('1.695 Fz/h'); expect(satz).toContain('1.538 Fz/h'); }); it('nennt im Fundstellenverzeichnis beide Fehlerrichtungen mit richtigen Grenzen', () => { const hinweis = QUELLEN['hbs-kapazitaet'].hinweis ?? ''; expect(hinweis).toContain('5,3 %'); expect(hinweis).toContain('2,4 %'); expect(hinweis).toContain('31,6 %'); expect(hinweis).not.toContain('bis zu 2,3 %'); }); }); // --- F6: Referat statt Zitat ------------------------------------------------ describe('F6 - der erschlossene Schluss ist durch das Original ersetzt', () => { it('nennt die Fundstelle statt des erschlossenen Umkehrschlusses', () => { /* * F6 verlangte, den Hamburger Leitfaden zu REFERIEREN und den daraus * gezogenen Schluss ("die RiLSA kennt keinen Sonderwert") als erschlossen * zu kennzeichnen. Das war richtig, solange nur der Leitfaden vorlag. Seit * 5.27.0 liegt die RiLSA vor, und der Schluss war falsch: Abschnitt 2.5.2, * Fall 2 nennt die 5 m/s samt Radiusschwelle. Ein erschlossener Schluss * neben der Fundstelle waere jetzt der Fehler. */ const quelle = QUELLEN['praxis-enger-radius']; const hinweis = quelle.hinweis ?? ''; expect(hinweis).not.toContain('erschlossen, nicht ausgesprochen'); expect(quelle.fundstelle).toContain('2.5.2'); expect(quelle.pruefstand).toBe('gesichert'); /* * Die alte Aussage bleibt im Text stehen, aber nur noch als Bericht ueber * den frueheren Stand: Sie muss HINTER dem Satz kommen, der sie widerruft. * Stuende sie davor, behauptete die Unterlage wieder, der Wert sei nicht * nachweisbar. */ expect(hinweis).toContain('ist widerlegt'); expect(hinweis.indexOf('nicht nachweisbar')).toBeGreaterThan( hinweis.indexOf('Bis zur Fassung 5.26.0'), ); }); }); // --- B6: gleiche Auskunft fuer beide bedingten Kennwerte ------------------- describe('B6 - Wirkungsvermerk auch fuer den engen Innenradius', () => { it('meldet die Abweichung als wirkungslos, solange keine Beziehung das Merkmal traegt', () => { const basis = createStandardIntersectionProject('Wirkungslos', DATUM); const project: Project = actions.setSettings(basis, { rilsa: { clearingSpeedKfzTurningEngerRadius: 4 }, }); const bericht = validateProject(project, buildSignalPlan(project), DATUM); const befund = bericht.findings.find( (f) => f.rule === 'vorgaben.abweichung.clearingSpeedKfzTurningEngerRadius', ); expect(befund?.message).toContain('keine Beziehung dieses Merkmal'); expect(befund?.suggestion).not.toContain('in den Planunterlagen zu begründen'); }); });