lsa-planer

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

/ tests export luftbildblatt.test.ts

13,3 KB Rohdatei
tests/export/luftbildblatt.test.ts — 378 Zeilen
1 import { inflateSync } from 'node:zlib';
2 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3 import { buildProjectPdf, DEFAULT_PDF_OPTIONS, lageplanblattVorlage } from '@/services/export/pdf';
4 import {
5 BLATTBILD_DPI,
6 BLATTBILD_ZIELBYTES,
7 blattbildGroesse,
8 blattbildVorbereiten,
9 datenBytes,
10 type Blattbild,
11 } from '@/services/export/blattbild';
12 import { buildSignalPlan } from '@/domain/plan/signalPlan';
13 import { validateProject } from '@/domain/validation';
14 import { createStandardIntersectionProject } from '@/domain/model/factory';
15 import { toWinAnsi } from '@/render/pdfSurface';
16 import type { Lageplan } from '@/domain/geometrie/lageplan';
17 import type { Project } from '@/domain/model/project';
18
19 /**
20 * Das zweite Lageplanblatt - derselbe Plan ueber dem Luftbild.
21 *
22 * WOZU (aus dem Betrieb, 11.09.2026): Das gedruckte Lageplanblatt zeigte
23 * Fahrlinien ohne Umgebung und trug keine Angabe zur Verortung. Der Anwender
24 * fragte, wie man damit ueberhaupt sagen soll, wo die Anlage liegt. Die
25 * Antwort war: gar nicht - die Herkunftszeile stand im Abschnitt
26 * "Planungsgrundlage" mehrere Seiten davor.
27 *
28 * WARUM DAS BILD NICHT EINFACH MITGEHT: bis zu 24 MiB je Unterlage. Es wird
29 * auf den gezeigten Ausschnitt zugeschnitten und auf die Aufloesung des
30 * Blattes verkleinert; danach haengt die Groesse an der Blattgroesse und nicht
31 * mehr an der Vorlage.
32 *
33 * jsdom bringt keine Zeichenflaeche mit. Der Stift schreibt hier mit, was in
34 * welcher Reihenfolge angeordnet wurde - dasselbe Verfahren wie bei
35 * `bildGrundfuellung`, und aus demselben Grund: Eine Fuellung NACH dem
36 * Zeichnen loeschte das Bild.
37 */
38
39 const STICHTAG = new Date('2026-01-01T12:00:00Z');
40
41 /** Ein wirkliches, wenn auch winziges JPEG - jsPDF liest die Bilddaten. */
42 const ECHTES_JPEG =
43 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL' +
44 'DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAAB' +
45 'AAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
46
47 function pdfText(bytes: Uint8Array): string {
48 const roh = new TextDecoder('latin1').decode(bytes);
49 const teile: string[] = [];
50 const suche = /stream\r?\n/g;
51 let treffer: RegExpExecArray | null;
52 while ((treffer = suche.exec(roh)) !== null) {
53 const start = treffer.index + treffer[0].length;
54 const ende = roh.indexOf('endstream', start);
55 if (ende < 0) continue;
56 let inhalt: string;
57 try {
58 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
59 } catch {
60 continue;
61 }
62 for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
63 teile.push(t[1]!.replace(/\\([()\\])/g, '$1'));
64 }
65 }
66 return teile.join(' ').replace(/\s+/g, ' ');
67 }
68
69 function lageplan(herkunft: 'gemessen' | 'georeferenziert', mitBild = true): Lageplan {
70 return {
71 arbeitsbereiche: [],
72 signalgeber: [],
73 bild: mitBild
74 ? {
75 datenUrl: ECHTES_JPEG,
76 breite: 400,
77 hoehe: 300,
78 herkunft:
79 herkunft === 'georeferenziert'
80 ? 'Nordrhein-Westfalen · Geobasis NRW · Mitte 50.941278, 6.958281 · Kante 200 m'
81 : 'katasterauszug.png',
82 geladenAm: '2026-01-01T10:00:00.000Z',
83 }
84 : null,
85 kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft },
86 haltlinien: [
87 {
88 id: 'h1',
89 name: 'Nord',
90 art: 'zufahrt',
91 armId: null,
92 von: { x: 0, y: 0 },
93 bis: { x: 0, y: 100 },
94 },
95 ],
96 linien: [
97 {
98 id: 'l1',
99 name: 'K1',
100 mode: 'kfz',
101 movement: 'geradeaus',
102 breiteMeter: 3,
103 punkte: [
104 { x: 0, y: 50 },
105 { x: 200, y: 50 },
106 ],
107 haltlinieId: 'h1',
108 startT: 0.5,
109 signalGroupId: null,
110 },
111 ],
112 };
113 }
114
115 function projekt(plan: Lageplan): Project {
116 return {
117 ...createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG),
118 lageplan: plan,
119 };
120 }
121
122 function ausdruck(plan: Lageplan, bild: Blattbild | null): Uint8Array {
123 const p = projekt(plan);
124 const signalplan = buildSignalPlan(p);
125 return buildProjectPdf(
126 p,
127 signalplan,
128 validateProject(p, signalplan, STICHTAG),
129 DEFAULT_PDF_OPTIONS,
130 STICHTAG,
131 bild,
132 );
133 }
134
135 function blattbild(): Blattbild {
136 const blatt = lageplanblattVorlage(lageplan('georeferenziert'));
137 if (blatt === null) throw new Error('Blatt erwartet');
138 return {
139 datenUrl: ECHTES_JPEG,
140 breite: 100,
141 hoehe: 40,
142 zielX: 0,
143 zielY: 0,
144 zielBreite: blatt.breite,
145 zielHoehe: blatt.hoehe,
146 };
147 }
148
149 describe('Die Unterlage druckt das zweite Blatt nur mit Bild', () => {
150 it('bleibt ohne Blattbild bei einem Lageplanblatt', () => {
151 const text = pdfText(ausdruck(lageplan('georeferenziert'), null));
152 expect(text).toContain(toWinAnsi('Lageplan der Fahrlinien'));
153 expect(text).not.toContain(toWinAnsi('über dem Luftbild'));
154 });
155
156 it('setzt mit Blattbild ein zweites Blatt darunter', () => {
157 const text = pdfText(ausdruck(lageplan('georeferenziert'), blattbild()));
158 expect(text).toContain(toWinAnsi('Lageplan der Fahrlinien'));
159 expect(text).toContain(toWinAnsi('Derselbe Lageplan über dem Luftbild'));
160 });
161
162 it('legt die Bilddaten wirklich in das Dokument', () => {
163 const roh = new TextDecoder('latin1').decode(
164 ausdruck(lageplan('georeferenziert'), blattbild()),
165 );
166 expect(roh, 'kein eingebettetes JPEG').toContain('DCTDecode');
167 });
168
169 it('sagt auf dem zweiten Blatt, dass daran nicht gemessen wird', () => {
170 /*
171 * DIE GEFAEHRLICHE VERWECHSLUNG. Ein Luftbild sieht nach Messgrundlage
172 * aus. Die verkleinerte Aufnahme traegt die Lage, nicht die Laenge -
173 * abgegriffen wird am Massstabsbalken.
174 */
175 const text = pdfText(ausdruck(lageplan('georeferenziert'), blattbild()));
176 expect(text).toContain(toWinAnsi('GEMESSEN WIRD NICHT AM BILD'));
177 });
178 });
179
180 describe('Die Verortung steht auf beiden Blaettern', () => {
181 it('druckt die Herkunft mit Ausschnittsmitte als Fußzeile', () => {
182 const text = pdfText(ausdruck(lageplan('georeferenziert'), null));
183 expect(text).toContain('50.941278');
184 });
185
186 it('setzt den Nordpfeil nur bei einem georeferenzierten Ausschnitt', () => {
187 const georef = pdfText(ausdruck(lageplan('georeferenziert'), null));
188 const gemessen = pdfText(ausdruck(lageplan('gemessen'), null));
189 /*
190 * Bei einem eingelesenen Bild ist die Ausrichtung unbekannt. Der Ausdruck
191 * sagt das ausdruecklich, statt es dem Leser zu ueberlassen - und setzt
192 * keinen Pfeil, den niemand nachpruefen kann.
193 */
194 expect(gemessen).toContain(toWinAnsi('ohne Verortung und ohne Nordrichtung'));
195 expect(georef).not.toContain(toWinAnsi('ohne Nordrichtung'));
196 });
197
198 it('laesst das Blatt ganz weg, wo es keine Bildgrundlage gibt', () => {
199 /*
200 * Ohne Bild gibt es kein Blatt - `drawPlanungsgrundlage` bricht den
201 * Abschnitt schon vorher ab, und der Abschnitt selbst prueft es noch
202 * einmal an der Stelle, an der es gilt. Kein dritter Fusszeilentext also:
203 * Ein Text fuer einen Fall, den nichts erreicht, sieht aus wie Vorsicht
204 * und ist eine Luecke im Nachweis.
205 */
206 const text = pdfText(ausdruck(lageplan('gemessen', false), null));
207 // Nicht an der Ueberschrift gemessen: Die Bestandteileuebersicht nennt den
208 // Abschnitt ohnehin. Der Satz hier steht nur unter der Zeichnung.
209 expect(text).not.toContain(toWinAnsi('MASSGEBEND IST DER MASSSTABSBALKEN'));
210 expect(text).toContain(toWinAnsi('kein Lageplan und kein Luftbild'));
211 });
212 });
213
214 // --- Das Verkleinern --------------------------------------------------------
215
216 describe('Die Groesse der Grafik folgt dem Blatt, nicht der Vorlage', () => {
217 it('rechnet Blattpunkte mit 200 Punkten je Zoll in Bildpunkte um', () => {
218 expect(BLATTBILD_DPI).toBe(200);
219 // 300 Blattpunkte sind 300/72 Zoll, mal 200 sind 833 Bildpunkte.
220 expect(blattbildGroesse(770, 300)).toEqual({ breite: 2139, hoehe: 833 });
221 });
222
223 it('deckelt eine unsinnig grosse Kante', () => {
224 expect(blattbildGroesse(100000, 300)!.breite).toBe(4000);
225 });
226
227 it('meldet null bei unbrauchbaren Massen', () => {
228 expect(blattbildGroesse(0, 300)).toBeNull();
229 expect(blattbildGroesse(770, Number.NaN)).toBeNull();
230 expect(blattbildGroesse(770, 300, 0)).toBeNull();
231 });
232
233 it('zaehlt die Bytes hinter einer Daten-URL, ohne sie zu entschluesseln', () => {
234 expect(datenBytes('data:image/jpeg;base64,////')).toBe(3);
235 expect(datenBytes('data:image/jpeg;base64,//8=')).toBe(2);
236 expect(datenBytes('data:image/jpeg;base64,//==')).toBe(1);
237 expect(datenBytes('unbrauchbar')).toBe(0);
238 });
239 });
240
241 interface Vorgang {
242 readonly art: 'fuellen' | 'zeichnen';
243 readonly werte: readonly number[];
244 }
245
246 let vorgaenge: Vorgang[] = [];
247 let stufen: (number | undefined)[] = [];
248 let geschlossen = 0;
249 let laenge = 40;
250
251 class Zeichenstift {
252 imageSmoothingQuality = 'low';
253 fillStyle = '';
254 fillRect(x: number, y: number, breite: number, hoehe: number): void {
255 vorgaenge.push({ art: 'fuellen', werte: [x, y, breite, hoehe] });
256 }
257 drawImage(_quelle: unknown, ...werte: number[]): void {
258 vorgaenge.push({ art: 'zeichnen', werte });
259 }
260 }
261
262 beforeEach(() => {
263 vorgaenge = [];
264 stufen = [];
265 geschlossen = 0;
266 laenge = 40;
267 vi.stubGlobal('createImageBitmap', () =>
268 Promise.resolve({
269 width: 400,
270 height: 300,
271 close: (): void => {
272 geschlossen += 1;
273 },
274 }),
275 );
276 vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
277 () => new Zeichenstift() as unknown as CanvasRenderingContext2D,
278 );
279 vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation(
280 (_typ?: string, stufe?: unknown) => {
281 stufen.push(stufe as number | undefined);
282 return `data:image/jpeg;base64,${'A'.repeat(laenge)}`;
283 },
284 );
285 });
286
287 afterEach(() => {
288 vi.unstubAllGlobals();
289 vi.restoreAllMocks();
290 });
291
292 async function vorbereitet(plan: Lageplan = lageplan('georeferenziert')) {
293 const blatt = lageplanblattVorlage(plan);
294 if (blatt === null) throw new Error('Blatt erwartet');
295 if (plan.bild === null) throw new Error('Bild erwartet');
296 return blattbildVorbereiten(plan.bild, blatt);
297 }
298
299 describe('Das Luftbild wird zugeschnitten und verkleinert', () => {
300 it('fuellt die Flaeche weiss, BEVOR gezeichnet wird', () => {
301 /*
302 * JPEG kennt keinen Alphakanal; eine durchsichtige Flaeche wird nach der
303 * HTML-Vorgabe auf SCHWARZ komponiert. Ragt die Zeichnung ueber den
304 * Bildrand hinaus - der Regelfall bei einer Zeichnung am Rand -, waere das
305 * ein schwarzer Balken neben der Aufnahme. Eine Fuellung NACH dem Zeichnen
306 * loeschte statt dessen das Bild.
307 */
308 return vorbereitet().then(() => {
309 expect(vorgaenge.map((v) => v.art)).toEqual(['fuellen', 'zeichnen']);
310 });
311 });
312
313 it('zeichnet mit Quell- und Zielbereich aus dem Zuschnitt', async () => {
314 await vorbereitet();
315 const zeichnen = vorgaenge.find((v) => v.art === 'zeichnen');
316 expect(zeichnen, 'nicht gezeichnet').toBeDefined();
317 // Acht Werte heisst: Quellrechteck UND Zielrechteck. Mit vieren waere der
318 // Zuschnitt gar nicht angewendet.
319 expect(zeichnen!.werte).toHaveLength(8);
320 });
321
322 it('nimmt die beste Verdichtungsstufe, wenn sie schon passt', async () => {
323 await vorbereitet();
324 expect(stufen).toEqual([0.72]);
325 });
326
327 it('geht die Stufen abwaerts, bis das Ziel erreicht ist', async () => {
328 // Eine Daten-URL, die erst unterhalb der Regelstufe klein genug wird.
329 let ruf = 0;
330 vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation(
331 (_typ?: string, stufe?: unknown) => {
332 stufen.push(stufe as number | undefined);
333 ruf += 1;
334 const zeichen = ruf < 3 ? BLATTBILD_ZIELBYTES * 4 : 100;
335 return `data:image/jpeg;base64,${'A'.repeat(zeichen)}`;
336 },
337 );
338 await vorbereitet();
339 expect(stufen).toEqual([0.72, 0.6, 0.45]);
340 });
341
342 it('gibt auch dann ein Bild aus, wenn keine Stufe das Ziel erreicht', async () => {
343 /*
344 * Eine Planunterlage am Erzeugen zu hindern, weil eine Aufnahme sich
345 * schlecht verdichtet, waere das falsche Verhaeltnis von Anlass und Folge.
346 */
347 laenge = BLATTBILD_ZIELBYTES * 4;
348 const ergebnis = await vorbereitet();
349 expect(ergebnis, 'kein Bild trotz letzter Stufe').not.toBeNull();
350 expect(stufen).toEqual([0.72, 0.6, 0.45, 0.32]);
351 });
352
353 it('deckt mit dem Ergebnis das ganze Blatt ab - der Zuschnitt steckt darin', async () => {
354 const blatt = lageplanblattVorlage(lageplan('georeferenziert'))!;
355 const ergebnis = await vorbereitet();
356 expect(ergebnis!.zielX).toBe(0);
357 expect(ergebnis!.zielY).toBe(0);
358 expect(ergebnis!.zielBreite).toBe(blatt.breite);
359 expect(ergebnis!.zielHoehe).toBe(blatt.hoehe);
360 });
361
362 it('gibt das entschluesselte Bild wieder frei', async () => {
363 // Bei bis zu 24 MiB ist das kein Nebenpunkt.
364 await vorbereitet();
365 expect(geschlossen).toBe(1);
366 });
367
368 it('meldet null bei einer unlesbaren Daten-URL, statt den Ausdruck zu verhindern', async () => {
369 const plan = lageplan('georeferenziert');
370 const kaputt: Lageplan = { ...plan, bild: { ...plan.bild!, datenUrl: 'kein Bild' } };
371 expect(await vorbereitet(kaputt)).toBeNull();
372 });
373
374 it('meldet null, wo es keine Zeichenflaeche gibt', async () => {
375 vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => null);
376 expect(await vorbereitet()).toBeNull();
377 });
378 });