import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { UMLAUFZEIT_ERSATZWERT_VERMERK, buildSignalPlan } from '@/domain/plan/signalPlan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants'; import type { Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { ASSISTENT_VORGABE, FURT_MINDESTWEGE, assistentZusammenfassung, fuelleZusammenfassung, furtAnnahmeText, furtWegeAnnahme, projektAusAngaben, starteAssistent, } from '@/ui/assistent'; import { HILFE } from '@/ui/help/texte'; import { planView, umlaufzeitText } from '@/ui/views/planView'; import { settingsView } from '@/ui/views/settingsView'; import { mindestfreigabeUntergrenze, signalGroupsView, wirksamerWertHinweis, } from '@/ui/views/signalGroupsView'; /** * Oberflaeche zu den Befunden C9 bis C12 - "stille Ersetzungen": Werte, die das * Programm ersetzt, ohne es zu sagen. * * - C9: Das Feld der Mindestfreigabezeit hat als Untergrenze den Wert, der bei * leerem Feld gilt; eine kleinere Eingabe wird angehoben und gemeldet, eine * kleinere gespeicherte Vorgabe steht mit "wirksam N s" daneben. * - C10: Die Vorgabenansicht loest die Vorgaben mit der Anlagenart auf - eine * groesste Umlaufzeit von 250 s bleibt bei einstreifiger Fuehrung 250 s. * - C11: Kennzahl und Kopfzeile des Signalzeitenplans tragen bei * Uebersaettigung den Ersatzwert-Vermerk des Fachkerns. * - C12: Der Assistent zeigt Schwerverkehrsanteil (Vorgabe 5 %) und * Furt-Annahme mit Zahl und fasst die Annahmen im letzten Schritt zusammen. * - Die Hilfe sagt, was das Programm tut - nicht mehr "wird abgewiesen". * * Gegen den Altstand schlagen die Faelle fehl: Feld ab 0 s ohne Hinweis, * Ansicht ohne Anlagenart, nackte "120 s", stille 5 % und halbe Wege. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); function zeichne( view: ViewDefinition, project: Project, ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); const aufraeumen = view.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, store, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } /** Spaltenindex zu einem sichtbaren Kopftext (ohne Hilfeknopf). */ function spalte(wurzel: ParentNode, kopf: string): number { const koepfe = [...wurzel.querySelectorAll('thead th')].map((th) => [...th.childNodes] .filter((k) => !(k instanceof Element && k.classList.contains('hilfe-knopf'))) .map((k) => k.textContent ?? '') .join('') .trim(), ); const index = koepfe.indexOf(kopf); if (index < 0) throw new Error(`Keine Spalte "${kopf}" in ${koepfe.join(' | ')}`); return index; } function mindestfreigabeZelle(wurzel: ParentNode, id: string): HTMLElement { const s = spalte(wurzel, 'Mindestfreigabe'); return wurzel.querySelector(`#sg-${id}`)!.children[s] as HTMLElement; } function uebersaettigt(): Project { const p = createStandardIntersectionProject('Ersatzwert', FESTES_DATUM); return { ...p, demands: p.demands.map((d) => ({ ...d, volume: 1500 })) }; } afterEach(() => { document.body.replaceChildren(); }); describe('Signalgruppen: Mindestfreigabezeit-Feld nimmt keine Unterschreitung an (Befund C9)', () => { it('setzt die Untergrenze des Felds auf den Wert, der bei leerem Feld gilt', () => { const roh = createStandardIntersectionProject('Furt', FESTES_DATUM); const p: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => g.name === 'F1' ? { ...g, blindenzusatz: true } : g, ), }; const plan = buildSignalPlan(p); const { wurzel, aufraeumen } = zeichne(signalGroupsView, p); try { for (const gruppe of p.signalGroups) { const feld = mindestfreigabeZelle(wurzel, gruppe.id).querySelector('input')!; const erwartet = mindestfreigabeUntergrenze(p, gruppe, plan.defaults); expect(feld.min, gruppe.name).toBe(String(erwartet)); expect(feld.title, gruppe.name).toContain( `Eingaben unter ${erwartet} s werden auf ${erwartet} s angehoben.`, ); // Ohne Vorgabe kein Zusatz "wirksam". expect(mindestfreigabeZelle(wurzel, gruppe.id).textContent).not.toContain('wirksam'); } // F1 mit Blindenzusatz: ganze Furt 12 m / 1,2 m/s = 10 s; K1: Regelwert 5 s. const f1 = p.signalGroups.find((g) => g.name === 'F1')!; const k1 = p.signalGroups.find((g) => g.name === 'K1')!; expect(mindestfreigabeZelle(wurzel, f1.id).querySelector('input')!.min).toBe('10'); expect(mindestfreigabeZelle(wurzel, k1.id).querySelector('input')!.min).toBe('5'); } finally { aufraeumen(); } }); it('hebt eine Eingabe unter der Untergrenze auf diese an und speichert den angehobenen Wert', () => { const p = createStandardIntersectionProject('Furt', FESTES_DATUM); const k1 = p.signalGroups.find((g) => g.name === 'K1')!; const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, p); try { const feld = mindestfreigabeZelle(wurzel, k1.id).querySelector('input')!; feld.value = '3'; feld.dispatchEvent(new Event('change')); // Das Feld zeigt den angehobenen Wert und ist als korrigiert gekennzeichnet. expect(feld.value).toBe('5'); expect(feld.getAttribute('aria-invalid')).toBe('true'); expect(store.getProject().signalGroups.find((g) => g.id === k1.id)!.minGreenOverride).toBe(5); // Eine Eingabe ueber der Untergrenze geht unveraendert durch. feld.value = '8'; feld.dispatchEvent(new Event('change')); expect(store.getProject().signalGroups.find((g) => g.id === k1.id)!.minGreenOverride).toBe(8); } finally { aufraeumen(); } }); it('zeigt neben einer gespeicherten kleineren Vorgabe den wirksamen Wert', () => { // Altdatei: 3 s eingetragen, der Plan rechnet mit 5 s. const roh = createStandardIntersectionProject('Altdatei', FESTES_DATUM); const p: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => g.name === 'K1' ? { ...g, minGreenOverride: 3 } : g, ), }; const k1 = p.signalGroups.find((g) => g.name === 'K1')!; const k2 = p.signalGroups.find((g) => g.name === 'K2')!; expect(buildSignalPlan(p).groups.find((g) => g.groupId === k1.id)!.times.minGreen).toBe(5); const { wurzel, aufraeumen } = zeichne(signalGroupsView, p); try { const zelle = mindestfreigabeZelle(wurzel, k1.id); const feld = zelle.querySelector('input')!; // Der gespeicherte Wert bleibt sichtbar - er wird nicht still ueberschrieben. expect(feld.value).toBe('3'); const zusatz = zelle.querySelector('.mindestfreigabe-wirksam'); expect(zusatz, 'Zusatz "wirksam" fehlt').not.toBeNull(); expect(zusatz!.textContent).toBe('wirksam 5 s'); expect(zusatz!.title).toContain('Vorgabe von 3 s'); expect(zusatz!.title).toContain('rechnet mit 5 s'); expect(zusatz!.title).toContain('Prüfbericht'); // Eine Gruppe ohne Vorgabe hat den Zusatz nicht. expect( mindestfreigabeZelle(wurzel, k2.id).querySelector('.mindestfreigabe-wirksam'), ).toBeNull(); } finally { aufraeumen(); } }); it('bildet Untergrenze und Zusatz aus dem Fachkern, ohne eine hoehere Vorgabe zur Untergrenze zu machen', () => { const roh = createStandardIntersectionProject('Vorgabe', FESTES_DATUM); const p: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => g.name === 'F1' ? { ...g, blindenzusatz: true, minGreenOverride: 15 } : g, ), }; const plan = buildSignalPlan(p); const f1 = p.signalGroups.find((g) => g.name === 'F1')!; // Massgebend ist die Vorgabe 15 s, die Untergrenze aber der Furtbedarf 10 s: // Sonst liesse sich die Vorgabe nie mehr auf 13 s senken. expect(plan.groups.find((g) => g.groupId === f1.id)!.times.minGreen).toBe(15); expect(mindestfreigabeUntergrenze(p, f1, plan.defaults)).toBe(10); expect(wirksamerWertHinweis(15, 15)).toBeNull(); expect(wirksamerWertHinweis(null, 5)).toBeNull(); expect(wirksamerWertHinweis(3, 5)).toBe('wirksam 5 s'); expect(wirksamerWertHinweis(5.5, 6)).toBe('wirksam 6 s'); }); }); describe('Vorgaben: Aufloesung mit der Anlagenart (Befund C10)', () => { it('zeigt bei einstreifiger Fuehrung eine groesste Umlaufzeit von 250 s als Abweichung, nicht gekappt', () => { const roh = createStandardIntersectionProject('Einstreifig', FESTES_DATUM); const p: Project = { ...roh, anlagenart: 'einstreifig', settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, cycleTimeMax: 250 } }, }; expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.max).toBeGreaterThanOrEqual(250); const { wurzel, aufraeumen } = zeichne(settingsView, p); try { const text = wurzel.textContent ?? ''; expect(text).toContain('Größte Umlaufzeit: 250 s statt 120 s'); } finally { aufraeumen(); } }); it('kappt dieselbe Vorgabe am Knotenpunkt auf 120 s und fuehrt sie nicht als Abweichung', () => { const roh = createStandardIntersectionProject('Knotenpunkt', FESTES_DATUM); const p: Project = { ...roh, settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, cycleTimeMax: 250 } }, }; const { wurzel, aufraeumen } = zeichne(settingsView, p); try { expect(wurzel.textContent ?? '').not.toContain('250 s statt 120 s'); } finally { aufraeumen(); } }); }); describe('Signalzeitenplan: Umlaufzeit mit Ersatzwert-Vermerk (Befund C11)', () => { it('haengt bei Uebersaettigung den Vermerk an Kennzahl und Kopfzeile', () => { const p = uebersaettigt(); const plan = buildSignalPlan(p); expect(plan.cycleResult?.bounded, 'Vorbedingung').toBe('uebersaettigt'); const { wurzel, aufraeumen } = zeichne(planView, p); try { const kennzahl = [...wurzel.querySelectorAll('.kennzahl')].find( (k) => k.querySelector('dt')?.textContent === 'Umlaufzeit', )!; // Der Vermerk steht IM WERT, nicht nur in einer Fussnote. expect(kennzahl.querySelector('dd')?.textContent).toBe( `${plan.cycleTime} s – ${UMLAUFZEIT_ERSATZWERT_VERMERK}`, ); expect(kennzahl.querySelector('.kennzahl-fussnote')?.textContent).toContain('übersättigt'); // Und in der Kopfzeile der Ansicht. const kopf = wurzel.querySelector('.ansicht-kopf p')!; expect(kopf.textContent).toContain( `Umlaufzeit ${plan.cycleTime} s – ${UMLAUFZEIT_ERSATZWERT_VERMERK}`, ); } finally { aufraeumen(); } }); it('zeigt ohne Uebersaettigung die nackte Zahl', () => { const p = createStandardIntersectionProject('Regelfall', FESTES_DATUM); const plan = buildSignalPlan(p); expect(umlaufzeitText(plan)).toBe(`${plan.cycleTime} s`); const { wurzel, aufraeumen } = zeichne(planView, p); try { const kennzahl = [...wurzel.querySelectorAll('.kennzahl')].find( (k) => k.querySelector('dt')?.textContent === 'Umlaufzeit', )!; expect(kennzahl.querySelector('dd')?.textContent).toBe(`${plan.cycleTime} s`); expect(kennzahl.querySelector('.kennzahl-fussnote')).toBeNull(); expect(wurzel.textContent).not.toContain('Ersatzwert'); } finally { aufraeumen(); } }); }); describe('Assistent: Schwerverkehrsanteil und Furt-Annahme offengelegt (Befund C12)', () => { it('traegt den angegebenen Schwerverkehrsanteil ein - Vorgabe 5 %', () => { expect(ASSISTENT_VORGABE.schwerverkehrsanteil).toBe(5); const vorgabe = projektAusAngaben( { ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }, 'V', FESTES_DATUM, ); expect(vorgabe.demands.length).toBeGreaterThan(0); for (const d of vorgabe.demands) expect(d.heavyVehicleShare).toBeCloseTo(0.05, 6); const zwoelf = projektAusAngaben( { ...ASSISTENT_VORGABE, verkehrsstaerke: 400, schwerverkehrsanteil: 12 }, 'V', FESTES_DATUM, ); for (const d of zwoelf.demands) expect(d.heavyVehicleShare).toBeCloseTo(0.12, 6); // Ohne Verkehrsstaerke keine Nachfrage - und damit auch kein Anteil. expect( projektAusAngaben({ ...ASSISTENT_VORGABE, schwerverkehrsanteil: 12 }, 'V', FESTES_DATUM) .demands, ).toEqual([]); }); it('rechnet die Furtwege aus EINER Funktion und schreibt die Zahl in die Bemerkung der Beziehung', () => { expect(furtWegeAnnahme({ raeumweg: 22 })).toEqual({ raeumweg: 11, einfahrweg: 0, }); /* * Bei kurzen Wegen greift die Untergrenze von 6 m - aber nur am Raeumweg. * * GEAENDERT (Fassung 5.11.0, Befund 9): Hier stand die Erwartung, dass auch * der EINFAHRWEG auf eine Untergrenze von 3 m angehoben wird. Damit schrieb * dieser Fall ein Verhalten fest, das in die gefaehrliche Richtung wirkt: * In tz = tue + tr - te wird die Einfahrzeit abgezogen, die Furt ist der * einfahrende Strom, und ein groesserer Einfahrweg verkuerzt deshalb die * Zwischenzeit ZUM Fussgaenger. * * NOCHMALS GEAENDERT (Fassung 5.43.0): Der Einfahrweg ist jetzt 0 m und * nicht mehr die Haelfte - nach RiLSA 2015 ist die Einfahrzeit des * Fussgaengers 0 s, wenn die Konfliktflaeche am Fahrbahnrand beginnt. Die * Wache dazu steht in furtEinfahrweg.test.ts. */ expect(furtWegeAnnahme({ raeumweg: 8 })).toEqual({ raeumweg: FURT_MINDESTWEGE.raeumweg, einfahrweg: 0, }); const p = projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, raeumweg: 22, einfahrweg: 8 }, 'F', FESTES_DATUM, ); const furten = new Set(p.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id)); expect(furten.size).toBeGreaterThan(0); for (const c of p.conflicts) { if (furten.has(c.fromId)) { expect(c.clearingDistance).toBe(11); expect(c.note).toContain('halber Räumweg (11,0 m)'); expect(c.note).toContain('im Lageplan prüfen'); } if (furten.has(c.toId)) { expect(c.enteringDistance).toBe(0); expect(c.note).toContain('Einfahrweg 0 m (Fahrbahnrand'); } if (!furten.has(c.fromId) && !furten.has(c.toId)) { expect(c.clearingDistance).toBe(22); expect(c.enteringDistance).toBe(8); expect(c.note).not.toContain('halber'); } } }); it('nennt die Furt-Annahme als Satz mit Zahl', () => { const satz = furtAnnahmeText({ raeumweg: 22 }); expect(satz).toContain('Furtlänge = halber Räumweg: 11,0 m'); expect(satz).toContain('Einfahrweg 0 m'); // Nur noch der Raeumweg hat eine Untergrenze (Befund 9). expect(satz).toContain('mindestens 6 m'); expect(satz).toContain('Annahme, im Lageplan prüfen'); }); it('fasst im letzten Schritt alle Annahmen mit Zahl zusammen', () => { const kreuzung = assistentZusammenfassung({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400, schwerverkehrsanteil: 7, fussgaenger: true, }); const text = kreuzung.join(' '); expect(text).toContain('Schwerverkehrsanteil 7 %'); expect(text).toContain('400 Fz/h'); expect(text).toContain('Räumweg 22,0 m und Einfahrweg 8,0 m'); expect(text).toContain('Furtlänge = halber Räumweg: 11,0 m'); expect(text).toContain('50 km/h'); // Ohne Furten keine Furt-Annahme; ohne Verkehrsstaerke kein Anteil, dafuer der Hinweis. const ohne = assistentZusammenfassung({ ...ASSISTENT_VORGABE, fussgaenger: false }).join(' '); expect(ohne).not.toContain('Furtlänge'); expect(ohne).not.toContain('Schwerverkehrsanteil'); expect(ohne).toContain('Keine Verkehrsstärken'); // Die anderen Formen nennen ihre eigene Massannahme. expect( assistentZusammenfassung({ ...ASSISTENT_VORGABE, form: 'fussgaengerschutzanlage', fahrbahnbreite: 7, }).join(' '), ).toContain('Furtlänge = Fahrbahnbreite 7,0 m; Haltlinie 3 m vor der Furt'); expect( assistentZusammenfassung({ ...ASSISTENT_VORGABE, form: 'einstreifig', engstellenlaenge: 120, }).join(' '), ).toContain('Länge der Engstelle 120 m'); // Der Kasten zeigt einen Satz je Zeile. const kasten = document.createElement('div'); fuelleZusammenfassung(kasten, { ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }); const punkte = [...kasten.querySelectorAll('li')].map((li) => li.textContent); expect(punkte).toEqual( assistentZusammenfassung({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }), ); expect(kasten.textContent).toContain('Das legt der Assistent an'); }); /** * Der Dialog selbst: Im Verkehrs-Schritt gibt es das Feld, im Masse-Schritt * den Satz zur Furt-Annahme mit Zahl (und er rechnet mit, wenn der Raeumweg * sich aendert), und im letzten Schritt steht die Zusammenfassung ueber * "anlegen". Was der Anwender im Feld eintraegt, landet im Projekt. */ it('zeigt im Dialog Schwerverkehrsanteil, Furt-Annahme und Zusammenfassung - und uebernimmt die Eingabe', async () => { const ergebnis = starteAssistent(); const dialog = (): HTMLElement => document.querySelector('.dialog')!; const knopf = (beschriftung: string): HTMLButtonElement => [...dialog().querySelectorAll('button')].find( (b) => b.textContent?.trim() === beschriftung, )!; const feldMit = (beschriftung: string): HTMLInputElement => { const label = [...dialog().querySelectorAll('label')].find( (l) => l.textContent?.trim() === beschriftung, )!; expect(label, `Feld "${beschriftung}" fehlt`).toBeDefined(); return dialog().querySelector(`#${label.htmlFor}`)!; }; const setzeZahl = (feld: HTMLInputElement, wert: string): void => { feld.value = wert; feld.dispatchEvent(new Event('change')); }; // Schritt 1 (Art), Schritt 2 (Signalisierung): kein Zusammenfassungskasten. expect(dialog().querySelector('.assistent-zusammenfassung')).toBeNull(); knopf('Weiter').click(); knopf('Weiter').click(); // Schritt 3: Verkehr - Verkehrsstaerke und Schwerverkehrsanteil mit Vorgabe 5. const sv = feldMit('Schwerverkehrsanteil'); expect(sv.value).toBe('5'); expect(dialog().textContent).toContain('Vorgabe 5 %'); setzeZahl(feldMit('Verkehrsstärke je Zufahrt'), '400'); setzeZahl(sv, '9'); knopf('Weiter').click(); // Schritt 4: Masse - Furt-Annahme mit Zahl, rechnet mit; Zusammenfassung da. const annahme = dialog().querySelector('.assistent-furtannahme')!; expect(annahme).not.toBeNull(); expect(annahme.textContent).toContain(furtAnnahmeText({ raeumweg: 22 })); setzeZahl(feldMit('Räumweg'), '30'); expect(annahme.textContent).toContain('halber Räumweg: 15,0 m'); const zusammenfassung = dialog().querySelector('.assistent-zusammenfassung')!; expect(zusammenfassung).not.toBeNull(); expect(zusammenfassung.textContent).toContain('Schwerverkehrsanteil 9 %'); expect(zusammenfassung.textContent).toContain('halber Räumweg: 15,0 m'); expect(zusammenfassung.textContent).toContain('Räumweg 30,0 m'); knopf('Knotenpunkt anlegen').click(); const projekt = await ergebnis; expect(projekt).not.toBeNull(); for (const d of projekt!.demands) expect(d.heavyVehicleShare).toBeCloseTo(0.09, 6); const furten = new Set(projekt!.signalGroups.filter((g) => g.mode === 'fuss').map((g) => g.id)); const furtRaeumt = projekt!.conflicts.find((c) => furten.has(c.fromId))!; expect(furtRaeumt.clearingDistance).toBe(15); }); }); describe('Hilfetexte zu C9, C11 und C12', () => { it('beschreibt bei der Mindestfreigabezeit das Anheben, nicht ein Abweisen', () => { const text = HILFE.mindestfreigabezeit.wennFalsch ?? ''; expect(text).not.toContain('wird abgewiesen'); expect(text).toContain('auf die maßgebende Mindestfreigabezeit angehoben'); expect(text).toContain('wirksam'); expect(text).toContain('Vorgabe unter der Mindestfreigabezeit'); }); it('nennt bei der Umlaufzeit den Ersatzwert bei Uebersaettigung mit dem Wortlaut des Vermerks', () => { expect(HILFE.umlaufzeit.woher).toContain('nicht bemessbar (übersättigt) – Ersatzwert'); expect(HILFE.umlaufzeit.woher).toContain('kein Bemessungsergebnis'); }); it('nennt beim Schwerverkehrsanteil die Vorgabe des Assistenten und beim Raeumweg die Furt-Annahme', () => { expect(HILFE.schwerverkehrsanteil.woher).toContain( `Vorgabe ${ASSISTENT_VORGABE.schwerverkehrsanteil} Prozent`, ); expect(HILFE.raeumweg.woher).toContain('halben Räumweg der Kfz-Ströme'); // Die Untergrenze gilt nur noch fuer den Raeumweg (Befund 9); // seit 5.43.0 setzt der Einstieg ueberhaupt keinen Einfahrweg // mehr an, und die Hilfe sagt warum. expect(HILFE.raeumweg.woher).toContain(`(mindestens ${FURT_MINDESTWEGE.raeumweg} m)`); expect(HILFE.raeumweg.woher).toContain('Einfahrweg 0 m'); }); });