lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests export beispieldateiAusdruck.test.ts

12,3 KB Rohdatei
tests/export/beispieldateiAusdruck.test.ts — 263 Zeilen
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 });