import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { MOVEMENT_LABELS, fahrzeugartBeschriftung } from '@/domain/rilsa/constants'; import { CATEGORY_LABELS } from '@/domain/validation/types'; import type { Project } from '@/domain/model/project'; /* * Der Ausdruck schrieb die internen Schluessel in die Spalten, in denen am * Bildschirm die Beschriftungen stehen. Der Anwender hat die Werte aus einer * Auswahlliste gewaehlt und fand sie in der Unterlage anders wieder: * "rechts" statt "rechts abbiegend", "keine" statt "ohne Laengenzuschlag" - * und im Pruefbericht "leistungsfaehigkeit", zugleich ein Verstoss gegen die * Umlautregel. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */ function pdfText(bytes: Uint8Array): string { const roh = new TextDecoder('latin1').decode(bytes); const teile: string[] = []; 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; } for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); } } return teile.join('\n'); } function ausdruck(project: Project): string { const plan = buildSignalPlan(project); return pdfText( buildProjectPdf(project, plan, validateProject(project, plan, STICHTAG), { ...DEFAULT_PDF_OPTIONS, }), ); } function vollstaendig(): Project { const base = createStandardIntersectionProject('Beschriftungen', STICHTAG); return { ...base, meta: { ...base.meta, projectNumber: 'VP-2026-001', client: 'Stadt Musterhausen', planner: 'O. Willerding', location: 'Hauptstraße / Bahnhofstraße', }, }; } describe('Ausdruck benennt wie der Bildschirm', () => { it('druckt Fahrbeziehung und Fahrzeugart als Beschriftung', () => { const text = ausdruck(vollstaendig()); // Der Regelknotenpunkt fuehrt geradeaus fahrende Kfz und querende // Fussgaenger; letztere ohne Laengenzuschlag. expect(text).toContain(MOVEMENT_LABELS.querung); expect(text).toContain(fahrzeugartBeschriftung('pkw')); expect(text).toContain(fahrzeugartBeschriftung('keine')); // Der WinAnsi-Text enthaelt die Schluessel nicht mehr als eigenstaendige // Zellen. "querung" kommt in keiner Beschriftung vor. expect(text).not.toMatch(/^querung$/m); expect(text).not.toMatch(/^keine$/m); expect(text).not.toMatch(/^pkw$/m); }); it('druckt den Bereich des Pruefberichts als Beschriftung', () => { const projekt = vollstaendig(); const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); expect(bericht.findings.length).toBeGreaterThan(0); const text = ausdruck(projekt); for (const befund of new Set(bericht.findings.map((f) => f.category))) { expect(text, befund).toContain(CATEGORY_LABELS[befund]); } // Umschrift im Anwendertext: der Schluessel traegt ein ae, die // Beschriftung ein ae-Umlaut. expect(text).not.toMatch(/^leistungsfaehigkeit$/m); expect(text).not.toMatch(/^zwischenzeiten$/m); }); it('druckt den Laengenzuschlag, mit dem tatsaechlich gerechnet wird', () => { // Eine Unterlage darf nicht zwei verschiedene Fahrzeuglaengen fuer dieselbe // Signalgruppe nennen. Die Abweichungstabelle im selben Dokument weist die // 22 m ohnehin aus - die Spalte "Fahrzeugart" sagte trotzdem 18,75 m. const roh = vollstaendig(); const projekt: Project = { ...roh, signalGroups: roh.signalGroups.map((g, i) => i === 0 ? { ...g, vehicleClass: 'lastzug' as const } : g, ), settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, vehicleLengthLastzug: 22 } }, }; const text = ausdruck(projekt); expect(text).toContain('Lastzug (22 m)'); expect(text).not.toContain('Lastzug (18,75 m)'); }); it('druckt beim OePNV-Haltefall Beschleunigung und Formel statt einer Scheingeschwindigkeit', () => { /* * Befund A2 - Grundsatz: Was gedruckt wird, muss sich aus den gedruckten * Groessen nachrechnen lassen. Beim Anfahransatz (RiLSA Fall 4) gibt es * keine konstante Raeumgeschwindigkeit; die Spalte vr nennt die * Anfahrbeschleunigung, die Fussnote die Formel, und die gedruckte * Raeumzeit ist genau deren Ergebnis. */ const roh = vollstaendig(); const bahn = { ...roh.signalGroups[0]!, id: 'sg-bahn', name: 'B1', mode: 'oepnv' as const, vehicleClass: 'strassenbahn' as const, vZul: 50, }; const projekt: Project = { ...roh, signalGroups: [...roh.signalGroups, bahn], conflicts: [ ...roh.conflicts, { ...roh.conflicts[0]!, id: 'cf-bahn', fromId: 'sg-bahn', toId: roh.signalGroups[1]!.id, clearingDistance: 25, enteringDistance: 8, haltVorKnoten: true, }, ], }; const plan = buildSignalPlan(projekt); const text = pdfText( buildProjectPdf(projekt, plan, validateProject(projekt, plan, STICHTAG), { ...DEFAULT_PDF_OPTIONS, includeCalculationDetails: true, }), ); // Fahrzeugart mit echtem Umlaut und Rechenlaenge in der Signalgruppentabelle. expect(text).toContain(fahrzeugartBeschriftung('strassenbahn')); // Spalte vr traegt die Beschleunigung, nicht eine Geschwindigkeit. expect(text).toContain('a = 1,0 m/s²'); // Die Fussnote benennt den Anfahransatz samt Formel. Der Fliesstext wird // fuer den Satz umbrochen; fuer den Vergleich werden die Zeilen wieder // zusammengesetzt. const fliesstext = text.replace(/\s+/g, ' '); expect(fliesstext).toContain('Anfahransatz'); expect(fliesstext).toContain('tr = Wurzel aus (2 · sr / a)'); // Die gedruckte Raeumzeit ist das Ergebnis der benannten Formel: // sr = 25 + 15 = 40 m, a = 1,0 -> Wurzel(80) = 8,94 s. expect(text).toContain('8,94 s'); }); it('fuehrt die Herkunft der Wege weiterhin aus derselben Quelle wie die Tabelle', () => { // Die Zelle entsteht seit dieser Aenderung in einem gemeinsamen Modul. // Bliebe sie im Ausdruck aus, waere die Zusammenlegung misslungen. Sie // steht in der Tabelle mit dem Rechenweg, die eigens zuzuschalten ist. const projekt = vollstaendig(); const plan = buildSignalPlan(projekt); const text = pdfText( buildProjectPdf(projekt, plan, validateProject(projekt, plan, STICHTAG), { ...DEFAULT_PDF_OPTIONS, includeCalculationDetails: true, }), ); expect(text).toContain('Herkunft der Wege'); expect(text).toContain('Herkunft nicht nachgewiesen'); }); });