import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { toWinAnsi } from '@/render/pdfSurface'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; const FIXED_DATE = new Date('2026-01-01T12:00:00Z'); function complete(): Project { const base = createStandardIntersectionProject('Hauptstrasse / Bahnhofstrasse', FIXED_DATE); return { ...base, meta: { ...base.meta, projectNumber: 'VP-2026-001', client: 'Stadt Musterhausen', planner: 'O. Willerding', location: 'Hauptstrasse, 12345 Musterstadt', description: 'Bestandsaufnahme mit Umlauten: aeoeue und ss.', }, }; } /** Rohbytes als Latin-1 - fuer Struktur-Angaben wie /Type /Page. */ function pdfRaw(bytes: Uint8Array): string { return new TextDecoder('latin1').decode(bytes); } /** * Sichtbaren Text aus dem PDF lesen. * * Die Inhaltsstroeme sind komprimiert. Ein Test, der nur die Rohbytes * durchsucht, findet keinen einzigen Buchstaben und geht deshalb immer durch - * er prueft dann nichts. Hier werden die Stroeme entpackt und die * Textoperatoren ausgewertet. */ function pdfText(bytes: Uint8Array): string { const roh = pdfRaw(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 stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) { teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')'))); } } return teile.join('\n'); } describe('PDF-Ausgabe', () => { it('erzeugt ein gueltiges PDF mit mehreren Seiten', () => { const project = complete(); const plan = buildSignalPlan(project); const report = validateProject(project, plan, FIXED_DATE); const bytes = buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE); expect(bytes.byteLength).toBeGreaterThan(5000); const roh = pdfRaw(bytes); expect(roh.startsWith('%PDF-')).toBe(true); expect(roh).toContain('/Type /Page'); // Mehrere Seiten: Deckblatt, Signalgruppen, Matrix, Phasen, Plan, ... const pageCount = roh.split('/Type /Page\n').length - 1; expect(pageCount).toBeGreaterThanOrEqual(6); // Der sichtbare Text muss lesbar herauszuloesen sein - sonst pruefen die // uebrigen Textzusicherungen nichts. const text = pdfText(bytes); expect(text).toContain('Signalzeitenplan'); expect(text).toContain('Hauptstrasse'); }); it('enthaelt keine unbedingte Genehmigungsempfehlung', () => { const project = complete(); const plan = buildSignalPlan(project); const report = validateProject(project, plan, FIXED_DATE); const raw = pdfText(buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE)); // Der Altbestand druckte unabhaengig vom Pruefergebnis, die Anlage werde // zur Genehmigung empfohlen. expect(raw.toLowerCase()).not.toContain('zur genehmigung empfohlen'); }); it('weist die Herkunft der Kennwerte nach', () => { const project = complete(); const plan = buildSignalPlan(project); const report = validateProject(project, plan, FIXED_DATE); const raw = pdfText( buildProjectPdf( project, plan, report, { ...DEFAULT_PDF_OPTIONS, fundstellen: { 'rilsa-signalzeiten': 'Abschnitt 3.4, Tabelle 6' }, }, FIXED_DATE, ), ); // Der Ausdruck muss die Rechtskette und das Fundstellenverzeichnis enthalten, // damit ein Pruefer die Kennwerte nachvollziehen kann. expect(raw).toContain('Grundlagen der Kennwerte'); expect(raw).toContain('Fundstellenverzeichnis'); expect(raw).toContain('37 StVO'); expect(raw).toContain('RiLSA 2015'); expect(raw).toContain('HBS 2015'); // Die selbst eingetragene Fundstelle erscheint im Dokument. expect(raw).toContain('Tabelle 6'); }); it('laesst sich auch fuer ein leeres Projekt erzeugen, ohne abzustuerzen', () => { const project = createEmptyProject('Leer', FIXED_DATE); const plan = buildSignalPlan(project); const report = validateProject(project, plan, FIXED_DATE); expect(() => buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE), ).not.toThrow(); }); it('beruecksichtigt die gewaehlten Abschnitte', () => { const project = complete(); const plan = buildSignalPlan(project); const report = validateProject(project, plan, FIXED_DATE); const full = buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE); const minimal = buildProjectPdf( project, plan, report, { includeSignalGroups: false, includeIntergreenMatrix: false, includePhases: false, includeCycleComparison: false, includePlan: false, includePerformance: false, includeReport: false, includeCalculationDetails: false, includeSources: false, fundstellen: {}, }, FIXED_DATE, ); const fullPages = pdfRaw(full).split('/Type /Page\n').length; const minimalPages = pdfRaw(minimal).split('/Type /Page\n').length; expect(minimalPages).toBeLessThan(fullPages); }); }); describe('Zeichenvorrat der PDF-Standardschriften', () => { it('erhaelt deutsche Umlaute unveraendert', () => { expect(toWinAnsi('Grünzeit, Räumweg, Fußgänger, Übergang')).toBe( 'Grünzeit, Räumweg, Fußgänger, Übergang', ); }); it('ersetzt nicht darstellbare Zeichen durch lesbaren Text', () => { // Der Altbestand druckte Haken, Kreuze und Emoji, die mit den // Standardschriften als leere Kaesten erschienen. expect(toWinAnsi('K1 → K2')).toBe('K1 -> K2'); expect(toWinAnsi('✓ erfuellt')).toBe('ja erfuellt'); expect(toWinAnsi('✗ nicht erfuellt')).toBe('nein nicht erfuellt'); expect(toWinAnsi('tz ≥ 5 s')).toBe('tz >= 5 s'); expect(toWinAnsi('60–90 s')).toBe('60-90 s'); }); it('entfernt Zeichen, die auch danach nicht darstellbar sind', () => { expect(toWinAnsi('Projekt 🚦 Ampel')).toBe('Projekt Ampel'); expect(toWinAnsi('Проект')).toBe(''); }); });