import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import * as actions from '@/app/actions'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import { roundTo } from '@/domain/units'; 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 { BEWERTUNGSVERFAHREN_LABELS, KRITERIUM_LABELS } from '@/domain/rilsa/constants'; import { HILFE, type Hilfetext } from '@/ui/help/texte'; import { planView, stufenErlaeuterung, bewertungsHinweis } from '@/ui/views/planView'; import { projectView } from '@/ui/views/projectView'; import { reportView } from '@/ui/views/reportView'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; import { simulationView } from '@/ui/views/simulationView'; import { settingsView } from '@/ui/views/settingsView'; /** * Anzeige der Bewertung seit Fassung 5.4.0 (Befunde B1 bis B4). * * Grundsatz: Was angezeigt wird, muss sich aus den angezeigten * Groessen nachrechnen lassen, und nichts darf behauptet werden, was nicht * gerechnet wird. Fuer die Oberflaeche heisst das: * * - Neben jeder Stufe stehen Verfahren UND Kriterium ("nach HBS 2015 * (maximale Wartezeit)"), denn derselbe Buchstabe bedeutet je Verkehrsart * etwas anderes, und F fuer Kfz heisst "ueberlastet", nicht "ueber 100 s". * - Fussgaenger und Radverkehr haben keine Kapazitaet, keinen Auslastungsgrad * und kein Verkehrsstaerkefeld; ihre Wartezeit-Spalte zeigt die MAXIMALE * Wartezeit (laengste Sperrzeit) und ist als "max." gekennzeichnet. * - Nach HCM erhalten Fussgaenger und Radverkehr keine Stufe - die Zelle sagt * das, statt einen Buchstaben zu erfinden. * * Gegen den Altstand schlagen diese Faelle fehl: Dort zeigte die Plan-Ansicht * fuer Furten die mittlere Wartezeit ohne Kennzeichnung, fuer Radgruppen * Kapazitaet und Auslastung aus einer Kfz-Saettigungsverkehrsstaerke, und die * Erlaeuterung nannte nur "nach HBS" ohne Kriterium. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); function projekt(verfahren: 'HBS' | 'HCM' = 'HBS'): Project { const basis = projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, verkehrsstaerke: 400 }, 'Bewertung', FESTES_DATUM, ); return { ...basis, settings: { ...basis.settings, serviceLevelScale: verfahren } }; } 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, }; } function zeichne( view: ViewDefinition, project: Project, ): { wurzel: HTMLElement; 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, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } /** Tabelle ueber ihre (vorgelesene) Beschriftung finden. */ function tabelleMit(wurzel: ParentNode, beschriftung: string): HTMLTableElement { const tabelle = [...wurzel.querySelectorAll('table')].find( (t) => t.querySelector('caption')?.textContent?.trim() === beschriftung, ); if (!tabelle) throw new Error(`Keine Tabelle "${beschriftung}"`); return tabelle; } /** Spaltenindex zu einem sichtbaren Kopftext (ohne Hilfeknopf). */ function spalte(tabelle: HTMLTableElement, kopf: string): number { const koepfe = [...tabelle.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; } /** Zeile der Signalzeitentabelle zu einer Signalgruppe (Name in Spalte 1). */ function zeileVon(tabelle: HTMLTableElement, name: string): HTMLTableRowElement { const zeile = [...tabelle.querySelectorAll('tbody tr')].find( (tr) => (tr.children[0]?.textContent ?? '').trim() === name, ); if (!zeile) throw new Error(`Keine Zeile "${name}"`); return zeile; } afterEach(() => { document.body.replaceChildren(); }); describe('Signalzeitenplan: Fussgaenger und Radverkehr nach maximaler Wartezeit', () => { it('zeigt fuer eine Furt nach HBS 2015 die maximale Wartezeit, eine Stufe und keine Kapazitaet', () => { const p = projekt('HBS'); const plan = buildSignalPlan(p); const furt = plan.groups.find((g) => g.mode === 'fuss')!; const { wurzel, aufraeumen } = zeichne(planView, p); try { const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe'); const zeile = zeileVon(tabelle, furt.name); const zelle = (kopf: string): HTMLElement => zeile.children[spalte(tabelle, kopf)] as HTMLElement; expect(zelle('Kapazität').textContent?.trim()).toBe('–'); expect(zelle('Auslastung').textContent?.trim()).toBe('–'); // Die MAXIMALE Wartezeit, als solche gekennzeichnet - nicht die mittlere. const wartezeit = zelle('Wartezeit'); expect(wartezeit.textContent).toMatch(/^max\. /); expect(wartezeit.textContent).toContain(furt.delay!.maximumDelay.toLocaleString('de-DE')); expect(wartezeit.title).toContain('maximale Wartezeit'); // Stufe mit Verfahren und Kriterium - sichtbar (title) und vorgelesen. const stufe = zelle('Stufe'); const marke = stufe.querySelector('.stufe'); expect(marke, 'Stufenmarke fehlt').not.toBeNull(); expect(marke!.textContent).toBe(furt.delay!.serviceLevel!.grade); expect(marke!.title).toContain('nach HBS 2015 (maximale Wartezeit)'); expect(stufe.textContent).toContain('nach HBS 2015 (maximale Wartezeit)'); } finally { aufraeumen(); } }); it('zeigt fuer eine Furt nach HCM keine Stufe und sagt warum', () => { const p = projekt('HCM'); const plan = buildSignalPlan(p); const furt = plan.groups.find((g) => g.mode === 'fuss')!; expect(furt.delay?.serviceLevel).toBeNull(); const { wurzel, aufraeumen } = zeichne(planView, p); try { const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe'); const zeile = zeileVon(tabelle, furt.name); const stufe = zeile.children[spalte(tabelle, 'Stufe')] as HTMLElement; expect(stufe.querySelector('.stufe')).toBeNull(); expect(stufe.textContent).toContain('–'); expect(stufe.textContent).toContain('keine Stufe (HCM'); // Die maximale Wartezeit steht trotzdem da - sie ist gerechnet. const wartezeit = zeile.children[spalte(tabelle, 'Wartezeit')] as HTMLElement; expect(wartezeit.textContent).toMatch(/^max\. /); // Und der Hinweis unter der Tabelle behauptet HCM, nicht HBS 2015. const hinweis = wurzel.textContent ?? ''; expect(hinweis).toContain('Bewertung nach HCM'); expect(hinweis).not.toContain('Bewertung nach HBS 2015'); } finally { aufraeumen(); } }); it('zeigt fuer Radverkehr weder Kapazitaet noch Auslastung, aber die maximale Wartezeit', () => { const p = projekt('HBS'); const plan = buildSignalPlan(p); const rad = plan.groups.find((g) => g.mode === 'rad')!; expect(rad.capacity).toBeNull(); const { wurzel, aufraeumen } = zeichne(planView, p); try { const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe'); const zeile = zeileVon(tabelle, rad.name); expect( (zeile.children[spalte(tabelle, 'Kapazität')] as HTMLElement).textContent?.trim(), ).toBe('–'); expect( (zeile.children[spalte(tabelle, 'Auslastung')] as HTMLElement).textContent?.trim(), ).toBe('–'); expect((zeile.children[spalte(tabelle, 'Wartezeit')] as HTMLElement).textContent).toMatch( /^max\. /, ); const marke = zeile.querySelector('.stufe'); expect(marke?.title).toContain('(maximale Wartezeit)'); } finally { aufraeumen(); } }); it('nennt bei Kfz die mittlere Wartezeit als Kriterium und bei Ueberlastung den Grund', () => { const p = projekt('HBS'); const kfz = p.signalGroups.find((g) => g.mode === 'kfz')!; // Eine Gruppe deutlich ueber die Kapazitaet treiben: q > C -> Stufe F wegen // Ueberlastung, unabhaengig von der Wartezeit. const ueberlastet = actions.setDemand(p, kfz.id, { volume: 6000 }); const plan = buildSignalPlan(ueberlastet); const geplant = plan.groups.find((g) => g.groupId === kfz.id)!; expect(geplant.capacity!.degreeOfSaturation!).toBeGreaterThan(1); expect(geplant.delay!.serviceLevel!.kriterium).toBe('ueberlastung'); const { wurzel, aufraeumen } = zeichne(planView, ueberlastet); try { const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe'); const zeile = zeileVon(tabelle, kfz.name); const marke = zeile.querySelector('.stufe')!; expect(marke.textContent).toBe('F'); expect(marke.title).toContain('überlastet'); expect(marke.title).toContain('nach HBS 2015'); // Die Wartezeit-Spalte ohne "max." - hier steht die mittlere. const wartezeit = zeile.children[spalte(tabelle, 'Wartezeit')] as HTMLElement; expect(wartezeit.textContent).not.toMatch(/max\./); expect(wartezeit.title).toContain('mittlere Wartezeit'); // Eine nicht ueberlastete Kfz-Gruppe nennt das Kriterium ausdruecklich. const andere = plan.groups.find((g) => g.mode === 'kfz' && g.groupId !== kfz.id)!; const andereMarke = zeileVon(tabelle, andere.name).querySelector('.stufe')!; expect(andereMarke.title).toContain('nach HBS 2015 (mittlere Wartezeit)'); } finally { aufraeumen(); } }); /** * Nachtrag zu Befund B1: Die Stufe F haengt an x > 1. Mit zwei * Nachkommastellen stuende bei x = 1,004 "1,00" neben "F (überlastet)" - * aus den angezeigten Ziffern nicht mehr nachvollziehbar. Pruefbericht und * Ausdruck fuehren drei Stellen; die Ansicht muss dieselben Ziffern zeigen. */ it('zeigt den Auslastungsgrad mit drei Nachkommastellen - in der Tabelle und in der Kennzahl', () => { const p = projekt('HBS'); const plan = buildSignalPlan(p); const kfzGruppen = plan.groups.filter((g) => g.mode === 'kfz' && g.capacity !== null); expect(kfzGruppen.length).toBeGreaterThan(0); const dreiStellen = (x: number): string => roundTo(x, 3).toLocaleString('de-DE', { minimumFractionDigits: 3, maximumFractionDigits: 3 }); const { wurzel, aufraeumen } = zeichne(planView, p); try { const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe'); for (const gruppe of kfzGruppen) { const zelle = zeileVon(tabelle, gruppe.name).children[ spalte(tabelle, 'Auslastung') ] as HTMLElement; expect(zelle.textContent?.trim()).toBe(dreiStellen(gruppe.capacity!.degreeOfSaturation!)); expect(zelle.textContent?.trim()).toMatch(/^\d+,\d{3}$/); } const groesste = Math.max(...kfzGruppen.map((g) => g.capacity!.degreeOfSaturation!)); const kennzahl = [...wurzel.querySelectorAll('.kennzahl')].find( (k) => k.querySelector('dt')?.textContent === 'Größte Auslastung', )!; expect(kennzahl.querySelector('dd')?.textContent).toBe(dreiStellen(groesste)); } finally { aufraeumen(); } }); it('mittelt die Kennzahl "Mittlere Wartezeit" nur ueber Kfz und OePNV und benennt die Basis', () => { const p = projekt('HBS'); const plan = buildSignalPlan(p); const kfzOepnv = plan.groups.filter((g) => g.mode === 'kfz' || g.mode === 'oepnv'); const werte = kfzOepnv.map((g) => g.delay!.averageDelay); const erwartet = werte.reduce((a, b) => a + b, 0) / werte.length; const { wurzel, aufraeumen } = zeichne(planView, p); try { const kennzahl = [...wurzel.querySelectorAll('.kennzahl')].find( (k) => k.querySelector('dt')?.textContent === 'Mittlere Wartezeit', )!; expect(kennzahl.querySelector('dd')?.textContent).toBe( `${erwartet.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`, ); expect(kennzahl.textContent).toContain('Kfz und ÖPNV'); // Und die laengste Sperrzeit fuer Fuss/Rad steht als eigene Kennzahl da. const fussRad = [...wurzel.querySelectorAll('.kennzahl')].find( (k) => k.querySelector('dt')?.textContent === 'Längste Wartezeit Fuß/Rad', )!; const laengste = Math.max( ...plan.groups .filter((g) => g.mode === 'fuss' || g.mode === 'rad') .map((g) => g.delay!.maximumDelay), ); expect(fussRad.querySelector('dd')?.textContent).toContain( laengste.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }), ); } finally { aufraeumen(); } }); }); describe('Stufenerlaeuterung', () => { it('nennt Verfahren und Kriterium', () => { expect( stufenErlaeuterung({ grade: 'D', label: 'ausreichend', scale: 'HBS', kriterium: 'maximale-wartezeit', }), ).toBe('ausreichend nach HBS 2015 (maximale Wartezeit)'); expect( stufenErlaeuterung({ grade: 'B', label: 'gut', scale: 'HCM', kriterium: 'mittlere-wartezeit', }), ).toBe('gut nach HCM (mittlere Wartezeit)'); expect( stufenErlaeuterung({ grade: 'F', label: 'ungenügend (überlastet)', scale: 'HBS', kriterium: 'ueberlastung', }), ).toBe('ungenügend (überlastet) nach HBS 2015'); }); /** * Nachtrag zu Befund B2: Die Simulation rechnet keine HBS-Wartezeit, * sie legt nur die Tafel des Verfahrens an den simulierten Mittelwert an. * Die Erlaeuterung dort darf deshalb keine HBS-Rechnung behaupten. */ it('sagt in der Simulationsfassung "nach der Tafel des ..." und "simuliert"', () => { expect( stufenErlaeuterung( { grade: 'C', label: 'befriedigend', scale: 'HBS', kriterium: 'mittlere-wartezeit' }, { simuliert: true }, ), ).toBe('befriedigend nach der Tafel des HBS 2015 (mittlere Wartezeit, simuliert)'); expect( stufenErlaeuterung( { grade: 'B', label: 'gut', scale: 'HCM', kriterium: 'mittlere-wartezeit' }, { simuliert: true }, ), ).toBe('gut nach der Tafel des HCM (mittlere Wartezeit, simuliert)'); // Die Stufe F bei Ueberlastung haengt auch hier am Auslastungsgrad q/C // des Signalzeitenplans - keine Simulationsgroesse, das steht dabei. const ueberlastet = stufenErlaeuterung( { grade: 'F', label: 'ungenügend (überlastet)', scale: 'HBS', kriterium: 'ueberlastung' }, { simuliert: true }, ); expect(ueberlastet).toContain('nach der Tafel des HBS 2015'); expect(ueberlastet).toContain('Signalzeitenplan'); expect(ueberlastet).not.toContain('simuliert'); }); /** * Eine Quelle der Woerter: Verfahren und Kriterium kommen aus constants.ts, * damit Bildschirm, Ausdruck und Tabelle nicht auseinanderlaufen koennen. */ it('nimmt Verfahrens- und Kriteriumsnamen aus den Konstanten des Fachkerns', () => { for (const scale of ['HBS', 'HCM'] as const) { const text = stufenErlaeuterung({ grade: 'A', label: 'sehr gut', scale, kriterium: 'maximale-wartezeit', }); expect(text).toContain(BEWERTUNGSVERFAHREN_LABELS[scale]); expect(text).toContain(KRITERIUM_LABELS['maximale-wartezeit']); } }); it('behauptet "HBS 2015" nur fuer das HBS-Verfahren', () => { expect(bewertungsHinweis('HCM', true)).not.toContain('HBS 2015'); expect(bewertungsHinweis('HCM', true)).toContain('keine Bewertungstafel'); expect(bewertungsHinweis('HBS', true)).toContain('maximalen Wartezeit'); expect(bewertungsHinweis('HBS', false)).not.toContain('Fußgänger'); }); }); describe('Signalgruppen: Verkehrsstaerke nur fuer Kfz und OePNV', () => { it('bietet fuer Radverkehr und Fussgaenger kein Verkehrsstaerkefeld an', () => { const p = projekt('HBS'); const { wurzel, aufraeumen } = zeichne(signalGroupsView, p); try { const tabelle = tabelleMit(wurzel, 'Signalgruppen mit ihren Kennwerten'); const sVerkehr = spalte(tabelle, 'Verkehrsstärke'); const sSv = spalte(tabelle, 'SV-Anteil'); for (const gruppe of p.signalGroups) { const zeile = wurzel.querySelector(`#sg-${gruppe.id}`)!; const verkehr = zeile.children[sVerkehr] as HTMLElement; const sv = zeile.children[sSv] as HTMLElement; if (gruppe.mode === 'kfz' || gruppe.mode === 'oepnv') { expect(verkehr.querySelector('input'), `${gruppe.name}: Feld fehlt`).not.toBeNull(); expect(sv.querySelector('input'), `${gruppe.name}: SV-Feld fehlt`).not.toBeNull(); } else { expect( verkehr.querySelector('input'), `${gruppe.name}: Feld darf nicht da sein`, ).toBeNull(); expect(verkehr.textContent?.trim()).toBe('–'); expect( sv.querySelector('input'), `${gruppe.name}: SV-Feld darf nicht da sein`, ).toBeNull(); } } } finally { aufraeumen(); } }); }); describe('Projekt: Bewertungsverfahren statt Bewertungsskala', () => { it('beschriftet das Feld als Verfahren und nennt, was jede Wahl rechnet', () => { const { wurzel, aufraeumen } = zeichne(projectView, projekt('HBS')); try { const label = [...wurzel.querySelectorAll('label')].find( (l) => l.textContent?.trim() === 'Bewertungsverfahren', ); expect(label, 'Feld "Bewertungsverfahren" fehlt').toBeDefined(); const auswahl = wurzel.querySelector(`#${label!.htmlFor}`)!; const optionen = [...auswahl.options].map((o) => o.textContent); expect(optionen).toEqual([ 'HBS 2015 (Kapazität, Wartezeit und Qualitätsstufen)', 'HCM (Highway Capacity Manual)', ]); expect(wurzel.textContent).not.toContain('Bewertungsskala'); } finally { aufraeumen(); } }); }); describe('Simulation: Stufe mit Verfahren und Kriterium', () => { /** * Nachtrag zu Befund B2: Die Simulation rechnet keine HBS-Wartezeit - * sie legt die Tafel des Verfahrens an den simulierten Mittelwert an. Die * Erlaeuterung sagt "nach der Tafel des HBS 2015 (mittlere Wartezeit, * simuliert)", nicht "nach HBS 2015 (mittlere Wartezeit)", und der Kopftext * sagt, was die Stufe hier ist. */ it('erlaeutert die Stufe je Gruppe mit "nach der Tafel des HBS 2015 (mittlere Wartezeit, simuliert)"', () => { const { wurzel, aufraeumen } = zeichne(simulationView, projekt('HBS')); try { const marken = [...wurzel.querySelectorAll('td .stufe')]; expect(marken.length).toBeGreaterThan(0); for (const marke of marken) { expect(marke.title).toContain('nach der Tafel des HBS 2015'); expect(marke.title).toMatch(/\(mittlere Wartezeit, simuliert\)$|Signalzeitenplan\)$/); expect(marke.title).not.toMatch(/nach HBS 2015 \(mittlere Wartezeit\)/); } // Auch die Gesamtstufe unter den Kennzahlen. const gesamt = [...wurzel.querySelectorAll('.kennzahl')].find( (k) => k.querySelector('dt')?.textContent === 'Qualitätsstufe', )!; expect(gesamt.textContent).toContain('nach der Tafel des HBS 2015'); // Der Kopftext benennt, was die Stufe ist und was die Wartezeit nicht ist. expect(wurzel.textContent).toContain( 'Die Stufe wendet die Tafel des gewählten Verfahrens auf den simulierten Mittelwert an; ' + 'die Wartezeit selbst ist eine Simulationsgröße, keine HBS-Rechnung.', ); expect(wurzel.textContent).toContain('Fußgänger und Radverkehr werden nicht simuliert'); } finally { aufraeumen(); } }); it('nennt nach HCM die Tafel des HCM, nicht das HBS', () => { const { wurzel, aufraeumen } = zeichne(simulationView, projekt('HCM')); try { const marken = [...wurzel.querySelectorAll('td .stufe')]; expect(marken.length).toBeGreaterThan(0); for (const marke of marken) { expect(marke.title).toContain('nach der Tafel des HCM'); expect(marke.title).not.toContain('HBS'); } } finally { aufraeumen(); } }); }); describe('Pruefbericht: Beschreibung behauptet kein Verfahren', () => { it('nennt in der Navigation das gewaehlte Bewertungsverfahren statt fest "HBS 2015"', () => { expect(reportView.description).not.toBe('Prüfung gegen RiLSA 2015 und HBS 2015'); expect(reportView.description).toContain('RiLSA 2015'); expect(reportView.description).toContain('gewählte Bewertungsverfahren'); expect(reportView.description).toContain('HCM'); }); }); describe('Vorgaben: Instationaritaetsfaktor', () => { it('fuehrt den Instationaritaetsfaktor mit Regelwert 1,1 in der Kapazitaetsgruppe', () => { const { wurzel, aufraeumen } = zeichne(settingsView, projekt('HBS')); try { const zeile = [...wurzel.querySelectorAll('tbody tr')].find((tr) => (tr.children[0]?.textContent ?? '').startsWith('Instationaritätsfaktor'), ); expect(zeile, 'Zeile Instationaritätsfaktor fehlt').toBeDefined(); expect(zeile!.children[2]?.textContent).toBe('1,1'); // Der Kopfsatz behauptet nicht mehr pauschal "alle Kennwerte nach RiLSA/HBS". expect(wurzel.textContent).not.toContain( 'Alle Kennwerte entsprechen RiLSA 2015 bzw. HBS 2015', ); } finally { aufraeumen(); } }); }); describe('Hilfetexte zur Bewertung', () => { it('fuehrt die HBS-2015-Tafeln und keine HBS-2001-Grenze von 100 s', () => { const q = HILFE.qualitaetsstufe; expect(q.wasIstDas).toContain('bis 70 s D, darüber E'); expect(q.wasIstDas).toContain('bis 85 s E'); expect(q.wasIstDas).toContain('bis 60 s E'); // "bis 100 s" darf nur noch als Hinweis auf den Altstand vorkommen. expect(q.wasIstDas).not.toContain('bis 100 s'); expect(HILFE.saettigungsverkehrsstaerke.woher).toContain('2000 Kfz/h'); expect(HILFE.saettigungsverkehrsstaerke.woher).not.toContain('1800'); expect(HILFE.wartezeit.wasIstDas).toContain('tW,G + tW,R'); expect(HILFE.wartezeit.wasIstDas).toContain('d1 + d2'); expect(HILFE.instationaritaetsfaktor.woher).toContain('1,1'); expect(HILFE.bewertungsverfahren.wasIstDas).toContain('tA = tF + 1 s'); expect(HILFE.verkehrsstaerke.wasIstDas).toContain('Radverkehrsstärke'); expect(HILFE.auslastungsgrad.wasIstDas).toContain('x = q / C'); }); /** * Nachtrag (Restbefunde zu B1/B2/B4): Die Hilfe darf nichts sagen, was * die Oberflaeche oder der Fachkern anders macht. */ it('nennt den Vorgaben-Abschnitt so, wie die Vorgabenansicht ihn beschriftet', () => { // Der Gruppenname in settingsView lautet "Umlaufzeit, Kapazität und Wartezeit". expect(HILFE.instationaritaetsfaktor.woher).toContain('Umlaufzeit, Kapazität und Wartezeit'); expect(HILFE.instationaritaetsfaktor.woher).not.toContain('Abschnitt Umlaufzeit und Kapazität'); }); it('sagt zur OePNV-Tafel, dass F auch bei Ueberlastung gilt - so rechnet delay.ts', () => { const oepnvZeile = HILFE.qualitaetsstufe.wasIstDas .split('\n') .find((zeile) => zeile.startsWith('• ÖPNV')); expect(oepnvZeile, 'OePNV-Zeile fehlt').toBeDefined(); expect(oepnvZeile).toContain('darüber F'); expect(oepnvZeile).toContain('Überlastung'); }); it('sagt zur gespeicherten Fuss/Rad-Verkehrsstaerke, wie der Pruefbericht sie meldet', () => { expect(HILFE.verkehrsstaerke.wasIstDas).toContain('Verkehrsstärke ohne Rechenwirkung'); expect(HILFE.verkehrsstaerke.wasIstDas).toContain('gedruckt wird sie nicht'); }); it('nennt den Pruefstand der HBS-Formelkette als "abzugleichen", nicht als gesichert', () => { expect(HILFE.wartezeit.woher).toContain('abzugleichen'); expect(HILFE.wartezeit.woher).toContain('Druckwerk'); const eintraege: readonly Hilfetext[] = [ HILFE.wartezeit, HILFE.bewertungsverfahren, HILFE.instationaritaetsfaktor, ]; for (const eintrag of eintraege) { const text = [ eintrag.wasIstDas, eintrag.woher ?? '', eintrag.ueblich ?? '', eintrag.wennFalsch ?? '', ].join(' '); expect(text).not.toMatch(/gesichert/i); } }); }); describe('Naechster Schritt und Assistent: Verkehrsstaerken nur fuer Kfz und OePNV', () => { /** * Eine vollstaendige Anlage nur aus Furt und Radfurt: Konflikte mit Wegen, * zwei Phasen, Projektdaten ausgefuellt - der Pruefbericht hat keinen * Fehler, und der naechste Schritt kommt bis zu der Stelle, an der vor * Fassung 5.4.0 (Befund B4) Verkehrsstaerken verlangt wurden, die * fuer diese Gruppen nirgends eingetragen werden koennen. */ it('verlangt fuer eine Anlage nur aus Furten und Radfurten keine Verkehrsstaerken', () => { const leer = createEmptyProject('Nur Fuss/Rad', FESTES_DATUM); const furt = createSignalGroup({ name: 'F1', mode: 'fuss', index: 0 }); const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: 1 }); const konflikte = [ { ...createConflict(furt.id, rad.id), clearingDistance: 10, enteringDistance: 5 }, { ...createConflict(rad.id, furt.id), clearingDistance: 10, enteringDistance: 5 }, ]; const phasen = [createPhase('Phase 1', [furt.id]), createPhase('Phase 2', [rad.id])]; const nurFussRad: Project = { ...leer, signalGroups: [furt, rad], conflicts: konflikte, phases: phasen, program: { ...leer.program, phaseOrder: phasen.map((p) => p.id) }, meta: { ...leer.meta, name: 'x', projectNumber: '1', client: 'c', planner: 'p', location: 'l', }, }; const state = zustand(nurFussRad); expect(state.report.errorCount, 'Vorbedingung: kein Fehler im Pruefbericht').toBe(0); const schritt = naechsterSchritt(state); expect(schritt.text).not.toContain('Verkehrsstärke'); expect(schritt.ansicht).not.toBe('signalgruppen'); }); it('nennt im Schritt die Kfz- und OePNV-Gruppen als Adressaten', () => { const p = projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true }, 'Ohne Zaehlung', FESTES_DATUM, ); const mitDaten: Project = { ...p, meta: { ...p.meta, name: 'x', projectNumber: '1', client: 'c', planner: 'p', location: 'l' }, }; const state = zustand(mitDaten); expect(state.report.errorCount, 'Vorbedingung: kein Fehler im Pruefbericht').toBe(0); const schritt = naechsterSchritt(state); expect(schritt.text).toContain('Verkehrsstärken der Kfz- und ÖPNV-Signalgruppen'); expect(schritt.grund).toContain('Fußgänger und Radverkehr brauchen keine Verkehrsstärke'); expect(schritt.ansicht).toBe('signalgruppen'); }); /** * Nachtrag zu Befund B4: Der Schritt zaehlte project.demands.length - * eine gespeicherte Radverkehrsstaerke (aeltere Projektdatei) liess ihn als * erledigt erscheinen, waehrend keine einzige Kfz-Gruppe eine Zahl hatte. */ it('verlangt weiterhin Verkehrsstaerken, wenn nur eine Radgruppe eine gespeicherte hat', () => { const p = projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true }, 'Nur Rad gezaehlt', FESTES_DATUM, ); const rad = p.signalGroups.find((g) => g.mode === 'rad')!; expect(p.signalGroups.some((g) => g.mode === 'kfz')).toBe(true); const mitRadZahl: Project = { ...actions.setDemand(p, rad.id, { volume: 300 }), meta: { ...p.meta, name: 'x', projectNumber: '1', client: 'c', planner: 'p', location: 'l' }, }; expect(mitRadZahl.demands.length).toBe(1); const state = zustand(mitRadZahl); expect(state.report.errorCount, 'Vorbedingung: kein Fehler im Pruefbericht').toBe(0); const schritt = naechsterSchritt(state); expect(schritt.text).toContain('Verkehrsstärken der Kfz- und ÖPNV-Signalgruppen'); expect(schritt.ansicht).toBe('signalgruppen'); }); it('legt im Assistenten fuer Radgruppen keine Verkehrsstaerke an', () => { const p = projektAusAngaben( { ...ASSISTENT_VORGABE, rad: true, fussgaenger: true, verkehrsstaerke: 400 }, 'Assistent', FESTES_DATUM, ); const radIds = new Set( p.signalGroups.filter((g) => g.mode === 'rad' || g.mode === 'fuss').map((g) => g.id), ); expect(radIds.size).toBeGreaterThan(0); expect(p.demands.some((d) => radIds.has(d.signalGroupId))).toBe(false); expect(p.demands.length).toBe(p.signalGroups.filter((g) => g.mode === 'kfz').length); }); });