lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export ergebniskasten.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 type { Project } from '@/domain/model/project'; |
| 8 | |
| 9 | /* |
| 10 | * BEFUND 6 (Fassung 5.11.0): Der Kasten "Ergebnis der Pruefung" wurde ohne |
| 11 | * Umbruchpruefung an context.cursor gesetzt. |
| 12 | * |
| 13 | * Was der Anwender sonst erlebt: Er traegt in der Ansicht "Projekt" eine lange |
| 14 | * Projektbezeichnung ein (das Feld hat keine Laengengrenze; auf dem Deckblatt |
| 15 | * wird sie bewusst umbrochen statt gekuerzt). Die beiden Deckblatttabellen |
| 16 | * ruecken damit nach unten, und der 52 pt hohe Ergebniskasten faellt in den |
| 17 | * Fusssteg - erst die farbige Flaeche, dann der Vorbehaltssatz "Die |
| 18 | * rechnerische Pruefung ersetzt nicht ... keine verkehrsbehoerdliche Anordnung |
| 19 | * nach Paragraf 45 StVO", und schliesslich fehlt er im Ausdruck ganz. |
| 20 | * |
| 21 | * Der Kasten traegt zugleich die einzige Stelle des Dokuments, an der |
| 22 | * report.errorCount steht. Deckblatttitel (Befund 43) und Bemerkung |
| 23 | * (Befund 18) sind fuer genau diesen Fall gemessen gesetzt worden, der Block |
| 24 | * dazwischen nicht. |
| 25 | */ |
| 26 | |
| 27 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 28 | |
| 29 | const PAGE_MARGIN = 36; |
| 30 | const FOOTER_HEIGHT = 26; |
| 31 | /** Untere Kante des Satzspiegels, von der Blattunterkante aus gemessen. */ |
| 32 | const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT; |
| 33 | /** Hoehe des Ergebniskastens - dieselbe Zahl wie in pdf.ts. */ |
| 34 | const KASTENHOEHE = 52; |
| 35 | /** Die Texte des Kastens beginnen 10 pt rechts des Satzspiegelrands. */ |
| 36 | const KASTEN_X = PAGE_MARGIN + 10; |
| 37 | /** Breite des Satzspiegels auf A4 quer - so breit ist die Flaeche des Kastens. */ |
| 38 | const BREITE_SATZSPIEGEL = 841.89 - 2 * PAGE_MARGIN; |
| 39 | /** Abstand zwischen der Unterkante des Kastens und dem, was folgt. */ |
| 40 | const KASTEN_NACHLAUF = 16; |
| 41 | |
| 42 | const BAUSTEIN = 'Knotenpunkt Hauptstraße / Bahnhofstraße Ausbaustufe '; |
| 43 | |
| 44 | /** Eine Projektbezeichnung der gewuenschten Laenge - das Feld ist unbegrenzt. */ |
| 45 | function bezeichnung(laenge: number): string { |
| 46 | let text = ''; |
| 47 | while (text.length < laenge) text += BAUSTEIN; |
| 48 | return text.slice(0, laenge).trim(); |
| 49 | } |
| 50 | |
| 51 | interface Stueck { |
| 52 | readonly blatt: number; |
| 53 | readonly x: number; |
| 54 | readonly y: number; |
| 55 | readonly text: string; |
| 56 | } |
| 57 | |
| 58 | interface Flaeche { |
| 59 | readonly blatt: number; |
| 60 | /** Untere Kante, von der Blattunterkante aus - jsPDF setzt die Hoehe negativ. */ |
| 61 | readonly unterkante: number; |
| 62 | readonly breite: number; |
| 63 | readonly hoehe: number; |
| 64 | } |
| 65 | |
| 66 | function entpacke(roh: string, von: number): string | null { |
| 67 | const anfang = roh.indexOf('stream', von); |
| 68 | if (anfang < 0) return null; |
| 69 | const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7); |
| 70 | const ende = roh.indexOf('endstream', start); |
| 71 | if (ende < 0) return null; |
| 72 | try { |
| 73 | return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 74 | } catch { |
| 75 | return null; |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | function seiteninhalte(bytes: Uint8Array): readonly string[] { |
| 80 | const roh = new TextDecoder('latin1').decode(bytes); |
| 81 | const raus: string[] = []; |
| 82 | for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) { |
| 83 | const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`); |
| 84 | const inhalt = objekt < 0 ? null : entpacke(roh, objekt); |
| 85 | if (inhalt !== null) raus.push(inhalt); |
| 86 | } |
| 87 | return raus; |
| 88 | } |
| 89 | |
| 90 | function textstuecke(bytes: Uint8Array): readonly Stueck[] { |
| 91 | const raus: Stueck[] = []; |
| 92 | let blatt = 0; |
| 93 | for (const inhalt of seiteninhalte(bytes)) { |
| 94 | blatt += 1; |
| 95 | let x = 0; |
| 96 | let y = 0; |
| 97 | let abstand = 0; |
| 98 | for (const roheZeile of inhalt.split('\n')) { |
| 99 | const zeile = roheZeile.trim(); |
| 100 | const tl = /^(-?[\d.]+) TL$/.exec(zeile); |
| 101 | if (tl) { |
| 102 | abstand = Number(tl[1]); |
| 103 | continue; |
| 104 | } |
| 105 | const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); |
| 106 | if (td) { |
| 107 | x = Number(td[1]); |
| 108 | y = Number(td[2]); |
| 109 | continue; |
| 110 | } |
| 111 | let rest = zeile; |
| 112 | while (rest.startsWith('T*')) { |
| 113 | y -= abstand; |
| 114 | rest = rest.slice(2).trim(); |
| 115 | } |
| 116 | const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest); |
| 117 | if (tj) raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); |
| 118 | } |
| 119 | } |
| 120 | return raus; |
| 121 | } |
| 122 | |
| 123 | /** Die gefuellten Rechtecke der Datei - jsPDF setzt sie als "x y b h re". */ |
| 124 | function flaechen(bytes: Uint8Array): readonly Flaeche[] { |
| 125 | const raus: Flaeche[] = []; |
| 126 | let blatt = 0; |
| 127 | for (const inhalt of seiteninhalte(bytes)) { |
| 128 | blatt += 1; |
| 129 | for (const treffer of inhalt.matchAll(/(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) re\b/g)) { |
| 130 | raus.push({ |
| 131 | blatt, |
| 132 | unterkante: Number(treffer[2]) + Number(treffer[4]), |
| 133 | breite: Number(treffer[3]), |
| 134 | hoehe: Math.abs(Number(treffer[4])), |
| 135 | }); |
| 136 | } |
| 137 | } |
| 138 | return raus; |
| 139 | } |
| 140 | |
| 141 | function unterlage(name: string, bemerkung = ''): Uint8Array { |
| 142 | const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); |
| 143 | const projekt: Project = { |
| 144 | ...basis, |
| 145 | meta: { ...basis.meta, name, description: bemerkung }, |
| 146 | }; |
| 147 | const plan = buildSignalPlan(projekt); |
| 148 | return buildProjectPdf( |
| 149 | projekt, |
| 150 | plan, |
| 151 | validateProject(projekt, plan, STICHTAG), |
| 152 | DEFAULT_PDF_OPTIONS, |
| 153 | STICHTAG, |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | /** Die drei Texte des Ergebniskastens - sie stehen als einzige bei x = 46. */ |
| 158 | function kastentexte(bytes: Uint8Array): readonly Stueck[] { |
| 159 | return textstuecke(bytes).filter((s) => Math.abs(s.x - KASTEN_X) < 0.01); |
| 160 | } |
| 161 | |
| 162 | describe('Befund 6 - Ergebniskasten des Deckblatts', () => { |
| 163 | for (const laenge of [400, 485, 809]) { |
| 164 | it(`haelt den Ergebniskasten bei ${laenge} Zeichen Bezeichnung im Satzspiegel`, () => { |
| 165 | const bytes = unterlage(bezeichnung(laenge)); |
| 166 | |
| 167 | const texte = kastentexte(bytes); |
| 168 | expect( |
| 169 | texte.map((s) => s.text), |
| 170 | 'Vorbedingung: Ueberschrift, Pruefergebnis und Vorbehalt stehen im Dokument', |
| 171 | ).toHaveLength(3); |
| 172 | expect(texte[0]!.text).toBe('Ergebnis der Prüfung'); |
| 173 | expect(texte[2]!.text).toContain('Paragraf 45 StVO'); |
| 174 | |
| 175 | for (const s of texte) { |
| 176 | expect( |
| 177 | s.y, |
| 178 | `"${s.text}" steht auf Blatt ${s.blatt} bei y ${s.y.toFixed(2)}`, |
| 179 | ).toBeGreaterThanOrEqual(SATZSPIEGEL_UNTEN); |
| 180 | } |
| 181 | |
| 182 | const kasten = flaechen(bytes).filter((f) => Math.abs(f.hoehe - KASTENHOEHE) < 0.01); |
| 183 | expect(kasten, 'Vorbedingung: die farbige Flaeche des Kastens').toHaveLength(1); |
| 184 | expect( |
| 185 | kasten[0]!.unterkante, |
| 186 | `Unterkante der farbigen Flaeche bei ${kasten[0]!.unterkante.toFixed(2)}`, |
| 187 | ).toBeGreaterThanOrEqual(SATZSPIEGEL_UNTEN); |
| 188 | }); |
| 189 | } |
| 190 | |
| 191 | it('laesst den Kasten bei kurzer Bezeichnung auf dem Deckblatt stehen', () => { |
| 192 | const texte = kastentexte(unterlage('Musterkreuzung')); |
| 193 | expect(texte).toHaveLength(3); |
| 194 | for (const s of texte) expect(s.blatt, `"${s.text}"`).toBe(1); |
| 195 | }); |
| 196 | |
| 197 | /* |
| 198 | * NACHTRAG ZU BEFUND 6: Die Kastenhoehe wird an drei Stellen gebraucht - |
| 199 | * zum Reservieren des Platzes, zum Zeichnen der Flaeche und zum |
| 200 | * Weiterruecken auf den Bemerkungsblock darunter. Die dritte stand als feste |
| 201 | * 68 (= 52 + 16) im Quelltext: Waechst die Kastenhoehe, wandern Reserve und |
| 202 | * Flaeche mit, die Ueberschrift "Bemerkungen" aber nicht - sie rutscht in den |
| 203 | * Kasten hinein. |
| 204 | * |
| 205 | * Gemessen wird deshalb nicht die Zahl, sondern der ABSTAND: Die Ueberschrift |
| 206 | * steht 16 pt unter der Unterkante der farbigen Flaeche, welche Hoehe diese |
| 207 | * auch hat. Gegen eine geaenderte Kastenhoehe ist der Fall rot, solange die |
| 208 | * dritte Stelle nicht mitrechnet. |
| 209 | */ |
| 210 | it('setzt die Ueberschrift "Bemerkungen" 16 pt unter die Unterkante des Kastens', () => { |
| 211 | // Mit langer Bezeichnung: Dann rutscht der Kasten geschlossen auf ein |
| 212 | // frisches Blatt, und die Bemerkung steht unmittelbar darunter - beide |
| 213 | // Groessen sind damit auf demselben Blatt messbar. Bei kurzer Bezeichnung |
| 214 | // steht der Kasten am Blattfuss, und die Ueberschrift beginnt ohnehin ein |
| 215 | // neues Blatt. |
| 216 | const bytes = unterlage( |
| 217 | bezeichnung(485), |
| 218 | 'Abstimmung mit der Straßenverkehrsbehörde steht aus.', |
| 219 | ); |
| 220 | |
| 221 | const ueberschrift = textstuecke(bytes).filter((s) => s.text === 'Bemerkungen'); |
| 222 | expect(ueberschrift, 'Vorbedingung: die Bemerkung wird gesetzt').toHaveLength(1); |
| 223 | |
| 224 | // Der Kasten ist das einzige gefuellte Rechteck ueber die volle Breite des |
| 225 | // Satzspiegels; die Tabellenzellen darueber sind schmaler. |
| 226 | const kasten = flaechen(bytes).filter( |
| 227 | (f) => f.blatt === ueberschrift[0]!.blatt && Math.abs(f.breite - BREITE_SATZSPIEGEL) < 0.01, |
| 228 | ); |
| 229 | expect(kasten, 'Vorbedingung: Kasten und Ueberschrift stehen auf demselben Blatt').toHaveLength( |
| 230 | 1, |
| 231 | ); |
| 232 | |
| 233 | expect( |
| 234 | Number((kasten[0]!.unterkante - ueberschrift[0]!.y).toFixed(2)), |
| 235 | `Kastenunterkante ${kasten[0]!.unterkante.toFixed(2)}, Überschrift bei ` + |
| 236 | `${ueberschrift[0]!.y.toFixed(2)} - der Abstand haengt an der Kastenhoehe`, |
| 237 | ).toBe(KASTEN_NACHLAUF); |
| 238 | }); |
| 239 | }); |