lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export pdfPruefstaende.test.ts
| 1 | import { inflateSync } from 'node:zlib'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; |
| 4 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 5 | import { validateProject } from '@/domain/validation'; |
| 6 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 7 | import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants'; |
| 8 | import { settingFieldsByQuelle } from '@/domain/rilsa/settings'; |
| 9 | import { |
| 10 | PRUEFSTAND_ERLAEUTERUNG, |
| 11 | PRUEFSTAND_LABELS, |
| 12 | PRUEFSTAND_REIHENFOLGE, |
| 13 | QUELLEN, |
| 14 | } from '@/domain/rilsa/quellen'; |
| 15 | import { toWinAnsi } from '@/render/pdfSurface'; |
| 16 | import type { Anlagenart, Project } from '@/domain/model/project'; |
| 17 | import * as fmt from '@/ui/format'; |
| 18 | |
| 19 | /* |
| 20 | * Befunde B-1 und B-2: EIN Ausdruck, EINE Aussage ueber denselben Zahlenwert. |
| 21 | * |
| 22 | * B-1: Das Fundstellenverzeichnis fuehrte alle vier Umlaufzeitfelder unter der |
| 23 | * Quelle 'rilsa-umlaufzeit' mit dem Pruefstand "gesichert" und ohne Hinweis. |
| 24 | * Gedruckt stand damit "Regelbereich Umlaufzeit von 60 s ... gesichert", |
| 25 | * waehrend die Tabelle "Schranken der Anlagenart" zwei Abschnitte vorher im |
| 26 | * SELBEN Dokument "abzugleichen" auswies - an der Fussgaengerschutzanlage sogar |
| 27 | * fuer einen ganz anderen Bereich (40 bis 70 s). Genau die 60 s, um die die |
| 28 | * Frage E4 (Fassung 5.4.0) geht, standen weiter als gesicherter RiLSA-Wert in der |
| 29 | * Unterlage. |
| 30 | * |
| 31 | * B-2: Die gedruckte Legende erklaerte "abzugleichen" allein als "am Original |
| 32 | * zu pruefen" - fuer eine Schwelle, die dieses Programm selbst setzt, gibt es |
| 33 | * kein Original. |
| 34 | * |
| 35 | * Dieser Test haelt die Pruefstaende BEIDER Tabellen desselben PDF |
| 36 | * gegeneinander; ein Test dieser Art fehlte. |
| 37 | */ |
| 38 | |
| 39 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 40 | |
| 41 | const ANLAGENARTEN: readonly Anlagenart[] = [ |
| 42 | 'knotenpunkt', |
| 43 | 'fussgaengerschutzanlage', |
| 44 | 'einstreifig', |
| 45 | ]; |
| 46 | |
| 47 | /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */ |
| 48 | function pdfText(bytes: Uint8Array): string { |
| 49 | const roh = new TextDecoder('latin1').decode(bytes); |
| 50 | const teile: string[] = []; |
| 51 | const suche = /stream\r?\n/g; |
| 52 | let treffer: RegExpExecArray | null; |
| 53 | |
| 54 | while ((treffer = suche.exec(roh)) !== null) { |
| 55 | const start = treffer.index + treffer[0].length; |
| 56 | const ende = roh.indexOf('endstream', start); |
| 57 | if (ende < 0) continue; |
| 58 | let inhalt: string; |
| 59 | try { |
| 60 | inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 61 | } catch { |
| 62 | continue; |
| 63 | } |
| 64 | for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { |
| 65 | teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); |
| 66 | } |
| 67 | } |
| 68 | return teile.join(' ').replace(/\s+/g, ' '); |
| 69 | } |
| 70 | |
| 71 | function projekt(anlagenart: Anlagenart): Project { |
| 72 | const basis = createStandardIntersectionProject('Hauptstrasse / Bahnhofstrasse', STICHTAG); |
| 73 | return { ...basis, anlagenart }; |
| 74 | } |
| 75 | |
| 76 | function ausdruck(project: Project): string { |
| 77 | const plan = buildSignalPlan(project); |
| 78 | const bericht = validateProject(project, plan, STICHTAG); |
| 79 | return pdfText(buildProjectPdf(project, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG)); |
| 80 | } |
| 81 | |
| 82 | const gedruckt = (text: string): string => toWinAnsi(text); |
| 83 | |
| 84 | /** |
| 85 | * Die erste Zeile einer Feldgruppe des Fundstellenverzeichnisses, so wie sie |
| 86 | * gedruckt wird: Regelwerk, Kennwert, Regelwert, Sachgebiet, eigene Fundstelle |
| 87 | * und Pruefstand. Nur diese erste Zeile traegt den Pruefstand der Gruppe. |
| 88 | * |
| 89 | * NACHGEZOGEN (Fassung 5.12.0): Die Spalte "Regelwert" verglich |
| 90 | * hier gegen `feld.standard`, also gegen den Knotenpunktwert aus |
| 91 | * RILSA_DEFAULTS. Das Verzeichnis liest sie seither ueber |
| 92 | * `fundstellenkennwerte` aus derselben Quelle wie die Aufstellung |
| 93 | * "Abweichungen von den Regelwerten" und druckt an einer |
| 94 | * Fussgaengerschutzanlage 40 s. Der erwartete Regelwert kommt deshalb von der |
| 95 | * Aufrufstelle - dort aus `ANLAGENART_GRENZEN` und damit aus einer von der |
| 96 | * Umsetzung unabhaengigen Zahl. Wer den Regelwert kuenftig woanders herholt, |
| 97 | * merkt es an dieser Zeile. |
| 98 | */ |
| 99 | function verzeichniszeile(project: Project, schluessel: string, regelwert?: number): string { |
| 100 | const plan = buildSignalPlan(project); |
| 101 | const gruppe = settingFieldsByQuelle(plan.defaults, plan.anlagenart).find( |
| 102 | (g) => g.schluessel === schluessel, |
| 103 | ); |
| 104 | expect(gruppe, schluessel).toBeDefined(); |
| 105 | const feld = gruppe!.felder[0]!; |
| 106 | /* |
| 107 | * Die Fundstellenspalte: seit 5.27.0 die im Programm hinterlegte |
| 108 | * Abschnittsnummer, wo es eine gibt, sonst weiterhin "nicht eingetragen". |
| 109 | * Diese Aufstellung traegt keine eigene Fundstelle des Anwenders. |
| 110 | */ |
| 111 | const fundstelle = gruppe!.quelle.fundstelle ?? 'nicht eingetragen'; |
| 112 | return ( |
| 113 | `${gruppe!.quelle.kurz} ${feld.label} ${fmt.numShort(regelwert ?? feld.standard, 3)} ${feld.unit} ` + |
| 114 | `${gruppe!.quelle.sachgebiet} ${fundstelle} ${PRUEFSTAND_LABELS[gruppe!.quelle.pruefstand]}` |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | describe('B-1 - Fundstellenverzeichnis und Schrankentabelle sagen dasselbe', () => { |
| 119 | it('fuehrt den Regelbereich in beiden Tabellen mit demselben Pruefstand', () => { |
| 120 | for (const art of ANLAGENARTEN) { |
| 121 | const grenzen = ANLAGENART_GRENZEN[art]; |
| 122 | const text = ausdruck(projekt(art)); |
| 123 | |
| 124 | // Schrankentabelle: Angesetzt - Grundlage - Pruefstand. |
| 125 | const schranke = |
| 126 | `${fmt.seconds(grenzen.cycleTime.recommendedMin)} bis ` + |
| 127 | `${fmt.seconds(grenzen.cycleTime.recommendedMax)} ${grenzen.herkunft.regelbereich.text} ` + |
| 128 | `${PRUEFSTAND_LABELS[grenzen.herkunft.regelbereich.pruefstand]}`; |
| 129 | expect(text, art).toContain(gedruckt(schranke)); |
| 130 | |
| 131 | /* |
| 132 | * Fundstellenverzeichnis: NIE zuversichtlicher als die Zeile der |
| 133 | * Anlagenart. |
| 134 | * |
| 135 | * Bis 5.26.0 stand hier "derselbe Pruefstand" - richtig, solange alle |
| 136 | * drei Anlagenarten denselben trugen. Seit 5.27.0 ist der Regelbereich |
| 137 | * am Knotenpunkt belegt (RiLSA 2015, Abschnitt 2.6), an der |
| 138 | * Fussgaengerschutzanlage und bei einstreifiger Fuehrung nach wie vor |
| 139 | * nicht. EINE Quelle kann das nicht in drei Staenden ausdruecken; sie |
| 140 | * traegt deshalb den schwaechsten. Was B-1 verhindern sollte, bleibt |
| 141 | * verhindert: dass das Verzeichnis MEHR behauptet als die |
| 142 | * Schrankentabelle. |
| 143 | */ |
| 144 | const quelle = PRUEFSTAND_REIHENFOLGE.indexOf( |
| 145 | QUELLEN['rilsa-umlaufzeit-regelbereich'].pruefstand, |
| 146 | ); |
| 147 | const zeile = PRUEFSTAND_REIHENFOLGE.indexOf(grenzen.herkunft.regelbereich.pruefstand); |
| 148 | expect( |
| 149 | quelle, |
| 150 | `${art}: das Verzeichnis behauptet mehr als die Schrankentabelle`, |
| 151 | ).toBeGreaterThanOrEqual(zeile); |
| 152 | expect(text, art).toContain( |
| 153 | gedruckt( |
| 154 | verzeichniszeile( |
| 155 | projekt(art), |
| 156 | 'rilsa-umlaufzeit-regelbereich', |
| 157 | grenzen.cycleTime.recommendedMin, |
| 158 | ), |
| 159 | ), |
| 160 | ); |
| 161 | } |
| 162 | }); |
| 163 | |
| 164 | it('nennt den Regelbereich in keiner Anlagenart mehr gesichert', () => { |
| 165 | for (const art of ANLAGENARTEN) { |
| 166 | const text = ausdruck(projekt(art)); |
| 167 | expect(text, art).not.toContain( |
| 168 | gedruckt( |
| 169 | 'Regelbereich Umlaufzeit von 60 s Umlaufzeit und Freigabezeitverteilung nicht eingetragen gesichert', |
| 170 | ), |
| 171 | ); |
| 172 | // Auch nicht ueber das neue Sachgebiet. |
| 173 | expect(text, art).not.toContain( |
| 174 | gedruckt('Umlaufzeit: empfohlener Bereich nicht eingetragen gesichert'), |
| 175 | ); |
| 176 | } |
| 177 | }); |
| 178 | |
| 179 | it('sagt beim zulaessigen Bereich, was belegt ist und was fuer DIESEN Plan gilt', () => { |
| 180 | // Die 30 bis 120 s sind der Regelwert der Vorgabenfelder - die |
| 181 | // Umlaufzeitzeile der RiLSA fuer den Knotenpunkt. Bei einer einstreifigen |
| 182 | // Fuehrung gilt fuer den Plan etwas anderes, und das muss dabeistehen, |
| 183 | // sonst stehen 120 s und 300 s unverbunden nebeneinander. |
| 184 | const hinweis = QUELLEN['rilsa-umlaufzeit'].hinweis; |
| 185 | expect(hinweis).toBeDefined(); |
| 186 | for (const art of ANLAGENARTEN) { |
| 187 | const text = ausdruck(projekt(art)); |
| 188 | expect(text, art).toContain(gedruckt(`Zu beachten: ${hinweis!}`)); |
| 189 | } |
| 190 | expect(hinweis!).toContain('30 bis 120 s am Knotenpunkt'); |
| 191 | expect(hinweis!).toContain('Schranken der Anlagenart'); |
| 192 | }); |
| 193 | |
| 194 | it('nennt beim Regelbereich, was belegt ist und was dieses Programm setzt', () => { |
| 195 | /* |
| 196 | * NACHGEZOGEN (5.27.0): Bis dahin hiess dieser Fall "die halbe Beleglage |
| 197 | * der Frage E4" (Fassung 5.4.0) und verlangte die Saetze "Obergrenze von |
| 198 | * 90 s am Knotenpunkt" und "60 s als Untergrenze". Am Knotenpunkt sind seit |
| 199 | * dem Abgleich beide Grenzen belegt; offen bleiben die Bereiche der beiden |
| 200 | * anderen Anlagenarten, und genau das muss der Hinweis sagen. |
| 201 | */ |
| 202 | const hinweis = QUELLEN['rilsa-umlaufzeit-regelbereich'].hinweis; |
| 203 | expect(hinweis).toBeDefined(); |
| 204 | expect(hinweis!).toContain('minimal 30 s und maximal 90 (120) s'); |
| 205 | expect(hinweis!).toContain('setzt dieses Programm den Bereich weiterhin selbst'); |
| 206 | for (const art of ANLAGENARTEN) { |
| 207 | expect(ausdruck(projekt(art)), art).toContain(gedruckt(`Zu beachten: ${hinweis!}`)); |
| 208 | } |
| 209 | }); |
| 210 | }); |
| 211 | |
| 212 | describe('B-2 - die Legende des Pruefstands kennt beide Faelle', () => { |
| 213 | it('druckt sie aus derselben Erlaeuterung, die der Bildschirm zeigt', () => { |
| 214 | const text = ausdruck(projekt('knotenpunkt')); |
| 215 | for (const stand of PRUEFSTAND_REIHENFOLGE) { |
| 216 | expect(text, stand).toContain( |
| 217 | gedruckt(`Prüfstand "${PRUEFSTAND_LABELS[stand]}": ${PRUEFSTAND_ERLAEUTERUNG[stand]}`), |
| 218 | ); |
| 219 | } |
| 220 | }); |
| 221 | |
| 222 | it('schickt den Pruefer bei selbst gesetzten Schwellen nicht mehr an ein Regelwerk', () => { |
| 223 | expect(PRUEFSTAND_ERLAEUTERUNG.abzugleichen).toContain('Straßenverkehrsbehörde'); |
| 224 | expect(PRUEFSTAND_ERLAEUTERUNG.abzugleichen).toContain('Schwelle dieses Programms'); |
| 225 | const text = ausdruck(projekt('einstreifig')); |
| 226 | expect(text).toContain(gedruckt('gibt es kein Original')); |
| 227 | // Der alte Wortlaut sagte nur die eine Haelfte. |
| 228 | expect(text).not.toContain( |
| 229 | gedruckt('"abzugleichen": praxisüblich, aber von Ausgabe oder Anwendungsfall abhängig'), |
| 230 | ); |
| 231 | }); |
| 232 | }); |