import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants'; import { settingFieldsByQuelle } from '@/domain/rilsa/settings'; import { PRUEFSTAND_ERLAEUTERUNG, PRUEFSTAND_LABELS, PRUEFSTAND_REIHENFOLGE, QUELLEN, } from '@/domain/rilsa/quellen'; import { toWinAnsi } from '@/render/pdfSurface'; import type { Anlagenart, Project } from '@/domain/model/project'; import * as fmt from '@/ui/format'; /* * Befunde B-1 und B-2: EIN Ausdruck, EINE Aussage ueber denselben Zahlenwert. * * B-1: Das Fundstellenverzeichnis fuehrte alle vier Umlaufzeitfelder unter der * Quelle 'rilsa-umlaufzeit' mit dem Pruefstand "gesichert" und ohne Hinweis. * Gedruckt stand damit "Regelbereich Umlaufzeit von 60 s ... gesichert", * waehrend die Tabelle "Schranken der Anlagenart" zwei Abschnitte vorher im * SELBEN Dokument "abzugleichen" auswies - an der Fussgaengerschutzanlage sogar * fuer einen ganz anderen Bereich (40 bis 70 s). Genau die 60 s, um die die * Frage E4 (Fassung 5.4.0) geht, standen weiter als gesicherter RiLSA-Wert in der * Unterlage. * * B-2: Die gedruckte Legende erklaerte "abzugleichen" allein als "am Original * zu pruefen" - fuer eine Schwelle, die dieses Programm selbst setzt, gibt es * kein Original. * * Dieser Test haelt die Pruefstaende BEIDER Tabellen desselben PDF * gegeneinander; ein Test dieser Art fehlte. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const ANLAGENARTEN: readonly Anlagenart[] = [ 'knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig', ]; /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */ function pdfText(bytes: Uint8Array): string { const roh = new TextDecoder('latin1').decode(bytes); const teile: string[] = []; const suche = /stream\r?\n/g; let treffer: RegExpExecArray | null; while ((treffer = suche.exec(roh)) !== null) { const start = treffer.index + treffer[0].length; const ende = roh.indexOf('endstream', start); if (ende < 0) continue; let inhalt: string; try { inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { continue; } for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); } } return teile.join(' ').replace(/\s+/g, ' '); } function projekt(anlagenart: Anlagenart): Project { const basis = createStandardIntersectionProject('Hauptstrasse / Bahnhofstrasse', STICHTAG); return { ...basis, anlagenart }; } function ausdruck(project: Project): string { const plan = buildSignalPlan(project); const bericht = validateProject(project, plan, STICHTAG); return pdfText(buildProjectPdf(project, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG)); } const gedruckt = (text: string): string => toWinAnsi(text); /** * Die erste Zeile einer Feldgruppe des Fundstellenverzeichnisses, so wie sie * gedruckt wird: Regelwerk, Kennwert, Regelwert, Sachgebiet, eigene Fundstelle * und Pruefstand. Nur diese erste Zeile traegt den Pruefstand der Gruppe. * * NACHGEZOGEN (Fassung 5.12.0): Die Spalte "Regelwert" verglich * hier gegen `feld.standard`, also gegen den Knotenpunktwert aus * RILSA_DEFAULTS. Das Verzeichnis liest sie seither ueber * `fundstellenkennwerte` aus derselben Quelle wie die Aufstellung * "Abweichungen von den Regelwerten" und druckt an einer * Fussgaengerschutzanlage 40 s. Der erwartete Regelwert kommt deshalb von der * Aufrufstelle - dort aus `ANLAGENART_GRENZEN` und damit aus einer von der * Umsetzung unabhaengigen Zahl. Wer den Regelwert kuenftig woanders herholt, * merkt es an dieser Zeile. */ function verzeichniszeile(project: Project, schluessel: string, regelwert?: number): string { const plan = buildSignalPlan(project); const gruppe = settingFieldsByQuelle(plan.defaults, plan.anlagenart).find( (g) => g.schluessel === schluessel, ); expect(gruppe, schluessel).toBeDefined(); const feld = gruppe!.felder[0]!; /* * Die Fundstellenspalte: seit 5.27.0 die im Programm hinterlegte * Abschnittsnummer, wo es eine gibt, sonst weiterhin "nicht eingetragen". * Diese Aufstellung traegt keine eigene Fundstelle des Anwenders. */ const fundstelle = gruppe!.quelle.fundstelle ?? 'nicht eingetragen'; return ( `${gruppe!.quelle.kurz} ${feld.label} ${fmt.numShort(regelwert ?? feld.standard, 3)} ${feld.unit} ` + `${gruppe!.quelle.sachgebiet} ${fundstelle} ${PRUEFSTAND_LABELS[gruppe!.quelle.pruefstand]}` ); } describe('B-1 - Fundstellenverzeichnis und Schrankentabelle sagen dasselbe', () => { it('fuehrt den Regelbereich in beiden Tabellen mit demselben Pruefstand', () => { for (const art of ANLAGENARTEN) { const grenzen = ANLAGENART_GRENZEN[art]; const text = ausdruck(projekt(art)); // Schrankentabelle: Angesetzt - Grundlage - Pruefstand. const schranke = `${fmt.seconds(grenzen.cycleTime.recommendedMin)} bis ` + `${fmt.seconds(grenzen.cycleTime.recommendedMax)} ${grenzen.herkunft.regelbereich.text} ` + `${PRUEFSTAND_LABELS[grenzen.herkunft.regelbereich.pruefstand]}`; expect(text, art).toContain(gedruckt(schranke)); /* * Fundstellenverzeichnis: NIE zuversichtlicher als die Zeile der * Anlagenart. * * Bis 5.26.0 stand hier "derselbe Pruefstand" - richtig, solange alle * drei Anlagenarten denselben trugen. Seit 5.27.0 ist der Regelbereich * am Knotenpunkt belegt (RiLSA 2015, Abschnitt 2.6), an der * Fussgaengerschutzanlage und bei einstreifiger Fuehrung nach wie vor * nicht. EINE Quelle kann das nicht in drei Staenden ausdruecken; sie * traegt deshalb den schwaechsten. Was B-1 verhindern sollte, bleibt * verhindert: dass das Verzeichnis MEHR behauptet als die * Schrankentabelle. */ const quelle = PRUEFSTAND_REIHENFOLGE.indexOf( QUELLEN['rilsa-umlaufzeit-regelbereich'].pruefstand, ); const zeile = PRUEFSTAND_REIHENFOLGE.indexOf(grenzen.herkunft.regelbereich.pruefstand); expect( quelle, `${art}: das Verzeichnis behauptet mehr als die Schrankentabelle`, ).toBeGreaterThanOrEqual(zeile); expect(text, art).toContain( gedruckt( verzeichniszeile( projekt(art), 'rilsa-umlaufzeit-regelbereich', grenzen.cycleTime.recommendedMin, ), ), ); } }); it('nennt den Regelbereich in keiner Anlagenart mehr gesichert', () => { for (const art of ANLAGENARTEN) { const text = ausdruck(projekt(art)); expect(text, art).not.toContain( gedruckt( 'Regelbereich Umlaufzeit von 60 s Umlaufzeit und Freigabezeitverteilung nicht eingetragen gesichert', ), ); // Auch nicht ueber das neue Sachgebiet. expect(text, art).not.toContain( gedruckt('Umlaufzeit: empfohlener Bereich nicht eingetragen gesichert'), ); } }); it('sagt beim zulaessigen Bereich, was belegt ist und was fuer DIESEN Plan gilt', () => { // Die 30 bis 120 s sind der Regelwert der Vorgabenfelder - die // Umlaufzeitzeile der RiLSA fuer den Knotenpunkt. Bei einer einstreifigen // Fuehrung gilt fuer den Plan etwas anderes, und das muss dabeistehen, // sonst stehen 120 s und 300 s unverbunden nebeneinander. const hinweis = QUELLEN['rilsa-umlaufzeit'].hinweis; expect(hinweis).toBeDefined(); for (const art of ANLAGENARTEN) { const text = ausdruck(projekt(art)); expect(text, art).toContain(gedruckt(`Zu beachten: ${hinweis!}`)); } expect(hinweis!).toContain('30 bis 120 s am Knotenpunkt'); expect(hinweis!).toContain('Schranken der Anlagenart'); }); it('nennt beim Regelbereich, was belegt ist und was dieses Programm setzt', () => { /* * NACHGEZOGEN (5.27.0): Bis dahin hiess dieser Fall "die halbe Beleglage * der Frage E4" (Fassung 5.4.0) und verlangte die Saetze "Obergrenze von * 90 s am Knotenpunkt" und "60 s als Untergrenze". Am Knotenpunkt sind seit * dem Abgleich beide Grenzen belegt; offen bleiben die Bereiche der beiden * anderen Anlagenarten, und genau das muss der Hinweis sagen. */ const hinweis = QUELLEN['rilsa-umlaufzeit-regelbereich'].hinweis; expect(hinweis).toBeDefined(); expect(hinweis!).toContain('minimal 30 s und maximal 90 (120) s'); expect(hinweis!).toContain('setzt dieses Programm den Bereich weiterhin selbst'); for (const art of ANLAGENARTEN) { expect(ausdruck(projekt(art)), art).toContain(gedruckt(`Zu beachten: ${hinweis!}`)); } }); }); describe('B-2 - die Legende des Pruefstands kennt beide Faelle', () => { it('druckt sie aus derselben Erlaeuterung, die der Bildschirm zeigt', () => { const text = ausdruck(projekt('knotenpunkt')); for (const stand of PRUEFSTAND_REIHENFOLGE) { expect(text, stand).toContain( gedruckt(`Prüfstand "${PRUEFSTAND_LABELS[stand]}": ${PRUEFSTAND_ERLAEUTERUNG[stand]}`), ); } }); it('schickt den Pruefer bei selbst gesetzten Schwellen nicht mehr an ein Regelwerk', () => { expect(PRUEFSTAND_ERLAEUTERUNG.abzugleichen).toContain('Straßenverkehrsbehörde'); expect(PRUEFSTAND_ERLAEUTERUNG.abzugleichen).toContain('Schwelle dieses Programms'); const text = ausdruck(projekt('einstreifig')); expect(text).toContain(gedruckt('gibt es kein Original')); // Der alte Wortlaut sagte nur die eine Haelfte. expect(text).not.toContain( gedruckt('"abzugleichen": praxisüblich, aber von Ausgabe oder Anwendungsfall abhängig'), ); }); });