lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export pdfBeschriftungen.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 { MOVEMENT_LABELS, fahrzeugartBeschriftung } from '@/domain/rilsa/constants'; |
| 8 | import { CATEGORY_LABELS } from '@/domain/validation/types'; |
| 9 | import type { Project } from '@/domain/model/project'; |
| 10 | |
| 11 | /* |
| 12 | * Der Ausdruck schrieb die internen Schluessel in die Spalten, in denen am |
| 13 | * Bildschirm die Beschriftungen stehen. Der Anwender hat die Werte aus einer |
| 14 | * Auswahlliste gewaehlt und fand sie in der Unterlage anders wieder: |
| 15 | * "rechts" statt "rechts abbiegend", "keine" statt "ohne Laengenzuschlag" - |
| 16 | * und im Pruefbericht "leistungsfaehigkeit", zugleich ein Verstoss gegen die |
| 17 | * Umlautregel. |
| 18 | */ |
| 19 | |
| 20 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 21 | |
| 22 | /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */ |
| 23 | function pdfText(bytes: Uint8Array): string { |
| 24 | const roh = new TextDecoder('latin1').decode(bytes); |
| 25 | const teile: string[] = []; |
| 26 | const suche = /stream\r?\n/g; |
| 27 | let treffer: RegExpExecArray | null; |
| 28 | |
| 29 | while ((treffer = suche.exec(roh)) !== null) { |
| 30 | const start = treffer.index + treffer[0].length; |
| 31 | const ende = roh.indexOf('endstream', start); |
| 32 | if (ende < 0) continue; |
| 33 | let inhalt: string; |
| 34 | try { |
| 35 | inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 36 | } catch { |
| 37 | continue; |
| 38 | } |
| 39 | for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { |
| 40 | teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); |
| 41 | } |
| 42 | } |
| 43 | return teile.join('\n'); |
| 44 | } |
| 45 | |
| 46 | function ausdruck(project: Project): string { |
| 47 | const plan = buildSignalPlan(project); |
| 48 | return pdfText( |
| 49 | buildProjectPdf(project, plan, validateProject(project, plan, STICHTAG), { |
| 50 | ...DEFAULT_PDF_OPTIONS, |
| 51 | }), |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | function vollstaendig(): Project { |
| 56 | const base = createStandardIntersectionProject('Beschriftungen', STICHTAG); |
| 57 | return { |
| 58 | ...base, |
| 59 | meta: { |
| 60 | ...base.meta, |
| 61 | projectNumber: 'VP-2026-001', |
| 62 | client: 'Stadt Musterhausen', |
| 63 | planner: 'O. Willerding', |
| 64 | location: 'Hauptstraße / Bahnhofstraße', |
| 65 | }, |
| 66 | }; |
| 67 | } |
| 68 | |
| 69 | describe('Ausdruck benennt wie der Bildschirm', () => { |
| 70 | it('druckt Fahrbeziehung und Fahrzeugart als Beschriftung', () => { |
| 71 | const text = ausdruck(vollstaendig()); |
| 72 | |
| 73 | // Der Regelknotenpunkt fuehrt geradeaus fahrende Kfz und querende |
| 74 | // Fussgaenger; letztere ohne Laengenzuschlag. |
| 75 | expect(text).toContain(MOVEMENT_LABELS.querung); |
| 76 | expect(text).toContain(fahrzeugartBeschriftung('pkw')); |
| 77 | expect(text).toContain(fahrzeugartBeschriftung('keine')); |
| 78 | |
| 79 | // Der WinAnsi-Text enthaelt die Schluessel nicht mehr als eigenstaendige |
| 80 | // Zellen. "querung" kommt in keiner Beschriftung vor. |
| 81 | expect(text).not.toMatch(/^querung$/m); |
| 82 | expect(text).not.toMatch(/^keine$/m); |
| 83 | expect(text).not.toMatch(/^pkw$/m); |
| 84 | }); |
| 85 | |
| 86 | it('druckt den Bereich des Pruefberichts als Beschriftung', () => { |
| 87 | const projekt = vollstaendig(); |
| 88 | const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); |
| 89 | expect(bericht.findings.length).toBeGreaterThan(0); |
| 90 | |
| 91 | const text = ausdruck(projekt); |
| 92 | for (const befund of new Set(bericht.findings.map((f) => f.category))) { |
| 93 | expect(text, befund).toContain(CATEGORY_LABELS[befund]); |
| 94 | } |
| 95 | // Umschrift im Anwendertext: der Schluessel traegt ein ae, die |
| 96 | // Beschriftung ein ae-Umlaut. |
| 97 | expect(text).not.toMatch(/^leistungsfaehigkeit$/m); |
| 98 | expect(text).not.toMatch(/^zwischenzeiten$/m); |
| 99 | }); |
| 100 | |
| 101 | it('druckt den Laengenzuschlag, mit dem tatsaechlich gerechnet wird', () => { |
| 102 | // Eine Unterlage darf nicht zwei verschiedene Fahrzeuglaengen fuer dieselbe |
| 103 | // Signalgruppe nennen. Die Abweichungstabelle im selben Dokument weist die |
| 104 | // 22 m ohnehin aus - die Spalte "Fahrzeugart" sagte trotzdem 18,75 m. |
| 105 | const roh = vollstaendig(); |
| 106 | const projekt: Project = { |
| 107 | ...roh, |
| 108 | signalGroups: roh.signalGroups.map((g, i) => |
| 109 | i === 0 ? { ...g, vehicleClass: 'lastzug' as const } : g, |
| 110 | ), |
| 111 | settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, vehicleLengthLastzug: 22 } }, |
| 112 | }; |
| 113 | const text = ausdruck(projekt); |
| 114 | |
| 115 | expect(text).toContain('Lastzug (22 m)'); |
| 116 | expect(text).not.toContain('Lastzug (18,75 m)'); |
| 117 | }); |
| 118 | |
| 119 | it('druckt beim OePNV-Haltefall Beschleunigung und Formel statt einer Scheingeschwindigkeit', () => { |
| 120 | /* |
| 121 | * Befund A2 - Grundsatz: Was gedruckt wird, muss sich aus den gedruckten |
| 122 | * Groessen nachrechnen lassen. Beim Anfahransatz (RiLSA Fall 4) gibt es |
| 123 | * keine konstante Raeumgeschwindigkeit; die Spalte vr nennt die |
| 124 | * Anfahrbeschleunigung, die Fussnote die Formel, und die gedruckte |
| 125 | * Raeumzeit ist genau deren Ergebnis. |
| 126 | */ |
| 127 | const roh = vollstaendig(); |
| 128 | const bahn = { |
| 129 | ...roh.signalGroups[0]!, |
| 130 | id: 'sg-bahn', |
| 131 | name: 'B1', |
| 132 | mode: 'oepnv' as const, |
| 133 | vehicleClass: 'strassenbahn' as const, |
| 134 | vZul: 50, |
| 135 | }; |
| 136 | const projekt: Project = { |
| 137 | ...roh, |
| 138 | signalGroups: [...roh.signalGroups, bahn], |
| 139 | conflicts: [ |
| 140 | ...roh.conflicts, |
| 141 | { |
| 142 | ...roh.conflicts[0]!, |
| 143 | id: 'cf-bahn', |
| 144 | fromId: 'sg-bahn', |
| 145 | toId: roh.signalGroups[1]!.id, |
| 146 | clearingDistance: 25, |
| 147 | enteringDistance: 8, |
| 148 | haltVorKnoten: true, |
| 149 | }, |
| 150 | ], |
| 151 | }; |
| 152 | const plan = buildSignalPlan(projekt); |
| 153 | const text = pdfText( |
| 154 | buildProjectPdf(projekt, plan, validateProject(projekt, plan, STICHTAG), { |
| 155 | ...DEFAULT_PDF_OPTIONS, |
| 156 | includeCalculationDetails: true, |
| 157 | }), |
| 158 | ); |
| 159 | |
| 160 | // Fahrzeugart mit echtem Umlaut und Rechenlaenge in der Signalgruppentabelle. |
| 161 | expect(text).toContain(fahrzeugartBeschriftung('strassenbahn')); |
| 162 | // Spalte vr traegt die Beschleunigung, nicht eine Geschwindigkeit. |
| 163 | expect(text).toContain('a = 1,0 m/s²'); |
| 164 | // Die Fussnote benennt den Anfahransatz samt Formel. Der Fliesstext wird |
| 165 | // fuer den Satz umbrochen; fuer den Vergleich werden die Zeilen wieder |
| 166 | // zusammengesetzt. |
| 167 | const fliesstext = text.replace(/\s+/g, ' '); |
| 168 | expect(fliesstext).toContain('Anfahransatz'); |
| 169 | expect(fliesstext).toContain('tr = Wurzel aus (2 · sr / a)'); |
| 170 | // Die gedruckte Raeumzeit ist das Ergebnis der benannten Formel: |
| 171 | // sr = 25 + 15 = 40 m, a = 1,0 -> Wurzel(80) = 8,94 s. |
| 172 | expect(text).toContain('8,94 s'); |
| 173 | }); |
| 174 | |
| 175 | it('fuehrt die Herkunft der Wege weiterhin aus derselben Quelle wie die Tabelle', () => { |
| 176 | // Die Zelle entsteht seit dieser Aenderung in einem gemeinsamen Modul. |
| 177 | // Bliebe sie im Ausdruck aus, waere die Zusammenlegung misslungen. Sie |
| 178 | // steht in der Tabelle mit dem Rechenweg, die eigens zuzuschalten ist. |
| 179 | const projekt = vollstaendig(); |
| 180 | const plan = buildSignalPlan(projekt); |
| 181 | const text = pdfText( |
| 182 | buildProjectPdf(projekt, plan, validateProject(projekt, plan, STICHTAG), { |
| 183 | ...DEFAULT_PDF_OPTIONS, |
| 184 | includeCalculationDetails: true, |
| 185 | }), |
| 186 | ); |
| 187 | expect(text).toContain('Herkunft der Wege'); |
| 188 | expect(text).toContain('Herkunft nicht nachgewiesen'); |
| 189 | }); |
| 190 | }); |