import { describe, expect, it } from 'vitest'; import { UMLAUFZEIT_ERSATZWERT_VERMERK, buildSignalPlan, intergreenKey, umlaufzeitMitVermerk, } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import { computeIntergreen } from '@/domain/rilsa/intergreen'; import { resolveRilsaSettings, settingFields } from '@/domain/rilsa/settings'; import { ANLAGENART_GRENZEN, RILSA_DEFAULTS, SAFETY_FLOORS, fahrzeugartBeschriftung, } from '@/domain/rilsa/constants'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Anlagenart, Project, SignalGroup } from '@/domain/model/project'; import type { VehicleClass } from '@/domain/rilsa/types'; /* * Befunde C7 bis C12 - weitere Fachkern-Befunde: * * - Der Ersatzwert-Vermerk traegt den Gedankenstrich der Anwendertexte, und * das Zusammenfuegen "Zahl – Vermerk" steht EINMAL im Fachkern. * - Der Ersatzwert ist die groesste ZULAESSIGE Umlaufzeit - eine engere eigene * Vorgabe gilt, nicht die Schranke der Anlagenart. * - Eine unzulaessige Vorgabe steht EINMAL im Bericht, nicht zweimal. * - Die Umlaufzeitfelder begruenden die verletzte Schranke, nicht die andere. * - Widerspruch kleinste > groesste Umlaufzeit: die Notiz ueberlebt den * Rueckfall auf die Regelwerte. * - Schwerverkehrsanteil: 0 bis 100 % wie das Schema; ausserhalb begrenzt UND * gemeldet - vorher stumm bei 90 % gekappt. * - Zwischenzeit von Hand: Aufrunden wird gemeldet, ein negativer Wert ist ein * Fehler und ersetzt die Rechnung nicht mehr durch 0 s. * - Beschriftung einer unbekannten Fahrzeugart nennt keine falsche Zahl. * * Gegen den Altstand schlagen alle Faelle fehl, bis auf die als Waechter * gekennzeichneten. */ const DATUM = new Date('2026-01-01T00:00:00Z'); /** Zwei Kfz-Gruppen in zwei Phasen; Konflikte und Gruppe 2 anpassbar. */ function zweiGruppen( zweite: Partial = {}, anlagenart: Anlagenart = 'knotenpunkt', rilsa: Project['settings']['rilsa'] = {}, konflikt: Partial = {}, ): Project { const basis = createEmptyProject('Zwei Gruppen', DATUM, anlagenart); const a = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }); const b: SignalGroup = { ...createSignalGroup({ name: 'K2', mode: 'kfz', index: 1 }), ...zweite }; const phase1 = createPhase('Phase 1', [a.id]); const phase2 = createPhase('Phase 2', [b.id]); return { ...basis, signalGroups: [a, b], conflicts: [ { ...createConflict(a.id, b.id), clearingDistance: 22, enteringDistance: 8, ...konflikt }, { ...createConflict(b.id, a.id), clearingDistance: 22, enteringDistance: 8 }, ], phases: [phase1, phase2], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] }, settings: { ...basis.settings, rilsa }, }; } /** Der Musterknotenpunkt mit Verkehrsstaerken weit ueber der Kapazitaet. */ function uebersaettigt(rilsa: Project['settings']['rilsa'] = {}): Project { const roh = createStandardIntersectionProject('Uebersaettigt', DATUM); return { ...roh, settings: { ...roh.settings, rilsa }, demands: roh.signalGroups .filter((g) => g.mode === 'kfz') .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })), }; } /** Musterknotenpunkt mit einem Schwerverkehrsanteil an K1. */ function mitSchwerverkehr(anteil: number): Project { const roh = createStandardIntersectionProject('Schwerverkehr', DATUM); const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; return { ...roh, demands: roh.demands.map((d) => d.signalGroupId === k1.id ? { ...d, heavyVehicleShare: anteil } : d, ), }; } function saettigungK1(project: Project): number { const k1 = project.signalGroups.find((g) => g.name === 'K1')!; return buildSignalPlan(project).groups.find((g) => g.groupId === k1.id)!.capacity!.saturationFlow; } // --- Vermerk ------------------------------------------------------------------ describe('Ersatzwert-Vermerk: EIN Wortlaut, EIN Zusammenfuegen', () => { it('traegt den Gedankenstrich der Anwendertexte - kein Bindestrich', () => { expect(UMLAUFZEIT_ERSATZWERT_VERMERK).toBe('nicht bemessbar (übersättigt) – Ersatzwert'); expect(UMLAUFZEIT_ERSATZWERT_VERMERK).not.toContain(' - '); }); it('haengt den Vermerk nur bei Uebersaettigung an die formatierte Zahl', () => { const ersatz = buildSignalPlan(uebersaettigt()); expect(umlaufzeitMitVermerk(ersatz, '120 s')).toBe(`120 s – ${UMLAUFZEIT_ERSATZWERT_VERMERK}`); const normal = buildSignalPlan(createStandardIntersectionProject('Normal', DATUM)); expect(umlaufzeitMitVermerk(normal, '80 s')).toBe('80 s'); // Waechter: keine Zahl im Fachkern - die formatiert der Aufrufer. expect(umlaufzeitMitVermerk(ersatz, 'x')).toBe(`x – ${UMLAUFZEIT_ERSATZWERT_VERMERK}`); }); it('ist die groesste ZULAESSIGE Umlaufzeit: eine engere eigene Vorgabe gilt vor der Anlagenart', () => { // Knotenpunkt (Schranke 120 s) mit "Groesste Umlaufzeit 90 s", uebersaettigt. const plan = buildSignalPlan(uebersaettigt({ cycleTimeMax: 90 })); expect(plan.cycleResult?.bounded).toBe('uebersaettigt'); expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max).toBe(120); expect(plan.cycleResult?.cycleTime).toBe(90); expect(plan.grenzen.cycleTime.max).toBe(90); // Die Fehlermeldung nennt die 90 s - nicht die 120 s der Anlagenart. const notiz = plan.notes.find((n) => n.code === 'uebersaettigt'); expect(notiz?.message).toContain('Obergrenze von 90 s'); }); }); // --- Doppelmeldung ------------------------------------------------------------ describe('Unzulaessige Vorgabe: einmal im Bericht, nicht zweimal', () => { it('fuehrt cycleTimeMax 150 s am Knotenpunkt als EINEN Fehler unter Vorgaben', () => { const projekt = zweiGruppen({}, 'knotenpunkt', { cycleTimeMax: 150 }); const plan = buildSignalPlan(projekt); // Der Plan traegt die Notiz weiterhin - er ist fuer sich vollstaendig. expect(plan.notes.filter((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')).toHaveLength( 1, ); const bericht = validateProject(projekt, plan, DATUM); const treffer = bericht.findings.filter((f) => f.rule.endsWith('vorgabe-ueberschritten:cycleTimeMax'), ); expect(treffer).toHaveLength(1); expect(treffer[0]!.rule).toBe('vorgaben.vorgabe-ueberschritten:cycleTimeMax'); expect(treffer[0]!.category).toBe('vorgaben'); expect(treffer[0]!.title).toBe('Unzulässige Vorgabe'); expect(bericht.findings.some((f) => f.rule.startsWith('signalplan.vorgabe-'))).toBe(false); // Und sie zaehlt genau einmal: Der Plan hat sonst keinen Fehler. expect(bericht.errorCount).toBe(1); expect(bericht.exportBlocked).toBe(true); }); it('gilt fuer jedes Vorgabenfeld - auch fuer die Gelbzeit', () => { const projekt = zweiGruppen({}, 'knotenpunkt', { yellowKfzUpTo50: 1 }); const bericht = validateProject(projekt, undefined, DATUM); expect( bericht.findings.filter((f) => f.rule.endsWith('vorgabe-unterschritten:yellowKfzUpTo50')), ).toHaveLength(1); expect(bericht.findings.some((f) => f.rule.startsWith('signalplan.vorgabe-'))).toBe(false); }); }); // --- Begruendung der Schranke ----------------------------------------------- describe('Umlaufzeitfelder begruenden die verletzte Schranke', () => { it('nennt bei "Kleinste Umlaufzeit 150 s" die Obergrenze der Anlagenart, nicht "nicht schaltbar"', () => { const { notes } = resolveRilsaSettings({ cycleTimeMin: 150 }, undefined, 'knotenpunkt'); const notiz = notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMin'); expect(notiz).toBeDefined(); expect(notiz!.message).toContain('höchstens 120 s'); expect(notiz!.message).toContain('Umlaufzeiten über 120 s sind nach RiLSA nicht vorzusehen.'); expect(notiz!.message).not.toContain('nicht schaltbar'); }); it('nennt bei "Groesste Umlaufzeit 10 s" die Schaltbarkeit, nicht die RiLSA-Obergrenze', () => { const { notes } = resolveRilsaSettings({ cycleTimeMax: 10 }, undefined, 'knotenpunkt'); const notiz = notes.find((n) => n.code === 'vorgabe-unterschritten:cycleTimeMax'); expect(notiz).toBeDefined(); expect(notiz!.message).toContain('mindestens 30 s'); expect(notiz!.message).toContain('Umlaufzeiten unter 30 s sind nicht schaltbar.'); expect(notiz!.message).not.toContain('nicht vorzusehen'); }); it('begruendet auch den Regelbereich - und bei einstreifiger Fuehrung mit dem RSA-Vorbehalt', () => { const { notes } = resolveRilsaSettings( { cycleTimeRecommendedMax: 400, cycleTimeRecommendedMin: 10 }, undefined, 'einstreifig', ); expect( notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeRecommendedMax')?.message, ).toContain('RSA'); expect( notes.find((n) => n.code === 'vorgabe-unterschritten:cycleTimeRecommendedMin')?.message, ).toContain('nicht schaltbar'); }); it('nennt in der Vorgabenansicht beide Gruende, wenn sie sich unterscheiden', () => { const feld = settingFields(undefined, 'knotenpunkt').find((f) => f.key === 'cycleTimeMax')!; expect(feld.schrankenBegruendung).toContain('nicht schaltbar'); expect(feld.schrankenBegruendung).toContain('nach RiLSA nicht vorzusehen'); // Waechter: Felder mit EINER Begruendung bleiben bei ihr. const gelb = settingFields().find((f) => f.key === 'yellowKfzUpTo50')!; expect(gelb.schrankenBegruendung).toBe( 'Die Gelbzeit für Kraftfahrzeuge darf 3 s nicht unterschreiten.', ); }); }); // --- Widerspruch -------------------------------------------------------------- describe('Kleinste Umlaufzeit groesser als groesste: der Widerspruch bleibt gemeldet', () => { it('faellt auf die Regelwerte zurueck UND behaelt die Notiz', () => { const { defaults, notes, deviations } = resolveRilsaSettings( { cycleTimeMin: 100, cycleTimeMax: 50 }, undefined, 'knotenpunkt', ); expect(defaults.cycleTime.min).toBe(RILSA_DEFAULTS.cycleTime.min); expect(defaults.cycleTime.max).toBe(RILSA_DEFAULTS.cycleTime.max); expect(deviations).toHaveLength(0); const notiz = notes.find((n) => n.code === 'vorgabe-umlaufzeit-widerspruch'); expect(notiz).toBeDefined(); expect(notiz!.severity).toBe('fehler'); // Und im Bericht - als Fehler mit Sperre. const bericht = validateProject( zweiGruppen({}, 'knotenpunkt', { cycleTimeMin: 100, cycleTimeMax: 50 }), undefined, DATUM, ); expect( bericht.findings.filter((f) => f.rule === 'vorgaben.vorgabe-umlaufzeit-widerspruch'), ).toHaveLength(1); expect(bericht.exportBlocked).toBe(true); }); }); // --- Schwerverkehrsanteil ----------------------------------------------------- describe('Schwerverkehrsanteil: 0 bis 100 % wie das Schema, kein stiller Deckel bei 90 %', () => { it('rechnet mit 95 %, wenn 95 % eingetragen sind', () => { const neunzig = saettigungK1(mitSchwerverkehr(0.9)); const fuenfundneunzig = saettigungK1(mitSchwerverkehr(0.95)); const hundert = saettigungK1(mitSchwerverkehr(1)); /* * Altstand: alle drei gleich (Deckel 0,9). * * ANPASSUNG (Fassung 5.5.0, Frage E6): Gerechnet wird nicht * mehr `base / (1 + Anteil)` - das war der Pkw-Gleichwert 2,0 des HCM -, * sondern `base / fSV` mit fSV = 1 + 0,90 * Anteil nach HBS 2015 * (Gl. 2-6). Die GEPRUEFTE AUSSAGE bleibt dieselbe: Es gibt keinen stillen * Deckel bei 90 %, ein hoeherer Anteil senkt die * Saettigungsverkehrsstaerke tatsaechlich. Nur die Zahlen wandern. */ expect(fuenfundneunzig).toBeLessThan(neunzig); expect(hundert).toBeLessThan(fuenfundneunzig); expect(hundert / neunzig).toBeCloseTo(1.81 / 1.9, 6); const ohne = saettigungK1(mitSchwerverkehr(0)); // fSV(0,95) = 1 + 0,9 * 0,95 = 1,855; fSV(0,90) = 1,81. expect(fuenfundneunzig).toBeCloseTo(ohne / 1.855, 6); expect(neunzig).toBeCloseTo(ohne / 1.81, 6); expect( buildSignalPlan(mitSchwerverkehr(0.95)).notes.some( (n) => n.code === 'schwerverkehrsanteil-ausserhalb', ), ).toBe(false); }); it('begrenzt einen Anteil ueber 100 % auf 100 % und MELDET das an der Gruppe', () => { const projekt = mitSchwerverkehr(1.2); const plan = buildSignalPlan(projekt); expect(saettigungK1(projekt)).toBe(saettigungK1(mitSchwerverkehr(1))); const notiz = plan.notes.find((n) => n.code === 'schwerverkehrsanteil-ausserhalb'); expect(notiz).toBeDefined(); expect(notiz!.severity).toBe('warnung'); expect(notiz!.message).toContain('"K1"'); expect(notiz!.message).toContain('120 %'); expect(notiz!.message).toContain('Gerechnet wird mit 100 %'); const bericht = validateProject(projekt, plan, DATUM); const befund = bericht.findings.find( (f) => f.rule === 'signalplan.schwerverkehrsanteil-ausserhalb', ); expect(befund).toBeDefined(); expect(befund!.category).toBe('leistungsfaehigkeit'); expect(befund!.target).toMatchObject({ kind: 'signalgruppe', label: 'K1' }); expect(befund!.title).toContain('Schwerverkehrsanteil'); expect(befund!.suggestion).toContain('0 und 100 %'); }); it('meldet einen negativen Anteil ebenso und rechnet mit 0 %', () => { const projekt = mitSchwerverkehr(-0.2); expect(saettigungK1(projekt)).toBe(saettigungK1(mitSchwerverkehr(0))); const notiz = buildSignalPlan(projekt).notes.find( (n) => n.code === 'schwerverkehrsanteil-ausserhalb', ); expect(notiz?.message).toContain('-20 %'); expect(notiz?.message).toContain('Gerechnet wird mit 0 %'); }); }); // --- Zwischenzeit von Hand ---------------------------------------------------- describe('Zwischenzeit von Hand: Aufrunden gemeldet, negativ ist ein Fehler', () => { it('rundet 4,2 s auf 5 s auf und sagt es als Hinweis an der Konfliktbeziehung', () => { const projekt = zweiGruppen({}, 'knotenpunkt', {}, { manualIntergreen: 4.2 }); const plan = buildSignalPlan(projekt); const [k1, k2] = projekt.signalGroups; const eintrag = plan.intergreens.get(intergreenKey(k1!.id, k2!.id))!; expect(eintrag.value).toBe(5); expect(eintrag.source).toBe('vorgegeben'); const notiz = plan.notes.find((n) => n.code === 'zwischenzeit-vorgabe-aufgerundet'); expect(notiz).toBeDefined(); expect(notiz!.severity).toBe('hinweis'); expect(notiz!.konfliktId).toBe(projekt.conflicts[0]!.id); expect(notiz!.message).toContain('4,2 s'); expect(notiz!.message).toContain('angesetzt werden 5 s'); const befund = validateProject(projekt, plan, DATUM).findings.find( (f) => f.rule === 'signalplan.zwischenzeit-vorgabe-aufgerundet', ); expect(befund).toBeDefined(); expect(befund!.category).toBe('zwischenzeiten'); expect(befund!.target).toMatchObject({ kind: 'konflikt' }); }); it('schweigt bei ganzen Sekunden (Waechter)', () => { const plan = buildSignalPlan(zweiGruppen({}, 'knotenpunkt', {}, { manualIntergreen: 9 })); expect(plan.notes.some((n) => n.code === 'zwischenzeit-vorgabe-aufgerundet')).toBe(false); }); it('nimmt eine negative Vorgabe nicht an: Fehler, gerechnet wird aus den Wegen - nicht 0 s', () => { const projekt = zweiGruppen({}, 'knotenpunkt', {}, { manualIntergreen: -3 }); const plan = buildSignalPlan(projekt); const [k1, k2] = projekt.signalGroups; const eintrag = plan.intergreens.get(intergreenKey(k1!.id, k2!.id))!; // Altstand: Math.max(0, ceil(-3)) = 0 s als VORGABE - keine Zwischenzeit. expect(eintrag.source).toBe('berechnet'); expect(eintrag.value).toBe( computeIntergreen( { mode: 'kfz', movement: 'geradeaus', clearingDistance: 22, vehicleClass: 'pkw', vZul: 50 }, { mode: 'kfz', movement: 'geradeaus', enteringDistance: 8 }, ).intergreen, ); expect(eintrag.value).toBeGreaterThan(0); const notiz = plan.notes.find((n) => n.code === 'zwischenzeit-vorgabe-ungueltig'); expect(notiz).toBeDefined(); expect(notiz!.severity).toBe('fehler'); expect(notiz!.message).toContain('-3 s'); expect(notiz!.message).toContain('nicht übernommen'); const bericht = validateProject(projekt, plan, DATUM); expect( bericht.findings.some((f) => f.rule === 'signalplan.zwischenzeit-vorgabe-ungueltig'), ).toBe(true); expect(bericht.exportBlocked).toBe(true); }); }); // --- Fahrzeugart -------------------------------------------------------------- describe('Beschriftung einer unbekannten Fahrzeugart', () => { it('nennt keine erfundene Zahl und kein "undefined"', () => { const text = fahrzeugartBeschriftung('traktor' as VehicleClass); expect(text).not.toContain('undefined'); expect(text).not.toContain('kein Zuschlag'); expect(text).toContain('unbekannt'); expect(text).toContain('traktor'); expect(text).toContain('Prüfbericht'); // Waechter: bekannte Fahrzeugarten wie bisher. expect(fahrzeugartBeschriftung('pkw')).toBe(`Pkw (${SAFETY_FLOORS.minVehicleLengthKfz} m)`); expect(fahrzeugartBeschriftung('keine')).toBe('ohne Längenzuschlag'); }); });