lsa-planer

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

/ tests export pdfZwischenzeitenmatrix.test.ts

29,4 KB Rohdatei
tests/export/pdfZwischenzeitenmatrix.test.ts — 779 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, intergreenKey, type SignalPlan } from '@/domain/plan/signalPlan';
5 import { validateProject } from '@/domain/validation';
6 import {
7 createArm,
8 createConflict,
9 createEmptyProject,
10 createPhase,
11 createSignalGroup,
12 } from '@/domain/model/factory';
13 import type { Conflict, Project, SignalGroup } from '@/domain/model/project';
14 import * as fmt from '@/ui/format';
15
16 /*
17 * PRUEFSTAND DER ZWISCHENZEITENMATRIX IM AUSDRUCK.
18 *
19 * Alle Zusicherungen werden AUS DER ERZEUGTEN DATEI zurueckgelesen, nicht aus
20 * dem Zwischenzustand des Tabellenaufbaus. Das ist der Unterschied, auf den es
21 * hier ankommt: Der Fehler, der behoben wurde, entstand nicht beim Fuellen der
22 * Tabelle - die uebergebenen Zellen waren immer vollstaendig und richtig -,
23 * sondern beim Setzen. jspdf-autotable zerlegte Namen und Zahlen beim Umbruch
24 * zeichenweise; wer nur die Uebergabe prueft, sieht davon nichts.
25 */
26
27 const FEST = new Date('2026-01-01T12:00:00Z');
28
29 const NUR_MATRIX: PdfOptions = {
30 ...DEFAULT_PDF_OPTIONS,
31 includeSignalGroups: false,
32 includePhases: false,
33 includePlan: false,
34 includePerformance: false,
35 includeReport: false,
36 includeCalculationDetails: false,
37 includeSources: false,
38 };
39
40 const ECKE = 'räumt \\ fährt ein';
41
42 /** Ein langer Signalgruppenname, wie ihn die Praxis erzeugt (rund 140 Zeichen). */
43 function langerName(i: number): string {
44 const richtungen = ['Nordzufahrt', 'Ostzufahrt', 'Südzufahrt', 'Westzufahrt'];
45 const richtung = richtungen[i % 4] ?? 'Nordzufahrt';
46 return (
47 `Kraftfahrzeuge ${richtung} Hauptstraße geradeaus und rechts abbiegend, ` +
48 `Fahrstreifen ${(i % 3) + 1}, Signalgeber N${i + 1}, Anforderung über Detektor ` +
49 `D${i + 1} (Schleife)`
50 );
51 }
52
53 /**
54 * Ein Name der gewuenschten Laenge - fuer die Frage, wie weit die Zusage
55 * "ungeteilt in einer Zeile" traegt. SignalGroup.name ist nicht begrenzt.
56 */
57 function namenlaenge(i: number, laenge: number): string {
58 let text = `Signalgruppe ${i + 1} Nordzufahrt Hauptstraße geradeaus rechts abbiegend Anforderung über Detektor `;
59 while (text.length < laenge) text += 'Zusatzangabe ';
60 return text.slice(0, laenge).trim();
61 }
62
63 interface Pruefprojekt {
64 readonly project: Project;
65 readonly plan: SignalPlan;
66 readonly groups: readonly SignalGroup[];
67 }
68
69 /**
70 * Knotenpunkt mit n Signalgruppen und vollstaendig besetzter Konfliktmatrix.
71 *
72 * `luecke` laesst eine Beziehung bewusst aus - dann muss in der Matrix der
73 * Punkt fuer "vertraeglich" stehen. `vorgabe` gibt jede zehnte Beziehung von
74 * Hand vor - dann muss der Stern stehen.
75 */
76 function pruefprojekt(n: number, lang: boolean, luecke = true): Pruefprojekt {
77 return benannt(n, lang ? langerName : (i) => `K${i + 1}`, luecke);
78 }
79
80 /** Ein Knotenpunkt mit frei gewaehlten Bezeichnungen - fuer die Grenzfaelle. */
81 function benannt(n: number, name: (i: number) => string, luecke = true): Pruefprojekt {
82 const basis = createEmptyProject('Pruefkreuzung', FEST, 'knotenpunkt');
83 const arme = ['Nord', 'Ost', 'Süd', 'West'].map((r) => createArm(r, r, 2, 50));
84
85 const groups: SignalGroup[] = [];
86 for (let i = 0; i < n; i += 1) {
87 groups.push(
88 createSignalGroup({
89 name: name(i),
90 mode: 'kfz',
91 armId: arme[i % 4]?.id ?? null,
92 index: i,
93 }),
94 );
95 }
96
97 const conflicts: Conflict[] = [];
98 for (let i = 0; i < n; i += 1) {
99 for (let j = 0; j < n; j += 1) {
100 if (i === j) continue;
101 // Eine einzige Luecke: K1 -> K2 bleibt ohne Konflikt und muss in der
102 // Matrix als Punkt erscheinen.
103 if (luecke && i === 0 && j === 1) continue;
104 const von = groups[i];
105 const nach = groups[j];
106 if (!von || !nach) continue;
107 conflicts.push({
108 ...createConflict(von.id, nach.id),
109 clearingDistance: 18 + (i % 7),
110 enteringDistance: 6 + (j % 5),
111 manualIntergreen: (i * n + j) % 10 === 0 ? 7 : null,
112 });
113 }
114 }
115
116 const haelfte = Math.ceil(n / 2);
117 const p1 = createPhase(
118 'Phase 1',
119 groups.slice(0, haelfte).map((g) => g.id),
120 );
121 const p2 = createPhase(
122 'Phase 2',
123 groups.slice(haelfte).map((g) => g.id),
124 );
125
126 const project: Project = {
127 ...basis,
128 meta: { ...basis.meta, planner: 'Pruefstand', location: 'Pruefort' },
129 intersection: { ...basis.intersection, arms: arme },
130 signalGroups: groups,
131 conflicts,
132 phases: [p1, p2],
133 program: { ...basis.program, phaseOrder: [p1.id, p2.id] },
134 demands: groups.map((g, i) => ({
135 signalGroupId: g.id,
136 volume: 300 + ((i * 37) % 400),
137 heavyVehicleShare: 0.05,
138 })),
139 };
140
141 return { project, plan: buildSignalPlan(project), groups };
142 }
143
144 /** Ein Knotenpunkt, dessen Namen fuer eine Bannerzeile zu lang sind. */
145 function langnamenprojekt(n: number, laenge: number): Pruefprojekt {
146 return benannt(n, (i) => namenlaenge(i, laenge));
147 }
148
149 function erzeuge(p: Pruefprojekt, optionen: PdfOptions = NUR_MATRIX): Uint8Array {
150 return buildProjectPdf(
151 p.project,
152 p.plan,
153 validateProject(p.project, p.plan, FEST),
154 optionen,
155 FEST,
156 );
157 }
158
159 /** Ein gesetztes Textstueck mit seiner Stelle auf dem Blatt. */
160 interface Textstueck {
161 readonly x: number;
162 readonly y: number;
163 readonly text: string;
164 }
165
166 /**
167 * Eine gesetzte Tabellenzelle mit ihren Textzeilen.
168 *
169 * WARUM DIE RECHTECKE MITGELESEN WERDEN (Befund B8): Aus den Textstuecken
170 * allein laesst sich nicht erkennen, ob eine Kopfzelle ihre Bezeichnung
171 * UNGETEILT traegt. Ein mitten im Wort gebrochener Kopf liefert auf der
172 * Grundlinie nur sein erstes Bruchstueck - "K1" aus "K10" - und eine Probe, die
173 * nur den Anfang prueft, nimmt genau den Fehler widerspruchslos an, um den es
174 * hier geht. jspdf-autotable setzt fuer jede Zelle ein Rechteck ("x y b h re")
175 * und danach deren Text; ueber die Koordinate gehoert jede Textzeile ihrer
176 * Zelle zu, und die Zeilenzahl ist ablesbar.
177 */
178 interface Zelle {
179 readonly x: number;
180 readonly oben: number;
181 readonly breite: number;
182 readonly hoehe: number;
183 readonly zeilen: readonly string[];
184 }
185
186 interface Blatt {
187 readonly nummer: number;
188 readonly stuecke: readonly Textstueck[];
189 readonly zellen: readonly Zelle[];
190 }
191
192 function entschluessele(roh: string): string {
193 return roh.replace(/\\([()\\])/g, '$1');
194 }
195
196 /**
197 * Die Blaetter der Datei mit allen gesetzten Textstuecken samt Koordinate.
198 *
199 * Gelesen wird der Inhaltsstrom: `x y Td (Text) Tj`, Folgezeilen ueber `T*`
200 * mit dem zuletzt gesetzten Zeilenabstand `TL`. Die Blattnummer kommt aus der
201 * Fusszeile ("Seite k von m") - so bleibt die Zuordnung auch dann richtig,
202 * wenn jsPDF die Stroeme einmal in anderer Reihenfolge ablegt.
203 */
204 function blaetter(bytes: Uint8Array): Blatt[] {
205 const roh = new TextDecoder('latin1').decode(bytes);
206 const gefunden: Blatt[] = [];
207 const suche = /stream\r?\n/g;
208 let treffer: RegExpExecArray | null;
209
210 while ((treffer = suche.exec(roh)) !== null) {
211 const start = treffer.index + treffer[0].length;
212 const ende = roh.indexOf('endstream', start);
213 if (ende < 0) continue;
214 let inhalt: string;
215 try {
216 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
217 } catch {
218 continue;
219 }
220 if (!inhalt.includes(' Tj')) continue;
221
222 const stuecke: Textstueck[] = [];
223 const zellen: { x: number; oben: number; breite: number; hoehe: number; zeilen: string[] }[] =
224 [];
225 let laufend: { oben: number; hoehe: number; zeilen: string[] } | null = null;
226 let x = 0;
227 let y = 0;
228 let abstand = 0;
229 for (const zeile of inhalt.split('\n')) {
230 const re = /^(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) re$/.exec(zeile.trim());
231 if (re) {
232 const neu = {
233 x: Number(re[1]),
234 oben: Number(re[2]),
235 breite: Number(re[3]),
236 hoehe: Math.abs(Number(re[4])),
237 zeilen: [] as string[],
238 };
239 // Fuellung und Rahmen derselben Zelle erzeugen zwei gleiche Rechtecke;
240 // das zweite ersetzt das noch leere erste.
241 const vorher = zellen[zellen.length - 1];
242 if (
243 vorher &&
244 vorher.zeilen.length === 0 &&
245 Math.abs(vorher.x - neu.x) < 0.001 &&
246 Math.abs(vorher.oben - neu.oben) < 0.001 &&
247 Math.abs(vorher.breite - neu.breite) < 0.001
248 ) {
249 zellen.pop();
250 }
251 zellen.push(neu);
252 laufend = neu;
253 continue;
254 }
255 const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile);
256 if (td) {
257 x = Number(td[1]);
258 y = Number(td[2]);
259 continue;
260 }
261 const tl = /^(-?[\d.]+) TL$/.exec(zeile);
262 if (tl) {
263 abstand = Number(tl[1]);
264 continue;
265 }
266 if (zeile.trim() === 'T*') {
267 y -= abstand;
268 continue;
269 }
270 // Folgezeilen eines mehrzeiligen Textes schreibt jsPDF als
271 // "T* (Text) Tj" in EINE Zeile - ohne das entginge dem Pruefstand genau
272 // das, was hier nachzuweisen ist.
273 const tj = /^(T\*\s+)?\((.*)\) Tj$/.exec(zeile);
274 if (tj) {
275 if (tj[1]) y -= abstand;
276 const text = entschluessele(tj[2] ?? '');
277 stuecke.push({ x, y, text });
278 if (laufend && y <= laufend.oben + 0.5 && y >= laufend.oben - laufend.hoehe - 0.5) {
279 laufend.zeilen.push(text);
280 }
281 }
282 }
283
284 const fuss = stuecke.map((s) => /^Seite (\d+) von \d+$/.exec(s.text)).find((m) => m !== null);
285 if (!fuss) continue;
286 gefunden.push({ nummer: Number(fuss[1]), stuecke, zellen });
287 }
288
289 return gefunden.sort((a, b) => a.nummer - b.nummer);
290 }
291
292 /** Der gesamte gesetzte Text der Datei, Blatt fuer Blatt. */
293 function ganzerText(bytes: Uint8Array): string {
294 return blaetter(bytes)
295 .flatMap((b) => b.stuecke.map((s) => s.text))
296 .join(' ');
297 }
298
299 /** Alle Textstuecke einer Grundlinie, von links nach rechts. */
300 function zeileBei(blatt: Blatt, y: number): Textstueck[] {
301 return blatt.stuecke.filter((s) => Math.abs(s.y - y) < 0.01).sort((a, b) => a.x - b.x);
302 }
303
304 /**
305 * Alle Zellen der Matrixtabellen - erkannt an der Kopfecke ihres Blattes.
306 *
307 * Genommen wird, was rechts von der Ecke und nicht ueber ihr steht: der Kopf
308 * und der Koerper der Matrix, ohne Kopf- und Fusszeile der Unterlage.
309 */
310 function matrixzellen(bytes: Uint8Array): { kopf: Zelle[]; koerper: Zelle[] } {
311 const kopf: Zelle[] = [];
312 const koerper: Zelle[] = [];
313 for (const blatt of blaetter(bytes)) {
314 for (const ecke of blatt.zellen.filter((z) => z.zeilen.join(' ') === ECKE)) {
315 const kopfzeile = blatt.zellen.filter(
316 (z) => Math.abs(z.oben - ecke.oben) < 0.01 && z.x >= ecke.x - 0.01,
317 );
318 kopf.push(...kopfzeile.filter((z) => z.zeilen.join(' ') !== ECKE));
319
320 /*
321 * Zum Koerper gehoert nur, was UNTER der Kopfzeile in EINER IHRER SPALTEN
322 * steht. Die Abgrenzung ueber die Spaltenstelle ist noetig, weil sonst
323 * die Fusszeile des Blattes mitgezaehlt wird - sie steht ebenfalls unter
324 * der Kopfzeile und rechts von der Ecke.
325 */
326 const spalten = kopfzeile.map((z) => z.x);
327 for (const z of blatt.zellen) {
328 if (z.oben >= ecke.oben) continue;
329 if (spalten.some((x) => Math.abs(x - z.x) < 0.01)) koerper.push(z);
330 }
331 }
332 }
333 return { kopf, koerper };
334 }
335
336 /** Der erwartete Zellinhalt aus dem Fachkern - unabhaengig vom Ausdruck gebildet. */
337 function erwarteterWert(plan: SignalPlan, von: SignalGroup, nach: SignalGroup): string {
338 if (von.id === nach.id) return '';
339 const gefunden = plan.intergreens.get(intergreenKey(von.id, nach.id));
340 if (!gefunden) return '·';
341 return gefunden.source === 'vorgegeben'
342 ? `${fmt.numShort(gefunden.value, 0)}*`
343 : fmt.numShort(gefunden.value, 0);
344 }
345
346 /**
347 * Die Matrix aus der Datei zurueckgelesen.
348 *
349 * Ergebnis ist je Blatt: welche einfahrenden Signalgruppen (Spalten) es
350 * traegt, und welche Zahlen in welcher Zeile stehen.
351 */
352 interface AusgeleseneZeile {
353 readonly zeilenmarke: string;
354 readonly werte: readonly string[];
355 }
356
357 interface AusgelesenesBlatt {
358 readonly nummer: number;
359 readonly spaltenmarken: readonly string[];
360 readonly zeilen: readonly AusgeleseneZeile[];
361 readonly zuordnung: ReadonlyMap<string, string>;
362 }
363
364 function leseMatrix(bytes: Uint8Array): AusgelesenesBlatt[] {
365 const ergebnis: AusgelesenesBlatt[] = [];
366
367 for (const blatt of blaetter(bytes)) {
368 /*
369 * Auf einem Blatt koennen ZWEI Ausschnitte stehen - der letzte Streifen des
370 * einen und der Anfang des naechsten. Jede Kopfecke eroeffnet deshalb einen
371 * eigenen Bereich, der bis zur naechsten Kopfecke darunter reicht.
372 *
373 * DIE ECKE WIRD UEBER IHRE ZELLE GESUCHT, nicht ueber ein Textstueck: Im
374 * bisherigen Satz wird die feste Beschriftung "räumt \ fährt ein" bei
375 * vielen Spalten selbst umbrochen (an Wortgrenzen - sie ist eine
376 * Beschriftung, keine Angabe). Dann gibt es kein Stueck mehr, das ihr
377 * gleicht, und eine Suche danach faende die Matrix ueberhaupt nicht.
378 * Anker ist die OBERSTE Textzeile der Eckzelle; alle Zellen einer Zeile
379 * sitzen oben buendig, ihre erste Zeile also auf derselben Grundlinie.
380 */
381 const ecken = blatt.zellen
382 .filter((z) => z.zeilen.join(' ') === ECKE)
383 .map(
384 (z) =>
385 blatt.stuecke
386 .filter(
387 (s) =>
388 s.text === z.zeilen[0] &&
389 s.y <= z.oben + 0.5 &&
390 s.y >= z.oben - z.hoehe - 0.5 &&
391 // Waagerecht nur GROB eingegrenzt: Die Kopfecke steht je nach
392 // Satzart linksbuendig oder mittig in ihrer Zelle.
393 s.x >= z.x - 0.5 &&
394 s.x <= z.x + z.breite + 0.5,
395 )
396 .sort((a, b) => b.y - a.y)[0],
397 )
398 .filter((s): s is Textstueck => s !== undefined)
399 .sort((a, b) => b.y - a.y);
400
401 for (let k = 0; k < ecken.length; k += 1) {
402 const ecke = ecken[k];
403 if (!ecke) continue;
404 const obergrenze = k === 0 ? Number.POSITIVE_INFINITY : (ecken[k - 1]?.y ?? 0);
405 const untergrenze = ecken[k + 1]?.y ?? Number.NEGATIVE_INFINITY;
406
407 const kopf = zeileBei(blatt, ecke.y);
408 const eckeStelle = kopf.indexOf(ecke);
409 const spaltenmarken = kopf.slice(eckeStelle + 1).map((s) => s.text);
410
411 // Die Zuordnung steht als Zeilenfolge "7 = Name" ueber der Tabelle.
412 const zuordnung = new Map<string, string>();
413 for (const stueck of blatt.stuecke) {
414 const m = /^(\d+) = (.+)$/.exec(stueck.text);
415 if (m && stueck.y > ecke.y && stueck.y < obergrenze) {
416 zuordnung.set(m[1] ?? '', m[2] ?? '');
417 }
418 }
419
420 // Koerperzeilen: Grundlinien UNTER der Kopfzeile, die mehr als die
421 // Zeilenmarke tragen. Alle Zellen einer Zeile sitzen oben buendig, ihre
422 // erste Textzeile also auf derselben Grundlinie.
423 const grundlinien = [
424 ...new Set(blatt.stuecke.filter((s) => s.y < ecke.y && s.y > untergrenze).map((s) => s.y)),
425 ].sort((a, b) => b - a);
426
427 const zeilen: AusgeleseneZeile[] = [];
428 for (const y of grundlinien) {
429 const stuecke = zeileBei(blatt, y);
430 if (stuecke.length < 3) continue;
431 // Fusszeile des Blattes ausschliessen.
432 if (stuecke.some((s) => /^Seite \d+ von \d+$/.test(s.text))) continue;
433 const marke = stuecke[0];
434 if (!marke) continue;
435 /*
436 * Vor den Werten stehen entweder zwei Vorspalten (Ordnungsnummer und
437 * Name) oder eine (nur der Name) - je nachdem, ob die Koepfe Nummern
438 * tragen. Woran man es erkennt, steht in der Kopfzeile: Die Kopfecke
439 * ist die letzte Vorspalte.
440 */
441 zeilen.push({
442 zeilenmarke: marke.text,
443 werte: stuecke.slice(eckeStelle + 1).map((s) => s.text),
444 });
445 }
446
447 ergebnis.push({ nummer: blatt.nummer, spaltenmarken, zeilen, zuordnung });
448 }
449 }
450
451 return ergebnis;
452 }
453
454 /**
455 * Vollstaendigkeitsprobe: Jede Zahl der Matrix steht in der Datei, in der
456 * richtigen Zeile und der richtigen Spalte.
457 *
458 * Die Marken der Koepfe sind je nach Satzart Ordnungsnummern oder die
459 * Bezeichnungen selbst; beides wird ueber dieselbe Zuordnung Marke -> Stelle
460 * aufgeloest, damit die Probe fuer beide Satzarten dieselbe ist.
461 */
462 function pruefeVollstaendigkeit(p: Pruefprojekt, bytes: Uint8Array): void {
463 const n = p.groups.length;
464 const blaetterDerMatrix = leseMatrix(bytes);
465 expect(blaetterDerMatrix.length).toBeGreaterThan(0);
466
467 const stelleVonMarke = new Map<string, number>();
468 p.groups.forEach((g, i) => {
469 stelleVonMarke.set(String(i + 1), i);
470 stelleVonMarke.set(g.name, i);
471 });
472
473 // Erwartete Matrix, unabhaengig vom Ausdruck aus dem Fachkern gebildet.
474 const erwartet = p.groups.map((von) => p.groups.map((nach) => erwarteterWert(p.plan, von, nach)));
475
476 let geprueft = 0;
477 const gesehen = new Set<string>();
478
479 for (const blatt of blaetterDerMatrix) {
480 const spalten = blatt.spaltenmarken.map((marke) => stelleVonMarke.get(marke) ?? -1);
481 expect(spalten.every((s) => s >= 0 && s < n)).toBe(true);
482
483 for (const zeile of blatt.zeilen) {
484 const i = stelleVonMarke.get(zeile.zeilenmarke) ?? -1;
485 expect(i).toBeGreaterThanOrEqual(0);
486
487 // Leere Zellen (die Diagonale) erzeugen kein Textstueck; die erwartete
488 // Folge wird deshalb ohne sie gebildet.
489 const sollte = spalten.map((j) => erwartet[i]?.[j] ?? '').filter((w) => w !== '');
490 expect(zeile.werte).toEqual(sollte);
491
492 for (const j of spalten) gesehen.add(`${i}:${j}`);
493 geprueft += sollte.length;
494 }
495 }
496
497 // Jede Beziehung der Matrix muss genau einmal vorgekommen sein.
498 expect(gesehen.size).toBe(n * n);
499 expect(geprueft).toBe(n * n - n);
500 }
501
502 describe('Zwischenzeitenmatrix im Ausdruck', () => {
503 it('setzt bei 40 Signalgruppen mit langen Namen jede Zahl in die richtige Zeile und Spalte', () => {
504 const p = pruefprojekt(40, true);
505 pruefeVollstaendigkeit(p, erzeuge(p));
506 });
507
508 it('setzt auch bei 40 Plannamen jede Zahl richtig - und behaelt die Namen im Kopf', () => {
509 const p = pruefprojekt(40, false);
510 const bytes = erzeuge(p);
511 pruefeVollstaendigkeit(p, bytes);
512
513 const blaetterDerMatrix = leseMatrix(bytes);
514 // Geteilt wird auch hier - aber die Koepfe tragen die Bezeichnung selbst,
515 // eine Zuordnungsliste braucht es dafuer nicht.
516 expect(new Set(blaetterDerMatrix.map((b) => b.spaltenmarken.join(','))).size).toBe(2);
517 for (const blatt of blaetterDerMatrix) {
518 expect(blatt.zuordnung.size).toBe(0);
519 }
520
521 /*
522 * Die Marken muessen GENAU die Bezeichnungen sein - nicht "irgendetwas, das
523 * mit K anfaengt" (Befund B8). Ein mitten im Wort gebrochener Kopf liefert
524 * auf der Grundlinie "K1" statt "K10"; das bestuende eine Anfangspruefung,
525 * und es waere genau der gemeldete Fehler.
526 */
527 const gesehen = new Set(blaetterDerMatrix.flatMap((b) => [...b.spaltenmarken]));
528 expect([...gesehen].sort()).toEqual(p.groups.map((g) => g.name).sort());
529
530 // Und jede Kopfzelle traegt ihre Marke in EINER Zeile.
531 const { kopf } = matrixzellen(bytes);
532 expect(kopf.length).toBeGreaterThan(0);
533 for (const zelle of kopf) expect(zelle.zeilen.length).toBe(1);
534 });
535
536 it('haelt Stern und Punkt auseinander', () => {
537 const p = pruefprojekt(40, true);
538 const blaetterDerMatrix = leseMatrix(erzeuge(p));
539
540 const alleWerte = blaetterDerMatrix.flatMap((b) => b.zeilen.flatMap((z) => [...z.werte]));
541 // Von Hand vorgegebene Werte tragen den Stern ...
542 expect(alleWerte.filter((w) => w.endsWith('*')).length).toBeGreaterThan(0);
543 /*
544 * ... und die eine ausgelassene Beziehung von Signalgruppe 1 zu 2 den
545 * Punkt. Die Stelle im ausgelesenen Zug ist nicht die Spaltenstelle: Die
546 * leere Zelle der Diagonale erzeugt kein Textstueck und faellt heraus.
547 */
548 const erstes = blaetterDerMatrix.find((b) => b.spaltenmarken.includes('2'));
549 const zeile = erstes?.zeilen.find((z) => z.zeilenmarke === '1');
550 const vorher = (erstes?.spaltenmarken ?? [])
551 .slice(0, erstes?.spaltenmarken.indexOf('2'))
552 .filter((marke) => marke !== '1').length;
553 expect(zeile?.werte[vorher]).toBe('·');
554 // Keine Zahl darf zerlegt worden sein: jeder Wert ist eine Zahl, ggf. mit
555 // Stern, oder der Punkt.
556 for (const wert of alleWerte) expect(wert).toMatch(/^(\d+\*?|·)$/);
557 });
558
559 it('loest die Zuordnung der Spaltenkoepfe auf JEDEM Blatt auf', () => {
560 const p = pruefprojekt(40, true);
561 const blaetterDerMatrix = leseMatrix(erzeuge(p));
562 expect(blaetterDerMatrix.length).toBeGreaterThan(1);
563
564 for (const blatt of blaetterDerMatrix) {
565 for (const marke of blatt.spaltenmarken) {
566 const name = p.groups[Number(marke) - 1]?.name;
567 expect(name).toBeDefined();
568 // Der volle Name steht ungeteilt auf demselben Blatt - eine
569 // Sprachausgabe liest ihn am Stueck, und niemand muss ein zweites
570 // Blatt danebenlegen.
571 expect(blatt.zuordnung.get(marke)).toBe(name);
572 }
573 }
574 });
575
576 it('laesst kurze Bezeichnungen unberuehrt: die Namen bleiben im Kopf', () => {
577 const p = pruefprojekt(16, false);
578 const blaetterDerMatrix = leseMatrix(erzeuge(p));
579 expect(blaetterDerMatrix).toHaveLength(1);
580
581 const blatt = blaetterDerMatrix[0];
582 // Der Kopf traegt die Bezeichnungen selbst, nicht Ordnungsnummern.
583 expect(blatt?.spaltenmarken).toEqual(p.groups.map((g) => g.name));
584 // Keine Zuordnungsliste, kein Ausschnitt, keine Nummernspalte.
585 expect(blatt?.zuordnung.size).toBe(0);
586 expect(blatt?.zeilen.map((z) => z.zeilenmarke)).toEqual(p.groups.map((g) => g.name));
587
588 const text = blaetter(erzeuge(p))
589 .flatMap((b) => b.stuecke.map((s) => s.text))
590 .join('\n');
591 expect(text).not.toContain('Ausschnitt');
592 expect(text).not.toContain('Nummern der Spaltenköpfe');
593 });
594
595 it('erzeugt den gemeldeten Fall (40 Gruppen, lange Namen) in vertretbarer Zeit', () => {
596 const p = pruefprojekt(40, true);
597
598 const beginn = Date.now();
599 let bytes: Uint8Array | null = null;
600 expect(() => {
601 bytes = erzeuge(p);
602 }).not.toThrow();
603 const dauer = Date.now() - beginn;
604
605 /*
606 * WAS HIER WIRKLICH FALSIFIZIERT: Am Stand vor dieser Aenderung stirbt der
607 * Arbeitsprozess, bevor `dauer` ueberhaupt gelesen wird - je nach Fall an
608 * der Halde oder am Aufrufstapel. Es sind also `not.toThrow()` und die
609 * Groessenschranke, die den Rueckfall anzeigen: Der alte Stand laege bei
610 * rund 31 MB, nicht unter 1 MB. Die Zeitschranke ist die schwaechste der
611 * drei - gemessen rund 0,14 s gegen 4 s Spielraum - und steht hier fuer den
612 * Fall, dass ein kuenftiger Umbau die Datei zwar klein haelt, aber teuer
613 * erkauft.
614 */
615 expect(dauer).toBeLessThan(4000);
616 expect(bytes).not.toBeNull();
617 expect((bytes as unknown as Uint8Array).byteLength).toBeLessThan(1_000_000);
618 });
619
620 it('bleibt bei 40 Gruppen mit langen Namen im vollen Ausdruck herstellbar', () => {
621 const p = pruefprojekt(40, true);
622 expect(() => erzeuge(p, DEFAULT_PDF_OPTIONS)).not.toThrow();
623 });
624
625 /*
626 * Der Abschnitt "Barrierefreiheit dieser Datei" ist ein Lueckenausweis. Er
627 * darf nur beschreiben, was in DIESER Ausgabe steht - eine Angabe ueber
628 * Ordnungsnummern in einer Datei, deren Matrix die Namen im Kopf traegt,
629 * waere eine falsche Angabe an der Stelle, die als einzige keine machen darf.
630 */
631 it('beschreibt die Ordnungsnummern nur in den Dateien, die welche haben', () => {
632 const satz = 'Zur Zwischenzeitenmatrix in dieser Datei';
633
634 const lang = ganzerText(erzeuge(pruefprojekt(40, true), DEFAULT_PDF_OPTIONS));
635 expect(lang).toContain(satz);
636 expect(lang).toContain('tragen die Ordnungsnummer der einfahrenden Signalgruppe');
637 // Und die Zusage bleibt bei dem, was sie belegen kann.
638 expect(lang).toContain('ersetzt es nicht');
639
640 const kurz = ganzerText(erzeuge(pruefprojekt(16, false), DEFAULT_PDF_OPTIONS));
641 expect(kurz).not.toContain(satz);
642
643 // Ohne Matrix gibt es auch nichts ueber ihre Spaltenkoepfe zu sagen.
644 const ohneMatrix = ganzerText(
645 erzeuge(pruefprojekt(40, true), { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false }),
646 );
647 expect(ohneMatrix).not.toContain(satz);
648 });
649
650 /*
651 * Der Fall oben faehrt n=40; der bedingte Absatz wird dabei von einer sehr
652 * grossen Unterlage mitgeschleppt - der eigentliche Gegenstand wird nicht
653 * geprueft, sondern vom n=40-Fall ueberdeckt. Hier steht er allein, mit der kleinsten Zahl von
654 * Signalgruppen, bei der er ueberhaupt vorkommt.
655 */
656 it('macht den bedingten Absatz an der Satzart fest, nicht an der Groesse', () => {
657 const satz = 'Zur Zwischenzeitenmatrix in dieser Datei';
658
659 /*
660 * Acht Gruppen mit einer Bezeichnung von 19 Zeichen: Sie ist ein einziges
661 * Wort und damit als Spaltenkopf zu breit - der Satz greift also schon bei
662 * acht Signalgruppen. Das ist zugleich der Fall, an dem sich zeigt, dass es
663 * NICHT um "grosse Knotenpunkte" geht (Befund B6 fachlich).
664 */
665 const achtBreit = ganzerText(
666 erzeuge(
667 benannt(8, (i) => `Fussgaengerueberweg${i + 1}`),
668 DEFAULT_PDF_OPTIONS,
669 ),
670 );
671 expect(achtBreit).toContain(satz);
672
673 // Acht Gruppen, Plannamen: Namen im Kopf, also kein Absatz.
674 const achtKurz = ganzerText(erzeuge(pruefprojekt(8, false), DEFAULT_PDF_OPTIONS));
675 expect(achtKurz).not.toContain(satz);
676 });
677
678 /*
679 * DIE ZUSAGE MUSS ZUR DATEI PASSEN. Der Abschnitt "Barrierefreiheit dieser
680 * Datei" sagte unbedingt zu, in der Zuordnung stehe "jeder Name ungeteilt in
681 * einer Zeile". SignalGroup.name ist in der Laenge nicht begrenzt; ab rund
682 * 180 Zeichen passt kein Name mehr in eine Bannerzeile. Genau an dieser
683 * Stelle darf die Unterlage keine zu weite Angabe machen.
684 */
685 it('sagt "ungeteilt" nur, solange die Namen ungeteilt hineinpassen', () => {
686 const kurzGenug = ganzerText(erzeuge(pruefprojekt(16, true), DEFAULT_PDF_OPTIONS));
687 expect(kurzGenug).toContain('steht jeder Name ungeteilt in einer Zeile');
688
689 const zuLang = ganzerText(erzeuge(langnamenprojekt(12, 260), DEFAULT_PDF_OPTIONS));
690 expect(zuLang).not.toContain('steht jeder Name ungeteilt in einer Zeile');
691 expect(zuLang).toContain('jede Folgezeile wiederholt die Nummer');
692 });
693
694 it('laesst keine Fortsetzungszeile der Zuordnung ohne ihre Nummer stehen', () => {
695 const p = langnamenprojekt(12, 260);
696 const bytes = erzeuge(p);
697
698 /*
699 * Jede Zeile des Zuordnungsbanners muss mit einer Nummer beginnen - "7 = "
700 * fuer den Anfang, "7 ... " fuer die Fortsetzung (toWinAnsi schreibt die
701 * Auslassungspunkte aus). Vorher trug die Fortsetzung nichts: Auf dem Blatt
702 * stand ein Namensrest, der zu jeder aufgefuehrten Nummer gehoeren konnte.
703 */
704 const bannerzeilen: string[] = [];
705 for (const blatt of blaetter(bytes)) {
706 for (const zelle of blatt.zellen) {
707 if (zelle.zeilen.some((z) => z.startsWith('Nummern der Spaltenköpfe'))) {
708 bannerzeilen.push(...zelle.zeilen.filter((z) => !z.startsWith('Nummern der')));
709 }
710 }
711 }
712 expect(bannerzeilen.length).toBeGreaterThan(p.groups.length);
713 for (const zeile of bannerzeilen) expect(zeile).toMatch(/^\d+ (=|\.\.\.) /);
714 // Und es gibt wirklich Fortsetzungen - sonst pruefte der Fall nichts.
715 expect(bannerzeilen.some((z) => /^\d+ \.\.\. /.test(z))).toBe(true);
716 });
717
718 /*
719 * EINE SPALTENMARKE MUSS DIE SIGNALGRUPPE EINDEUTIG BENENNEN (Befunde B3 und
720 * B4). Fehlt ein Name oder tragen zwei denselben, half die Breitenrechnung
721 * nicht: Der Kopf blieb leer oder mehrdeutig, und die Matrix war als Nachweis
722 * wertlos.
723 */
724 it('faengt einen fehlenden Namen mit Ordnungsnummern auf', () => {
725 const p = benannt(12, (i) => (i === 7 ? '' : `K${i + 1}`));
726 const bytes = erzeuge(p);
727 pruefeVollstaendigkeit(p, bytes);
728
729 const blaetterDerMatrix = leseMatrix(bytes);
730 for (const blatt of blaetterDerMatrix) {
731 expect(blatt.spaltenmarken).toEqual(
732 blatt.spaltenmarken.map((_, k) => String(k + 1)).slice(0, blatt.spaltenmarken.length),
733 );
734 expect(blatt.zuordnung.get('8')).toBe('(ohne Bezeichnung)');
735 expect(blatt.zuordnung.get('1')).toBe('K1');
736 }
737 });
738
739 it('haelt zwei gleiche Bezeichnungen auseinander', () => {
740 const p = benannt(12, (i) => (i === 5 ? 'K3' : `K${i + 1}`));
741 const bytes = erzeuge(p);
742 pruefeVollstaendigkeit(p, bytes);
743
744 for (const blatt of leseMatrix(bytes)) {
745 // Keine Marke kommt zweimal vor ...
746 expect(new Set(blatt.spaltenmarken).size).toBe(blatt.spaltenmarken.length);
747 // ... und beide Traeger des Namens "K3" sind ueber ihre Nummer benannt.
748 expect(blatt.zuordnung.get('3')).toBe('K3');
749 expect(blatt.zuordnung.get('6')).toBe('K3');
750 }
751 });
752
753 it('haelt den bisherigen Satz auch dort, wo die Schranke ihn traegt', () => {
754 /*
755 * Die Schranke rechnet mit dem Verfahren von jspdf-autotable (Summe der
756 * Mindestbreiten) statt mit dem gleichen Anteil je Spalte (Befund B7). Bei
757 * 36 Plannamen traegt der bisherige Satz die Matrix nachweislich noch:
758 * keine Zelle ist geteilt.
759 */
760 const p = benannt(36, (i) => `K${i + 1}`);
761 const bytes = erzeuge(p);
762 pruefeVollstaendigkeit(p, bytes);
763
764 const text = ganzerText(bytes);
765 expect(text).not.toContain('Ausschnitt ');
766
767 /*
768 * Keine Bezeichnung im Kopf ist geteilt. Die Kopfecke selbst bleibt hier
769 * aussen vor: Sie ist eine feste Beschriftung, sie bricht an Wortgrenzen,
770 * und wer "räumt \ fährt ein" ueber drei Zeilen liest, liest denselben
771 * Satz. matrixzellen laesst sie deshalb aus dem Kopf heraus.
772 */
773 const { kopf } = matrixzellen(bytes);
774 // Die Kopfzeile wiederholt sich auf jedem Blatt; gezaehlt werden deshalb
775 // die verschiedenen Marken, nicht die Zellen.
776 for (const zelle of kopf) expect(zelle.zeilen.length).toBe(1);
777 expect(new Set(kopf.map((z) => z.zeilen[0])).size).toBe(p.groups.length);
778 });
779 });