lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export hoechstfreigabezeit.test.ts
| 1 | import { inflateSync } from 'node:zlib'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import { signalGroupsCsv } from '@/services/export/csv'; |
| 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 { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 8 | import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; |
| 9 | import { toWinAnsi } from '@/render/pdfSurface'; |
| 10 | import type { Project } from '@/domain/model/project'; |
| 11 | import * as fmt from '@/ui/format'; |
| 12 | |
| 13 | /* |
| 14 | * Fassung 5.9.0 - die Hoechstfreigabezeit stand in keiner Unterlage. |
| 15 | * |
| 16 | * Seit den Befunden 36 und 42 wirkt eine je Signalgruppe eingetragene |
| 17 | * Hoechstfreigabezeit (`SignalGroup.maxGreenOverride`) in der |
| 18 | * Freigabezeitverteilung: `phaseMaxGreen` (signalPlan.ts) nimmt die KLEINSTE |
| 19 | * Vorgabe der Signalgruppen einer Phase statt des Regelwerts. Gedruckt wurde |
| 20 | * sie nirgends - weder im Ausdruck noch in der Tabellenausgabe. Damit stand in |
| 21 | * der Planunterlage eine Phasendauer, die sich aus den gedruckten Groessen |
| 22 | * nicht mehr herleiten laesst, und das ist der Zweck der Unterlage. |
| 23 | * |
| 24 | * DIE SPALTE NENNT DIE VORGABE, NICHT DIE GELTENDE SCHRANKE. Die erste Fassung schrieb |
| 25 | * ohne Eintrag den Regelwert und sagte im Docblock zu, die Spalte nenne "die |
| 26 | * Schranke, die gilt". Das hielt in drei Lagen nicht: bei einer festen |
| 27 | * Freigabezeit der Phase (sie geht vor), bei einer Vorgabe unter der |
| 28 | * Mindestfreigabezeit (dann gilt diese, und der Pruefbericht sagt es im selben |
| 29 | * Dokument) und bei einer negativen Vorgabe, die das Einlesen durchlaesst. |
| 30 | * Massgebend ist ohnehin nichts, was an EINER Signalgruppe steht: Die wirksame |
| 31 | * obere Schranke bildet `phaseMaxGreen` je PHASE aus der kleinsten Vorgabe |
| 32 | * ihrer Signalgruppen. Die Zelle weist deshalb den Eintrag als Eintrag aus, |
| 33 | * "–" bzw. leer heisst "keiner", und der Absatz unter der Tabelle sagt, was |
| 34 | * ueber die wirksame Schranke entscheidet. |
| 35 | * |
| 36 | * MUTATIONSNACHWEIS, nachgemessen gegen den Stand vor dieser Berichtigung |
| 37 | * (Spaltenkopf "Höchstfreigabezeit [s]" bzw. "Min./Max.", Regelwert statt |
| 38 | * leerer Zelle, kein Absatz unter der Tabelle): 7 von 8 Faellen fallen - die |
| 39 | * ersten sieben. Der achte misst den Satzspiegel bei sechzehn Signalgruppen |
| 40 | * mit langen Namen; er ist in beiden Staenden gruen und bewacht, dass die |
| 41 | * Spalte den Satz nicht sprengt. |
| 42 | */ |
| 43 | |
| 44 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 45 | const VORGABE = 47; |
| 46 | /** Feste Freigabezeit einer Phase, unter der Vorgabe der Signalgruppe. */ |
| 47 | const FEST = 30; |
| 48 | /** Vorgabe unter der Mindestfreigabezeit - sie kommt nicht zur Wirkung. */ |
| 49 | const ZU_KLEIN = 1; |
| 50 | const KOPF_MIN = 'Mindestfreigabezeit [s]'; |
| 51 | const KOPF_MAX = 'Höchstfreigabezeit: Vorgabe der Signalgruppe [s] (leer = keine)'; |
| 52 | /** |
| 53 | * Der Spaltenkopf des Ausdrucks: Mindestfreigabezeit und Vorgabe teilen sich |
| 54 | * dort eine Spalte. |
| 55 | */ |
| 56 | const SPALTE_PDF = 'Min./Vorgabe'; |
| 57 | |
| 58 | const BLATTBREITE = 841.89; |
| 59 | const PAGE_MARGIN = 36; |
| 60 | |
| 61 | /** Knotenpunkt, dessen erste Signalgruppe eine Hoechstfreigabezeit traegt. */ |
| 62 | function mitVorgabe(vorgabe: number = VORGABE): Project { |
| 63 | const basis = createStandardIntersectionProject('Höchstfreigabezeit', STICHTAG); |
| 64 | return { |
| 65 | ...basis, |
| 66 | signalGroups: basis.signalGroups.map((g, index) => |
| 67 | index === 0 ? { ...g, maxGreenOverride: vorgabe } : g, |
| 68 | ), |
| 69 | }; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Derselbe Knotenpunkt, dessen erste Phase zusaetzlich eine feste |
| 74 | * Freigabezeit traegt - die geht der Vorgabe der Signalgruppen vor |
| 75 | * (`phaseMaxGreen`, signalPlan.ts). |
| 76 | */ |
| 77 | function mitFesterFreigabezeit(): Project { |
| 78 | const basis = mitVorgabe(); |
| 79 | return { |
| 80 | ...basis, |
| 81 | phases: basis.phases.map((p, index) => (index === 0 ? { ...p, manualGreen: FEST } : p)), |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | // --- Tabellenausgabe -------------------------------------------------------- |
| 86 | |
| 87 | function zerlege(zeile: string): string[] { |
| 88 | return (zeile.match(/("([^"]|"")*"|[^;]*)(;|$)/g) ?? []).map((feld) => |
| 89 | feld.replace(/;$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'), |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | function tabelle(project: Project): string[][] { |
| 94 | return signalGroupsCsv(project, buildSignalPlan(project)) |
| 95 | .replace(/^/, '') |
| 96 | .split('\r\n') |
| 97 | .filter((z) => z.length > 0) |
| 98 | .map(zerlege); |
| 99 | } |
| 100 | |
| 101 | // --- Ausdruck --------------------------------------------------------------- |
| 102 | |
| 103 | interface Stueck { |
| 104 | readonly blatt: number; |
| 105 | readonly x: number; |
| 106 | readonly y: number; |
| 107 | readonly text: string; |
| 108 | } |
| 109 | |
| 110 | /** Textstuecke mit ihrer Lage auf dem Blatt - eine Tabellenzeile teilt sich ein y. */ |
| 111 | function stuecke(bytes: Uint8Array): Stueck[] { |
| 112 | const roh = new TextDecoder('latin1').decode(bytes); |
| 113 | const raus: Stueck[] = []; |
| 114 | const suche = /stream\r?\n/g; |
| 115 | let treffer: RegExpExecArray | null; |
| 116 | let blatt = 0; |
| 117 | |
| 118 | while ((treffer = suche.exec(roh)) !== null) { |
| 119 | const start = treffer.index + treffer[0].length; |
| 120 | const ende = roh.indexOf('endstream', start); |
| 121 | if (ende < 0) continue; |
| 122 | let inhalt: string; |
| 123 | try { |
| 124 | inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 125 | } catch { |
| 126 | continue; |
| 127 | } |
| 128 | if (!inhalt.includes('Td') && !inhalt.includes('Tj')) continue; |
| 129 | blatt += 1; |
| 130 | let x = 0; |
| 131 | let y = 0; |
| 132 | for (const zeile of inhalt.split('\n')) { |
| 133 | const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile.trim()); |
| 134 | if (td) { |
| 135 | x = Number(td[1]); |
| 136 | y = Number(td[2]); |
| 137 | continue; |
| 138 | } |
| 139 | const tj = /^\((.*)\)\s*Tj$/.exec(zeile.trim()); |
| 140 | if (tj && tj[1]!.trim() !== '') { |
| 141 | raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | return raus; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Der ganze sichtbare Text der Unterlage in einer Zeichenkette. |
| 150 | * |
| 151 | * Neben `stuecke`, weil beide etwas anderes koennen: `stuecke` haelt die Lage |
| 152 | * fest (daran haengt die Zeile der Signalgruppentabelle), erfasst aber nur |
| 153 | * Textstuecke mit eigener Setzmarke - die Fliesstextzeilen der Absaetze setzt |
| 154 | * jsPDF mit "T*" fort und fielen dort durch. |
| 155 | */ |
| 156 | function volltext(bytes: Uint8Array): string { |
| 157 | const roh = new TextDecoder('latin1').decode(bytes); |
| 158 | const teile: string[] = []; |
| 159 | const suche = /stream\r?\n/g; |
| 160 | let treffer: RegExpExecArray | null; |
| 161 | |
| 162 | while ((treffer = suche.exec(roh)) !== null) { |
| 163 | const start = treffer.index + treffer[0].length; |
| 164 | const ende = roh.indexOf('endstream', start); |
| 165 | if (ende < 0) continue; |
| 166 | let inhalt: string; |
| 167 | try { |
| 168 | inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 169 | } catch { |
| 170 | continue; |
| 171 | } |
| 172 | for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { |
| 173 | teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); |
| 174 | } |
| 175 | } |
| 176 | return teile.join(' ').replace(/\s+/g, ' '); |
| 177 | } |
| 178 | |
| 179 | function unterlage(project: Project): Uint8Array { |
| 180 | const plan = buildSignalPlan(project); |
| 181 | return buildProjectPdf( |
| 182 | project, |
| 183 | plan, |
| 184 | validateProject(project, plan, STICHTAG), |
| 185 | DEFAULT_PDF_OPTIONS, |
| 186 | STICHTAG, |
| 187 | ); |
| 188 | } |
| 189 | |
| 190 | function ausdruck(project: Project): Stueck[] { |
| 191 | return stuecke(unterlage(project)); |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * Die Zeile der Signalgruppentabelle zu einer Gruppe, Zelle fuer Zelle von |
| 196 | * links nach rechts. |
| 197 | * |
| 198 | * Erkannt wird sie an der Zelle "V zul", die es nur in dieser Tabelle gibt; |
| 199 | * Zellen mit mehreren Zeilen stehen mit ihrer ERSTEN Zeile auf derselben |
| 200 | * Grundlinie wie der Name. |
| 201 | */ |
| 202 | function signalgruppenzeile(alle: readonly Stueck[], name: string): string[] { |
| 203 | const anker = alle.find( |
| 204 | (s) => |
| 205 | s.text === name && |
| 206 | alle.some((n) => n.blatt === s.blatt && n.y === s.y && n.text.endsWith('km/h')), |
| 207 | ); |
| 208 | expect(anker, `Zeile der Gruppe "${name}" steht in der Signalgruppentabelle`).toBeDefined(); |
| 209 | return alle |
| 210 | .filter((s) => s.blatt === anker!.blatt && s.y === anker!.y) |
| 211 | .sort((a, b) => a.x - b.x) |
| 212 | .map((s) => s.text); |
| 213 | } |
| 214 | |
| 215 | describe('Die Höchstfreigabezeit steht in Ausdruck und Tabellenausgabe', () => { |
| 216 | it('führt in der Tabellenausgabe eine Spalte neben der Mindestfreigabezeit', () => { |
| 217 | const zeilen = tabelle(mitVorgabe()); |
| 218 | const kopf = zeilen[0]!; |
| 219 | const min = kopf.indexOf(KOPF_MIN); |
| 220 | |
| 221 | expect(min, 'Vorbedingung: die Mindestfreigabezeit steht in der Tabelle').toBeGreaterThan(0); |
| 222 | expect(kopf[min + 1]).toBe(KOPF_MAX); |
| 223 | }); |
| 224 | |
| 225 | it('schreibt in der Tabellenausgabe den Eintrag der Gruppe, sonst nichts', () => { |
| 226 | const projekt = mitVorgabe(); |
| 227 | const plan = buildSignalPlan(projekt); |
| 228 | const zeilen = tabelle(projekt); |
| 229 | const spalte = zeilen[0]!.indexOf(KOPF_MAX); |
| 230 | |
| 231 | expect(plan.defaults.maxGreen, 'Vorbedingung: es gibt einen Regelwert').toBeGreaterThan(0); |
| 232 | for (const gruppe of projekt.signalGroups) { |
| 233 | const zeile = zeilen.slice(1).find((z) => z[0] === gruppe.name)!; |
| 234 | // Ohne Eintrag bleibt die Zelle LEER und nicht der Regelwert: Der gilt |
| 235 | // nur, solange keine Signalgruppe DERSELBEN PHASE eine kleinere Vorgabe |
| 236 | // traegt - eine Zelle je Gruppe kann das nicht sagen. |
| 237 | expect(zeile[spalte], gruppe.name).toBe( |
| 238 | gruppe.maxGreenOverride === null ? '' : fmt.numShort(gruppe.maxGreenOverride), |
| 239 | ); |
| 240 | } |
| 241 | }); |
| 242 | |
| 243 | it('nennt sie im Ausdruck an der Stelle der Mindestfreigabezeit', () => { |
| 244 | const alle = ausdruck(mitVorgabe()); |
| 245 | |
| 246 | /* |
| 247 | * EINE SPALTE FUER BEIDE ANGABEN: Die Signalgruppentabelle des Ausdrucks |
| 248 | * ist quer voll (siehe die Begruendung am Spaltenkopf in pdf.ts). Eine |
| 249 | * vierzehnte Spalte nimmt den Nachbarn so viel Breite, dass |
| 250 | * Signalgruppennamen und Fahrzeugarten umbrechen, die Zeilen hoeher werden |
| 251 | * und die Tabelle frueher auf das naechste Blatt umbricht - nachgestellt |
| 252 | * mit einer eigenen Spalte "Max. Freigabe" hinter "Min. Freigabe": Dabei |
| 253 | * faellt tests/export/pdfSignalgruppenNummer.test.ts mit 3 Faellen (die |
| 254 | * Ausgabe zeigt NaN statt der Nummern 6 und 10). tests/export/ |
| 255 | * pdfBeschriftungen.test.ts und tests/export/pdfSatzspiegel.test.ts melden |
| 256 | * den Fall NICHT - sie bleiben dabei gruen; der letzte Fall dieser Datei |
| 257 | * misst deshalb selbst. |
| 258 | */ |
| 259 | expect(alle.map((s) => s.text)).toContain(SPALTE_PDF); |
| 260 | }); |
| 261 | |
| 262 | it('druckt den Eintrag, sonst einen Gedankenstrich', () => { |
| 263 | const projekt = mitVorgabe(); |
| 264 | const plan = buildSignalPlan(projekt); |
| 265 | const alle = ausdruck(projekt); |
| 266 | |
| 267 | for (const gruppe of projekt.signalGroups) { |
| 268 | const zellen = signalgruppenzeile(alle, gruppe.name); |
| 269 | const planned = plan.groups.find((g) => g.groupId === gruppe.id)!; |
| 270 | // Nr., SG, Verkehrsart, Fahrbeziehung, V zul, Fahrstr., Fahrzeugart, |
| 271 | // Furt, Rot-Gelb, Gelb, Min./Vorgabe, ... |
| 272 | expect(zellen[10], `${gruppe.name}: ${SPALTE_PDF}`).toBe( |
| 273 | `${fmt.seconds(planned.times.minGreen)} / ` + |
| 274 | (gruppe.maxGreenOverride === null |
| 275 | ? toWinAnsi('–') |
| 276 | : fmt.seconds(gruppe.maxGreenOverride)), |
| 277 | ); |
| 278 | } |
| 279 | }); |
| 280 | |
| 281 | it('sagt unter der Tabelle, was über die wirksame obere Schranke entscheidet', () => { |
| 282 | const projekt = mitVorgabe(); |
| 283 | const plan = buildSignalPlan(projekt); |
| 284 | const text = volltext(unterlage(projekt)); |
| 285 | |
| 286 | // Der Absatz nennt den Regelwert dieses Plans - in der Zelle steht er |
| 287 | // nicht mehr, und ohne ihn liesse sich die Phasendauer einer Gruppe ohne |
| 288 | // Eintrag nicht herleiten. |
| 289 | expect(text).toContain(toWinAnsi(`Regelwert von ${fmt.seconds(plan.defaults.maxGreen)}`)); |
| 290 | // Und er sagt, dass die rechte Zahl der Zelle die Eingangsgroesse ist ... |
| 291 | expect(text).toContain(toWinAnsi('nicht die im Plan wirksame obere Schranke')); |
| 292 | // ... und wer ueber die wirksame entscheidet: die Phase. |
| 293 | expect(text).toContain(toWinAnsi('kleinste Vorgabe ihrer Signalgruppen')); |
| 294 | }); |
| 295 | |
| 296 | it('widerspricht dem Prüfbericht nicht, wenn die Vorgabe unter der Mindestfreigabezeit liegt', () => { |
| 297 | /* |
| 298 | * Die Lage, an der die erste Fassung scheiterte: Die Tabelle wies 1 s als |
| 299 | * geltende Hoechstfreigabezeit aus - eine Obergrenze unter der Untergrenze |
| 300 | * -, waehrend der Pruefbericht desselben Dokuments sagte, massgebend bleibe |
| 301 | * die Mindestfreigabezeit. Jetzt weist die Spalte den Eintrag als Eintrag |
| 302 | * aus, und der Absatz nennt den Pruefbericht als die Stelle, die den Fall |
| 303 | * meldet. |
| 304 | */ |
| 305 | const projekt = mitVorgabe(ZU_KLEIN); |
| 306 | const plan = buildSignalPlan(projekt); |
| 307 | const gruppe = projekt.signalGroups[0]!; |
| 308 | const bytes = unterlage(projekt); |
| 309 | const alle = stuecke(bytes); |
| 310 | const text = volltext(bytes); |
| 311 | |
| 312 | expect( |
| 313 | plan.groups.find((g) => g.groupId === gruppe.id)!.times.minGreen, |
| 314 | 'Vorbedingung: die Mindestfreigabezeit liegt über der Vorgabe', |
| 315 | ).toBeGreaterThan(ZU_KLEIN); |
| 316 | // Der Pruefbericht steht in derselben Datei und sagt, was gilt. |
| 317 | expect(text).toContain(toWinAnsi('Maßgebend bleibt die Mindestfreigabezeit')); |
| 318 | // Die Zelle behauptet daneben keine geltende Schranke mehr, sondern nennt |
| 319 | // den Eintrag - und der Absatz sagt, wo der Fall gemeldet ist. Der ganze |
| 320 | // Satzteil, nicht nur "meldet der Prüfbericht": Den Wortlaut fuehrt auch |
| 321 | // der Absatz zum engen Innenradius, und ein Fall, der ihn dort findet, |
| 322 | // bewacht nichts. |
| 323 | expect(signalgruppenzeile(alle, gruppe.name)[10]).toBe( |
| 324 | `${fmt.seconds(plan.groups.find((g) => g.groupId === gruppe.id)!.times.minGreen)} / ` + |
| 325 | fmt.seconds(ZU_KLEIN), |
| 326 | ); |
| 327 | expect(text).toContain( |
| 328 | toWinAnsi( |
| 329 | 'die in ihrer Phase unter der Mindestfreigabezeit oder unter einer festen Freigabezeit ' + |
| 330 | 'liegt, meldet der Prüfbericht', |
| 331 | ), |
| 332 | ); |
| 333 | }); |
| 334 | |
| 335 | it('lässt bei fester Freigabezeit der Phase die feste Zeit gelten', () => { |
| 336 | /* |
| 337 | * Zweite Lage der ersten Fassung: Die Phase traegt eine feste Freigabezeit |
| 338 | * von 30 s, die Gruppe eine Vorgabe von 47 s. Gedruckt wurde "5 s / 47 s" |
| 339 | * als geltende Schranke, obwohl 30 s gelten. Die Vorgabe steht weiter da - |
| 340 | * sie ist eingetragen -, und der Absatz sagt, dass die feste Freigabezeit |
| 341 | * ihr vorgeht; die Phasentabelle nennt die 30 s. |
| 342 | */ |
| 343 | const projekt = mitFesterFreigabezeit(); |
| 344 | const plan = buildSignalPlan(projekt); |
| 345 | const bytes = unterlage(projekt); |
| 346 | const alle = stuecke(bytes); |
| 347 | const text = volltext(bytes); |
| 348 | |
| 349 | expect(plan.phases[0]!.duration, 'Vorbedingung: die feste Freigabezeit gilt').toBe(FEST); |
| 350 | expect(text).toContain(toWinAnsi('feste Freigabezeit der Phase')); |
| 351 | expect(signalgruppenzeile(alle, projekt.signalGroups[0]!.name)[10]).toContain( |
| 352 | fmt.seconds(VORGABE), |
| 353 | ); |
| 354 | }); |
| 355 | |
| 356 | it('sprengt bei sechzehn Signalgruppen mit langen Namen den Satzspiegel nicht', () => { |
| 357 | /* |
| 358 | * Der Ausdruck hat feste Spaltenbreiten und Umbruchregeln; eine |
| 359 | * zusaetzliche Spalte kann den Satz sprengen. Gemessen wird an demselben |
| 360 | * Pruefstueck wie in tests/export/pdfSatzspiegel.test.ts: sechzehn |
| 361 | * Signalgruppen mit langen Namen. |
| 362 | */ |
| 363 | const basis = projektAusAngaben({ |
| 364 | ...ASSISTENT_VORGABE, |
| 365 | form: 'kreuzung', |
| 366 | fussgaenger: true, |
| 367 | rad: true, |
| 368 | linksabbieger: true, |
| 369 | }); |
| 370 | const gruppen = Array.from({ length: 16 }, (_, i) => ({ |
| 371 | ...basis.signalGroups[i % basis.signalGroups.length]!, |
| 372 | id: `sg-${i}`, |
| 373 | name: `Signalgruppe ${i + 1} Nordwest Geradeaus und Rechtsabbieger`, |
| 374 | maxGreenOverride: i === 0 ? VORGABE : null, |
| 375 | })); |
| 376 | const alle = ausdruck({ ...basis, signalGroups: gruppen, conflicts: [] }); |
| 377 | |
| 378 | expect(alle.length, 'Vorbedingung: die Unterlage trägt Text').toBeGreaterThan(100); |
| 379 | const raus = alle.filter((s) => s.x < PAGE_MARGIN || s.x > BLATTBREITE - PAGE_MARGIN); |
| 380 | expect( |
| 381 | raus.length, |
| 382 | raus.length ? `zuerst x=${raus[0]!.x}: "${raus[0]!.text.slice(0, 40)}"` : '', |
| 383 | ).toBe(0); |
| 384 | }); |
| 385 | }); |