lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export beispieldateiAusdruck.test.ts
| 1 | import { readFileSync } from 'node:fs'; |
| 2 | import { inflateSync } from 'node:zlib'; |
| 3 | import { describe, expect, it } from 'vitest'; |
| 4 | import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; |
| 5 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 6 | import { validateProject } from '@/domain/validation'; |
| 7 | import { parseProject } from '@/domain/model/schema'; |
| 8 | import { CURRENT_SCHEMA_VERSION } from '@/domain/model/project'; |
| 9 | |
| 10 | /** |
| 11 | * Die mitgelieferte Beispieldatei - die ganze Kette bis zum Ausdruck. |
| 12 | * |
| 13 | * DIE LUECKE, GEMESSEN: Zehn Testdateien nennen |
| 14 | * `beispiele/vierarmiger-knotenpunkt.lsap`, neun lesen sie wirklich ein. Drei |
| 15 | * der vier Stufen laufen darin - Einlesen samt Schemaaufstieg, `buildSignalPlan` |
| 16 | * und `validateProject`. Die vierte lief nirgends: Die Datei ist nie GEDRUCKT |
| 17 | * worden. Und ihr Pruefbericht wurde nie als Ganzes beurteilt, sondern immer |
| 18 | * nur nach je einer Regel gefiltert - was sonst noch darin steht, hat niemand |
| 19 | * angesehen. |
| 20 | * |
| 21 | * WARUM GERADE DIESE DATEI. Sie ist das einzige vollstaendige Projekt, das das |
| 22 | * Programm mitbringt, und zugleich das Pruefstueck fuer den Altdatei-Pfad: Sie |
| 23 | * traegt auf der Platte Schemastand 7, waehrend das Programm bei 18 steht |
| 24 | * (bewusst so). Was hier gedruckt wird, ist also nicht irgendein |
| 25 | * Projekt, sondern das Ergebnis einer Migration ueber elf Schemastaende - |
| 26 | * der Weg, den die Datei eines Anwenders geht, der ein altes Projekt oeffnet |
| 27 | * und daraus eine Anordnungsunterlage macht. |
| 28 | * |
| 29 | * WAS DABEI HERAUSKAM, gehoert benannt, denn es ist mehr, als der erste Blick |
| 30 | * vermuten laesst. Die 15 Warnungen verteilen sich so: vier |
| 31 | * Raeumbeziehungen ohne Nachweis der Herkunft, die eine |
| 32 | * Altdatei nicht mitbringen kann; drei Projektangaben, die ein Beispiel nicht |
| 33 | * fuehren kann (Projektnummer, Auftraggeber, Bearbeiter) - und **acht, die |
| 34 | * den vier Fussgaengerfurten gelten**: Jede von ihnen wartet laengstens 94 s, |
| 35 | * und das ist nach der HBS-Tafel die Qualitaetsstufe F. |
| 36 | * |
| 37 | * Vier Furten der Stufe F in dem Beispiel, das dem Programm beiliegt: Das ist |
| 38 | * keine Aussage dieses Tests darueber, ob die Planung taugt - sie ist eine |
| 39 | * eingefrorene Altfassung und als Pruefstueck gedacht, nicht als Vorbild. Es |
| 40 | * ist eine Aussage darueber, was ein Anwender sieht, der sie oeffnet. Dass sie |
| 41 | * hier steht, ist der ganze Ertrag der Beurteilung "als Ganzes"; die frueheren |
| 42 | * Pruefungen an derselben Datei filterten immer nach einer einzelnen Regel und |
| 43 | * haetten es nie gezeigt. |
| 44 | * |
| 45 | * SEIT FASSUNG 5.43.0 SPERRT DER BERICHT DEN AUSDRUCK. Die Datei ist |
| 46 | * eingefroren und traegt die Wege, die die damalige Vermessung aus ihrem |
| 47 | * Lageplan uebernahm - jede Furt allein in ihrer Zeichenrichtung gemessen. Die |
| 48 | * Regel `zwischenzeiten.kuerzer-als-lageplan` haelt jede gespeicherte |
| 49 | * Zwischenzeit gegen die, die derselbe Lageplan heute ergibt, und meldet 36 |
| 50 | * Beziehungen als zu kurz: 28 Furtbeziehungen - die acht Nullen werden 6 s, "F |
| 51 | * Nord" nach "K Nord" wird aus 3 s 12 s - und acht Kfz-Beziehungen, deren |
| 52 | * massgebender Strom (Rechtsabbieger, Gleichstand) erst spaetere Fassungen |
| 53 | * richtig waehlen. Die Wege sind nach dem Einlesen "unbestimmt" (Schemastand 7 |
| 54 | * kennt keinen Herkunftsnachweis), deshalb Fehler und nicht Warnung. Das ist |
| 55 | * die richtige Auskunft ueber diese Datei. Wer sie oeffnet und die Wege im |
| 56 | * Lageplan neu uebernimmt, bekommt einen fehlerfreien Plan mit 110 s Umlauf. |
| 57 | * |
| 58 | * WOHER DIE 28 FURTBEZIEHUNGEN STAMMEN (nachgemessen am 17.09.2026 mit einer |
| 59 | * Vermessung, die nur die Huelle beider Richtungen bildet): 20 aus der |
| 60 | * Zeichenrichtung. Die uebrigen 8 aus den beiden Annahmen, |
| 61 | * die das Programm fuer den Fussgaenger trifft - viermal "K x links" nach |
| 62 | * "F x" (gemessen Einfahrweg 3,74 m, angesetzt 0 m; der Linksabbieger faehrt |
| 63 | * im eigenen Fahrstreifen der Zufahrt, und dort ist 0 m eine Verschaerfung |
| 64 | * dieses Programms) und viermal "F x" nach "K y links" (gemessen Raeumweg |
| 65 | * 10,26 m, angesetzt die ganze Furt 14 m; im inneren Fahrstreifen der |
| 66 | * Ausfahrt gezeichnet). |
| 67 | * |
| 68 | * DIE ACHT WARNUNGEN `zwischenzeiten.null` SIND FORT (seit dem |
| 69 | * 17.09.2026): Bis 5.42.1 erklaerte die Regel die acht Nullen fuer |
| 70 | * zulaessig; sie sind Artefakte der Zeichenrichtung. An einer Beziehung, die |
| 71 | * `zwischenzeiten.kuerzer-als-lageplan` schon als Fehler meldet, schweigt die |
| 72 | * Warnung - dieselbe Beziehung stuende sonst zweimal im Bericht. Aus 23 |
| 73 | * Warnungen werden 15. |
| 74 | */ |
| 75 | |
| 76 | const DATEI = 'beispiele/vierarmiger-knotenpunkt.lsap'; |
| 77 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 78 | |
| 79 | /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */ |
| 80 | function pdfText(bytes: Uint8Array): string { |
| 81 | const roh = new TextDecoder('latin1').decode(bytes); |
| 82 | const teile: string[] = []; |
| 83 | const suche = /stream\r?\n/g; |
| 84 | let treffer: RegExpExecArray | null; |
| 85 | |
| 86 | while ((treffer = suche.exec(roh)) !== null) { |
| 87 | const start = treffer.index + treffer[0].length; |
| 88 | const ende = roh.indexOf('endstream', start); |
| 89 | if (ende < 0) continue; |
| 90 | let inhalt: string; |
| 91 | try { |
| 92 | inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 93 | } catch { |
| 94 | continue; |
| 95 | } |
| 96 | for (const stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) { |
| 97 | teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')'))); |
| 98 | } |
| 99 | } |
| 100 | return teile.join(' ').replace(/\s+/g, ' '); |
| 101 | } |
| 102 | |
| 103 | const roh: unknown = JSON.parse(readFileSync(DATEI, 'utf8')); |
| 104 | const eingelesen = parseProject(roh, STICHTAG); |
| 105 | const plan = buildSignalPlan(eingelesen.project); |
| 106 | const bericht = validateProject(eingelesen.project, plan, STICHTAG); |
| 107 | const bytes = buildProjectPdf(eingelesen.project, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG); |
| 108 | |
| 109 | describe('Die Beispieldatei laeuft durch alle vier Stufen', () => { |
| 110 | it('liegt als Schemastand 7 auf der Platte und kommt als aktueller Stand heraus', () => { |
| 111 | /* |
| 112 | * Die Datei wird nicht "aktualisiert" - sie ist mit Absicht eingefroren |
| 113 | * (bewusst so). Genau deshalb ist sie das Pruefstueck: Was hier gedruckt |
| 114 | * wird, hat die Migration hinter sich. |
| 115 | */ |
| 116 | expect((roh as { schemaVersion?: unknown }).schemaVersion, 'die Datei ist nicht mehr alt').toBe( |
| 117 | 7, |
| 118 | ); |
| 119 | expect(eingelesen.project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); |
| 120 | expect(eingelesen.issues, 'das Einlesen beanstandet etwas').toEqual([]); |
| 121 | }); |
| 122 | |
| 123 | it('rechnet zu einem vollstaendigen Plan', () => { |
| 124 | expect(eingelesen.project.signalGroups).toHaveLength(12); |
| 125 | expect(eingelesen.project.phases).toHaveLength(5); |
| 126 | expect(plan.cycleTime).toBe(100); |
| 127 | expect(plan.feasible, 'der Plan ist nicht schaltbar').toBe(true); |
| 128 | }); |
| 129 | }); |
| 130 | |
| 131 | describe('Der Pruefbericht der Beispieldatei - als Ganzes', () => { |
| 132 | /** Jede Regel mit der Zahl ihrer Beanstandungen, alphabetisch. */ |
| 133 | function nachRegel(schwere: 'fehler' | 'warnung' | 'hinweis'): readonly string[] { |
| 134 | const zaehler = new Map<string, number>(); |
| 135 | for (const befund of bericht.findings.filter((f) => f.severity === schwere)) { |
| 136 | zaehler.set(befund.rule, (zaehler.get(befund.rule) ?? 0) + 1); |
| 137 | } |
| 138 | return [...zaehler.entries()].map(([regel, zahl]) => `${regel} x${zahl}`).sort(); |
| 139 | } |
| 140 | |
| 141 | it('meldet die zu kurz gespeicherten Zwischenzeiten als Fehler', () => { |
| 142 | /* |
| 143 | * NACHGEZOGEN MIT FASSUNG 5.43.0 (Gruppe Furt). Hier stand "meldet keinen |
| 144 | * einzigen Fehler" mit der Begruendung, ein Beispiel, das beanstandet |
| 145 | * wird, waere ein schlechtes Beispiel. Das bleibt wahr - aber die Datei |
| 146 | * ist eingefroren, und ihre Wege sind die der fehlerhaften Vermessung. |
| 147 | * Schwiege der Bericht, waere er es, der schlecht ist. Siehe Kopf. |
| 148 | */ |
| 149 | expect(nachRegel('fehler')).toEqual(['zwischenzeiten.kuerzer-als-lageplan x36']); |
| 150 | expect(bericht.exportBlocked, 'der Ausdruck ist gesperrt').toBe(true); |
| 151 | const furt = bericht.findings.find( |
| 152 | (f) => |
| 153 | f.rule === 'zwischenzeiten.kuerzer-als-lageplan' && |
| 154 | f.target?.label === 'F Nord nach K Nord', |
| 155 | ); |
| 156 | expect(furt?.message).toContain('Zwischenzeit von 3 s'); |
| 157 | expect(furt?.message).toContain('damit 12 s'); |
| 158 | }); |
| 159 | |
| 160 | it('meldet genau diese Warnungen und keine anderen', () => { |
| 161 | /* |
| 162 | * DIE EIGENTLICHE NEUERUNG DIESER DATEI. Bisher wurde der Bericht der |
| 163 | * Beispieldatei immer nur nach einer einzelnen Regel gefiltert; was sonst |
| 164 | * darin stand, sah niemand. Hier steht das ganze Bild. |
| 165 | */ |
| 166 | expect( |
| 167 | bericht.findings.filter((f) => f.severity === 'warnung'), |
| 168 | 'Zahl der Warnungen', |
| 169 | ).toHaveLength(15); |
| 170 | // Bis zum 17.09.2026 stand hier 'zwischenzeiten.null x8' |
| 171 | // und die Zahl 23 - siehe Kopf. |
| 172 | expect(nachRegel('warnung')).toEqual([ |
| 173 | 'leistungsfaehigkeit.qualitaetsstufe x4', |
| 174 | 'projektdaten.client x1', |
| 175 | 'projektdaten.planner x1', |
| 176 | 'projektdaten.projectNumber x1', |
| 177 | 'signalplan.wartezeit-lang x4', |
| 178 | 'zwischenzeiten.raeumbeziehung-nicht-nachgewiesen x4', |
| 179 | ]); |
| 180 | }); |
| 181 | |
| 182 | it('meldet genau diese Hinweise und keine anderen', () => { |
| 183 | expect(nachRegel('hinweis')).toEqual([ |
| 184 | 'leistungsfaehigkeit.abbiegerfaktor-pauschal x4', |
| 185 | 'leistungsfaehigkeit.verkehrsstaerke-ohne-wirkung x1', |
| 186 | 'projektdaten.betriebsverantwortlicher x1', |
| 187 | 'projektdaten.einsatzzeiten x1', |
| 188 | 'signalplan.mindestfreigabezeit-aus-furtlaenge x4', |
| 189 | 'signalplan.umlaufzeit-ausserhalb-regelbereich x1', |
| 190 | ]); |
| 191 | }); |
| 192 | }); |
| 193 | |
| 194 | describe('Der Ausdruck der Beispieldatei', () => { |
| 195 | const text = pdfText(bytes); |
| 196 | |
| 197 | it('entsteht als vollstaendige PDF-Datei', () => { |
| 198 | expect(new TextDecoder('latin1').decode(bytes.subarray(0, 5)), 'kein PDF-Kopf').toBe('%PDF-'); |
| 199 | expect(bytes.length, 'die Datei ist verdaechtig klein').toBeGreaterThan(50_000); |
| 200 | }); |
| 201 | |
| 202 | it('traegt die Angaben dieser Datei und nicht die einer Vorlage', () => { |
| 203 | /* |
| 204 | * Die Beispieldatei ist eine ANDERE Planung als |
| 205 | * `createStandardIntersectionProject`, mit dem die uebrigen Ausdruckstests |
| 206 | * arbeiten: zwoelf Signalgruppen mit eigenen Namen statt sechs mit K1..K4, |
| 207 | * fuenf Phasen statt zwei, 100 s Umlauf. Steht das im Ausdruck, ist es |
| 208 | * wirklich diese Datei, die gedruckt wurde. |
| 209 | */ |
| 210 | expect(text).toContain('Musterkreuzung'); |
| 211 | expect(text).toContain('Anzahl Signalgruppen 12'); |
| 212 | expect(text).toContain('Anzahl Phasen 5'); |
| 213 | expect(text).toContain('Umlaufzeit 100 s'); |
| 214 | for (const name of ['K Nord links', 'F West']) { |
| 215 | expect(text, `die Signalgruppe ${name} fehlt im Ausdruck`).toContain(name); |
| 216 | } |
| 217 | }); |
| 218 | |
| 219 | it('druckt den Rechenweg der Furt mit den Wegen dieser Datei', () => { |
| 220 | /* |
| 221 | * Die Furten der Beispieldatei raeumen bis zu 14,39 m - die Vorlage der |
| 222 | * uebrigen Tests kommt auf 12 m. Steht die Zahl im Ausdruck, kommt sie aus |
| 223 | * der migrierten Datei und nicht aus einer Vorlage. |
| 224 | * |
| 225 | * DIESELBE ZAHL STEHT SCHON IN `tests/domain/beispielprojekt.test.ts`, und |
| 226 | * das ist Absicht: Dort wird sie am PLAN geprueft, hier am AUSDRUCK. Genau |
| 227 | * diese Strecke - vom gerechneten Wert bis auf das Papier - war fuer diese |
| 228 | * Datei bisher unbewacht. |
| 229 | */ |
| 230 | expect(text).toContain('14,39 m'); |
| 231 | expect(text).toContain('7,20 m / 1,2 m/s = 5,996 s, aufgerundet 6 s'); |
| 232 | }); |
| 233 | |
| 234 | it('nennt das Ergebnis der Pruefung mit der Zahl, die der Bericht fuehrt', () => { |
| 235 | /* |
| 236 | * Die Zahl steht hier ausgeschrieben und nicht als Ausdruck aus demselben |
| 237 | * Bericht: Beide kaemen sonst aus derselben Quelle, und der Fall pruefte |
| 238 | * eine Groesse gegen sich selbst. Ausgeschrieben haelt er zweierlei - dass |
| 239 | * das Deckblatt zaehlt, was der Bericht auffuehrt, UND dass es 15 sind. |
| 240 | * Seit 5.43.0 stehen daneben 36 Fehler, und aus 23 Warnungen sind 15 |
| 241 | * geworden (siehe Kopf). |
| 242 | */ |
| 243 | expect(text).toContain('36 Fehler und 15 Warnungen'); |
| 244 | }); |
| 245 | |
| 246 | it('fuehrt die vier Furten mit der Stufe F auf', () => { |
| 247 | /* |
| 248 | * Die Aussage, um derentwillen der Bericht als Ganzes beurteilt wird. Sie |
| 249 | * steht nicht nur im Bericht, sondern auch im Ausdruck - wer die Datei |
| 250 | * druckt, gibt eine Unterlage aus der Hand, in der viermal die |
| 251 | * schlechteste Qualitaetsstufe steht. |
| 252 | */ |
| 253 | expect(text).toContain('Qualitätsstufe F'); |
| 254 | for (const furt of ['F Nord', 'F Ost', 'F Sued', 'F West']) { |
| 255 | const befund = bericht.findings.find( |
| 256 | (f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe' && f.target?.label === furt, |
| 257 | ); |
| 258 | expect(befund, `keine Stufe fuer ${furt}`).toBeDefined(); |
| 259 | expect(befund?.message, `${furt} steht nicht auf F`).toContain('Qualitätsstufe F'); |
| 260 | expect(befund?.message, `${furt} wartet nicht 94 s`).toContain('94 s'); |
| 261 | } |
| 262 | }); |
| 263 | }); |