lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export regelbereichFundstelle.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, RILSA_DEFAULTS } from '@/domain/rilsa/constants'; |
| 8 | import { toWinAnsi } from '@/render/pdfSurface'; |
| 9 | import type { Anlagenart, Project } from '@/domain/model/project'; |
| 10 | import type { RilsaOverrides } from '@/domain/rilsa/settings'; |
| 11 | |
| 12 | /* |
| 13 | * Offener Rest von Befund 48 (tests/domain/regelbereichBezug.test.ts). |
| 14 | * |
| 15 | * Befund 48 hat den Bezugswert der Abweichungsliste berichtigt: Bei den beiden |
| 16 | * Regelbereichsfeldern der Umlaufzeit misst `resolveRilsaSettings` seither |
| 17 | * gegen den Wert, der OHNE Eintrag fuer die Anlagenart gilt (40 bis 70 s an der |
| 18 | * Fussgaengerschutzanlage), nicht mehr gegen die Knotenpunktzahl. Das |
| 19 | * Fundstellenverzeichnis bildete seine Angabe aber weiter selbst, aus einem |
| 20 | * Wertvergleich gegen `settingFields(...).standard`. Gemessen am erzeugten PDF |
| 21 | * einer Fussgaengerschutzanlage sagte dieselbe Unterlage damit zweierlei: |
| 22 | * |
| 23 | * Eintrag 40 s - also genau der Wert, der dort ohnehin gilt: |
| 24 | * "Abweichungen von den Regelwerten": keine Zeile. |
| 25 | * Fundstellenverzeichnis: "Regelbereich Umlaufzeit von (angesetzt: 40 s, |
| 26 | * abweichend vom Regelwert)" neben der Regelwertspalte "60 s". |
| 27 | * |
| 28 | * Eintrag 60 s - die wirksame Verschiebung des Regelbereichs: |
| 29 | * "Abweichungen von den Regelwerten": "Regelbereich Umlaufzeit von | 40 | |
| 30 | * 60 | s", und der Pruefbericht daneben "Angesetzt: 60 s, Regelwert: 40 s." |
| 31 | * Fundstellenverzeichnis: kein Vermerk, Regelwertspalte "60 s". |
| 32 | * |
| 33 | * Vor der Behoerde stehen damit zwei Regelwerte fuer denselben Kennwert in |
| 34 | * einer Anordnungsunterlage - und einmal eine Begruendungspflicht fuer eine |
| 35 | * Eingabe, die an keiner Zahl des Plans etwas aendert. |
| 36 | * |
| 37 | * DIESER TEST HAELT BEIDE AUFSTELLUNGEN DESSELBEN PDF GEGENEINANDER; genau ihr |
| 38 | * Auseinanderlaufen ist der Befund, und ein Test der neuen Stelle fuer sich |
| 39 | * allein wuerde ihn nicht fassen. |
| 40 | * |
| 41 | * GEGEN DEN ALTSTAND schlagen vier der sechs Faelle fehl: die drei zur |
| 42 | * Fussgaengerschutzanlage und der zur einstreifigen Verkehrsfuehrung, deren |
| 43 | * Regelbereich (60 bis 180 s) dieses Programm ebenfalls selbst setzt. Gruen |
| 44 | * bleiben dort die Vorbedingung und der Knotenpunkt. |
| 45 | */ |
| 46 | |
| 47 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 48 | |
| 49 | const PAGE_MARGIN = 36; |
| 50 | const CONTENT_WIDTH = 841.89 - 2 * PAGE_MARGIN; |
| 51 | /* |
| 52 | * Die Spalten des Fundstellenverzeichnisses, aus den festen Breiten des |
| 53 | * Ausdrucks gerechnet: Regelwerk 70, Kennwert (Rest), Regelwert 62, |
| 54 | * Sachgebiet 175, Fundstelle 110, Pruefstand 62 - wie in |
| 55 | * tests/export/fundstellenRegelwert.test.ts. |
| 56 | */ |
| 57 | const KENNWERT_X = PAGE_MARGIN + 70; |
| 58 | const KENNWERT_BREITE = CONTENT_WIDTH - (70 + 62 + 175 + 110 + 62); |
| 59 | const REGELWERT_X = KENNWERT_X + KENNWERT_BREITE; |
| 60 | const REGELWERT_ENDE = REGELWERT_X + 62; |
| 61 | /** Innenabstand der Tabellenzellen dieses Verzeichnisses. */ |
| 62 | const ZELLENRAND = 2.5; |
| 63 | |
| 64 | interface Stueck { |
| 65 | readonly blatt: number; |
| 66 | readonly x: number; |
| 67 | readonly y: number; |
| 68 | readonly text: string; |
| 69 | } |
| 70 | |
| 71 | function entpacke(roh: string, von: number): string | null { |
| 72 | const anfang = roh.indexOf('stream', von); |
| 73 | if (anfang < 0) return null; |
| 74 | const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7); |
| 75 | const ende = roh.indexOf('endstream', start); |
| 76 | if (ende < 0) return null; |
| 77 | try { |
| 78 | return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 79 | } catch { |
| 80 | return null; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | function textstuecke(bytes: Uint8Array): readonly Stueck[] { |
| 85 | const roh = new TextDecoder('latin1').decode(bytes); |
| 86 | const raus: Stueck[] = []; |
| 87 | let blatt = 0; |
| 88 | |
| 89 | for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) { |
| 90 | const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`); |
| 91 | const inhalt = objekt < 0 ? null : entpacke(roh, objekt); |
| 92 | if (inhalt === null) continue; |
| 93 | blatt += 1; |
| 94 | let x = 0; |
| 95 | let y = 0; |
| 96 | let abstand = 0; |
| 97 | for (const roheZeile of inhalt.split('\n')) { |
| 98 | const zeile = roheZeile.trim(); |
| 99 | const tl = /^(-?[\d.]+) TL$/.exec(zeile); |
| 100 | if (tl) { |
| 101 | abstand = Number(tl[1]); |
| 102 | continue; |
| 103 | } |
| 104 | const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); |
| 105 | if (td) { |
| 106 | x = Number(td[1]); |
| 107 | y = Number(td[2]); |
| 108 | continue; |
| 109 | } |
| 110 | let rest = zeile; |
| 111 | while (rest.startsWith('T*')) { |
| 112 | y -= abstand; |
| 113 | rest = rest.slice(2).trim(); |
| 114 | } |
| 115 | const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest); |
| 116 | if (tj) raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); |
| 117 | } |
| 118 | } |
| 119 | return raus; |
| 120 | } |
| 121 | |
| 122 | function projekt(anlagenart: Anlagenart, rilsa: RilsaOverrides = {}): Project { |
| 123 | const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); |
| 124 | return { |
| 125 | ...basis, |
| 126 | anlagenart, |
| 127 | settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, ...rilsa } }, |
| 128 | }; |
| 129 | } |
| 130 | |
| 131 | function unterlage(p: Project): readonly Stueck[] { |
| 132 | const plan = buildSignalPlan(p); |
| 133 | return textstuecke( |
| 134 | buildProjectPdf(p, plan, validateProject(p, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG), |
| 135 | ); |
| 136 | } |
| 137 | |
| 138 | /** Die Kennwertzelle des Fundstellenverzeichnisses zu einem Kennwert. */ |
| 139 | function verzeichnisKennwert(stuecke: readonly Stueck[], label: string): Stueck { |
| 140 | const treffer = stuecke.filter( |
| 141 | (s) => Math.abs(s.x - (KENNWERT_X + ZELLENRAND)) < 0.5 && s.text.startsWith(toWinAnsi(label)), |
| 142 | ); |
| 143 | expect(treffer.length, `Vorbedingung: Zeile "${label}" im Fundstellenverzeichnis`).toBe(1); |
| 144 | return treffer[0]!; |
| 145 | } |
| 146 | |
| 147 | /** Die Zelle "Regelwert" des Fundstellenverzeichnisses zu einem Kennwert. */ |
| 148 | function verzeichnisRegelwert(stuecke: readonly Stueck[], label: string): readonly string[] { |
| 149 | const zeile = verzeichnisKennwert(stuecke, label); |
| 150 | return stuecke |
| 151 | .filter((s) => s.blatt === zeile.blatt && Math.abs(s.y - zeile.y) < 0.5) |
| 152 | .filter((s) => s.x >= REGELWERT_X && s.x < REGELWERT_ENDE) |
| 153 | .map((s) => s.text); |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Die Zeile der Aufstellung "Abweichungen von den Regelwerten" zu einem |
| 158 | * Kennwert - Kennwert, Regelwert, Angesetzt, Einheit -, oder null, wo die |
| 159 | * Aufstellung den Kennwert nicht fuehrt. Ihre erste Spalte beginnt am linken |
| 160 | * Satzspiegel und steht damit links der Kennwertspalte des Verzeichnisses. |
| 161 | */ |
| 162 | function aufstellungZeile(stuecke: readonly Stueck[], label: string): readonly string[] | null { |
| 163 | const treffer = stuecke.filter((s) => s.x < KENNWERT_X && s.text === toWinAnsi(label)); |
| 164 | if (treffer.length === 0) return null; |
| 165 | expect(treffer.length, `Zeile "${label}" in "Abweichungen von den Regelwerten"`).toBe(1); |
| 166 | const zeile = treffer[0]!; |
| 167 | return stuecke |
| 168 | .filter((s) => s.blatt === zeile.blatt && Math.abs(s.y - zeile.y) < 0.5) |
| 169 | .sort((a, b) => a.x - b.x) |
| 170 | .map((s) => s.text); |
| 171 | } |
| 172 | |
| 173 | const VON = 'Regelbereich Umlaufzeit von'; |
| 174 | const BIS = 'Regelbereich Umlaufzeit bis'; |
| 175 | |
| 176 | describe('Regelwerte beider Aufstellungen desselben PDF', () => { |
| 177 | it('haelt an der Fussgaengerschutzanlage die Zahlen der Anlagenart bereit', () => { |
| 178 | // Voraussetzung des Aufbaus, wie in regelbereichBezug: Die |
| 179 | // Anlagenart fuehrt einen anderen Regelbereich als der Knotenpunkt. |
| 180 | expect(ANLAGENART_GRENZEN.fussgaengerschutzanlage.cycleTime.recommendedMin).toBe(40); |
| 181 | // Seit 5.27.0 der Richtwert der RiLSA (Abschnitt 2.6) statt der 60 s. |
| 182 | expect(RILSA_DEFAULTS.cycleTime.recommendedMin).toBe(30); |
| 183 | }); |
| 184 | |
| 185 | it('nennt bei einer wirksamen Vorgabe in beiden Aufstellungen denselben Regelwert', () => { |
| 186 | const stuecke = unterlage(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 60 })); |
| 187 | |
| 188 | // 60 s verschieben den Regelbereich von 40 bis 70 auf 60 bis 70. |
| 189 | expect(aufstellungZeile(stuecke, VON)).toEqual([toWinAnsi(VON), '40', '60', 's']); |
| 190 | expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']); |
| 191 | expect(verzeichnisKennwert(stuecke, VON).text).toBe( |
| 192 | toWinAnsi(`${VON} (angesetzt: 60 s, abweichend vom Regelwert)`), |
| 193 | ); |
| 194 | }); |
| 195 | |
| 196 | it('macht aus einer wirkungslosen Vorgabe auch im Verzeichnis keine Abweichung', () => { |
| 197 | const stuecke = unterlage(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 40 })); |
| 198 | |
| 199 | // 40 s gelten dort ohnehin - keine der beiden Aufstellungen darf dafuer |
| 200 | // eine Begruendung verlangen. |
| 201 | expect(aufstellungZeile(stuecke, VON)).toBeNull(); |
| 202 | expect(verzeichnisKennwert(stuecke, VON).text).toBe(toWinAnsi(VON)); |
| 203 | expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']); |
| 204 | }); |
| 205 | |
| 206 | it('haengt die Spalte "Regelwert" an der Anlagenart und nicht am Eintrag', () => { |
| 207 | // Ohne Eintrag dieselbe Zahl wie mit den beiden Eintraegen darueber: Eine |
| 208 | // Regelwertspalte, die zwischen 40 und 60 s wechselt, je nachdem ob jemand |
| 209 | // etwas eingetragen hat, waere derselbe Widerspruch in einer Zeile. |
| 210 | const stuecke = unterlage(projekt('fussgaengerschutzanlage')); |
| 211 | |
| 212 | expect(aufstellungZeile(stuecke, VON)).toBeNull(); |
| 213 | expect(verzeichnisKennwert(stuecke, VON).text).toBe(toWinAnsi(VON)); |
| 214 | expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']); |
| 215 | expect(verzeichnisRegelwert(stuecke, BIS)).toEqual(['70 s']); |
| 216 | }); |
| 217 | |
| 218 | it('misst am Knotenpunkt gegen den Richtwert der RiLSA', () => { |
| 219 | // 40 s weichen seit 5.27.0 vom Regelwert 30 s ab - zuvor lag der bei 60 s, |
| 220 | // und dieselbe Eingabe war eine Abweichung in die andere Richtung. |
| 221 | const stuecke = unterlage(projekt('knotenpunkt', { cycleTimeRecommendedMin: 40 })); |
| 222 | |
| 223 | expect(aufstellungZeile(stuecke, VON)).toEqual([toWinAnsi(VON), '30', '40', 's']); |
| 224 | expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['30 s']); |
| 225 | expect(verzeichnisKennwert(stuecke, VON).text).toBe( |
| 226 | toWinAnsi(`${VON} (angesetzt: 40 s, abweichend vom Regelwert)`), |
| 227 | ); |
| 228 | }); |
| 229 | |
| 230 | it('misst die groesste Umlaufzeit weiterhin gegen den Regelwerkswert', () => { |
| 231 | /* |
| 232 | * Die Fortsetzung der Gegenprobe aus regelbereichBezug im Ausdruck: |
| 233 | * Bei der kleinsten und groessten Umlaufzeit bleibt der Knotenpunktwert der |
| 234 | * Bezug, weil er dort ein Regelwerkswert ist (30 bis 120 s, nicht nach |
| 235 | * Anlagenart gestaffelt) - die beiden Zeilen zur groessten Umlaufzeit sind |
| 236 | * deshalb in beiden Staenden gruen. Nur die beiden Regelbereichsfelder |
| 237 | * folgen der Anlagenart; bei einstreifiger Verkehrsfuehrung setzt dieses |
| 238 | * Programm 60 bis 180 s, und der Altstand druckte dort die 90 s des |
| 239 | * Knotenpunkts. |
| 240 | */ |
| 241 | const stuecke = unterlage(projekt('einstreifig', { cycleTimeMax: 250 })); |
| 242 | |
| 243 | expect(aufstellungZeile(stuecke, 'Größte Umlaufzeit')).toEqual([ |
| 244 | toWinAnsi('Größte Umlaufzeit'), |
| 245 | '120', |
| 246 | '250', |
| 247 | 's', |
| 248 | ]); |
| 249 | expect(verzeichnisRegelwert(stuecke, 'Größte Umlaufzeit')).toEqual(['120 s']); |
| 250 | expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['60 s']); |
| 251 | expect(verzeichnisRegelwert(stuecke, BIS)).toEqual(['180 s']); |
| 252 | }); |
| 253 | }); |