lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export pdfZwischenzeitenmatrix.test.ts
| 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 | }); |