import { describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { ENGSTELLE_PRAXIS, RSA_EINSTREIFIG, VZUL_OBERGRENZE_LSA } from '@/domain/rilsa/constants'; import type { Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { ASSISTENT_VORGABE, fuelleEngstellenkasten } from '@/ui/assistent'; import { HILFE } from '@/ui/help/texte'; import { projectView } from '@/ui/views/projectView'; import { mindestfreigabeHinweis, signalGroupsView } from '@/ui/views/signalGroupsView'; /** * Oberflaeche zu den Befunden C1 bis C6: * * - C1: Das Kontrollkaestchen "Zusatzeinrichtung fuer Blinde und Sehbehinderte" * gibt es nur fuer Fussgaengergruppen, und der Platzhalter der * Mindestfreigabezeit nennt den MASSGEBENDEN Wert aus dem Plan - nicht mehr * nur den Regelwert, waehrend der Plan mit einem hoeheren verteilt. * - C2/C6: Hilfe und Projektansicht nennen die 70-km/h-Grenze der VwV-StVO. * - C3/C5: Der Engstellen-Schritt des Assistenten zeigt die Einstufung nach * Behoerdenpraxis (400/600 m, als solche benannt) und die RSA-Kriterien. * * Gegen den Altstand schlagen diese Faelle fehl: Dort gab es weder Kaestchen * noch Hilfeeintrag, der Platzhalter stand fest auf dem Regelwert, und der * Assistent schwieg zwischen 400 und rund 835 m. */ 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; } describe('Signalgruppen: Zusatzeinrichtung fuer Blinde und Sehbehinderte (Befund C1)', () => { it('bietet das Kaestchen nur fuer Fussgaengergruppen an', () => { const p = createStandardIntersectionProject('Furt', FESTES_DATUM); const { wurzel, aufraeumen } = zeichne(signalGroupsView, p); try { const s = spalte(wurzel, 'Blindenzusatz'); for (const gruppe of p.signalGroups) { const zeile = wurzel.querySelector(`#sg-${gruppe.id}`)!; const zelle = zeile.children[s] as HTMLElement; const kaestchen = zelle.querySelector('input[type="checkbox"]'); if (gruppe.mode === 'fuss') { expect(kaestchen, `${gruppe.name}: Kaestchen fehlt`).not.toBeNull(); expect(kaestchen!.checked).toBe(false); /* * Der Name sagt, was der Haken bewirkt - nicht nur "Blindenzusatz" - * UND welche Furt gemeint ist. Vor der Fassung 5.11.0 (Befund 18) * stand der ganze Erklaersatz im `aria-label`, und damit hiessen alle * Furtzeilen gleich. Der Satz ist nicht verschwunden, er ist jetzt * die vorgelesene Beschreibung des Feldes; geprueft wird er dort, wo * er steht. */ expect(kaestchen!.getAttribute('aria-label')).toContain('Blinde und Sehbehinderte'); expect(kaestchen!.getAttribute('aria-label')).toContain(gruppe.name); const beschreibung = wurzel.querySelector( `[id="${kaestchen!.getAttribute('aria-describedby') ?? ''}"]`, ); expect(beschreibung?.textContent).toContain('ganze Furt'); } else { expect(kaestchen, `${gruppe.name}: Kaestchen darf nicht da sein`).toBeNull(); expect(zelle.textContent?.trim()).toBe('–'); } } } finally { aufraeumen(); } }); it('schreibt den Haken in die Signalgruppe', () => { const p = createStandardIntersectionProject('Furt', FESTES_DATUM); const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, p); try { const f1 = p.signalGroups.find((g) => g.name === 'F1')!; const s = spalte(wurzel, 'Blindenzusatz'); const zeile = wurzel.querySelector(`#sg-${f1.id}`)!; const kaestchen = (zeile.children[s] as HTMLElement).querySelector( 'input', )!; kaestchen.checked = true; kaestchen.dispatchEvent(new Event('change')); expect(store.getProject().signalGroups.find((g) => g.id === f1.id)!.blindenzusatz).toBe(true); // Der Plan rechnet sofort mit der ganzen Furt: 12 m / 1,2 m/s = 10 s. expect(store.getPlan().groups.find((g) => g.groupId === f1.id)!.times.minGreen).toBe(10); } finally { aufraeumen(); } }); it('nennt im Platzhalter der Mindestfreigabezeit den massgebenden Wert, nicht nur den Regelwert', () => { const roh = createStandardIntersectionProject('Furt', FESTES_DATUM); const p: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => g.name === 'F1' ? { ...g, blindenzusatz: true } : g, ), }; const { wurzel, aufraeumen } = zeichne(signalGroupsView, p); try { const s = spalte(wurzel, 'Mindestfreigabe'); const f1 = p.signalGroups.find((g) => g.name === 'F1')!; const f2 = p.signalGroups.find((g) => g.name === 'F2')!; const feld = (id: string): HTMLInputElement => ( wurzel.querySelector(`#sg-${id}`)!.children[s] as HTMLElement ).querySelector('input')!; // F1 mit Zusatz: ganze Furt 12 m / 1,2 m/s = 10 s; F2 ohne: halbe Furt 6 m -> 5 s = Regelwert. expect(feld(f1.id).placeholder).toBe('10'); expect(feld(f1.id).title).toContain('Regelwert 5 s'); expect(feld(f1.id).title).toContain('Maßgebend 10 s aus der Furtlänge'); expect(feld(f1.id).title).toContain('12,00 m / 1,2 m/s = 10,000 s'); // "Leer lassen" verspricht hier den Furtbedarf, nicht den Regelwert - // den gaebe es bei leerem Feld nicht. expect(feld(f1.id).title).toContain('Leer lassen: es gilt der Furtbedarf 10 s'); expect(feld(f1.id).title).not.toContain('um den Regelwert zu verwenden'); expect(feld(f2.id).placeholder).toBe('5'); expect(feld(f2.id).title).not.toContain('Furtlänge'); expect(feld(f2.id).title).toContain('Leer lassen, um den Regelwert zu verwenden'); } finally { aufraeumen(); } }); it('formuliert den Kurzhinweis aus dem Rechenweg des Plans', () => { const roh = createStandardIntersectionProject('Furt', FESTES_DATUM); const p: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => g.name === 'F1' ? { ...g, reducedMobility: true, blindenzusatz: true } : g, ), }; const plan = buildSignalPlan(p); const f1 = plan.groups.find((g) => g.name === 'F1')!; // 12 m / 1,0 m/s = 12 s. expect(f1.times.minGreen).toBe(12); const hinweis = mindestfreigabeHinweis(5, f1.mindestfreigabezeit); expect(hinweis).toContain('ganze Furt'); expect(hinweis).toContain('Blinde und Sehbehinderte'); expect(hinweis).toContain('12,00 m / 1,0 m/s = 12,000 s, aufgerundet 12 s'); expect(hinweis).toContain('Leer lassen: es gilt der Furtbedarf 12 s'); // Ohne Plan-Eintrag bleibt es beim Regelwert - und ohne Behauptung. expect(mindestfreigabeHinweis(5, undefined)).toBe( 'Regelwert 5 s. Leer lassen, um den Regelwert zu verwenden; ein Eintrag gilt nur nach oben.', ); }); it('nennt bei einer hoeheren Vorgabe den Furtbedarf trotzdem - denn der gilt bei leerem Feld', () => { // Vorgabe 15 s ueber dem Furtbedarf von 12 s: massgebend die Vorgabe, aber // "Leer lassen" fuehrt auf 12 s, nicht auf den Regelwert 5 s. const roh = createStandardIntersectionProject('Furt', FESTES_DATUM); const p: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => g.name === 'F1' ? { ...g, reducedMobility: true, blindenzusatz: true, minGreenOverride: 15 } : g, ), }; const f1 = buildSignalPlan(p).groups.find((g) => g.name === 'F1')!; expect(f1.mindestfreigabezeit.massgebend).toBe('vorgabe'); const hinweis = mindestfreigabeHinweis(5, f1.mindestfreigabezeit); expect(hinweis).toContain('Furtbedarf aus der Furtlänge: ganze Furt'); expect(hinweis).toContain( '12,00 m / 1,0 m/s = 12,000 s, aufgerundet 12 s; maßgebend die Vorgabe von 15 s.', ); expect(hinweis).toContain('Leer lassen: es gilt der Furtbedarf 12 s'); expect(hinweis).not.toContain('um den Regelwert zu verwenden'); }); }); describe('Hilfetexte zu den Befunden C1 bis C6', () => { it('erklaert die Zusatzeinrichtung fuer Blinde und Sehbehinderte', () => { const h = HILFE.blindenzusatz; expect(h.titel).toBe('Zusatzeinrichtung für Blinde und Sehbehinderte'); expect(h.wasIstDas).toContain('DIN 32981'); expect(h.wasIstDas).toContain('ganze Furt'); expect(h.wennFalsch).toContain('Sicherheitsrelevant'); expect(h.siehe).toContain('mindestfreigabezeit'); }); it('nennt bei der Mindestfreigabezeit die Furtregel mit Rechenweg', () => { const h = HILFE.mindestfreigabezeit; expect(h.wasIstDas).toContain('halbe Furt'); expect(h.wasIstDas).toContain('7 m / 1,2 m/s = 5,83 s'); expect(h.woher).toContain('ist die Furtlänge unbekannt'); expect(h.siehe).toContain('blindenzusatz'); }); it('nennt bei der Hoechstgeschwindigkeit die 70-km/h-Grenze der VwV-StVO', () => { const h = HILFE.vZulKnoten; expect(h.ueblich).toContain( `Über ${VZUL_OBERGRENZE_LSA} km/h sollen Lichtsignalanlagen nicht eingerichtet werden`, ); expect(h.ueblich).toContain('VwV-StVO'); expect(h.ueblich).toContain(`${RSA_EINSTREIFIG.vZulRegel} km/h`); }); it('erklaert die Engstellenlaenge mit den Praxisgrenzen als Behoerdenpraxis', () => { const h = HILFE.engstellenlaenge; expect(h.ueblich).toContain(`${ENGSTELLE_PRAXIS.hinweisAb} m`); expect(h.ueblich).toContain(`${ENGSTELLE_PRAXIS.warnungAb} m`); expect(h.ueblich).toContain('Behördenpraxis'); expect(h.ueblich).toContain('kein Regelwerkswert'); expect(h.ueblich).toContain(`${RSA_EINSTREIFIG.kurzeEngstelleBis} m`); expect(h.wennFalsch).toContain('Sicherheitsrelevant'); }); it('fuehrt die RSA-Anordnungskriterien als Hinweise, nicht als Rechenpruefung', () => { const h = HILFE.rsaAnordnung; expect(h.wasIstDas).toContain(`${RSA_EINSTREIFIG.verkehrsstaerkeRichtwert} Kfz/h`); expect(h.wasIstDas).toContain(`${RSA_EINSTREIFIG.kurzeEngstelleBis} m`); expect(h.wasIstDas).toContain('Typ D'); expect(h.wennFalsch).toContain('Keine Rechenwirkung'); expect(h.woher).toContain('abzugleichen'); }); }); describe('Assistent: Engstellen-Schritt mit Behoerdenpraxis und RSA-Hinweisen (Befunde C3, C5)', () => { const kasten = (laenge: number): HTMLElement => { const node = document.createElement('div'); fuelleEngstellenkasten(node, { ...ASSISTENT_VORGABE, form: 'einstreifig', engstellenlaenge: laenge, }); return node; }; it('schweigt bei 120 m zur Behoerdenpraxis', () => { const k = kasten(120); expect(k.textContent).not.toContain('Behördenpraxis'); expect(k.className).toBe('hinweisbalken'); }); it('weist bei 450 m auf den Abstimmungsbedarf hin - als Behoerdenpraxis, kein Regelwerkswert', () => { const k = kasten(450); const praxis = k.querySelector('.assistent-praxishinweis')!; expect(praxis, 'Absatz zur Behoerdenpraxis fehlt').not.toBeNull(); expect(praxis.textContent).toContain('Hinweis:'); expect(praxis.textContent).toContain('450 m lang'); expect(praxis.textContent).toContain('Behördenpraxis'); expect(praxis.textContent).toContain('kein Regelwerkswert'); expect(praxis.textContent).toContain(`bis ${ENGSTELLE_PRAXIS.hinweisAb} m`); expect(praxis.textContent).toContain('Abstimmung mit der Straßenverkehrsbehörde'); // Die Wartezeit ist bei 450 m noch unauffaellig - der Kasten bleibt ein Hinweis. expect(k.className).toBe('hinweisbalken'); expect(k.querySelector('strong')!.textContent).toBe('Das bewirkt diese Länge'); }); it('warnt bei 650 m, obwohl die Wartezeit noch nicht warnt', () => { const k = kasten(650); const praxis = k.querySelector('.assistent-praxishinweis')!; expect(praxis.textContent).toContain('Warnung:'); expect(praxis.textContent).toContain( 'erreicht oder überschreitet auch die Ausnahmegrenze von 600 m', ); expect(praxis.textContent).toContain('Ablehnung'); // Die Behoerdenpraxis hebt die Stufe des Kastens an: Farbe UND Ueberschrift. expect(k.className).toContain('hinweisbalken-warnung'); expect(k.querySelector('strong')!.textContent).toContain('Behördenpraxis'); }); it('nennt bei kurzer Engstelle die Regelung ohne Ampel, sonst die drei RSA-Kriterien', () => { const kurz = kasten(RSA_EINSTREIFIG.kurzeEngstelleBis); expect(kurz.querySelector('.assistent-rsahinweis')!.textContent).toContain('Zeichen 208/308'); expect(kurz.querySelector('.assistent-rsahinweis')!.textContent).toContain('nicht zwingend'); const lang = kasten(RSA_EINSTREIFIG.kurzeEngstelleBis + 10); const rsa = lang.querySelector('.assistent-rsahinweis')!.textContent ?? ''; expect(rsa).not.toContain('nicht zwingend'); expect(rsa).toContain('Verkehrsstärke'); expect(rsa).toContain(`${RSA_EINSTREIFIG.verkehrsstaerkeRichtwert} Fz/h`); expect(rsa).toContain('Sichtverhältnisse'); }); }); describe('Projekt: Hoechstgeschwindigkeit mit 70-km/h-Hinweis (Befund C2)', () => { it('nennt am Feld die Soll-Vorschrift der VwV-StVO', () => { const { wurzel, aufraeumen } = zeichne( projectView, createStandardIntersectionProject('V', FESTES_DATUM), ); try { const label = [...wurzel.querySelectorAll('label')].find( (l) => l.textContent?.trim() === 'Zulässige Höchstgeschwindigkeit', ); expect(label, 'Feld fehlt').toBeDefined(); const feld = wurzel.querySelector(`#${label!.htmlFor}`)!; const hinweis = wurzel.querySelector(`#${feld.getAttribute('aria-describedby')}`)!; expect(hinweis.textContent).toContain(`Über ${VZUL_OBERGRENZE_LSA} km/h`); expect(hinweis.textContent).toContain('VwV-StVO'); expect(hinweis.textContent).toContain('Prüfbericht'); } finally { aufraeumen(); } }); });