import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createEmptyProject, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import { einstreifigBefunde } from '@/domain/validation/rules'; import { mitBewahrtemFokus } from '@/ui/fokus'; import type { ViewContext } from '@/ui/shell'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; /** * Befund 58 zur Fassung 5.9.0 (Datenverlust). * * WAS DER ANWENDER SONST ERLEBT * * Er traegt bei einer Kfz-Gruppe Verkehrsstaerke 620, Schwerverkehrsanteil * 12 % und Lastzuege am Schwerverkehr 40 % ein. Dann leert er das Feld * "Verkehrsstärke" (markieren, Entf, Tabulator) - und beide Nachbarfelder sind * mit verschwunden: Das leere Feld fuehrte auf `removeDemand`, und das entfernt * den GANZEN Datensatz. Traegt er die Verkehrsstaerke wieder ein, steht der * Schwerverkehrsanteil auf 0 und die Aufteilung ist fort; gerechnet wird mit * fSV = 1 statt mit den erfassten Werten. Die Kapazitaet faellt zu gross aus, * der Auslastungsgrad zu klein - die unsichere Richtung der Bemessung, und * kein Pruefbericht meldet es. * * Beide Angaben haengen fachlich nicht an der Verkehrsstaerke; das Programm * sieht das selbst so: `setLastzugAnteil` legt eigens einen Datensatz mit * `volume: 0` an, damit eine Aufteilung OHNE Verkehrsstaerke gehalten werden * kann, und das Nachbarfeld derselben Zeile leert nicht, sondern setzt. * * Richtig ist deshalb: `volume: 0` - im Fachkern durchgaengig "keine Angabe" - * und entfernt wird der Datensatz nur, wenn ausser der Verkehrsstaerke nichts * erfasst ist. * * DAZU DIE ANZEIGE: Wird der Datensatz behalten, muss das Feld * nach dem Neuaufbau LEER bleiben. `volume: 0` als Ziffer zu zeichnen setzte * eine "0" in ein Feld, das der Anwender gerade geleert hat - eine gezaehlte * Null, die niemand gezaehlt hat. Die Ansicht baut sich nach jeder Eingabe neu * auf; die Prueffaelle hier tun das ebenso (`refresh` zeichnet wirklich neu). */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); interface Ansicht { readonly wurzel: HTMLElement; readonly store: ProjectStore; } /** * Ansicht mit dem Neuaufbau, den die Shell fuer `refresh` vorsieht * (src/ui/shell.ts, `neuAufbauen`): aufraeumen, leeren, neu zeichnen, Fokus * bewahren. Ein `refresh: () => {}` liesse gerade das ungeprueft, was der * Anwender nach seiner Eingabe zu sehen bekommt. */ function zeichne(project: Project): Ansicht { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); let abmelden: () => void = () => {}; const context: ViewContext = { store, refresh: () => { mitBewahrtemFokus(wurzel, () => { abmelden(); wurzel.replaceChildren(); abmelden = signalGroupsView.render(wurzel, context); }); }, navigate: () => {}, target: null, }; abmelden = signalGroupsView.render(wurzel, context); aufraeumen = () => { abmelden(); wurzel.remove(); }; return { wurzel, store }; } /** Kennung der Gruppe "K1" - die traegt im Regelknotenpunkt eine Verkehrsstaerke. */ function k1(project: Project): string { const gruppe = project.signalGroups.find((g) => g.name === 'K1'); if (gruppe === undefined) throw new Error('Keine Signalgruppe "K1"'); return gruppe.id; } /** Das Feld "Verkehrsstärke" der Gruppe - im gerade gezeichneten Stand. */ function verkehrsstaerkefeld(ansicht: Ansicht, groupId: string): HTMLInputElement { const feld = ansicht.wurzel.querySelector( `[data-fokus="signalgruppe|${groupId}|verkehrsstaerke"]`, ); if (feld === null) throw new Error('Kein Feld "Verkehrsstärke"'); return feld; } /** Leert das Feld "Verkehrsstärke" der Gruppe, wie es der Bedienschritt tut. */ function leereVerkehrsstaerke(ansicht: Ansicht, groupId: string): void { const feld = verkehrsstaerkefeld(ansicht, groupId); feld.value = ''; feld.dispatchEvent(new Event('change', { bubbles: true })); } /** Einstreifige Fuehrung mit einer Kfz-Gruppe und einer Verkehrsstaerke. */ function einstreifigMitEinerStaerke(): { readonly projekt: Project; readonly gruppeId: string } { const basis = createEmptyProject('Arbeitsstelle', FESTES_DATUM, 'einstreifig'); const gruppe = createSignalGroup({ name: 'A1', mode: 'kfz', index: 0 }); return { projekt: { ...basis, signalGroups: [gruppe], demands: [{ signalGroupId: gruppe.id, volume: 620, heavyVehicleShare: 0 }], }, gruppeId: gruppe.id, }; } /** Der Satz der RSA-Anordnungskriterien zur Verkehrsstaerke. */ function anordnungskriterien(projekt: Project): string { const befund = einstreifigBefunde(projekt).find( (f) => f.rule === 'einstreifig.rsa-anordnungskriterien', ); if (befund === undefined) throw new Error('Kein Befund "einstreifig.rsa-anordnungskriterien"'); return befund.message; } describe('Signalgruppen: das Leeren der Verkehrsstaerke laesst die Zaehlangaben stehen', () => { it('behaelt Schwerverkehrsanteil und Lastzuganteil', () => { const basis = createStandardIntersectionProject('Verkehrsstärke', FESTES_DATUM); const id = k1(basis); const projekt: Project = { ...basis, demands: basis.demands.map((d) => d.signalGroupId === id ? { ...d, volume: 620, heavyVehicleShare: 0.12, lastzugAnteil: 0.4 } : d, ), }; const ansicht = zeichne(projekt); leereVerkehrsstaerke(ansicht, id); const nachher = ansicht.store.getProject().demands.find((d) => d.signalGroupId === id); expect(nachher, 'der ganze Datensatz ist fort').toBeDefined(); expect(nachher?.volume, '"keine Angabe" ist die Verkehrsstaerke 0').toBe(0); expect(nachher?.heavyVehicleShare, 'der erfasste Schwerverkehrsanteil ist fort').toBe(0.12); expect(nachher?.lastzugAnteil, 'die erfasste Aufteilung ist fort').toBe(0.4); }); it('behaelt auch einen Lastzuganteil ohne Schwerverkehrsanteil', () => { const basis = createStandardIntersectionProject('Verkehrsstärke', FESTES_DATUM); const id = k1(basis); const projekt: Project = { ...basis, demands: basis.demands.map((d) => d.signalGroupId === id ? { ...d, volume: 620, heavyVehicleShare: 0, lastzugAnteil: 0.4 } : d, ), }; const ansicht = zeichne(projekt); leereVerkehrsstaerke(ansicht, id); const nachher = ansicht.store.getProject().demands.find((d) => d.signalGroupId === id); expect(nachher?.lastzugAnteil).toBe(0.4); }); it('laesst das geleerte Feld leer und schreibt keine 0 hinein', () => { // Der behaltene Datensatz traegt `volume: 0`. Zeichnete die Zelle diesen // Wert als Ziffer, stuende nach dem Neuaufbau eine "0" in dem Feld, das der // Anwender gerade geleert hat - und der Bildschirm behauptete eine // Zaehlung, die es nicht gibt. const basis = createStandardIntersectionProject('Verkehrsstärke', FESTES_DATUM); const id = k1(basis); const projekt: Project = { ...basis, demands: basis.demands.map((d) => d.signalGroupId === id ? { ...d, volume: 620, heavyVehicleShare: 0.12 } : d, ), }; const ansicht = zeichne(projekt); expect(verkehrsstaerkefeld(ansicht, id).value, 'Vorbedingung: die Zahl steht im Feld').toBe( '620', ); leereVerkehrsstaerke(ansicht, id); expect( ansicht.store.getProject().demands.find((d) => d.signalGroupId === id)?.volume, 'Vorbedingung: der Datensatz bleibt mit volume 0 stehen', ).toBe(0); expect( verkehrsstaerkefeld(ansicht, id).value, 'im geleerten Feld steht nach dem Neuaufbau eine Zahl', ).toBe(''); }); it('zeichnet auch einen Datensatz aus dem Schwerverkehrsanteil allein mit leerem Feld', () => { // Denselben Datensatz legt `setDemand` an, sobald nur der // Schwerverkehrsanteil ausgefuellt wird - auch dort hat niemand eine // Verkehrsstaerke eingetragen. const basis = createStandardIntersectionProject('Verkehrsstärke', FESTES_DATUM); const id = k1(basis); const projekt: Project = { ...basis, demands: basis.demands.map((d) => d.signalGroupId === id ? { ...d, volume: 0, heavyVehicleShare: 0.12 } : d, ), }; const ansicht = zeichne(projekt); expect(verkehrsstaerkefeld(ansicht, id).value).toBe(''); }); it('entfernt den Datensatz, wenn ausser der Verkehrsstaerke nichts erfasst ist', () => { // Sonst bliebe ein Datensatz ohne jede Angabe stehen, und an zwei Stellen // zaehlt der DATENSATZ und nicht seine Zahl: `einstreifigBefunde` // (domain/validation/rules.ts) nimmt jeden Datensatz einer Kfz- oder // OePNV-Gruppe als erfasste Verkehrsstaerke, und die Fehlermeldung an den // Hauptprozess (src/main.ts) meldet `demands.length` als Zahl der // Verkehrsstaerken. Die Fuehrung (naechsterSchritt.ts) ist NICHT betroffen: // Sie zaehlt nur Datensaetze mit einer Zahl groesser 0 (Befund 48 zur // Fassung 5.8.0, bewacht in tests/ui/verkehrsstaerkeNull). const basis = createStandardIntersectionProject('Verkehrsstärke', FESTES_DATUM); const id = k1(basis); const projekt: Project = { ...basis, demands: basis.demands.map((d) => d.signalGroupId === id ? { ...d, volume: 620, heavyVehicleShare: 0 } : d, ), }; const ansicht = zeichne(projekt); leereVerkehrsstaerke(ansicht, id); expect( ansicht.store.getProject().demands.some((d) => d.signalGroupId === id), 'ein leerer Datensatz bleibt zurueck', ).toBe(false); }); it('haelt den Pruefbericht bei "Verkehrsstärken sind nicht erfasst"', () => { // Der Beleg fuer den Zweig darueber, an der Stelle, die den Unterschied // druckt: Bliebe der leere Datensatz stehen, schriebe der Pruefbericht der // einstreifigen Fuehrung "Die erfassten Verkehrsstärken ergeben zusammen // 0 Kfz/h und liegen damit unter dem Richtwert" - eine Zaehlung mit dem // Ergebnis null statt einer fehlenden Zaehlung. const { projekt, gruppeId } = einstreifigMitEinerStaerke(); expect(anordnungskriterien(projekt), 'Vorbedingung: die Zahl steht im Bericht').toContain( '620 Kfz/h', ); const ansicht = zeichne(projekt); leereVerkehrsstaerke(ansicht, gruppeId); const satz = anordnungskriterien(ansicht.store.getProject()); expect(satz, 'der Bericht zaehlt eine Verkehrsstaerke, die niemand erfasst hat').toContain( 'Verkehrsstärken sind nicht erfasst', ); // Nicht auf "0 Kfz/h" pruefen: Der Richtwert "500 Kfz/h" steht im selben Satz. expect(satz).not.toContain('ergeben zusammen'); }); });