import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { RECHTSGRUNDLAGEN, RECHTSGRUNDLAGEN_EINSTREIFIG } from '@/domain/rilsa/quellen'; import { DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import type { Anlagenart, Project } from '@/domain/model/project'; import type { AppState } from '@/app/store'; import type { ViewDefinition } from '@/ui/shell'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { naechsterSchritt } from '@/ui/naechsterSchritt'; import { HILFE, glossarEintraege, glossarSuche } from '@/ui/help/texte'; import { projectView } from '@/ui/views/projectView'; import { settingsView } from '@/ui/views/settingsView'; import { RECHENWEG_ABGEWAEHLT_HINWEIS, RECHENWEG_SCHALTER, exportView, } from '@/ui/views/exportView'; /** * Unterlagen und Rechtsgrundlagen in der Oberflaeche (Befunde C13, C14, C15, * C17, C19). * * C14 - Einsatzzeiten und Betriebsverantwortlicher: Beide Pflichtinhalte einer * verkehrsbehoerdlichen Anordnung hatten im Programm kein Feld. Der gefaehrliche * Teil war nicht die fehlende Zeile, sondern der fehlende Lueckenausweis: Eine * Unterlage, die alle Felder ausgefuellt zeigt, die sie kennt, sieht * vollstaendig aus. * C15 - Der Rechenweg der Zwischenzeiten und der Wartezeit war voreingestellt * abgewaehlt und hiess nur "Rechenweg der Zwischenzeiten". * C13 - RSA 21, TL und ZTV transportable LSA 2023 fehlten in der rechtlichen * Einordnung; sie erscheinen jetzt bei einstreifiger Fuehrung. * C17 - Die VwV-StVO gibt Gelb- und Rot-Gelb-Zeit selbst vor; die Hilfe sagte * nur "aus dem Regelwerk abgeleitet". * C19 - Die OePNV-Mindestfreigabezeit stand als gesicherter RiLSA-Wert. * * Gegen den Altstand uebersetzt diese Datei nicht einmal: HILFE.rechtsgrundlagen, * HILFE.einsatzzeiten, HILFE.betriebsverantwortlicher, RECHENWEG_SCHALTER, * RECHENWEG_ABGEWAEHLT_HINWEIS, RECHTSGRUNDLAGEN_EINSTREIFIG und * meta.einsatzzeiten gab es nicht. */ 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 abmelden = view.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, store, aufraeumen: () => { abmelden(); wurzel.remove(); }, }; } /** Eingabefeld zu einer sichtbaren Beschriftung - ueber `for`, nicht ueber die Lage. */ function feldZu(wurzel: ParentNode, beschriftung: string): HTMLInputElement { const label = [...wurzel.querySelectorAll('label')].find( (l) => (l.textContent ?? '').trim() === beschriftung, ); if (label === undefined) throw new Error(`Keine Beschriftung "${beschriftung}"`); const feld = wurzel.querySelector(`#${label.htmlFor}`); if (feld === null) throw new Error(`Beschriftung "${beschriftung}" ohne Feld`); return feld; } /** Kontrollkaestchen zu einer sichtbaren Beschriftung. */ function schalterZu(wurzel: ParentNode, beschriftung: string): HTMLInputElement { return feldZu(wurzel, beschriftung); } function projektMit(anlagenart: Anlagenart, patch: Partial = {}): Project { const basis = createStandardIntersectionProject('Anordnung', FESTES_DATUM); return { ...basis, anlagenart, meta: { ...basis.meta, ...patch }, }; } function zustand(project: Project): AppState { const plan = buildSignalPlan(project); return { project, plan, report: validateProject(project, plan, FESTES_DATUM), dirty: false, filePath: null, canUndo: false, canRedo: false, undoLabel: null, redoLabel: null, }; } /** Eine bemessene Engstelle ohne Fehler - damit der Schritt ueberhaupt so weit kommt. */ function engstelle(patch: Partial = {}): Project { const basis = projektAusAngaben( { ...ASSISTENT_VORGABE, form: 'einstreifig', verkehrsstaerke: 300 }, 'Engstelle', FESTES_DATUM, ); return { ...basis, meta: { ...basis.meta, projectNumber: 'VP-2026-018', client: 'Stadt Musterhausen', planner: 'Willerding', location: 'Hauptstraße', ...patch, }, }; } afterEach(() => { document.body.replaceChildren(); }); describe('Projektdaten: Einsatzzeiten und Betriebsverantwortlicher (Befund C14)', () => { it('zeigt beide Felder mit ihrem Wert und schreibt Änderungen mit eigener Undo-Beschriftung', () => { const { wurzel, store, aufraeumen } = zeichne( projectView, projektMit('knotenpunkt', { einsatzzeiten: '12.05.–30.06.2027, ganztägig' }), ); try { const zeiten = feldZu(wurzel, 'Einsatzzeiten / Geltungszeitraum'); expect(zeiten.value).toBe('12.05.–30.06.2027, ganztägig'); const verantwortlich = feldZu(wurzel, 'Betriebsverantwortlicher'); expect(verantwortlich.value).toBe(''); verantwortlich.value = 'Mustermann Bau GmbH, 0170 1234567'; verantwortlich.dispatchEvent(new Event('change')); expect(store.getProject().meta.betriebsverantwortlicher).toBe( 'Mustermann Bau GmbH, 0170 1234567', ); // Die Undo-Beschriftung nennt die Angabe, nicht "Projektdaten geändert": // Wer zurueckgeht, muss wissen, was er zuruecknimmt. expect(store.getState().undoLabel).toBe('Betriebsverantwortlicher geändert'); zeiten.value = 'werktags 6–20 Uhr'; zeiten.dispatchEvent(new Event('change')); expect(store.getProject().meta.einsatzzeiten).toBe('werktags 6–20 Uhr'); expect(store.getState().undoLabel).toBe('Einsatzzeiten geändert'); } finally { aufraeumen(); } }); it('nennt bei einstreifiger Führung die RSA-Fundstelle und sonst § 45 StVO', () => { const einstreifig = zeichne(projectView, projektMit('einstreifig')); try { const text = einstreifig.wurzel.textContent ?? ''; expect(text).toContain('Angaben der verkehrsbehördlichen Anordnung'); expect(text).toContain('RSA 21, Teil A 1.4'); expect(text).toContain('warnt der Prüfbericht'); } finally { einstreifig.aufraeumen(); } const knoten = zeichne(projectView, projektMit('knotenpunkt')); try { const text = knoten.wurzel.textContent ?? ''; // Am Knotenpunkt keine RSA: Sie zu nennen behauptete einen Bezug, den es // nicht gibt - und genau das ist der Grundsatz hinter Befund C13. expect(text).not.toContain('RSA 21'); expect(text).toContain('§ 45 StVO'); expect(text).toContain('kein bezifferter Regelwerkswert'); } finally { knoten.aufraeumen(); } }); it('stellt beiden Feldern einen Hilfeknopf zur Seite, ohne ihn in den Namen zu ziehen', () => { const { wurzel, aufraeumen } = zeichne(projectView, projektMit('einstreifig')); try { for (const beschriftung of ['Einsatzzeiten / Geltungszeitraum', 'Betriebsverantwortlicher']) { const label = [...wurzel.querySelectorAll('label')].find( (l) => (l.textContent ?? '').trim() === beschriftung, ); expect(label, beschriftung).toBeDefined(); const kopf = label!.parentElement; expect(kopf?.classList.contains('feld-kopf'), beschriftung).toBe(true); // Der Knopf steht NEBEN der Beschriftung: Im `label` waere er Teil des // Feldnamens ("Betriebsverantwortlicher Hilfe zu ..."). expect(label!.querySelector('.hilfe-knopf'), beschriftung).toBeNull(); expect(kopf!.querySelector('button.hilfe-knopf'), beschriftung).not.toBeNull(); } } finally { aufraeumen(); } }); }); describe('Nächster Arbeitsschritt nennt die Anordnungsangaben (Befund C14)', () => { it('verlangt bei einstreifiger Führung beide Angaben und führt zu den Projektdaten', () => { const state = zustand(engstelle()); expect(state.report.errorCount, 'Vorbedingung: kein Fehler mehr offen').toBe(0); const schritt = naechsterSchritt(state); expect(schritt.ansicht).toBe('projekt'); expect(schritt.text).toContain('die Einsatzzeiten'); expect(schritt.text).toContain('den Betriebsverantwortlichen'); expect(schritt.grund).toContain('RSA 21, Teil A 1.4'); expect(schritt.ziel).toBe('Zu den Projektdaten'); }); it('nennt nur noch die fehlende der beiden Angaben', () => { const schritt = naechsterSchritt(zustand(engstelle({ einsatzzeiten: 'ganztägig' }))); expect(schritt.ansicht).toBe('projekt'); expect(schritt.text).not.toContain('die Einsatzzeiten'); expect(schritt.text).toContain('den Betriebsverantwortlichen'); }); it('schweigt, sobald beide Angaben stehen', () => { const schritt = naechsterSchritt( zustand(engstelle({ einsatzzeiten: 'ganztägig', betriebsverantwortlicher: 'Mustermann' })), ); expect(schritt.text).not.toContain('Einsatzzeiten'); expect(schritt.text).not.toContain('Betriebsverantwortlichen'); }); it('verlangt sie am Knotenpunkt nicht, auch wenn sie fehlen', () => { // Dort meldet der Pruefbericht sie als Hinweis - ein Arbeitsschritt, der an // jeder Anlage nach zwei Textfeldern verlangt, waere der Schritt, den man // ueberliest. const knoten = zustand({ ...engstelle(), anlagenart: 'knotenpunkt' }); expect(knoten.project.meta.einsatzzeiten ?? '').toBe(''); expect(naechsterSchritt(knoten).ansicht).not.toBe('projekt'); }); }); describe('Ausgabe: Rechenweg der Zwischenzeiten und der Wartezeit (Befund C15)', () => { it('beschriftet den Schalter mit beiden Rechenwegen und übernimmt die Vorgabe des Ausdrucks', () => { const { wurzel, aufraeumen } = zeichne(exportView, projektMit('knotenpunkt')); try { const schalter = schalterZu(wurzel, RECHENWEG_SCHALTER); expect(schalter.type).toBe('checkbox'); // Die Vorgabe selbst steht in pdf.ts (DEFAULT_PDF_OPTIONS); die Ansicht // fuehrt keine zweite Fassung, sondern zeigt genau sie an - und sie steht // seit Befund C15 auf "an", weil die Behoerdenpraxis den // Zwischenzeitennachweis verlangt. expect(DEFAULT_PDF_OPTIONS.includeCalculationDetails).toBe(true); expect(schalter.checked).toBe(DEFAULT_PDF_OPTIONS.includeCalculationDetails); const hinweis = document.getElementById(`${schalter.id}-hinweis`)?.textContent ?? ''; expect(hinweis).toContain('Rechenweg der Wartezeit'); expect(hinweis).toContain('Zwischenzeitennachweis'); } finally { aufraeumen(); } }); it('blendet beim Abwählen den Vermerk ein und beim Anwählen wieder aus', () => { const { wurzel, aufraeumen } = zeichne(exportView, projektMit('knotenpunkt')); try { const schalter = schalterZu(wurzel, RECHENWEG_SCHALTER); const vermerk = [...wurzel.querySelectorAll('[role="status"]')].find((k) => (k.textContent ?? '').includes(RECHENWEG_ABGEWAEHLT_HINWEIS), ); expect(vermerk, 'Vermerk zum abgewählten Rechenweg fehlt').toBeDefined(); const setze = (wert: boolean): void => { schalter.checked = wert; schalter.dispatchEvent(new Event('change')); }; setze(false); expect(vermerk!.hidden, 'abgewählt: Vermerk sichtbar').toBe(false); expect(vermerk!.textContent).toContain('nicht nachrechenbar'); setze(true); expect(vermerk!.hidden, 'angewählt: Vermerk verborgen').toBe(true); setze(false); expect(vermerk!.hidden, 'erneut abgewählt: Vermerk wieder sichtbar').toBe(false); } finally { aufraeumen(); } }); }); describe('Rechtliche Einordnung je Anlagenart (Befund C13)', () => { const nurEinstreifig = RECHTSGRUNDLAGEN_EINSTREIFIG.map((g) => g.bezeichnung); it('führt bei einstreifiger Führung RSA 21, TL und ZTV zusätzlich zum Grundstock', () => { const { wurzel, aufraeumen } = zeichne(settingsView, projektMit('einstreifig')); try { const text = wurzel.textContent ?? ''; for (const bezeichnung of [ ...RECHTSGRUNDLAGEN.map((g) => g.bezeichnung), ...nurEinstreifig, ]) { expect(text, bezeichnung).toContain(bezeichnung); } expect(text).toContain('ARS Nr. 07/2024'); expect(text).toContain('Regelwerke der Arbeitsstellensicherung'); } finally { aufraeumen(); } }); it('lässt sie am Knotenpunkt weg und sagt, wann sie erscheinen', () => { const { wurzel, aufraeumen } = zeichne(settingsView, projektMit('knotenpunkt')); try { const text = wurzel.textContent ?? ''; for (const bezeichnung of RECHTSGRUNDLAGEN.map((g) => g.bezeichnung)) { expect(text, bezeichnung).toContain(bezeichnung); } for (const bezeichnung of nurEinstreifig) { expect(text, bezeichnung).not.toContain(bezeichnung); } expect(text).toContain('nur bei der Anlagenart „Einstreifige Verkehrsführung“'); } finally { aufraeumen(); } }); it('stellt dem Abschnitt einen Hilfeknopf zu den Rechtsgrundlagen voran', () => { const { wurzel, aufraeumen } = zeichne(settingsView, projektMit('knotenpunkt')); try { const ueberschrift = [...wurzel.querySelectorAll('h2, h3')].find((h) => (h.textContent ?? '').includes('Rechtliche Einordnung'), ); expect(ueberschrift, 'Abschnitt "Rechtliche Einordnung" fehlt').toBeDefined(); const kopf = ueberschrift!.closest('.abschnitt-kopf') ?? ueberschrift!.parentElement; const knopf = kopf?.querySelector('button.hilfe-knopf'); expect(knopf, 'Hilfeknopf am Abschnitt fehlt').not.toBeNull(); expect(knopf!.getAttribute('aria-label')).toBe(`Hilfe zu ${HILFE.rechtsgrundlagen.titel}`); } finally { aufraeumen(); } }); }); describe('Hilfetexte zu Rechtsgrundlagen und Kennwerten (Befunde C13, C17, C19)', () => { it('erklärt die VwV-StVO als eigene Vorgabe, nicht als Verweis auf die FGSV', () => { const eintrag = HILFE.rechtsgrundlagen; expect(eintrag.wasIstDas).toContain('Verwaltungsvorschrift'); expect(eintrag.wasIstDas).toContain('selbst vor (Rn. 17)'); expect(eintrag.wasIstDas).toContain('3/4/5 s bei 50/60/70 km/h'); expect(eintrag.wasIstDas).toContain('1 s, höchstens 2 s'); expect(eintrag.wasIstDas).toContain('Rn. 10'); // Der Vorbehalt zur Neufassung 2025 gehoert dazu: Die Randnummern koennten // sich verschoben haben. expect(eintrag.ueblich).toContain('2025 neu gefasst'); }); it('nennt die Regelwerke der Arbeitsstellensicherung und ihre Einführung', () => { const text = HILFE.rechtsgrundlagen.wasIstDas; expect(text).toContain('RSA 21'); expect(text).toContain('TL und ZTV transportable Lichtsignalanlagen 2023'); expect(text).toContain('ARS Nr. 07/2024'); // Und den Unterschied Gesetz / Vorschrift / Regel der Technik. expect(text).toContain('anerkannte Regeln der Technik'); expect(HILFE.rechtsgrundlagen.woher).toContain('nur bei einstreifiger Verkehrsführung'); }); it('führt die Gelbzeitstaffel auf die VwV-StVO zurück', () => { expect(HILFE.gelbzeit.woher).toContain('VwV-StVO zu § 37 (Rn. 17) selbst vor'); expect(HILFE.gelbzeit.woher).toContain('die RiLSA nennt dieselben Werte'); }); it('weist die ÖPNV-Mindestfreigabezeit als Wert der Planungspraxis aus', () => { const text = HILFE.mindestfreigabezeit.wasIstDas; expect(text).toContain(`${RILSA_DEFAULTS.minGreen.oepnv} s`); expect(text).toContain('Plausibilitätswert der Planungspraxis'); expect(text).toContain('kein Wert der RiLSA'); expect(text).toContain('Planungspraxis“'); // Der belegte Wert bleibt der belegte Wert. expect(text).toContain('allgemeine Mindestfreigabezeit von 5 s'); }); it('erklärt beide Anordnungsangaben und führt sie im Glossar', () => { for (const schluessel of ['einsatzzeiten', 'betriebsverantwortlicher'] as const) { const eintrag = HILFE[schluessel]; expect(eintrag.woher.length, schluessel).toBeGreaterThan(40); // Beide sind ausdruecklich KEINE Rechengroessen - sonst sucht jemand die // Wirkung im Signalzeitenplan. expect(eintrag.wennFalsch, schluessel).toContain('Keine Rechenwirkung'); expect( glossarEintraege().map((e) => e.schluessel), schluessel, ).toContain(schluessel); } expect(glossarSuche('Betriebsverantwortlicher').map((e) => e.schluessel)).toContain( 'betriebsverantwortlicher', ); expect(glossarSuche('Rechtsgrundlagen').map((e) => e.schluessel)).toContain('rechtsgrundlagen'); }); });