lsa-planer

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

/ tests export absatzUmbruch.test.ts

7,2 KB Rohdatei
tests/export/absatzUmbruch.test.ts — 209 Zeilen
1 import { inflateSync } from 'node:zlib';
2 import { describe, expect, it } from 'vitest';
3 import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf';
4 import { buildSignalPlan } from '@/domain/plan/signalPlan';
5 import { validateProject } from '@/domain/validation';
6 import {
7 createArm,
8 createEmptyProject,
9 createPhase,
10 createSignalGroup,
11 } from '@/domain/model/factory';
12 import type { Project, SignalGroup } from '@/domain/model/project';
13 import type { TrafficMode } from '@/domain/rilsa/types';
14
15 /*
16 * BEFUNDE 20 UND 51: `writeParagraph` rief `ensureSpace`,
17 * und `ensureSpace` legt genau EIN Folgeblatt an. Danach setzte
18 * `doc.text(lines, ...)` alle Zeilen fortlaufend nach unten.
19 *
20 * Was der Anwender sonst erlebt: Er fuehrt viele Signalgruppen mit langen
21 * Bezeichnungen - weder Zahl noch Namenslaenge sind begrenzt, in src/ steht
22 * kein einziges `maxlength`. Drei Absaetze werden daraus so lang, dass sie
23 * nicht mehr auf ein Blatt passen:
24 *
25 * - der Furtregelabsatz unter der Signalgruppentabelle (Befund 20) - er
26 * traegt den Rechenweg der Mindestfreigabezeit je Fussgaengergruppe und
27 * endet mit dem Mittelinselvermerk, also mit der Einschraenkungserklaerung
28 * zu zweistufigen Furten;
29 * - der Fahrstreifensatz der Leistungstabelle bei abgewaehlter Tabelle
30 * "Signalgruppen" (Befund 51) - ohne ihn ist qS nicht prueffaehig;
31 * - der Vmax-Nachsatz des Anfahransatzes, ebenfalls bei abgewaehlter Tabelle.
32 *
33 * Die Zeilen liefen in den Fusssteg, ueberdruckten die Fusszeile und fielen
34 * schliesslich aus dem Blatt - ohne jeden Hinweis. Fuer Deckblatttitel
35 * (Befund 43) und Bemerkung (Befund 18) ist genau das mit
36 * `setzeZeilenMitUmbruch` bereits behoben worden; `writeParagraph` selbst
37 * nicht.
38 */
39
40 const FEST = new Date('2026-01-01T12:00:00Z');
41
42 const PAGE_MARGIN = 36;
43 const FOOTER_HEIGHT = 26;
44 /** Unterkante des Satzspiegels, in PDF-Koordinaten (Ursprung unten). */
45 const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT;
46 /** Grundlinie der Fusszeile - der einzige Text, der darunter stehen darf. */
47 const FUSSZEILE_Y = PAGE_MARGIN + 6;
48
49 /** Ein Name der gewuenschten Laenge, wie ihn eine Anordnungsunterlage traegt. */
50 function langerName(i: number, laenge: number): string {
51 let text =
52 `Fußgängerfurt ${i + 1} über die Hauptstraße nördliche Knotenpunktzufahrt mit Mittelinsel ` +
53 'und Blindenzusatz Anforderung über Taster ';
54 while (text.length < laenge) text += 'Zusatzangabe ';
55 return text.slice(0, laenge).trim();
56 }
57
58 interface Stueck {
59 readonly blatt: number;
60 readonly y: number;
61 readonly text: string;
62 }
63
64 function entpacke(roh: string, von: number): string | null {
65 const anfang = roh.indexOf('stream', von);
66 if (anfang < 0) return null;
67 const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7);
68 const ende = roh.indexOf('endstream', start);
69 if (ende < 0) return null;
70 try {
71 return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
72 } catch {
73 return null;
74 }
75 }
76
77 function textstuecke(bytes: Uint8Array): readonly Stueck[] {
78 const roh = new TextDecoder('latin1').decode(bytes);
79 const raus: Stueck[] = [];
80 let blatt = 0;
81
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) continue;
86 blatt += 1;
87 let y = 0;
88 let abstand = 0;
89 for (const roheZeile of inhalt.split('\n')) {
90 const zeile = roheZeile.trim();
91 const tl = /^(-?[\d.]+) TL$/.exec(zeile);
92 if (tl) {
93 abstand = Number(tl[1]);
94 continue;
95 }
96 const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile);
97 if (td) {
98 y = Number(td[2]);
99 continue;
100 }
101 let rest = zeile;
102 while (rest.startsWith('T*')) {
103 y -= abstand;
104 rest = rest.slice(2).trim();
105 }
106 const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest);
107 if (tj) raus.push({ blatt, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') });
108 }
109 }
110 return raus;
111 }
112
113 /**
114 * Ein Knotenpunkt mit n Signalgruppen einer Verkehrsart und langen Namen, ohne
115 * Konfliktbeziehungen - dann greift beim Furtregelabsatz der Zweig
116 * "Furtlaenge unbekannt", und jede Gruppe steuert einen Satz bei.
117 */
118 function projekt(n: number, mode: TrafficMode, laenge: number): Project {
119 const basis = createEmptyProject('Pruefkreuzung', FEST, 'knotenpunkt');
120 const arme = ['Nord', 'Ost', 'Süd', 'West'].map((r) => createArm(r, r, 2, 50));
121
122 const groups: SignalGroup[] = [];
123 for (let i = 0; i < n; i += 1) {
124 groups.push(
125 createSignalGroup({
126 name: langerName(i, laenge),
127 mode,
128 armId: arme[i % 4]?.id ?? null,
129 index: i,
130 }),
131 );
132 }
133
134 const phase = createPhase(
135 'Phase 1',
136 groups.map((g) => g.id),
137 );
138
139 return {
140 ...basis,
141 intersection: { ...basis.intersection, arms: arme },
142 signalGroups: groups,
143 conflicts: [],
144 phases: [phase],
145 program: { ...basis.program, phaseOrder: [phase.id] },
146 demands:
147 mode === 'kfz'
148 ? groups.map((g, i) => ({
149 signalGroupId: g.id,
150 volume: 300 + ((i * 37) % 400),
151 heavyVehicleShare: 0.05,
152 }))
153 : [],
154 };
155 }
156
157 function unterlage(project: Project, optionen: Partial<PdfOptions> = {}): Uint8Array {
158 const plan = buildSignalPlan(project);
159 return buildProjectPdf(
160 project,
161 plan,
162 validateProject(project, plan, FEST),
163 { ...DEFAULT_PDF_OPTIONS, ...optionen },
164 FEST,
165 );
166 }
167
168 /** Jeder Text unterhalb des Satzspiegels - ausser der Fusszeile selbst. */
169 function imFusssteg(bytes: Uint8Array): readonly string[] {
170 return textstuecke(bytes)
171 .filter((s) => s.y < SATZSPIEGEL_UNTEN && Math.abs(s.y - FUSSZEILE_Y) > 0.5)
172 .map((s) => `Blatt ${s.blatt}, y ${s.y.toFixed(2)}: "${s.text}"`);
173 }
174
175 describe('Befunde 20 und 51 - Absaetze ueber mehrere Blaetter', () => {
176 it('setzt den Furtregelabsatz bei 32 Fussgaengergruppen auf Folgeblaettern fort', () => {
177 const bytes = unterlage(projekt(32, 'fuss', 200));
178
179 // Vorbedingung: Der Absatz steht im Dokument und ist wirklich lang.
180 const zeilen = textstuecke(bytes).filter((s) => s.text.includes('Furtlänge unbekannt'));
181 expect(zeilen.length, 'Vorbedingung: der Furtregelabsatz steht im Dokument').toBeGreaterThan(
182 30,
183 );
184
185 expect(imFusssteg(bytes), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile').toEqual(
186 [],
187 );
188
189 // Gegenprobe: Der Mittelinselvermerk steht am Ende des Absatzes und fiel
190 // deshalb als erstes vom Blatt.
191 expect(
192 textstuecke(bytes).filter((s) => s.text.includes('Mittelinsel')).length,
193 'der Schluss des Absatzes fehlt im Dokument',
194 ).toBeGreaterThan(0);
195 });
196
197 it('setzt den Fahrstreifensatz bei 40 Kfz-Gruppen ohne Signalgruppentabelle fort', () => {
198 const bytes = unterlage(projekt(40, 'kfz', 200), { includeSignalGroups: false });
199
200 const zeilen = textstuecke(bytes).filter((s) => s.text.includes('Fahrstreifen'));
201 expect(zeilen.length, 'Vorbedingung: der Fahrstreifensatz steht im Dokument').toBeGreaterThan(
202 10,
203 );
204
205 expect(imFusssteg(bytes), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile').toEqual(
206 [],
207 );
208 });
209 });