import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project, TrafficDemand } from '@/domain/model/project'; import { HBS_LASTZUGANTEIL_BEI_UNBEKANNTER_AUFTEILUNG, HBS_PKW_GLEICHWERTE, } from '@/domain/rilsa/constants'; import { SCHWERVERKEHR_AUFTEILUNG_MELDESCHWELLE } from '@/domain/validation/rules'; import { HILFE, glossarEintraege, glossarSuche } from '@/ui/help/texte'; import { ASSISTENT_VORGABE, assistentZusammenfassung, projektAusAngaben, starteAssistent, } from '@/ui/assistent'; import type { ViewDefinition } from '@/ui/shell'; import { meldungsverlauf } from '@/ui/feedback'; import * as fmt from '@/ui/format'; import { lastzuganteilAusserhalbFolge, lastzuganteilHinweis, lastzuganteilPlatzhalter, schwerverkehrsanteilHinweis, schwerverkehrsfaktorVermerk, signalGroupsView, wirksamerSchwerverkehrsfaktor, } from '@/ui/views/signalGroupsView'; /** * Die Aufteilung des Schwerverkehrs auf dem Bildschirm. * * Der Fachkern rechnet nach HBS 2015 Gl. 2-5, sobald an einer * Verkehrsstaerke ein Lastzuganteil erfasst ist, und sonst unveraendert nach * Gl. 2-6. Erfassen laesst sich der Anteil aber nur, wenn die Oberflaeche ein * Feld dafuer hat - und ohne den wirksamen Faktor daneben bliebe eine Eingabe * folgenlos aussehen: Man traegt 100 % Lastzuege ein, am Bildschirm aendert * sich nichts, und ob das Feld ueberhaupt gewirkt hat, erfaehrt man erst im * Ausdruck. * * GEGEN DEN ALTSTAND SCHLAEGT JEDER UNTERSCHEIDENDE FALL FEHL: Dort gab es * weder die Spalte noch den Hilfeeintrag noch die Ausfuhr * `wirksamerSchwerverkehrsfaktor`, und der Satz unter der Tabelle behauptete * ausdruecklich, "eine Aufteilung nach Fahrzeugklassen erfasst dieses Programm * nicht". * * KORREKTUR DIESER ZUSAGE (Fassung 5.6.0): Hier stand "JEDER FALL". Zwei Faelle * dieser Datei bestehen auch gegen den Altstand, und das ist Absicht und kein * Mangel: * * - "steht unter der Tabelle, sobald es eine Gruppe mit * Saettigungsverkehrsstaerke gibt" prueft die VERDRAHTUNG - dass der Satz * ueberhaupt angezeigt wird - gegen `schwerverkehrsanteilHinweis()`, also * gegen sich selbst. Er faellt, wenn die Ansicht den Satz weglaesst, und * nicht, wenn der Satz anders lautet. Was IM Satz steht, pruefen die * Faelle unter "Satz unter der Signalgruppentabelle", und die schlagen * gegen den Altstand fehl. * - "Assistent legt die Verkehrsstaerken ohne Lastzuganteil an" ist eine * NEGATIVzusage. Auf dem Altstand gilt sie trivial - dort kann es das Feld * gar nicht geben. Sie ist trotzdem noetig: Sie haelt fest, dass der * Assistent es auch kuenftig nicht vorbelegt, denn eine Vorbelegung waere * eine Behauptung ueber eine Zaehlung, die es nicht gibt. * * Eine Zusage, die mehr behauptet, als der Nachweis hergibt, ist so wenig wert * wie die Zusage, die sie prueft. * * DIE ZAHLEN SIND NACHGERECHNET, nicht abgeschrieben: * pSV 20 %, keine Aufteilung -> fSV = 1 + 0,90 · 0,20 = 1,180 -> qS 1695 Kfz/h * pSV 20 %, 100 % Lastzuege -> fSV = 1 + 1,50 · 0,20 = 1,300 -> qS 1538 Kfz/h * pSV 20 %, 20 % Lastzuege -> fSV = 1 + 0,75 · 0,16 + 1,50 · 0,04 = 1,180 * Der letzte Fall ist der Konsistenzbeleg: 1,75 · 0,80 + 2,50 · 0,20 = 1,90, * also ist Gl. 2-6 nichts anderes als Gl. 2-5 mit 20 % Lastzuegen (BASt-Bericht * V 400, 2025, S. 10). Wer 20 % eintraegt, aendert an seinen Zahlen nichts - * und das muss auch der Bildschirm zeigen. */ 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(); }, }; } /** * Ein Knotenpunkt, bei dem K1 den geprueften Fall traegt: 20 % Schwerverkehr * und - wenn `lastzugAnteil` gesetzt ist - eine erfasste Aufteilung. Die * uebrigen Stroeme bleiben unveraendert, damit ein Unterschied in der Zeile K1 * nicht aus einer Verschiebung des ganzen Plans kommen kann. */ function mitAufteilung(lastzugAnteil?: number): Project { const basis = createStandardIntersectionProject('Aufteilung', FESTES_DATUM); const k1 = basis.signalGroups.find((g) => g.name === 'K1')!; return { ...basis, demands: basis.demands.map((d) => d.signalGroupId === k1.id ? { ...d, heavyVehicleShare: 0.2, // Ausdruecklich weggelassen statt auf `undefined` gesetzt: Mit // `exactOptionalPropertyTypes` ist das nicht dasselbe, und genau // dieser Unterschied - Feld da oder nicht - entscheidet ueber die // Gleichung. ...(lastzugAnteil === undefined ? {} : { lastzugAnteil }), } : d, ), }; } function zeile(wurzel: HTMLElement, name: string): HTMLTableRowElement { const treffer = [...wurzel.querySelectorAll('tbody tr')].find( (tr) => tr.querySelector('input[type="text"]')?.value === name, ); if (!treffer) throw new Error(`Zeile "${name}" fehlt`); return treffer; } function lastzugFeld(wurzel: HTMLElement, name: string): HTMLInputElement | null { return zeile(wurzel, name).querySelector( `input[aria-label="Anteil der Lastzüge am Schwerverkehr in Prozent, ${name}"]`, ); } function faktorVermerk(wurzel: HTMLElement, name: string): HTMLElement | null { return zeile(wurzel, name).querySelector('.schwerverkehrsfaktor'); } /** * Das Feld "Schwerverkehrsanteil" derselben Zeile - das Gegenstueck, an dem * BEGRENZEN richtig ist. * * Es traegt keinen eigenen `aria-label`-Aufruf in der Ansicht; den Namen * vergibt `benenneBedienelemente()` aus Spaltenkopf und Zeilennamen. */ /** * Der Satz, den eine Sprachausgabe VOR der Eingabe vorliest. * * `numberCell` legt ihn als `data-bereich` ab; beim Einhaengen macht * `ergaenzeBereichshinweis()` daraus einen eigenen Absatz mit `aria-describedby` * und entfernt das Merkmal wieder. Geprueft wird deshalb, was am Ende am Feld * haengt, und nicht der Zwischenstand. */ function bereichssatz(feld: HTMLInputElement): string { const id = feld.getAttribute('aria-describedby'); if (id === null) return feld.getAttribute('data-bereich') ?? ''; return document.getElementById(id)?.textContent ?? ''; } function schwerverkehrsfeld(wurzel: HTMLElement, name: string): HTMLInputElement { const feld = zeile(wurzel, name).querySelector( 'input[aria-label^="Schwerverkehrsanteil in Prozent"]', ); if (!feld) throw new Error(`Schwerverkehrsfeld der Zeile "${name}" fehlt`); return feld; } function nachfrage(project: Project, name: string): TrafficDemand { const gruppe = project.signalGroups.find((g) => g.name === name)!; const demand = project.demands.find((d) => d.signalGroupId === gruppe.id); if (!demand) throw new Error(`Keine Verkehrsstärke zu "${name}"`); return demand; } afterEach(() => { document.body.replaceChildren(); }); describe('Spalte "Lastzüge am SV"', () => { it('steht neben dem Schwerverkehrsanteil und führt zur Hilfe über die Aufteilung', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { const koepfe = [...wurzel.querySelectorAll('thead th')]; const namen = koepfe.map((th) => th.querySelector('span')?.textContent ?? ''); const svSpalte = namen.indexOf('SV-Anteil'); expect(svSpalte, 'Spalte "SV-Anteil" fehlt').toBeGreaterThan(-1); expect(namen[svSpalte + 1]).toBe('Lastzüge am SV'); // Der Hilfeknopf der Spalte zeigt auf den neuen Eintrag - sonst landet // wer fragt beim Schwerverkehrsanteil, der die Aufteilung nicht erklaert. const knopf = koepfe[svSpalte + 1]!.querySelector('.hilfe-knopf'); expect(knopf, 'Spaltenkopf ohne Hilfeknopf').not.toBeNull(); expect(knopf!.getAttribute('aria-label')).toBe( `Hilfe zu ${HILFE.schwerverkehrsaufteilung.titel}`, ); } finally { aufraeumen(); } }); it('gibt es nur für Signalgruppen mit Sättigungsverkehrsstärke', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { expect(lastzugFeld(wurzel, 'K1'), 'Kfz-Gruppe ohne Feld').not.toBeNull(); // Fussgaenger werden nach HBS 2015 ueber die maximale Wartezeit bewertet; // ein Schwerverkehr, den es nicht gibt, laesst sich nicht aufteilen. expect(lastzugFeld(wurzel, 'F1'), 'Fußgängergruppe mit Aufteilungsfeld').toBeNull(); expect(faktorVermerk(wurzel, 'F1')).toBeNull(); } finally { aufraeumen(); } }); it('nennt im Platzhalter die Annahme, die bei leerem Feld gilt', () => { // Nicht "unbekannt": Leer lassen heisst nicht, auf eine Annahme zu // verzichten, sondern mit 20 % zu rechnen (Gl. 2-6 IST Gl. 2-5 mit 20 %). expect(lastzuganteilPlatzhalter()).toBe('20 (Annahme)'); const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { const feld = lastzugFeld(wurzel, 'K1')!; expect(feld.value, 'Ohne Erfassung muss das Feld leer sein').toBe(''); expect(feld.placeholder).toBe(lastzuganteilPlatzhalter()); } finally { aufraeumen(); } }); it('nennt im title die Bezugsgröße, beide Gleichungen und die Herkunft der 20 %', () => { const hinweis = lastzuganteilHinweis(); expect(hinweis).toContain('AM SCHWERVERKEHR'); expect(hinweis).toContain('nicht an der Verkehrsstärke'); expect(hinweis).toContain('Gleichung (2-5)'); expect(hinweis).toContain('Gleichung (2-6)'); expect(hinweis).toContain('1,75'); expect(hinweis).toContain('2,50'); expect(hinweis).toContain('1,90'); expect(hinweis).toContain('20 % Lastzüge am Schwerverkehr'); expect(hinweis).toContain('BASt-Bericht V 400, 2025, S. 10'); expect(hinweis).toContain('ändert an den Zahlen nichts'); const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { expect(lastzugFeld(wurzel, 'K1')!.title).toBe(hinweis); } finally { aufraeumen(); } }); it('behält trotz des title seinen Namen für die Sprachausgabe', () => { // benenneBedienelemente() ueberspringt jedes Feld mit `title`. Ohne // eigenes aria-label waere der ganze Hinweistext der Name des Felds, und // jede Meldung von numberCell begaenne damit. const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { const feld = lastzugFeld(wurzel, 'K1')!; expect(feld.getAttribute('aria-label')).toBe( 'Anteil der Lastzüge am Schwerverkehr in Prozent, K1', ); expect(feld.getAttribute('aria-label')).not.toBe(feld.title); } finally { aufraeumen(); } }); }); describe('Der wirksame Faktor fSV steht an der Zeile', () => { it('zeigt ohne erfasste Aufteilung den Pauschalwert und sagt, welche Gleichung gilt', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { const vermerk = faktorVermerk(wurzel, 'K1')!; expect(vermerk.textContent).toBe('fSV 1,180'); expect(vermerk.title).toContain('Keine Aufteilung des Schwerverkehrs erfasst'); expect(vermerk.title).toContain('Gleichung (2-6)'); expect(vermerk.title).toContain('Pauschalwert 1,90'); expect(vermerk.title).toContain('20 % Lastzüge am Schwerverkehr'); expect(vermerk.title).toContain('Sättigungsverkehrsstärke 1.695 Fz/h'); } finally { aufraeumen(); } }); it('springt mit der erfassten Aufteilung auf Gleichung (2-5)', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung(1)); try { const vermerk = faktorVermerk(wurzel, 'K1')!; // Der Nachweis, dass die Eingabe wirkt: 1,180 -> 1,300, 1695 -> 1538. expect(vermerk.textContent).toBe('fSV 1,300'); expect(vermerk.title).toContain('Gleichung (2-5)'); expect(vermerk.title).toContain('20 % Schwerverkehr, davon 100 % Lastzüge'); expect(vermerk.title).toContain('Sättigungsverkehrsstärke 1.538 Fz/h'); expect(vermerk.title).not.toContain('Keine Aufteilung'); // Und die Nachbarzeile ohne Eintrag bleibt, wo sie war. expect(faktorVermerk(wurzel, 'K2')!.textContent).toBe('fSV 1,045'); } finally { aufraeumen(); } }); it('zeigt bei genau der Annahme dieselbe Zahl wie ohne Eintrag', () => { // Der Konsistenzbeleg am Bildschirm: 1,75 · 0,80 + 2,50 · 0,20 = 1,90. const ohne = zeichne(signalGroupsView, mitAufteilung()); const mit = zeichne( signalGroupsView, mitAufteilung(HBS_LASTZUGANTEIL_BEI_UNBEKANNTER_AUFTEILUNG), ); try { expect(faktorVermerk(mit.wurzel, 'K1')!.textContent).toBe( faktorVermerk(ohne.wurzel, 'K1')!.textContent, ); // Aber die Herkunft der Zahl ist eine andere - erfasst statt angenommen. expect(faktorVermerk(mit.wurzel, 'K1')!.title).toContain('Gleichung (2-5)'); expect(faktorVermerk(ohne.wurzel, 'K1')!.title).toContain('Gleichung (2-6)'); } finally { mit.aufraeumen(); ohne.aufraeumen(); } }); it('macht einen unbrauchbaren Eintrag sichtbar, statt ihn still zu verwerfen', () => { // Ueber die Datei ist der Fall nicht erreichbar (das Schema verwirft ihn // beim Lesen), ueber Fremdwerkzeuge schon. Verworfen wird er auch hier - // aber nicht stillschweigend. const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung(1.5)); try { const vermerk = faktorVermerk(wurzel, 'K1')!; expect(vermerk.textContent).toBe('fSV 1,180'); expect(vermerk.title).toContain('außerhalb von 0 bis 100 %'); expect(vermerk.title).toContain('gilt als nicht erfasst'); expect(vermerk.title).toContain('Gleichung (2-6)'); expect(vermerk.title).toContain('meldet das als Warnung'); // Der gespeicherte Wert bleibt im Feld stehen - er ist gespeichert, nur // ohne Wirkung; ein leeres Feld verschwiege die Fehleingabe. expect(lastzugFeld(wurzel, 'K1')!.value).toBe('150'); } finally { aufraeumen(); } }); it('rechnet denselben Faktor wie der Signalzeitenplan', () => { // Keine zweite Fassung der Fallunterscheidung in der Ansicht: qS des Plans // geteilt durch den angezeigten Faktor muss die Grundsaettigung ergeben. for (const anteil of [undefined, 0, 0.2, 0.35, 1]) { const projekt = mitAufteilung(anteil); const plan = buildSignalPlan(projekt); const k1 = projekt.signalGroups.find((g) => g.name === 'K1')!; const geplant = plan.groups.find((g) => g.groupId === k1.id)!; const faktor = wirksamerSchwerverkehrsfaktor(nachfrage(projekt, 'K1')); expect(geplant.capacity, `Kein Kapazitätsergebnis bei ${String(anteil)}`).not.toBeNull(); expect(geplant.capacity!.saturationFlow * faktor, `Aufteilung ${String(anteil)}`).toBeCloseTo( 2000, 6, ); } }); it('steht ohne Verkehrsstärke gar nicht erst da', () => { // Ein Faktor zu einem Strom, ueber den nichts erfasst ist, waere eine Zahl // ohne Grundlage - das Feld bleibt, der Vermerk nicht. const ohne: Project = { ...mitAufteilung(), demands: [] }; expect(schwerverkehrsfaktorVermerk(undefined, null)).toBeNull(); const { wurzel, aufraeumen } = zeichne(signalGroupsView, ohne); try { expect(lastzugFeld(wurzel, 'K1'), 'Das Feld selbst bleibt').not.toBeNull(); expect(faktorVermerk(wurzel, 'K1')).toBeNull(); } finally { aufraeumen(); } }); it('spricht ohne Schwerverkehr nicht von einer fehlenden Aufteilung', () => { // Bei 0 % Schwerverkehr liefern beide Gleichungen fSV = 1. Ein Hinweis auf // eine "nicht erfasste Aufteilung" spraeche dort von einer Annahme, die auf // keine Zahl wirkt - und wird nach dem dritten Mal ueberlesen. const ohneSV: TrafficDemand = { signalGroupId: 'x', volume: 500, heavyVehicleShare: 0 }; const vermerk = schwerverkehrsfaktorVermerk(ohneSV, 2000)!; expect(vermerk.text).toBe('fSV 1,000'); expect(vermerk.title).toContain('Ohne Schwerverkehr wirkt die Aufteilung nicht'); expect(vermerk.title).not.toContain('Keine Aufteilung des Schwerverkehrs erfasst'); }); it('nennt die Sättigungsverkehrsstärke nur, wenn der Plan eine hergibt', () => { const demand: TrafficDemand = { signalGroupId: 'x', volume: 500, heavyVehicleShare: 0.2 }; expect(schwerverkehrsfaktorVermerk(demand, null)!.title).not.toContain( 'Sättigungsverkehrsstärke', ); expect(schwerverkehrsfaktorVermerk(demand, 1695)!.title).toContain( 'Sättigungsverkehrsstärke 1.695 Fz/h', ); }); }); describe('Eingabe des Lastzuganteils', () => { const setze = (feld: HTMLInputElement, wert: string): void => { feld.value = wert; feld.dispatchEvent(new Event('change')); }; it('speichert den Anteil AM SCHWERVERKEHR, nicht an der Verkehrsstärke', () => { const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { setze(lastzugFeld(wurzel, 'K1')!, '25'); const demand = nachfrage(store.getProject(), 'K1'); // 25 % der Schwerfahrzeuge, nicht 25 % der Verkehrsstaerke: Der // Schwerverkehrsanteil bleibt unveraendert bei 20 %. expect(demand.lastzugAnteil).toBeCloseTo(0.25, 9); expect(demand.heavyVehicleShare).toBeCloseTo(0.2, 9); expect(demand.volume).toBe(620); } finally { aufraeumen(); } }); it('entfernt die Angabe beim Leeren, statt 0 % zu speichern', () => { // "0 % Lastzuege" und "nicht erfasst" sind der Unterschied zwischen // Gl. 2-5 und Gl. 2-6 - ein Feld, das beides gleich behandelt, waere // wertlos. const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung(0.4)); try { const feld = lastzugFeld(wurzel, 'K1')!; expect(feld.value).toBe('40'); setze(feld, ''); const demand = nachfrage(store.getProject(), 'K1'); expect('lastzugAnteil' in demand, 'Das Feld muss verschwinden, nicht auf 0 fallen').toBe( false, ); } finally { aufraeumen(); } }); it('zeigt 12,5 % als 12,5 und nicht als 13', () => { /* * Der Befund: Das Feld rundete auf ganze Prozent, waehrend Ausdruck und CSV * eine Nachkommastelle ausweisen und gerechnet wird mit dem gespeicherten * Wert - drei Zahlen zu einer Angabe. Der Schaden steht eine Zeile weiter: * Wer das Feld anfasst, schreibt die gerundete Zahl zurueck. */ const basis = mitAufteilung(0.125); const projekt: Project = { ...basis, // Derselbe Fehler stand am Feld daneben; beide Zahlen kommen aus // derselben Umrechnung. demands: basis.demands.map((d) => d.lastzugAnteil === 0.125 ? { ...d, heavyVehicleShare: 0.125 } : d, ), }; const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, projekt); try { expect(lastzugFeld(wurzel, 'K1')!.value).toBe('12.5'); expect(schwerverkehrsfeld(wurzel, 'K1').value).toBe('12.5'); // Nichts geaendert heisst nichts gespeichert: Der Wert bleibt, wie er ist. expect(nachfrage(store.getProject(), 'K1').lastzugAnteil).toBe(0.125); } finally { aufraeumen(); } }); it('unterscheidet 0 % von "nicht erfasst"', () => { const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { setze(lastzugFeld(wurzel, 'K1')!, '0'); const demand = nachfrage(store.getProject(), 'K1'); expect(demand.lastzugAnteil).toBe(0); // Ein Schwerverkehr ohne Lastzuege ist die guenstigere Rechnung als der // Pauschalwert - deshalb darf sie nur aus einer Angabe entstehen. expect(wirksamerSchwerverkehrsfaktor(demand)).toBeCloseTo(1.15, 9); } finally { aufraeumen(); } }); }); /** * Das Feld klemmt einen Wert ausserhalb von 0 bis 100 % NICHT. * * DER SCHWERSTE BEFUND ZUR AUFTEILUNG DES SCHWERVERKEHRS (Fassung 5.6.0). Die * Ansicht gab `min: 0, max: 100` an `numberCell`, und das begrenzt: Aus `-30` * wurde still `0`. Damit galt der Strom als "Aufteilung erfasst, kein einziger * Lastzug", die Saettigungsverkehrsstaerke stieg von 1695 auf 1739 Kfz/h (2,6 % * in die unsichere Richtung), die Plannotiz `lastzuganteil-ausserhalb` entstand * nie, der Pruefbericht schwieg, und Ausdruck wie CSV druckten "Lastzuege 0,0 % * des Schwerverkehrs" - eine Behauptung ueber eine Zaehlung, die niemand * vorgenommen hat. Das einzige Signal war eine Kurzmeldung, die nach fuenf * Sekunden verschwindet; der Eintrag war dauerhaft. * * Genau diesen Rueckfall schliessen `istLastzuganteil`, * `actions.setLastzugAnteil`, das Schema und die Bedienungsanleitung * ausdruecklich aus - er war nur eine Ebene hoeher wieder eingebaut. */ describe('Ein Wert außerhalb von 0 bis 100 % bleibt stehen', () => { const setze = (feld: HTMLInputElement, wert: string): void => { feld.value = wert; feld.dispatchEvent(new Event('change')); }; it('macht aus -30 % keine gezählte 0 %', () => { const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { const feld = lastzugFeld(wurzel, 'K1')!; setze(feld, '-30'); const demand = nachfrage(store.getProject(), 'K1'); expect(demand.lastzugAnteil, 'Der Eintrag darf nicht auf 0 gezogen werden').toBeCloseTo( -0.3, 9, ); expect(feld.value, 'Im Feld steht, was eingegeben wurde').toBe('-30'); expect(feld.getAttribute('aria-invalid'), 'Das Feld ist gekennzeichnet').toBe('true'); // Verworfen und nicht gerechnet: Gl. 2-6 wie ohne jede Angabe - und // NICHT 1,150, was ein Rueckfall auf 0 % ergaebe. expect(wirksamerSchwerverkehrsfaktor(demand)).toBeCloseTo(1.18, 9); const plan = buildSignalPlan(store.getProject()); const notiz = plan.notes.find((n) => n.code === 'lastzuganteil-ausserhalb'); expect(notiz?.severity, 'Der Plan meldet den unbrauchbaren Eintrag').toBe('warnung'); expect(notiz?.message).toContain('-30 %'); /* * Und beim naechsten Zeichnen steht es immer noch da. Das ist der * eigentliche Unterschied zur Kurzmeldung: Die Ansicht baut sich nach * jeder Eingabe neu auf, die Meldung verschwindet nach fuenf Sekunden - * der Eintrag bleibt. Kennzeichnung und Vermerk muessen den Neuaufbau * ueberleben, sonst ist der unbrauchbare Wert ab der naechsten Sitzung * nur noch im Pruefbericht zu finden. */ const neu = zeichne(signalGroupsView, store.getProject()); try { expect(lastzugFeld(neu.wurzel, 'K1')!.value).toBe('-30'); expect(lastzugFeld(neu.wurzel, 'K1')!.getAttribute('aria-invalid')).toBe('true'); expect(faktorVermerk(neu.wurzel, 'K1')!.title).toContain('gilt als nicht erfasst'); } finally { neu.aufraeumen(); } } finally { aufraeumen(); } }); it('macht aus 150 % keine gezählten 100 %', () => { // Die andere Richtung ist die konservative - und derselbe Fehler: Aus // einer Fehleingabe wuerde die Aussage "alles Lastzuege". const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { setze(lastzugFeld(wurzel, 'K1')!, '150'); const demand = nachfrage(store.getProject(), 'K1'); expect(demand.lastzugAnteil).toBeCloseTo(1.5, 9); expect(wirksamerSchwerverkehrsfaktor(demand), 'nicht 1,300').toBeCloseTo(1.18, 9); } finally { aufraeumen(); } }); it('sagt vor der Eingabe und nach der Fehleingabe, was daraus folgt', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { const feld = lastzugFeld(wurzel, 'K1')!; // Vorgelesen wird der Satz beim Fokussieren (dom.ts, // ergaenzeBereichshinweis) - er muss die Folge nennen und nicht nur die // Grenze, sonst hoert der Anwender "zulaessig sind 0 bis 100 %" und // schliesst daraus auf ein Feld, das ihn schuetzt. expect(bereichssatz(feld)).toContain(lastzuganteilAusserhalbFolge()); expect(bereichssatz(feld)).toContain('Zulässig sind 0 % bis 100 %.'); setze(feld, '-30'); const meldung = meldungsverlauf()[0]!; expect(meldung.art).toBe('warnung'); expect(meldung.text).toContain('außerhalb des zulässigen Bereichs'); expect(meldung.text).toContain(lastzuganteilAusserhalbFolge()); // Kein Satz ueber einen ersatzweise eingetragenen Wert - es gibt keinen. expect(meldung.text).not.toContain('Eingetragen wurde'); } finally { aufraeumen(); } }); it('nennt in der Folge die Gleichung und den Pauschalwert', () => { const satz = lastzuganteilAusserhalbFolge(); expect(satz).toContain('nicht begrenzt'); expect(satz).toContain('verworfen'); expect(satz).toContain('Gleichung (2-6)'); expect(satz).toContain(fmt.num(HBS_PKW_GLEICHWERTE.schwerverkehrPauschal, 2)); }); it('begrenzt den Schwerverkehrsanteil daneben unverändert', () => { /* * Die Ausnahme gilt genau EINEM Feld. Beim Schwerverkehrsanteil ist * Begrenzen richtig: Ein Anteil ueber 100 % ist eine unmoegliche Menge, * aber eine Angabe - "sehr viel Schwerverkehr" auf 100 % zu ziehen bleibt * in der Sache. Beim Lastzuganteil entscheidet der Bereich dagegen * darueber, OB eine Aufteilung erfasst ist. */ const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { const feld = schwerverkehrsfeld(wurzel, 'K1'); setze(feld, '150'); expect(feld.value).toBe('100'); expect(nachfrage(store.getProject(), 'K1').heavyVehicleShare).toBeCloseTo(1, 9); expect(bereichssatz(feld)).not.toContain('nicht begrenzt'); } finally { aufraeumen(); } }); }); describe('Satz unter der Signalgruppentabelle', () => { const satz = schwerverkehrsanteilHinweis(); it('nennt beide Gleichungen und rechnet den Pauschalwert nach', () => { expect(satz).toContain('Gleichung (2-5)'); expect(satz).toContain('Gleichung (2-6)'); expect(satz).toContain('1,75 · 0,80 + 2,50 · 0,20 = 1,90'); expect(satz).toContain('Lastzüge am SV'); }); it('behauptet nicht mehr, das Programm erfasse keine Aufteilung', () => { expect(satz).not.toContain('erfasst dieses Programm nicht'); // Der Pauschalwert ist nicht mehr die Eigenschaft des Programms, sondern // die Folge einer Datenlage - und das muss der Satz sagen. expect(satz).toContain('Welche Gleichung des HBS 2015 gilt, entscheidet die Spalte'); }); it('bildet die Zahlen aus den Konstanten, nicht als festen Text', () => { // Befund C21: Ein fester Text veraltet still. Wenn eine Konstante sich // aendert, muss der Satz mitgehen - hier nachgewiesen ueber die Zahl selbst. expect(satz).toContain(HBS_PKW_GLEICHWERTE.schwerverkehrPauschal.toFixed(2).replace('.', ',')); expect(satz).toContain(HBS_PKW_GLEICHWERTE.lkwMitAnhaenger.toFixed(2).replace('.', ',')); expect(satz).toContain(`${HBS_LASTZUGANTEIL_BEI_UNBEKANNTER_AUFTEILUNG * 100} %`); }); it('steht unter der Tabelle, sobald es eine Gruppe mit Sättigungsverkehrsstärke gibt', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung()); try { expect(wurzel.textContent).toContain(satz); } finally { aufraeumen(); } }); }); describe('Hilfe zur Aufteilung des Schwerverkehrs', () => { const eintrag = HILFE.schwerverkehrsaufteilung; it('sagt, was zu zählen ist', () => { expect(eintrag.wasIstDas).toContain('Lkw mit Anhänger und Sattelkraftfahrzeuge'); // Die Bezugsgroesse ist die haeufigste Verwechslung: bei 20 % // Schwerverkehr liegt zwischen beiden Lesarten ein Faktor 5. expect(eintrag.wasIstDas).toContain( 'bezieht sich auf den SCHWERVERKEHR, nicht auf die Verkehrsstärke', ); expect(eintrag.wasIstDas).toContain('jedes vierte Schwerfahrzeug ist ein Lastzug'); expect(eintrag.woher).toBeDefined(); expect(eintrag.woher).toContain('nicht die Solo-Lkw und nicht die Busse'); expect(eintrag.woher).toContain('Fahrzeugklassen'); }); it('nennt die drei Pkw-Gleichwerte und rechnet den Pauschalwert nach', () => { expect(eintrag.wasIstDas).toContain('1,75'); expect(eintrag.wasIstDas).toContain('2,50'); expect(eintrag.wasIstDas).toContain('1,90'); expect(eintrag.wasIstDas).toContain('1,75 · 0,80 + 2,50 · 0,20 = 1,90'); expect(eintrag.wasIstDas).toContain('BASt-Bericht V 400'); }); it('sagt, was ohne Angabe passiert - und dass ein leeres Feld keine vorsichtige Wahl ist', () => { expect(eintrag.woher).toContain('lassen Sie das Feld leer'); expect(eintrag.woher).toContain('Gleichung (2-6)'); expect(eintrag.wennFalsch).toBeDefined(); expect(eintrag.wennFalsch).toContain('1538'); expect(eintrag.wennFalsch).toContain('1695'); expect(eintrag.wennFalsch).toContain('10,2 Prozent zu groß'); expect(eintrag.wennFalsch).toContain('31,6 Prozent'); expect(eintrag.wennFalsch).toContain('2,5 Prozent zu klein'); expect(eintrag.wennFalsch).toContain('keine vorsichtige Wahl'); }); it('nennt die Meldeschwelle der Prüfregel und nicht eine eigene Zahl', () => { expect(eintrag.woher).toContain(`über ${SCHWERVERKEHR_AUFTEILUNG_MELDESCHWELLE * 100} Prozent`); }); it('trennt Regelwerks- von Erfahrungswerten', () => { expect(eintrag.ueblich).toBeDefined(); expect(eintrag.ueblich).toContain('einzige belegte Zahl'); expect(eintrag.ueblich).toContain('Erfahrung und kein Regelwerkswert'); // Und der Umgang mit unbrauchbaren Werten: verworfen, nicht begrenzt. expect(eintrag.ueblich).toContain('nicht begrenzt, sondern verworfen'); }); it('steht im Glossar und ist von den Nachbareinträgen aus erreichbar', () => { expect(glossarEintraege().map((e) => e.schluessel)).toContain('schwerverkehrsaufteilung'); expect(glossarSuche('Aufteilung').map((e) => e.schluessel)).toContain( 'schwerverkehrsaufteilung', ); for (const schluessel of ['schwerverkehrsanteil', 'saettigungsverkehrsstaerke'] as const) { expect(HILFE[schluessel].siehe ?? [], schluessel).toContain('schwerverkehrsaufteilung'); } }); it('nimmt dem Eintrag zum Schwerverkehrsanteil die überholte Behauptung', () => { const anteil = HILFE.schwerverkehrsanteil; expect(anteil.wasIstDas).not.toContain('keine Aufteilung nach Fahrzeugklassen'); expect(anteil.wasIstDas).not.toContain('rechnet deshalb immer mit dem Pauschalwert'); expect(anteil.wasIstDas).toContain('Bleibt die Spalte „Lastzüge am SV“ daneben leer'); expect(anteil.wennFalsch).toContain('Tragen Sie in diesem Fall den Lastzuganteil ein'); }); }); describe('Assistent erfindet keine Aufteilung', () => { it('legt die Verkehrsstärken ohne Lastzuganteil an', () => { const projekt = projektAusAngaben( { ...ASSISTENT_VORGABE, verkehrsstaerke: 400, schwerverkehrsanteil: 9 }, 'Assistent', FESTES_DATUM, ); expect(projekt.demands.length).toBeGreaterThan(0); for (const demand of projekt.demands) { // Nicht 0 und nicht 20 %: Der Assistent hat nicht gezaehlt. expect('lastzugAnteil' in demand, 'Der Assistent behauptet eine Erhebung').toBe(false); } }); it('nennt die Annahme trotzdem in der Zusammenfassung', () => { const saetze = assistentZusammenfassung({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }).join( ' ', ); expect(saetze).toContain('Keine Aufteilung des Schwerverkehrs'); expect(saetze).toContain('Gleichung (2-6)'); expect(saetze).toContain('Pauschalwert 1,90'); expect(saetze).toContain('20 % Lastzügen am Schwerverkehr'); // Ohne Verkehrsstaerke gibt es keinen Schwerverkehr und nichts anzunehmen. const ohne = assistentZusammenfassung({ ...ASSISTENT_VORGABE, verkehrsstaerke: 0 }).join(' '); expect(ohne).not.toContain('Aufteilung des Schwerverkehrs'); }); it('erwähnt sie auch im Hinweis am Schwerverkehrsfeld des Dialogs', async () => { const ergebnis = starteAssistent(); const dialog = (): HTMLElement => document.querySelector('.dialog')!; const knopf = (beschriftung: string): HTMLButtonElement => [...dialog().querySelectorAll('button')].find( (b) => b.textContent?.trim() === beschriftung, )!; knopf('Weiter').click(); knopf('Weiter').click(); const label = [...dialog().querySelectorAll('label')].find( (l) => l.textContent?.trim() === 'Schwerverkehrsanteil', )!; const feld = dialog().querySelector(`#${label.htmlFor}`)!; const hinweis = dialog().querySelector( `#${feld.getAttribute('aria-describedby')}`, )!; expect(hinweis.textContent).toContain( 'Eine Aufteilung des Schwerverkehrs erfasst der Assistent nicht', ); expect(hinweis.textContent).toContain('Gleichung (2-6)'); expect(hinweis.textContent).toContain('20 %'); expect(hinweis.textContent).toContain('Lastzüge am SV'); expect(hinweis.textContent).toContain('Gleichung (2-5)'); knopf('Abbrechen').click(); await ergebnis; }); });