import { readFileSync } from 'node:fs'; import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { bereichsHinweis, numberCell } from '@/ui/inputs'; import { mitBewahrtemFokus } from '@/ui/fokus'; import type { ViewContext } from '@/ui/shell'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; /* * Fassung 5.9.0 - die getippte Fahrstreifenzahl. * * WAS DER ANWENDER SONST ERLEBT: Er tippt "2,5" in die Spalte Fahrstreifen. * Das Feld nimmt die Zahl an, `onChange` reicht 2,5 weiter, das Feld behaelt * "2.5", es gibt keine Kennzeichnung und keine Meldung. Ab da rechnet * `saturationFlow` mit zweieinhalb Fahrstreifen, und die Zahl steht in Tabelle * und Ausdruck. Der Einlesepfad zieht diese Grenze seit R4.2 - die Bedienung * nicht, und derselbe Eintrag bekam zwei Antworten. * * WARUM NICHT UEBER `step`: Die Schrittweite ist an jedem Feld dieses Programms * die Sprungweite der Pfeiltasten und kein Raster; das steht in `inputs.ts` * (`bereichsHinweis`) und in `phasesView.ts` (`umlaufGrenzen`) ausdruecklich so * da. Wer sie durchsetzte, verwuerfe eine gezaehlte Verkehrsstaerke von * 620 Fz/h (Feld mit `step: 50`), eine von Hand gesetzte Umlaufzeit von 78 s * (`step: 5`) und einen abgemessenen Raeumweg von 12,34 m (`step: 0,1`). * Ganzzahlig ist nicht die Schrittweite, sondern die GROESSE - eine Anzahl. * Die letzten beiden Faelle dieser Datei bewachen genau das. */ afterEach(() => { document.body.replaceChildren(); }); const ANZAHLFELD = { min: 1, max: 8, step: 1, ganzzahlAbrunden: true } as const; function letzteWarnung(): string { const meldungen = [...document.querySelectorAll('.meldung-warnung')]; return meldungen[meldungen.length - 1]?.textContent ?? ''; } function warnungen(): number { return document.querySelectorAll('.meldung-warnung').length; } /** Ein Zahlenfeld im Baum, das seine Eingabe wie in der Tabelle bekommt. */ function tippe( eingabe: string, optionen: Parameters[2], start: number | null = 2, ): { feld: HTMLInputElement; gemeldet: number | null | undefined } { let gemeldet: number | null | undefined; const feld = numberCell( start, (wert) => { gemeldet = wert; }, optionen, ); feld.setAttribute('aria-label', 'Fahrstreifen'); document.body.append(feld); feld.value = eingabe; feld.dispatchEvent(new Event('change')); return { feld, gemeldet }; } describe('Eine Anzahl nimmt keine Nachkommastelle an', () => { it('macht aus der getippten 2,5 eine 2 und sagt es', () => { const { feld, gemeldet } = tippe('2.5', ANZAHLFELD); expect(gemeldet, 'der Aufrufer bekommt die ganze Zahl').toBe(2); expect(feld.value, 'und dieselbe Zahl steht im Feld').toBe('2'); expect(feld.getAttribute('aria-invalid')).toBe('true'); const meldung = letzteWarnung(); expect(meldung).toContain('Fahrstreifen'); expect(meldung).toContain('2,5'); expect(meldung).toContain('keine ganze Zahl'); expect(meldung, 'kein englischer Dezimalpunkt im vorgelesenen Satz').not.toMatch(/\d\.\d/); }); it('rundet ab und nicht auf - 7,9 wird 7', () => { // Dieselbe Richtung wie beim Einlesen: Die Fahrstreifenzahl geht ueber // `saturationFlow` in die Kapazitaet ein; aufrunden gaebe // Leistungsfaehigkeit, die es nicht gibt. const { feld, gemeldet } = tippe('7.9', ANZAHLFELD); expect(gemeldet).toBe(7); expect(feld.value).toBe('7'); expect(letzteWarnung()).toContain('abgerundet'); }); it('sagt den Bereich vor der Eingabe an', () => { expect(bereichsHinweis(ANZAHLFELD)).toContain('Nur ganze Zahlen.'); }); it('meldet einen Wert unter dem Mindestwert nur einmal', () => { // 0,5 liegt unter 1. Die Begrenzung hebt auf 1 an - und 1 ist bereits ganz. const { feld, gemeldet } = tippe('0.5', ANZAHLFELD); expect(gemeldet).toBe(1); expect(feld.value).toBe('1'); expect(warnungen(), 'eine Eingabe, eine Meldung').toBe(1); expect(letzteWarnung()).toContain('außerhalb'); }); it('bleibt auch bei einem gebrochenen Mindestwert im Bereich', () => { // Ein gebrochenes `min` hat an einer Anzahl nichts zu suchen, waere aber // eintragbar. Dann gilt die kleinste ganze Zahl im Bereich, und die // Meldung sagt nicht "abgerundet". const { feld, gemeldet } = tippe('1.7', { min: 1.5, max: 8, ganzzahlAbrunden: true }); expect(gemeldet).toBe(2); expect(feld.value).toBe('2'); expect(letzteWarnung()).toContain('kleinste zulässige ganze Wert'); }); it('laesst eine ganze Zahl unberuehrt und schweigt dazu', () => { const { feld, gemeldet } = tippe('3', ANZAHLFELD); expect(gemeldet).toBe(3); expect(feld.value).toBe('3'); expect(feld.hasAttribute('aria-invalid')).toBe(false); expect(warnungen()).toBe(0); }); }); describe('Gegenproben - die Schrittweite bleibt die Sprungweite der Pfeiltasten', () => { it('nimmt einen abgemessenen Räumweg von 12,34 m unverändert an', () => { // Feld aus dem Konfliktfenster: min 0, max 3000, step 0,1. const { feld, gemeldet } = tippe('12.34', { min: 0, max: 3000, step: 0.1, einheit: 'm' }, 22); expect(gemeldet).toBe(12.34); expect(feld.value).toBe('12.34'); expect(warnungen()).toBe(0); }); it('nimmt eine gezählte Verkehrsstärke von 620 Fz/h unverändert an', () => { // Feld aus dem Assistenten: min 0, max 3000, step 50. Ein Raster wuerde // aus der Zaehlung 600 oder 650 machen. const { feld, gemeldet } = tippe('620', { min: 0, max: 3000, step: 50, einheit: 'Fz/h' }, 0); expect(gemeldet).toBe(620); expect(feld.value).toBe('620'); expect(warnungen()).toBe(0); }); it('nennt die Schrittweite weiterhin als Bedienhinweis und nicht als Bedingung', () => { const satz = bereichsHinweis({ min: 0, max: 3000, step: 50, einheit: 'Fz/h' }); expect(satz).toContain('Pfeiltasten ändern in Schritten von 50 Fz/h.'); expect(satz).not.toContain('Nur ganze Zahlen.'); }); }); /* --- Dieselbe Grenze in der Signalgruppentabelle -------------------------- */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); describe('Signalgruppentabelle: die Spalte Fahrstreifen', () => { it('legt keine gebrochene Fahrstreifenzahl im Speicher ab', () => { const projekt = createStandardIntersectionProject('Fahrstreifen', FESTES_DATUM); const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(projekt); 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); const feld = wurzel.querySelector( 'input[type="number"][aria-label^="Anzahl der Fahrstreifen"]', ); expect(feld, 'Vorbedingung: die Tabelle hat eine Spalte Fahrstreifen').not.toBeNull(); feld!.value = '2.5'; feld!.dispatchEvent(new Event('change', { bubbles: true })); expect(store.getProject().signalGroups[0]?.lanes).toBe(2); abmelden(); }); }); /* --- Was der Kommentar am Einlesen noch offen nennt ----------------------- */ describe('Der Vorbehalt an der Zufahrt nennt den Stand nach S1.2', () => { /* * Der Kommentar vor `lanes` in `parseArms` beschrieb den Weg ueber die * Oberflaeche als ganz offen und verwies auf eine Regel in engine.ts. Beides * stimmt seit S1.2 nicht mehr: Die Spalte der Signalgruppentabelle zieht die * Grenze jetzt am Feld, und eine Regel im Pruefbericht ist nicht mehr der * gemeinte Weg. Ein Vorbehalt, der auf die falsche Datei zeigt, schickt den * naechsten Leser in die Irre - deshalb steht er hier unter Aufsicht. * * NACHTRAG: Der dritte Fall dieses Blocks war die Gegenprobe zum letzten * offenen Weg - dem Fahrstreifenfeld der Zufahrt - und sagte selbst voraus: * "Sobald `ganzzahlAbrunden` auch dort steht, faellt dieser Fall." Genau das * ist eingetreten. Er prueft jetzt das Gegenteil, und der zweite Fall * verlangt vom Kommentar nicht mehr den Verweis auf die offene Stelle, * sondern die Aussage, dass alle drei Wege geschlossen sind. */ const SCHEMA = readFileSync('src/domain/model/schema.ts', 'utf8'); const PROJECTVIEW = readFileSync('src/ui/views/projectView.ts', 'utf8'); const zufahrtskommentar = SCHEMA.slice( SCHEMA.indexOf('Eine Anzahl, keine Messgroesse'), SCHEMA.indexOf('`intersection.arms[${index}].lanes`'), ); it('weist alle drei Wege als geschlossen aus und nicht mehr auf eine Regel im Pruefbericht', () => { expect(zufahrtskommentar, 'Fundstelle des Zufahrtskommentars').not.toBe(''); expect(zufahrtskommentar).toContain('alle drei Wege'); expect(zufahrtskommentar, 'der Vorschlag von damals ist nicht der Weg von heute').not.toContain( 'checkSignalGroups', ); }); it('behauptet nicht mehr, das Zahlenfeld begrenze nur min und max', () => { expect(zufahrtskommentar).not.toContain('begrenzt nur min und max'); expect(zufahrtskommentar, 'die Spalte der Signalgruppe ist geschlossen').toContain( 'ganzzahlAbrunden', ); }); it('trifft zu: auch das Zufahrtsfeld traegt die Grenze jetzt', () => { // Die Gegenprobe zur Aussage des Kommentars. Faellt die Option an der // Zufahrt wieder heraus, faellt dieser Fall - und der Kommentar darueber // wuerde einen geschlossenen Weg behaupten, den es nicht gibt. const zufahrtsfeld = PROJECTVIEW.slice( PROJECTVIEW.indexOf('arm.lanes'), PROJECTVIEW.indexOf('arm.vZul'), ); expect(zufahrtsfeld, 'Fundstelle des Fahrstreifenfelds der Zufahrt').not.toBe(''); expect(zufahrtsfeld).toContain('ganzzahlAbrunden: true'); }); });