import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf'; import { buildSignalPlan, intergreenKey, type SignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createArm, createConflict, createEmptyProject, createPhase, createSignalGroup, } from '@/domain/model/factory'; import type { Conflict, Project, SignalGroup } from '@/domain/model/project'; import * as fmt from '@/ui/format'; /* * PRUEFSTAND DER ZWISCHENZEITENMATRIX IM AUSDRUCK. * * Alle Zusicherungen werden AUS DER ERZEUGTEN DATEI zurueckgelesen, nicht aus * dem Zwischenzustand des Tabellenaufbaus. Das ist der Unterschied, auf den es * hier ankommt: Der Fehler, der behoben wurde, entstand nicht beim Fuellen der * Tabelle - die uebergebenen Zellen waren immer vollstaendig und richtig -, * sondern beim Setzen. jspdf-autotable zerlegte Namen und Zahlen beim Umbruch * zeichenweise; wer nur die Uebergabe prueft, sieht davon nichts. */ const FEST = new Date('2026-01-01T12:00:00Z'); const NUR_MATRIX: PdfOptions = { ...DEFAULT_PDF_OPTIONS, includeSignalGroups: false, includePhases: false, includePlan: false, includePerformance: false, includeReport: false, includeCalculationDetails: false, includeSources: false, }; const ECKE = 'räumt \\ fährt ein'; /** Ein langer Signalgruppenname, wie ihn die Praxis erzeugt (rund 140 Zeichen). */ function langerName(i: number): string { const richtungen = ['Nordzufahrt', 'Ostzufahrt', 'Südzufahrt', 'Westzufahrt']; const richtung = richtungen[i % 4] ?? 'Nordzufahrt'; return ( `Kraftfahrzeuge ${richtung} Hauptstraße geradeaus und rechts abbiegend, ` + `Fahrstreifen ${(i % 3) + 1}, Signalgeber N${i + 1}, Anforderung über Detektor ` + `D${i + 1} (Schleife)` ); } /** * Ein Name der gewuenschten Laenge - fuer die Frage, wie weit die Zusage * "ungeteilt in einer Zeile" traegt. SignalGroup.name ist nicht begrenzt. */ function namenlaenge(i: number, laenge: number): string { let text = `Signalgruppe ${i + 1} Nordzufahrt Hauptstraße geradeaus rechts abbiegend Anforderung über Detektor `; while (text.length < laenge) text += 'Zusatzangabe '; return text.slice(0, laenge).trim(); } interface Pruefprojekt { readonly project: Project; readonly plan: SignalPlan; readonly groups: readonly SignalGroup[]; } /** * Knotenpunkt mit n Signalgruppen und vollstaendig besetzter Konfliktmatrix. * * `luecke` laesst eine Beziehung bewusst aus - dann muss in der Matrix der * Punkt fuer "vertraeglich" stehen. `vorgabe` gibt jede zehnte Beziehung von * Hand vor - dann muss der Stern stehen. */ function pruefprojekt(n: number, lang: boolean, luecke = true): Pruefprojekt { return benannt(n, lang ? langerName : (i) => `K${i + 1}`, luecke); } /** Ein Knotenpunkt mit frei gewaehlten Bezeichnungen - fuer die Grenzfaelle. */ function benannt(n: number, name: (i: number) => string, luecke = true): Pruefprojekt { const basis = createEmptyProject('Pruefkreuzung', FEST, 'knotenpunkt'); const arme = ['Nord', 'Ost', 'Süd', 'West'].map((r) => createArm(r, r, 2, 50)); const groups: SignalGroup[] = []; for (let i = 0; i < n; i += 1) { groups.push( createSignalGroup({ name: name(i), mode: 'kfz', armId: arme[i % 4]?.id ?? null, index: i, }), ); } const conflicts: Conflict[] = []; for (let i = 0; i < n; i += 1) { for (let j = 0; j < n; j += 1) { if (i === j) continue; // Eine einzige Luecke: K1 -> K2 bleibt ohne Konflikt und muss in der // Matrix als Punkt erscheinen. if (luecke && i === 0 && j === 1) continue; const von = groups[i]; const nach = groups[j]; if (!von || !nach) continue; conflicts.push({ ...createConflict(von.id, nach.id), clearingDistance: 18 + (i % 7), enteringDistance: 6 + (j % 5), manualIntergreen: (i * n + j) % 10 === 0 ? 7 : null, }); } } const haelfte = Math.ceil(n / 2); const p1 = createPhase( 'Phase 1', groups.slice(0, haelfte).map((g) => g.id), ); const p2 = createPhase( 'Phase 2', groups.slice(haelfte).map((g) => g.id), ); const project: Project = { ...basis, meta: { ...basis.meta, planner: 'Pruefstand', location: 'Pruefort' }, intersection: { ...basis.intersection, arms: arme }, signalGroups: groups, conflicts, phases: [p1, p2], program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, demands: groups.map((g, i) => ({ signalGroupId: g.id, volume: 300 + ((i * 37) % 400), heavyVehicleShare: 0.05, })), }; return { project, plan: buildSignalPlan(project), groups }; } /** Ein Knotenpunkt, dessen Namen fuer eine Bannerzeile zu lang sind. */ function langnamenprojekt(n: number, laenge: number): Pruefprojekt { return benannt(n, (i) => namenlaenge(i, laenge)); } function erzeuge(p: Pruefprojekt, optionen: PdfOptions = NUR_MATRIX): Uint8Array { return buildProjectPdf( p.project, p.plan, validateProject(p.project, p.plan, FEST), optionen, FEST, ); } /** Ein gesetztes Textstueck mit seiner Stelle auf dem Blatt. */ interface Textstueck { readonly x: number; readonly y: number; readonly text: string; } /** * Eine gesetzte Tabellenzelle mit ihren Textzeilen. * * WARUM DIE RECHTECKE MITGELESEN WERDEN (Befund B8): Aus den Textstuecken * allein laesst sich nicht erkennen, ob eine Kopfzelle ihre Bezeichnung * UNGETEILT traegt. Ein mitten im Wort gebrochener Kopf liefert auf der * Grundlinie nur sein erstes Bruchstueck - "K1" aus "K10" - und eine Probe, die * nur den Anfang prueft, nimmt genau den Fehler widerspruchslos an, um den es * hier geht. jspdf-autotable setzt fuer jede Zelle ein Rechteck ("x y b h re") * und danach deren Text; ueber die Koordinate gehoert jede Textzeile ihrer * Zelle zu, und die Zeilenzahl ist ablesbar. */ interface Zelle { readonly x: number; readonly oben: number; readonly breite: number; readonly hoehe: number; readonly zeilen: readonly string[]; } interface Blatt { readonly nummer: number; readonly stuecke: readonly Textstueck[]; readonly zellen: readonly Zelle[]; } function entschluessele(roh: string): string { return roh.replace(/\\([()\\])/g, '$1'); } /** * Die Blaetter der Datei mit allen gesetzten Textstuecken samt Koordinate. * * Gelesen wird der Inhaltsstrom: `x y Td (Text) Tj`, Folgezeilen ueber `T*` * mit dem zuletzt gesetzten Zeilenabstand `TL`. Die Blattnummer kommt aus der * Fusszeile ("Seite k von m") - so bleibt die Zuordnung auch dann richtig, * wenn jsPDF die Stroeme einmal in anderer Reihenfolge ablegt. */ function blaetter(bytes: Uint8Array): Blatt[] { const roh = new TextDecoder('latin1').decode(bytes); const gefunden: Blatt[] = []; const suche = /stream\r?\n/g; let treffer: RegExpExecArray | null; while ((treffer = suche.exec(roh)) !== null) { const start = treffer.index + treffer[0].length; const ende = roh.indexOf('endstream', start); if (ende < 0) continue; let inhalt: string; try { inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { continue; } if (!inhalt.includes(' Tj')) continue; const stuecke: Textstueck[] = []; const zellen: { x: number; oben: number; breite: number; hoehe: number; zeilen: string[] }[] = []; let laufend: { oben: number; hoehe: number; zeilen: string[] } | null = null; let x = 0; let y = 0; let abstand = 0; for (const zeile of inhalt.split('\n')) { const re = /^(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) re$/.exec(zeile.trim()); if (re) { const neu = { x: Number(re[1]), oben: Number(re[2]), breite: Number(re[3]), hoehe: Math.abs(Number(re[4])), zeilen: [] as string[], }; // Fuellung und Rahmen derselben Zelle erzeugen zwei gleiche Rechtecke; // das zweite ersetzt das noch leere erste. const vorher = zellen[zellen.length - 1]; if ( vorher && vorher.zeilen.length === 0 && Math.abs(vorher.x - neu.x) < 0.001 && Math.abs(vorher.oben - neu.oben) < 0.001 && Math.abs(vorher.breite - neu.breite) < 0.001 ) { zellen.pop(); } zellen.push(neu); laufend = neu; continue; } const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); if (td) { x = Number(td[1]); y = Number(td[2]); continue; } const tl = /^(-?[\d.]+) TL$/.exec(zeile); if (tl) { abstand = Number(tl[1]); continue; } if (zeile.trim() === 'T*') { y -= abstand; continue; } // Folgezeilen eines mehrzeiligen Textes schreibt jsPDF als // "T* (Text) Tj" in EINE Zeile - ohne das entginge dem Pruefstand genau // das, was hier nachzuweisen ist. const tj = /^(T\*\s+)?\((.*)\) Tj$/.exec(zeile); if (tj) { if (tj[1]) y -= abstand; const text = entschluessele(tj[2] ?? ''); stuecke.push({ x, y, text }); if (laufend && y <= laufend.oben + 0.5 && y >= laufend.oben - laufend.hoehe - 0.5) { laufend.zeilen.push(text); } } } const fuss = stuecke.map((s) => /^Seite (\d+) von \d+$/.exec(s.text)).find((m) => m !== null); if (!fuss) continue; gefunden.push({ nummer: Number(fuss[1]), stuecke, zellen }); } return gefunden.sort((a, b) => a.nummer - b.nummer); } /** Der gesamte gesetzte Text der Datei, Blatt fuer Blatt. */ function ganzerText(bytes: Uint8Array): string { return blaetter(bytes) .flatMap((b) => b.stuecke.map((s) => s.text)) .join(' '); } /** Alle Textstuecke einer Grundlinie, von links nach rechts. */ function zeileBei(blatt: Blatt, y: number): Textstueck[] { return blatt.stuecke.filter((s) => Math.abs(s.y - y) < 0.01).sort((a, b) => a.x - b.x); } /** * Alle Zellen der Matrixtabellen - erkannt an der Kopfecke ihres Blattes. * * Genommen wird, was rechts von der Ecke und nicht ueber ihr steht: der Kopf * und der Koerper der Matrix, ohne Kopf- und Fusszeile der Unterlage. */ function matrixzellen(bytes: Uint8Array): { kopf: Zelle[]; koerper: Zelle[] } { const kopf: Zelle[] = []; const koerper: Zelle[] = []; for (const blatt of blaetter(bytes)) { for (const ecke of blatt.zellen.filter((z) => z.zeilen.join(' ') === ECKE)) { const kopfzeile = blatt.zellen.filter( (z) => Math.abs(z.oben - ecke.oben) < 0.01 && z.x >= ecke.x - 0.01, ); kopf.push(...kopfzeile.filter((z) => z.zeilen.join(' ') !== ECKE)); /* * Zum Koerper gehoert nur, was UNTER der Kopfzeile in EINER IHRER SPALTEN * steht. Die Abgrenzung ueber die Spaltenstelle ist noetig, weil sonst * die Fusszeile des Blattes mitgezaehlt wird - sie steht ebenfalls unter * der Kopfzeile und rechts von der Ecke. */ const spalten = kopfzeile.map((z) => z.x); for (const z of blatt.zellen) { if (z.oben >= ecke.oben) continue; if (spalten.some((x) => Math.abs(x - z.x) < 0.01)) koerper.push(z); } } } return { kopf, koerper }; } /** Der erwartete Zellinhalt aus dem Fachkern - unabhaengig vom Ausdruck gebildet. */ function erwarteterWert(plan: SignalPlan, von: SignalGroup, nach: SignalGroup): string { if (von.id === nach.id) return ''; const gefunden = plan.intergreens.get(intergreenKey(von.id, nach.id)); if (!gefunden) return '·'; return gefunden.source === 'vorgegeben' ? `${fmt.numShort(gefunden.value, 0)}*` : fmt.numShort(gefunden.value, 0); } /** * Die Matrix aus der Datei zurueckgelesen. * * Ergebnis ist je Blatt: welche einfahrenden Signalgruppen (Spalten) es * traegt, und welche Zahlen in welcher Zeile stehen. */ interface AusgeleseneZeile { readonly zeilenmarke: string; readonly werte: readonly string[]; } interface AusgelesenesBlatt { readonly nummer: number; readonly spaltenmarken: readonly string[]; readonly zeilen: readonly AusgeleseneZeile[]; readonly zuordnung: ReadonlyMap; } function leseMatrix(bytes: Uint8Array): AusgelesenesBlatt[] { const ergebnis: AusgelesenesBlatt[] = []; for (const blatt of blaetter(bytes)) { /* * Auf einem Blatt koennen ZWEI Ausschnitte stehen - der letzte Streifen des * einen und der Anfang des naechsten. Jede Kopfecke eroeffnet deshalb einen * eigenen Bereich, der bis zur naechsten Kopfecke darunter reicht. * * DIE ECKE WIRD UEBER IHRE ZELLE GESUCHT, nicht ueber ein Textstueck: Im * bisherigen Satz wird die feste Beschriftung "räumt \ fährt ein" bei * vielen Spalten selbst umbrochen (an Wortgrenzen - sie ist eine * Beschriftung, keine Angabe). Dann gibt es kein Stueck mehr, das ihr * gleicht, und eine Suche danach faende die Matrix ueberhaupt nicht. * Anker ist die OBERSTE Textzeile der Eckzelle; alle Zellen einer Zeile * sitzen oben buendig, ihre erste Zeile also auf derselben Grundlinie. */ const ecken = blatt.zellen .filter((z) => z.zeilen.join(' ') === ECKE) .map( (z) => blatt.stuecke .filter( (s) => s.text === z.zeilen[0] && s.y <= z.oben + 0.5 && s.y >= z.oben - z.hoehe - 0.5 && // Waagerecht nur GROB eingegrenzt: Die Kopfecke steht je nach // Satzart linksbuendig oder mittig in ihrer Zelle. s.x >= z.x - 0.5 && s.x <= z.x + z.breite + 0.5, ) .sort((a, b) => b.y - a.y)[0], ) .filter((s): s is Textstueck => s !== undefined) .sort((a, b) => b.y - a.y); for (let k = 0; k < ecken.length; k += 1) { const ecke = ecken[k]; if (!ecke) continue; const obergrenze = k === 0 ? Number.POSITIVE_INFINITY : (ecken[k - 1]?.y ?? 0); const untergrenze = ecken[k + 1]?.y ?? Number.NEGATIVE_INFINITY; const kopf = zeileBei(blatt, ecke.y); const eckeStelle = kopf.indexOf(ecke); const spaltenmarken = kopf.slice(eckeStelle + 1).map((s) => s.text); // Die Zuordnung steht als Zeilenfolge "7 = Name" ueber der Tabelle. const zuordnung = new Map(); for (const stueck of blatt.stuecke) { const m = /^(\d+) = (.+)$/.exec(stueck.text); if (m && stueck.y > ecke.y && stueck.y < obergrenze) { zuordnung.set(m[1] ?? '', m[2] ?? ''); } } // Koerperzeilen: Grundlinien UNTER der Kopfzeile, die mehr als die // Zeilenmarke tragen. Alle Zellen einer Zeile sitzen oben buendig, ihre // erste Textzeile also auf derselben Grundlinie. const grundlinien = [ ...new Set(blatt.stuecke.filter((s) => s.y < ecke.y && s.y > untergrenze).map((s) => s.y)), ].sort((a, b) => b - a); const zeilen: AusgeleseneZeile[] = []; for (const y of grundlinien) { const stuecke = zeileBei(blatt, y); if (stuecke.length < 3) continue; // Fusszeile des Blattes ausschliessen. if (stuecke.some((s) => /^Seite \d+ von \d+$/.test(s.text))) continue; const marke = stuecke[0]; if (!marke) continue; /* * Vor den Werten stehen entweder zwei Vorspalten (Ordnungsnummer und * Name) oder eine (nur der Name) - je nachdem, ob die Koepfe Nummern * tragen. Woran man es erkennt, steht in der Kopfzeile: Die Kopfecke * ist die letzte Vorspalte. */ zeilen.push({ zeilenmarke: marke.text, werte: stuecke.slice(eckeStelle + 1).map((s) => s.text), }); } ergebnis.push({ nummer: blatt.nummer, spaltenmarken, zeilen, zuordnung }); } } return ergebnis; } /** * Vollstaendigkeitsprobe: Jede Zahl der Matrix steht in der Datei, in der * richtigen Zeile und der richtigen Spalte. * * Die Marken der Koepfe sind je nach Satzart Ordnungsnummern oder die * Bezeichnungen selbst; beides wird ueber dieselbe Zuordnung Marke -> Stelle * aufgeloest, damit die Probe fuer beide Satzarten dieselbe ist. */ function pruefeVollstaendigkeit(p: Pruefprojekt, bytes: Uint8Array): void { const n = p.groups.length; const blaetterDerMatrix = leseMatrix(bytes); expect(blaetterDerMatrix.length).toBeGreaterThan(0); const stelleVonMarke = new Map(); p.groups.forEach((g, i) => { stelleVonMarke.set(String(i + 1), i); stelleVonMarke.set(g.name, i); }); // Erwartete Matrix, unabhaengig vom Ausdruck aus dem Fachkern gebildet. const erwartet = p.groups.map((von) => p.groups.map((nach) => erwarteterWert(p.plan, von, nach))); let geprueft = 0; const gesehen = new Set(); for (const blatt of blaetterDerMatrix) { const spalten = blatt.spaltenmarken.map((marke) => stelleVonMarke.get(marke) ?? -1); expect(spalten.every((s) => s >= 0 && s < n)).toBe(true); for (const zeile of blatt.zeilen) { const i = stelleVonMarke.get(zeile.zeilenmarke) ?? -1; expect(i).toBeGreaterThanOrEqual(0); // Leere Zellen (die Diagonale) erzeugen kein Textstueck; die erwartete // Folge wird deshalb ohne sie gebildet. const sollte = spalten.map((j) => erwartet[i]?.[j] ?? '').filter((w) => w !== ''); expect(zeile.werte).toEqual(sollte); for (const j of spalten) gesehen.add(`${i}:${j}`); geprueft += sollte.length; } } // Jede Beziehung der Matrix muss genau einmal vorgekommen sein. expect(gesehen.size).toBe(n * n); expect(geprueft).toBe(n * n - n); } describe('Zwischenzeitenmatrix im Ausdruck', () => { it('setzt bei 40 Signalgruppen mit langen Namen jede Zahl in die richtige Zeile und Spalte', () => { const p = pruefprojekt(40, true); pruefeVollstaendigkeit(p, erzeuge(p)); }); it('setzt auch bei 40 Plannamen jede Zahl richtig - und behaelt die Namen im Kopf', () => { const p = pruefprojekt(40, false); const bytes = erzeuge(p); pruefeVollstaendigkeit(p, bytes); const blaetterDerMatrix = leseMatrix(bytes); // Geteilt wird auch hier - aber die Koepfe tragen die Bezeichnung selbst, // eine Zuordnungsliste braucht es dafuer nicht. expect(new Set(blaetterDerMatrix.map((b) => b.spaltenmarken.join(','))).size).toBe(2); for (const blatt of blaetterDerMatrix) { expect(blatt.zuordnung.size).toBe(0); } /* * Die Marken muessen GENAU die Bezeichnungen sein - nicht "irgendetwas, das * mit K anfaengt" (Befund B8). Ein mitten im Wort gebrochener Kopf liefert * auf der Grundlinie "K1" statt "K10"; das bestuende eine Anfangspruefung, * und es waere genau der gemeldete Fehler. */ const gesehen = new Set(blaetterDerMatrix.flatMap((b) => [...b.spaltenmarken])); expect([...gesehen].sort()).toEqual(p.groups.map((g) => g.name).sort()); // Und jede Kopfzelle traegt ihre Marke in EINER Zeile. const { kopf } = matrixzellen(bytes); expect(kopf.length).toBeGreaterThan(0); for (const zelle of kopf) expect(zelle.zeilen.length).toBe(1); }); it('haelt Stern und Punkt auseinander', () => { const p = pruefprojekt(40, true); const blaetterDerMatrix = leseMatrix(erzeuge(p)); const alleWerte = blaetterDerMatrix.flatMap((b) => b.zeilen.flatMap((z) => [...z.werte])); // Von Hand vorgegebene Werte tragen den Stern ... expect(alleWerte.filter((w) => w.endsWith('*')).length).toBeGreaterThan(0); /* * ... und die eine ausgelassene Beziehung von Signalgruppe 1 zu 2 den * Punkt. Die Stelle im ausgelesenen Zug ist nicht die Spaltenstelle: Die * leere Zelle der Diagonale erzeugt kein Textstueck und faellt heraus. */ const erstes = blaetterDerMatrix.find((b) => b.spaltenmarken.includes('2')); const zeile = erstes?.zeilen.find((z) => z.zeilenmarke === '1'); const vorher = (erstes?.spaltenmarken ?? []) .slice(0, erstes?.spaltenmarken.indexOf('2')) .filter((marke) => marke !== '1').length; expect(zeile?.werte[vorher]).toBe('·'); // Keine Zahl darf zerlegt worden sein: jeder Wert ist eine Zahl, ggf. mit // Stern, oder der Punkt. for (const wert of alleWerte) expect(wert).toMatch(/^(\d+\*?|·)$/); }); it('loest die Zuordnung der Spaltenkoepfe auf JEDEM Blatt auf', () => { const p = pruefprojekt(40, true); const blaetterDerMatrix = leseMatrix(erzeuge(p)); expect(blaetterDerMatrix.length).toBeGreaterThan(1); for (const blatt of blaetterDerMatrix) { for (const marke of blatt.spaltenmarken) { const name = p.groups[Number(marke) - 1]?.name; expect(name).toBeDefined(); // Der volle Name steht ungeteilt auf demselben Blatt - eine // Sprachausgabe liest ihn am Stueck, und niemand muss ein zweites // Blatt danebenlegen. expect(blatt.zuordnung.get(marke)).toBe(name); } } }); it('laesst kurze Bezeichnungen unberuehrt: die Namen bleiben im Kopf', () => { const p = pruefprojekt(16, false); const blaetterDerMatrix = leseMatrix(erzeuge(p)); expect(blaetterDerMatrix).toHaveLength(1); const blatt = blaetterDerMatrix[0]; // Der Kopf traegt die Bezeichnungen selbst, nicht Ordnungsnummern. expect(blatt?.spaltenmarken).toEqual(p.groups.map((g) => g.name)); // Keine Zuordnungsliste, kein Ausschnitt, keine Nummernspalte. expect(blatt?.zuordnung.size).toBe(0); expect(blatt?.zeilen.map((z) => z.zeilenmarke)).toEqual(p.groups.map((g) => g.name)); const text = blaetter(erzeuge(p)) .flatMap((b) => b.stuecke.map((s) => s.text)) .join('\n'); expect(text).not.toContain('Ausschnitt'); expect(text).not.toContain('Nummern der Spaltenköpfe'); }); it('erzeugt den gemeldeten Fall (40 Gruppen, lange Namen) in vertretbarer Zeit', () => { const p = pruefprojekt(40, true); const beginn = Date.now(); let bytes: Uint8Array | null = null; expect(() => { bytes = erzeuge(p); }).not.toThrow(); const dauer = Date.now() - beginn; /* * WAS HIER WIRKLICH FALSIFIZIERT: Am Stand vor dieser Aenderung stirbt der * Arbeitsprozess, bevor `dauer` ueberhaupt gelesen wird - je nach Fall an * der Halde oder am Aufrufstapel. Es sind also `not.toThrow()` und die * Groessenschranke, die den Rueckfall anzeigen: Der alte Stand laege bei * rund 31 MB, nicht unter 1 MB. Die Zeitschranke ist die schwaechste der * drei - gemessen rund 0,14 s gegen 4 s Spielraum - und steht hier fuer den * Fall, dass ein kuenftiger Umbau die Datei zwar klein haelt, aber teuer * erkauft. */ expect(dauer).toBeLessThan(4000); expect(bytes).not.toBeNull(); expect((bytes as unknown as Uint8Array).byteLength).toBeLessThan(1_000_000); }); it('bleibt bei 40 Gruppen mit langen Namen im vollen Ausdruck herstellbar', () => { const p = pruefprojekt(40, true); expect(() => erzeuge(p, DEFAULT_PDF_OPTIONS)).not.toThrow(); }); /* * Der Abschnitt "Barrierefreiheit dieser Datei" ist ein Lueckenausweis. Er * darf nur beschreiben, was in DIESER Ausgabe steht - eine Angabe ueber * Ordnungsnummern in einer Datei, deren Matrix die Namen im Kopf traegt, * waere eine falsche Angabe an der Stelle, die als einzige keine machen darf. */ it('beschreibt die Ordnungsnummern nur in den Dateien, die welche haben', () => { const satz = 'Zur Zwischenzeitenmatrix in dieser Datei'; const lang = ganzerText(erzeuge(pruefprojekt(40, true), DEFAULT_PDF_OPTIONS)); expect(lang).toContain(satz); expect(lang).toContain('tragen die Ordnungsnummer der einfahrenden Signalgruppe'); // Und die Zusage bleibt bei dem, was sie belegen kann. expect(lang).toContain('ersetzt es nicht'); const kurz = ganzerText(erzeuge(pruefprojekt(16, false), DEFAULT_PDF_OPTIONS)); expect(kurz).not.toContain(satz); // Ohne Matrix gibt es auch nichts ueber ihre Spaltenkoepfe zu sagen. const ohneMatrix = ganzerText( erzeuge(pruefprojekt(40, true), { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false }), ); expect(ohneMatrix).not.toContain(satz); }); /* * Der Fall oben faehrt n=40; der bedingte Absatz wird dabei von einer sehr * grossen Unterlage mitgeschleppt - der eigentliche Gegenstand wird nicht * geprueft, sondern vom n=40-Fall ueberdeckt. Hier steht er allein, mit der kleinsten Zahl von * Signalgruppen, bei der er ueberhaupt vorkommt. */ it('macht den bedingten Absatz an der Satzart fest, nicht an der Groesse', () => { const satz = 'Zur Zwischenzeitenmatrix in dieser Datei'; /* * Acht Gruppen mit einer Bezeichnung von 19 Zeichen: Sie ist ein einziges * Wort und damit als Spaltenkopf zu breit - der Satz greift also schon bei * acht Signalgruppen. Das ist zugleich der Fall, an dem sich zeigt, dass es * NICHT um "grosse Knotenpunkte" geht (Befund B6 fachlich). */ const achtBreit = ganzerText( erzeuge( benannt(8, (i) => `Fussgaengerueberweg${i + 1}`), DEFAULT_PDF_OPTIONS, ), ); expect(achtBreit).toContain(satz); // Acht Gruppen, Plannamen: Namen im Kopf, also kein Absatz. const achtKurz = ganzerText(erzeuge(pruefprojekt(8, false), DEFAULT_PDF_OPTIONS)); expect(achtKurz).not.toContain(satz); }); /* * DIE ZUSAGE MUSS ZUR DATEI PASSEN. Der Abschnitt "Barrierefreiheit dieser * Datei" sagte unbedingt zu, in der Zuordnung stehe "jeder Name ungeteilt in * einer Zeile". SignalGroup.name ist in der Laenge nicht begrenzt; ab rund * 180 Zeichen passt kein Name mehr in eine Bannerzeile. Genau an dieser * Stelle darf die Unterlage keine zu weite Angabe machen. */ it('sagt "ungeteilt" nur, solange die Namen ungeteilt hineinpassen', () => { const kurzGenug = ganzerText(erzeuge(pruefprojekt(16, true), DEFAULT_PDF_OPTIONS)); expect(kurzGenug).toContain('steht jeder Name ungeteilt in einer Zeile'); const zuLang = ganzerText(erzeuge(langnamenprojekt(12, 260), DEFAULT_PDF_OPTIONS)); expect(zuLang).not.toContain('steht jeder Name ungeteilt in einer Zeile'); expect(zuLang).toContain('jede Folgezeile wiederholt die Nummer'); }); it('laesst keine Fortsetzungszeile der Zuordnung ohne ihre Nummer stehen', () => { const p = langnamenprojekt(12, 260); const bytes = erzeuge(p); /* * Jede Zeile des Zuordnungsbanners muss mit einer Nummer beginnen - "7 = " * fuer den Anfang, "7 ... " fuer die Fortsetzung (toWinAnsi schreibt die * Auslassungspunkte aus). Vorher trug die Fortsetzung nichts: Auf dem Blatt * stand ein Namensrest, der zu jeder aufgefuehrten Nummer gehoeren konnte. */ const bannerzeilen: string[] = []; for (const blatt of blaetter(bytes)) { for (const zelle of blatt.zellen) { if (zelle.zeilen.some((z) => z.startsWith('Nummern der Spaltenköpfe'))) { bannerzeilen.push(...zelle.zeilen.filter((z) => !z.startsWith('Nummern der'))); } } } expect(bannerzeilen.length).toBeGreaterThan(p.groups.length); for (const zeile of bannerzeilen) expect(zeile).toMatch(/^\d+ (=|\.\.\.) /); // Und es gibt wirklich Fortsetzungen - sonst pruefte der Fall nichts. expect(bannerzeilen.some((z) => /^\d+ \.\.\. /.test(z))).toBe(true); }); /* * EINE SPALTENMARKE MUSS DIE SIGNALGRUPPE EINDEUTIG BENENNEN (Befunde B3 und * B4). Fehlt ein Name oder tragen zwei denselben, half die Breitenrechnung * nicht: Der Kopf blieb leer oder mehrdeutig, und die Matrix war als Nachweis * wertlos. */ it('faengt einen fehlenden Namen mit Ordnungsnummern auf', () => { const p = benannt(12, (i) => (i === 7 ? '' : `K${i + 1}`)); const bytes = erzeuge(p); pruefeVollstaendigkeit(p, bytes); const blaetterDerMatrix = leseMatrix(bytes); for (const blatt of blaetterDerMatrix) { expect(blatt.spaltenmarken).toEqual( blatt.spaltenmarken.map((_, k) => String(k + 1)).slice(0, blatt.spaltenmarken.length), ); expect(blatt.zuordnung.get('8')).toBe('(ohne Bezeichnung)'); expect(blatt.zuordnung.get('1')).toBe('K1'); } }); it('haelt zwei gleiche Bezeichnungen auseinander', () => { const p = benannt(12, (i) => (i === 5 ? 'K3' : `K${i + 1}`)); const bytes = erzeuge(p); pruefeVollstaendigkeit(p, bytes); for (const blatt of leseMatrix(bytes)) { // Keine Marke kommt zweimal vor ... expect(new Set(blatt.spaltenmarken).size).toBe(blatt.spaltenmarken.length); // ... und beide Traeger des Namens "K3" sind ueber ihre Nummer benannt. expect(blatt.zuordnung.get('3')).toBe('K3'); expect(blatt.zuordnung.get('6')).toBe('K3'); } }); it('haelt den bisherigen Satz auch dort, wo die Schranke ihn traegt', () => { /* * Die Schranke rechnet mit dem Verfahren von jspdf-autotable (Summe der * Mindestbreiten) statt mit dem gleichen Anteil je Spalte (Befund B7). Bei * 36 Plannamen traegt der bisherige Satz die Matrix nachweislich noch: * keine Zelle ist geteilt. */ const p = benannt(36, (i) => `K${i + 1}`); const bytes = erzeuge(p); pruefeVollstaendigkeit(p, bytes); const text = ganzerText(bytes); expect(text).not.toContain('Ausschnitt '); /* * Keine Bezeichnung im Kopf ist geteilt. Die Kopfecke selbst bleibt hier * aussen vor: Sie ist eine feste Beschriftung, sie bricht an Wortgrenzen, * und wer "räumt \ fährt ein" ueber drei Zeilen liest, liest denselben * Satz. matrixzellen laesst sie deshalb aus dem Kopf heraus. */ const { kopf } = matrixzellen(bytes); // Die Kopfzeile wiederholt sich auf jedem Blatt; gezaehlt werden deshalb // die verschiedenen Marken, nicht die Zellen. for (const zelle of kopf) expect(zelle.zeilen.length).toBe(1); expect(new Set(kopf.map((z) => z.zeilen[0])).size).toBe(p.groups.length); }); });