lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export pdfKoordinierung.test.ts
| 1 | import { inflateSync } from 'node:zlib'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import * as actions from '@/app/actions'; |
| 4 | import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; |
| 5 | import { freigabefensterZelle } from '@/services/export/csv'; |
| 6 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 7 | import { validateProject } from '@/domain/validation'; |
| 8 | import { createPhase, createStandardIntersectionProject } from '@/domain/model/factory'; |
| 9 | import { toWinAnsi } from '@/render/pdfSurface'; |
| 10 | import * as fmt from '@/ui/format'; |
| 11 | import type { Project } from '@/domain/model/project'; |
| 12 | |
| 13 | /** |
| 14 | * Die Koordinierung in der Unterlage. |
| 15 | * |
| 16 | * DER SATZ, AUF DEN ES ANKOMMT: Der Versatz ist eine Empfehlung fuer die |
| 17 | * NACHBARANLAGEN und verschiebt den Signalzeitenplan dieses Knotenpunkts |
| 18 | * nicht. Wer das nicht liest, sucht die Wirkung im Zeitdiagramm daneben - und |
| 19 | * findet sie nicht. |
| 20 | */ |
| 21 | |
| 22 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 23 | |
| 24 | function pdfText(bytes: Uint8Array): string { |
| 25 | const roh = new TextDecoder('latin1').decode(bytes); |
| 26 | const teile: string[] = []; |
| 27 | const suche = /stream\r?\n/g; |
| 28 | let treffer: RegExpExecArray | null; |
| 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(' ').replace(/\s+/g, ' '); |
| 44 | } |
| 45 | |
| 46 | function ausdruck(project: Project): string { |
| 47 | const plan = buildSignalPlan(project); |
| 48 | return pdfText( |
| 49 | buildProjectPdf( |
| 50 | project, |
| 51 | plan, |
| 52 | validateProject(project, plan, STICHTAG), |
| 53 | DEFAULT_PDF_OPTIONS, |
| 54 | STICHTAG, |
| 55 | ), |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | function ohneKette(): Project { |
| 60 | return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG); |
| 61 | } |
| 62 | |
| 63 | function mitKette( |
| 64 | optionen: { gegenrichtung?: boolean; beginn?: number; dauer?: number } = {}, |
| 65 | ): Project { |
| 66 | const roh = ohneKette(); |
| 67 | const k1 = roh.signalGroups[0]!.id; |
| 68 | const k2 = roh.signalGroups[1]!.id; |
| 69 | let projekt: Project = { |
| 70 | ...roh, |
| 71 | program: { ...roh.program, method: 'manuell', manualCycleTime: 60 }, |
| 72 | }; |
| 73 | projekt = actions.setzeKoordinierung(projekt, { |
| 74 | hauptrichtung: k1, |
| 75 | ...(optionen.gegenrichtung === true ? { gegenrichtung: k2 } : {}), |
| 76 | progressionsgeschwindigkeit: 36, |
| 77 | platz: 0, |
| 78 | }); |
| 79 | projekt = actions.koordinierungsknotenAnlegen(projekt, 'Weststraße'); |
| 80 | const west = projekt.koordinierung!.nachbarn[0]!; |
| 81 | return actions.koordinierungsknotenAendern(projekt, west.id, { |
| 82 | abstand: 200, |
| 83 | freigabeBeginn: optionen.beginn ?? 20, |
| 84 | freigabeDauer: optionen.dauer ?? 25, |
| 85 | ...(optionen.gegenrichtung === true ? { gegenBeginn: 20, gegenDauer: 25 } : {}), |
| 86 | }); |
| 87 | } |
| 88 | |
| 89 | /** Der Ausdruck vom Kopf der Koordinierungstabelle bis zur Ueberschrift des Zeit-Weg-Diagramms. */ |
| 90 | function koordinierungstabelle(text: string): string { |
| 91 | const anfang = text.indexOf(toWinAnsi('Freigabe Hauptrichtung')); |
| 92 | expect(anfang).toBeGreaterThanOrEqual(0); |
| 93 | const ende = text.indexOf(toWinAnsi('Zeit-Weg-Diagramm'), anfang); |
| 94 | expect(ende).toBeGreaterThan(anfang); |
| 95 | return text.slice(anfang, ende); |
| 96 | } |
| 97 | |
| 98 | describe('Planunterlage - Koordinierung', () => { |
| 99 | it('druckt den Abschnitt mit Entfernung, Reisezeit und Versatz', () => { |
| 100 | const text = ausdruck(mitKette()); |
| 101 | expect(text).toContain(toWinAnsi('Koordinierung')); |
| 102 | expect(text).toContain(toWinAnsi('Weststraße')); |
| 103 | expect(text).toContain(toWinAnsi('Empfohlener Versatz')); |
| 104 | expect(text).toContain(toWinAnsi('Zeit-Weg-Diagramm')); |
| 105 | expect(text).toContain(toWinAnsi('(dieser Knotenpunkt)')); |
| 106 | }); |
| 107 | |
| 108 | it('sagt, dass der Versatz den eigenen Plan nicht verschiebt', () => { |
| 109 | const text = ausdruck(mitKette()); |
| 110 | expect(text).toContain(toWinAnsi('DER VERSATZ IST EINE EMPFEHLUNG FÜR DIE NACHBARANLAGEN')); |
| 111 | expect(text).toContain(toWinAnsi('gilt zwischen Anlagen, nicht innerhalb einer')); |
| 112 | }); |
| 113 | |
| 114 | it('sagt, dass die Nachbaranlagen hier nicht geprüft sind', () => { |
| 115 | /* |
| 116 | * Die wichtigste Abgrenzung des Abschnitts. Eine Behoerde, die eine |
| 117 | * Bandbreite und Versatzzeiten fuer drei Anlagen sieht, koennte den |
| 118 | * Ausdruck fuer die Unterlage des ganzen Strassenzugs halten. |
| 119 | */ |
| 120 | const text = ausdruck(mitKette()); |
| 121 | expect(text).toContain(toWinAnsi('weder wiedergegeben noch geprüft')); |
| 122 | expect(text).toContain(toWinAnsi('gemeinsame Umlaufzeit')); |
| 123 | }); |
| 124 | |
| 125 | it('benennt die fehlende Gegenrichtung, statt sie anzunehmen', () => { |
| 126 | const ohne = ausdruck(mitKette()); |
| 127 | expect(ohne).toContain(toWinAnsi('ein zweites Band wird deshalb NICHT gerechnet')); |
| 128 | |
| 129 | const mit = ausdruck(mitKette({ gegenrichtung: true })); |
| 130 | expect(mit).toContain(toWinAnsi('Bandbreite in der Gegenrichtung')); |
| 131 | expect(mit).not.toContain(toWinAnsi('ein zweites Band wird deshalb NICHT gerechnet')); |
| 132 | }); |
| 133 | |
| 134 | it('führt eine Zeile in der Bestandteileübersicht - auch ohne Straßenzug', () => { |
| 135 | const mit = ausdruck(mitKette()); |
| 136 | expect(mit).toContain(toWinAnsi('Koordinierung im Straßenzug (Grüne Welle)')); |
| 137 | expect(mit).toContain(toWinAnsi('ja – Abschnitt „Koordinierung"')); |
| 138 | |
| 139 | const ohne = ausdruck(ohneKette()); |
| 140 | expect(ohne).toContain(toWinAnsi('kein Straßenzug erfasst')); |
| 141 | // Ohne Kette gibt es den Abschnitt nicht - und damit keinen seiner Sätze. |
| 142 | expect(ohne).not.toContain(toWinAnsi('Zeit-Weg-Diagramm')); |
| 143 | expect(ohne).not.toContain(toWinAnsi('Empfohlener Versatz')); |
| 144 | }); |
| 145 | }); |
| 146 | |
| 147 | /* |
| 148 | * FASSUNG 5.43.0, BEFUND 13: Die Tabelle des Abschnitts nannte das |
| 149 | * Freigabefenster als "Beginn bis Beginn + Dauer", ohne Faltung - "91 s bis |
| 150 | * 139 s" bei 95 s Umlaufzeit, waehrend die Signalzeitentabelle derselben |
| 151 | * Unterlage "91–44" fuehrt und das Diagramm darunter (seit derselben Pruefung) |
| 152 | * mit dem gefalteten Fenster zeichnet. |
| 153 | */ |
| 154 | describe('Planunterlage - Freigabefenster der Koordinierungstabelle', () => { |
| 155 | it('nennt ein einzelnes eigenes Fenster mit derselben Zeichenkette wie die Signalzeitentabelle - auch über das Umlaufende', () => { |
| 156 | // Eine dritte Phase fuer Nord/Sued: K1 ist in der letzten und der ersten |
| 157 | // Phase frei, sein Fenster laeuft ueber das Umlaufende. |
| 158 | const basis = mitKette(); |
| 159 | const k1 = basis.signalGroups[0]!; |
| 160 | const k2 = basis.signalGroups[1]!; |
| 161 | const phase3 = createPhase('Phase 3 - Nord/Süd', [k1.id, k2.id]); |
| 162 | const projekt: Project = { |
| 163 | ...basis, |
| 164 | phases: [...basis.phases, phase3], |
| 165 | program: { ...basis.program, phaseOrder: [...basis.program.phaseOrder, phase3.id] }, |
| 166 | }; |
| 167 | const plan = buildSignalPlan(projekt); |
| 168 | const fenster = plan.groups.find((g) => g.groupId === k1.id)!.greens; |
| 169 | expect(fenster).toHaveLength(1); |
| 170 | expect(fenster[0]!.start + fenster[0]!.duration).toBeGreaterThan(plan.cycleTime); |
| 171 | |
| 172 | const zelle = freigabefensterZelle(fenster, plan.cycleTime); |
| 173 | const tabelle = koordinierungstabelle(ausdruck(projekt)); |
| 174 | expect(tabelle).toContain(toWinAnsi(zelle)); |
| 175 | expect(tabelle).not.toContain( |
| 176 | toWinAnsi(`bis ${fmt.seconds(fenster[0]!.start + fenster[0]!.duration)}`), |
| 177 | ); |
| 178 | }); |
| 179 | |
| 180 | it.each([ |
| 181 | { beginn: 50, dauer: 20, soll: '50–10', alt: 'bis 70 s' }, |
| 182 | // "Freigabe ab" darf die Umlaufzeit uebersteigen; gerechnet wird gefaltet. |
| 183 | { beginn: 70, dauer: 20, soll: '10–30', alt: 'bis 90 s' }, |
| 184 | ])( |
| 185 | 'faltet das Fenster einer Nachbaranlage in den Umlauf, wie die Koordinierung rechnet (Beginn $beginn s)', |
| 186 | ({ beginn, dauer, soll, alt }) => { |
| 187 | const tabelle = koordinierungstabelle(ausdruck(mitKette({ beginn, dauer }))); |
| 188 | expect(tabelle).toContain(toWinAnsi(soll)); |
| 189 | expect(tabelle).not.toContain(toWinAnsi(alt)); |
| 190 | }, |
| 191 | ); |
| 192 | |
| 193 | it('sagt, dass ein Fenster über den Umlaufbeginn hinaus vor seinem Beginn endet', () => { |
| 194 | const tabelle = koordinierungstabelle(ausdruck(mitKette({ beginn: 50, dauer: 20 }))); |
| 195 | expect(tabelle).toContain(toWinAnsi('in den Umlauf gefaltet')); |
| 196 | expect(tabelle).toContain(toWinAnsi('liegt sein Ende vor seinem Beginn')); |
| 197 | }); |
| 198 | }); |