lsa-planer

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

/ tests export spaltenkoepfe.test.ts

7,2 KB Rohdatei
tests/export/spaltenkoepfe.test.ts — 176 Zeilen
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 type { Project, TrafficDemand } from '@/domain/model/project';
9
10 /*
11 * Befund 54 zur Fassung 5.9.0, ein Rest.
12 *
13 * Die beiden Prozentspalten der Tabellenausgabe trugen ihre Zahlen ohne jeden
14 * Massstab: "Schwerverkehrsanteil" und "Lastzuganteil am Schwerverkehr
15 * (leer = nicht erfasst)". In der Zeile steht 12 - und daneben, in der Spalte
16 * "Auslastungsgrad", eine Zahl zwischen 0 und 1, die ebenfalls ein Anteil ist.
17 * Der Ausdruck schreibt an derselben Stelle "12 %" (pdf.ts,
18 * schwerverkehrZelle). Wer beide Unterlagen nebeneinanderlegt - und dafuer sind
19 * sie da -, liest in der einen einen Anteil von 12 % und in der anderen eine
20 * blanke 12.
21 *
22 * Behoben ist es im KOPF, nicht am Wert: Eine Tabellenkalkulation liest "12 %"
23 * je nach Einstellung als Text oder als 0,12; die Spalte waere in einer
24 * Auswertung unbrauchbar. Die uebrigen Spalten dieser Ausgabe fuehren ihre
25 * Einheit ebenso in eckigen Klammern ("V zul [km/h]", "Rot-Gelb [s]",
26 * "Verkehrsstärke [Fz/h]").
27 *
28 * Die Behebung unterblieb zunaechst, weil die beiden Koepfe in drei
29 * Testdateien als exakte Suchschluessel fuer den Spaltenindex dienen; sie sind
30 * mit dieser Aenderung nachgezogen.
31 *
32 * MUTATIONSNACHWEIS (nachgemessen: beide Koepfe im Altstand zurueckgesetzt,
33 * diese Datei dagegen gelaufen): 2 von 3 Faellen fallen - die beiden ersten.
34 * Der dritte haelt fest, dass die WERTE ohne Einheit bleiben; er ist in beiden
35 * Staenden gruen und bewacht die Gegenrichtung dieser Aenderung.
36 *
37 * BERICHTIGT (Fassung 5.10.0): Hier stand dieselbe Aussage, sie
38 * traf aber nicht zu - der dritte Fall suchte seine Spalte an ihrem KOPF und
39 * fiel gegen den Altstand mit; gemessen fielen 3 von 3. Er sucht sie jetzt
40 * ueber die Nachbarspalte "Schwerverkehrsfaktor fSV", die diese Aenderung nicht
41 * angefasst hat, und ist damit wirklich unabhaengig.
42 */
43
44 const STICHTAG = new Date('2026-01-01T12:00:00Z');
45
46 const KOPF_ANTEIL = 'Schwerverkehrsanteil [%]';
47 const KOPF_LASTZUG = 'Lastzuganteil am Schwerverkehr [%] (leer = nicht erfasst)';
48
49 /** Knotenpunkt mit erfasstem Schwerverkehrsanteil und erfasster Aufteilung. */
50 function mitAufteilung(): Project {
51 const basis = createStandardIntersectionProject('Spaltenköpfe', STICHTAG);
52 return {
53 ...basis,
54 demands: basis.demands.map((d): TrafficDemand => ({
55 ...d,
56 heavyVehicleShare: 0.12,
57 lastzugAnteil: 0.4,
58 })),
59 };
60 }
61
62 /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */
63 function pdfText(bytes: Uint8Array): string {
64 const roh = new TextDecoder('latin1').decode(bytes);
65 const teile: string[] = [];
66 const suche = /stream\r?\n/g;
67 let treffer: RegExpExecArray | null;
68
69 while ((treffer = suche.exec(roh)) !== null) {
70 const start = treffer.index + treffer[0].length;
71 const ende = roh.indexOf('endstream', start);
72 if (ende < 0) continue;
73 let inhalt: string;
74 try {
75 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
76 } catch {
77 continue;
78 }
79 for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
80 teile.push(t[1]!.replace(/\\([()\\])/g, '$1'));
81 }
82 }
83 return teile.join(' ').replace(/\s+/g, ' ');
84 }
85
86 /**
87 * Eine Zeile in ihre Felder zerlegen - mit Anfuehrungszeichen: Die Spalte
88 * "Freigabefenster" traegt bei mehreren Fenstern ein Semikolon, das die
89 * Ausgabe ordnungsgemaess in Anfuehrungszeichen setzt.
90 */
91 function zerlege(zeile: string): string[] {
92 return (zeile.match(/("([^"]|"")*"|[^;]*)(;|$)/g) ?? []).map((feld) =>
93 feld.replace(/;$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'),
94 );
95 }
96
97 function tabelle(project: Project): string[][] {
98 const zeilen = signalGroupsCsv(project, buildSignalPlan(project))
99 .replace(/^/, '')
100 .split('\r\n')
101 .filter((z) => z.length > 0);
102 return zeilen.map(zerlege);
103 }
104
105 function kopfzeile(project: Project): string[] {
106 return tabelle(project)[0]!;
107 }
108
109 /**
110 * Der Wert einer der beiden Prozentspalten - gefunden ueber die NACHBARSPALTE.
111 *
112 * Nicht ueber den eigenen Kopf: Ein Fall, der die Spalte an ihrem Kopf sucht,
113 * faellt mit jeder Aenderung des Kopfes und bewacht die Gegenrichtung deshalb
114 * nicht. Massgebend ist hier der Kopf "Schwerverkehrsfaktor fSV", den diese
115 * Aenderung nicht angefasst hat: Zwei Spalten davor steht der Anteil, eine
116 * davor die Aufteilung.
117 */
118 function wertNebenFsv(project: Project, versatz: number, gruppe: string): string {
119 const zeilen = tabelle(project);
120 const fsv = zeilen[0]!.indexOf('Schwerverkehrsfaktor fSV');
121 expect(
122 fsv,
123 'Vorbedingung: die Spalte "Schwerverkehrsfaktor fSV" steht in der Ausgabe',
124 ).toBeGreaterThan(0);
125 const zeile = zeilen.slice(1).find((z) => z[0] === gruppe)!;
126 return zeile[fsv + versatz]!;
127 }
128
129 describe('Befund 54 - die Prozentspalten der Tabellenausgabe nennen ihre Einheit', () => {
130 it('führt die Einheit im Kopf beider Spalten', () => {
131 const kopf = kopfzeile(mitAufteilung());
132
133 expect(kopf).toContain(KOPF_ANTEIL);
134 expect(kopf).toContain(KOPF_LASTZUG);
135 // Und nicht mehr ohne: Ein Kopf ohne Massstab neben dem Auslastungsgrad
136 // war der Befund.
137 expect(kopf).not.toContain('Schwerverkehrsanteil');
138 expect(kopf).not.toContain('Lastzuganteil am Schwerverkehr (leer = nicht erfasst)');
139 });
140
141 it('nimmt dasselbe Einheitenzeichen wie der Ausdruck und dieselbe Klammerform wie die Nachbarspalten', () => {
142 const projekt = mitAufteilung();
143 const kopf = kopfzeile(projekt);
144 const plan = buildSignalPlan(projekt);
145 const bericht = validateProject(projekt, plan, STICHTAG);
146 const gedruckt = pdfText(
147 buildProjectPdf(projekt, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG),
148 );
149
150 // So schreibt der Ausdruck den Anteil an den Wert (pdf.ts,
151 // schwerverkehrZelle) - Zahl, Leerzeichen, Prozentzeichen.
152 expect(gedruckt, 'Vorbedingung: der Ausdruck nennt den Anteil mit Einheit').toContain('12 %');
153
154 // Dasselbe Zeichen steht in der Tabelle im Kopf, in der Klammerform der
155 // uebrigen Einheitenspalten dieser Ausgabe.
156 for (const spalte of kopf.filter(
157 (s) => s.startsWith('Schwerverkehrsanteil') || s.startsWith('Lastzuganteil'),
158 )) {
159 expect(spalte, spalte).toContain('[%]');
160 }
161 expect(kopf, 'Vorbedingung: die Klammerform ist die dieser Ausgabe').toContain('V zul [km/h]');
162 expect(kopf).toContain('Verkehrsstärke [Fz/h]');
163 });
164
165 it('lässt die Werte ohne Einheit - die Spalte bleibt eine Zahlenspalte', () => {
166 const projekt = mitAufteilung();
167 const gruppe = projekt.signalGroups[0]!.name;
168
169 // 12,0 % und 40,0 % als blanke Zahlen: Mit angehaengtem Prozentzeichen
170 // laese eine Tabellenkalkulation die Zelle als Text. Gesucht ueber die
171 // Nachbarspalte fSV, damit dieser Fall vom Kopf unabhaengig ist und die
172 // Gegenrichtung wirklich bewacht.
173 expect(wertNebenFsv(projekt, -2, gruppe)).toBe('12');
174 expect(wertNebenFsv(projekt, -1, gruppe)).toBe('40');
175 });
176 });