import { describe, expect, it } from 'vitest'; import { HILFE, glossarEintraege, glossarSuche, istHilfeSchluessel } from '@/ui/help/texte'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { naechsterSchritt } from '@/ui/naechsterSchritt'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { isGreenAt } from '@/domain/plan/aspects'; import { createEmptyProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import type { AppState } from '@/app/store'; const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); function zustand(project: Project): AppState { const plan = buildSignalPlan(project); return { project, plan, report: validateProject(project, plan, FIXED_DATE), dirty: false, filePath: null, canUndo: false, canRedo: false, undoLabel: null, redoLabel: null, }; } describe('Fachliche Hilfe', () => { it('erklaert jeden Begriff vollstaendig', () => { for (const [schluessel, eintrag] of Object.entries(HILFE)) { expect(eintrag.titel, schluessel).not.toBe(''); expect(eintrag.kurz, schluessel).not.toBe(''); // Der Kurztext soll ein Satz sein, kein Absatz. expect(eintrag.kurz.length, schluessel).toBeLessThan(140); expect(eintrag.wasIstDas.length, schluessel).toBeGreaterThan(60); } }); it('loest alle Querverweise auf', () => { for (const [schluessel, eintrag] of Object.entries(HILFE)) { for (const verweis of eintrag.siehe ?? []) { expect(istHilfeSchluessel(verweis), `${schluessel} verweist auf ${verweis}`).toBe(true); } } }); it('verweist nicht auf sich selbst', () => { for (const [schluessel, eintrag] of Object.entries(HILFE)) { expect(eintrag.siehe ?? [], schluessel).not.toContain(schluessel); } }); it('sagt bei sicherheitsrelevanten Groessen, was eine falsche Angabe bewirkt', () => { const kritisch = [ 'zwischenzeit', 'raeumweg', 'einfahrweg', 'verkehrsart', 'fahrbeziehung', 'gelbzeit', 'konfliktbeziehung', 'phase', ] as const; for (const schluessel of kritisch) { const eintrag = HILFE[schluessel]; expect(eintrag.wennFalsch, schluessel).toBeDefined(); expect(eintrag.wennFalsch.length, schluessel).toBeGreaterThan(40); } }); it('erklaert bei Eingabefeldern, woher der Wert kommt', () => { // 'bewertungsverfahren' und 'instationaritaetsfaktor' kamen mit // Fassung 5.4.0 (Befund B2) hinzu: Beide sind Einstellungen, die // in Kapazitaet und Wartezeit eingehen - wer sie setzt, muss wissen, woher // der Wert kommt (Zaehldaten bzw. FGSV-Dialog zum HBS 2015). const eingabefelder = [ 'raeumweg', 'einfahrweg', 'verkehrsstaerke', 'zufahrt', 'fahrstreifen', 'bewertungsverfahren', 'instationaritaetsfaktor', ] as const; for (const schluessel of eingabefelder) { // toBeDefined() allein liesse eine leere Zeichenkette durch - der Test // darueber prueft an derselben Struktur bereits eine Mindestlaenge. const woher = HILFE[schluessel].woher; expect(woher, schluessel).toBeDefined(); expect(woher.trim().length, schluessel).toBeGreaterThan(40); } }); it('fuehrt die Fachbegriffe im Glossar, Bedienfelder nicht', () => { const schluessel = glossarEintraege().map((e) => e.schluessel); expect(schluessel).toContain('zwischenzeit'); expect(schluessel).toContain('zufahrt'); expect(schluessel).not.toContain('manuelleZwischenzeit'); }); it('findet Begriffe ueber die Suche', () => { expect(glossarSuche('Räumweg').map((e) => e.schluessel)).toContain('raeumweg'); expect(glossarSuche('Haltlinie').length).toBeGreaterThan(0); expect(glossarSuche('xyzabc')).toHaveLength(0); expect(glossarSuche('').length).toBe(glossarEintraege().length); }); }); /** * Befund D2: Das Programm rechnet jede Furt als durchgehende Furt; eine * progressive (zweistufige) Signalisierung der Furtabschnitte bildet es nicht * ab. Das ist die konservative Seite - und trotzdem eine Grenze des * Leistungsumfangs, die in der Hilfe stehen muss. Gegen den Altstand schlaegt * jeder Fall fehl: Dort gab es das Wort "Mittelinsel" in der Hilfe nicht. */ describe('Furt mit Mittelinsel (Befund D2)', () => { it('erklaert, was gerechnet wird und was nicht', () => { const eintrag = HILFE.mittelinsel; expect(eintrag.wasIstDas).toContain('durchgehende Furt'); expect(eintrag.wasIstDas).toContain('von Bordkante zu Bordkante'); // Beides gehoert zusammen: die konservative Richtung UND die Luecke. expect(eintrag.wasIstDas).toContain('sicheren Seite'); expect(eintrag.wasIstDas).toContain('Nicht abgebildet ist die progressive Signalisierung'); expect(eintrag.wasIstDas).toContain( 'zeitliche Abstimmung zwischen ihnen ist außerhalb dieses Programms zu planen', ); // Seit 5.38.0 prueft der Bericht EINEN Punkt davon; der Eintrag darf das // nicht laenger pauschal verneinen. expect(eintrag.wasIstDas).toContain('warnt der Prüfbericht'); }); it('sagt, was eine falsche Annahme kostet', () => { // Der teure Irrtum ist nicht "zu lange Freigabe", sondern der halbierte // Raeumweg: Er verkuerzt die Zwischenzeit fuer einen Querenden, der nach // diesem Plan in einem Zug durchgehen darf. expect(HILFE.mittelinsel.wennFalsch).toBeDefined(); expect(HILFE.mittelinsel.wennFalsch).toContain('Sicherheitsrelevant'); expect(HILFE.mittelinsel.wennFalsch).toContain('Zwischenzeit wird zu kurz'); }); it('steht im Glossar und ist ueber die Suche zu finden', () => { expect(glossarEintraege().map((e) => e.schluessel)).toContain('mittelinsel'); expect(glossarSuche('Mittelinsel').map((e) => e.schluessel)).toContain('mittelinsel'); }); it('haengt an den Eintraegen, an denen die Furtmasse entstehen', () => { // Wer den Raeumweg misst oder die Mindestfreigabezeit liest, muss auf den // Eintrag stossen - sonst findet ihn nur, wer ihn schon kennt. for (const schluessel of ['raeumweg', 'mindestfreigabezeit', 'blindenzusatz'] as const) { expect(HILFE[schluessel].siehe ?? [], schluessel).toContain('mittelinsel'); } expect(HILFE.raeumweg.woher).toContain( 'Auch bei einer Mittelinsel wird über die ganze Breite gemessen', ); expect(HILFE.mindestfreigabezeit.wasIstDas).toContain('mit ihrer ganzen Länge anzusetzen'); }); }); describe('Gefuehrter Einstieg', () => { it('erzeugt aus den Vorgaben einen schaltbaren Plan ohne Fehler', () => { const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'Assistent', FIXED_DATE); const plan = buildSignalPlan(projekt); const bericht = validateProject(projekt, plan, FIXED_DATE); expect(plan.feasible).toBe(true); expect(plan.cycleTime).toBeGreaterThan(0); const fachfehler = bericht.findings.filter( (f) => f.severity === 'fehler' && f.category !== 'projektdaten', ); expect(fachfehler, fachfehler.map((f) => `${f.rule}: ${f.message}`).join('\n')).toHaveLength(0); }); it('gibt feindliche Signalgruppen nie gleichzeitig frei', () => { const projekt = projektAusAngaben( { ...ASSISTENT_VORGABE, rad: true, linksabbieger: true }, 'Assistent', FIXED_DATE, ); const plan = buildSignalPlan(projekt); for (const konflikt of projekt.conflicts) { const a = plan.groups.find((g) => g.groupId === konflikt.fromId)!; const b = plan.groups.find((g) => g.groupId === konflikt.toId)!; for (let t = 0; t < plan.cycleTime; t += 0.5) { expect( isGreenAt(a, t, plan.cycleTime) && isGreenAt(b, t, plan.cycleTime), `${a.name} und ${b.name} bei t=${t}s`, ).toBe(false); } } }); it('legt fuer die Einmuendung drei Zufahrten an', () => { const projekt = projektAusAngaben( { ...ASSISTENT_VORGABE, form: 'einmuendung' }, 'Einmuendung', FIXED_DATE, ); expect(projekt.intersection.arms).toHaveLength(3); expect(buildSignalPlan(projekt).feasible).toBe(true); }); it('legt Fussgaengerfurten nur auf Wunsch an', () => { const mit = projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE); const ohne = projektAusAngaben({ ...ASSISTENT_VORGABE, fussgaenger: false }, 'x', FIXED_DATE); expect(mit.signalGroups.some((g) => g.mode === 'fuss')).toBe(true); expect(ohne.signalGroups.some((g) => g.mode === 'fuss')).toBe(false); }); it('uebernimmt die Verkehrsstaerke nur, wenn sie angegeben ist', () => { const ohne = projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE); const mit = projektAusAngaben({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }, 'x', FIXED_DATE); expect(ohne.demands).toHaveLength(0); expect(mit.demands.length).toBeGreaterThan(0); expect(mit.demands.every((d) => d.volume === 400)).toBe(true); }); it('vergibt eindeutige Bezeichnungen', () => { const projekt = projektAusAngaben( { ...ASSISTENT_VORGABE, rad: true, linksabbieger: true }, 'x', FIXED_DATE, ); const namen = projekt.signalGroups.map((g) => g.name); expect(new Set(namen).size).toBe(namen.length); }); }); describe('Naechster Arbeitsschritt', () => { it('fuehrt vom leeren Projekt bis zur Ausgabe', () => { const leer = naechsterSchritt(zustand(createEmptyProject('Leer', FIXED_DATE))); expect(leer.ansicht).toBe('signalgruppen'); expect(leer.text).toContain('Signalgruppen'); const fertig = zustand({ ...projektAusAngaben({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }, 'x', FIXED_DATE), meta: { ...createEmptyProject('x', FIXED_DATE).meta, name: 'Vollstaendig', projectNumber: 'VP-1', client: 'Stadt', planner: 'Willerding', location: 'Hauptstrasse', }, }); const schritt = naechsterSchritt(fertig); // Kein Fehler mehr offen - es geht nur noch um Warnungen oder die Ausgabe. expect(schritt.dringlichkeit).not.toBe('fehler'); }); it('nennt fehlende Raeumwege vor allem anderen', () => { const projekt = projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE); const ohneWege: Project = { ...projekt, conflicts: projekt.conflicts.map((c) => ({ ...c, clearingDistance: 0 })), }; const schritt = naechsterSchritt(zustand(ohneWege)); expect(schritt.ansicht).toBe('zwischenzeiten'); expect(schritt.text).toContain('Räumwege'); expect(schritt.dringlichkeit).toBe('fehler'); }); it('gibt zu jedem Schritt eine fachliche Begruendung', () => { for (const projekt of [ createEmptyProject('Leer', FIXED_DATE), projektAusAngaben(ASSISTENT_VORGABE, 'x', FIXED_DATE), ]) { const schritt = naechsterSchritt(zustand(projekt)); expect(schritt.grund.length).toBeGreaterThan(30); expect(schritt.ziel).not.toBe(''); } }); });