import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createArm, createEmptyProject, createPhase, createSignalGroup, } from '@/domain/model/factory'; import type { Project, SignalGroup } from '@/domain/model/project'; import type { TrafficMode } from '@/domain/rilsa/types'; /* * BEFUNDE 20 UND 51: `writeParagraph` rief `ensureSpace`, * und `ensureSpace` legt genau EIN Folgeblatt an. Danach setzte * `doc.text(lines, ...)` alle Zeilen fortlaufend nach unten. * * Was der Anwender sonst erlebt: Er fuehrt viele Signalgruppen mit langen * Bezeichnungen - weder Zahl noch Namenslaenge sind begrenzt, in src/ steht * kein einziges `maxlength`. Drei Absaetze werden daraus so lang, dass sie * nicht mehr auf ein Blatt passen: * * - der Furtregelabsatz unter der Signalgruppentabelle (Befund 20) - er * traegt den Rechenweg der Mindestfreigabezeit je Fussgaengergruppe und * endet mit dem Mittelinselvermerk, also mit der Einschraenkungserklaerung * zu zweistufigen Furten; * - der Fahrstreifensatz der Leistungstabelle bei abgewaehlter Tabelle * "Signalgruppen" (Befund 51) - ohne ihn ist qS nicht prueffaehig; * - der Vmax-Nachsatz des Anfahransatzes, ebenfalls bei abgewaehlter Tabelle. * * Die Zeilen liefen in den Fusssteg, ueberdruckten die Fusszeile und fielen * schliesslich aus dem Blatt - ohne jeden Hinweis. Fuer Deckblatttitel * (Befund 43) und Bemerkung (Befund 18) ist genau das mit * `setzeZeilenMitUmbruch` bereits behoben worden; `writeParagraph` selbst * nicht. */ const FEST = new Date('2026-01-01T12:00:00Z'); const PAGE_MARGIN = 36; const FOOTER_HEIGHT = 26; /** Unterkante des Satzspiegels, in PDF-Koordinaten (Ursprung unten). */ const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT; /** Grundlinie der Fusszeile - der einzige Text, der darunter stehen darf. */ const FUSSZEILE_Y = PAGE_MARGIN + 6; /** Ein Name der gewuenschten Laenge, wie ihn eine Anordnungsunterlage traegt. */ function langerName(i: number, laenge: number): string { let text = `Fußgängerfurt ${i + 1} über die Hauptstraße nördliche Knotenpunktzufahrt mit Mittelinsel ` + 'und Blindenzusatz Anforderung über Taster '; while (text.length < laenge) text += 'Zusatzangabe '; return text.slice(0, laenge).trim(); } interface Stueck { readonly blatt: number; readonly y: number; readonly text: string; } function entpacke(roh: string, von: number): string | null { const anfang = roh.indexOf('stream', von); if (anfang < 0) return null; const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7); const ende = roh.indexOf('endstream', start); if (ende < 0) return null; try { return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { return null; } } function textstuecke(bytes: Uint8Array): readonly Stueck[] { const roh = new TextDecoder('latin1').decode(bytes); const raus: Stueck[] = []; let blatt = 0; for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) { const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`); const inhalt = objekt < 0 ? null : entpacke(roh, objekt); if (inhalt === null) continue; blatt += 1; let y = 0; let abstand = 0; for (const roheZeile of inhalt.split('\n')) { const zeile = roheZeile.trim(); const tl = /^(-?[\d.]+) TL$/.exec(zeile); if (tl) { abstand = Number(tl[1]); continue; } const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); if (td) { y = Number(td[2]); continue; } let rest = zeile; while (rest.startsWith('T*')) { y -= abstand; rest = rest.slice(2).trim(); } const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest); if (tj) raus.push({ blatt, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); } } return raus; } /** * Ein Knotenpunkt mit n Signalgruppen einer Verkehrsart und langen Namen, ohne * Konfliktbeziehungen - dann greift beim Furtregelabsatz der Zweig * "Furtlaenge unbekannt", und jede Gruppe steuert einen Satz bei. */ function projekt(n: number, mode: TrafficMode, laenge: number): Project { 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: langerName(i, laenge), mode, armId: arme[i % 4]?.id ?? null, index: i, }), ); } const phase = createPhase( 'Phase 1', groups.map((g) => g.id), ); return { ...basis, intersection: { ...basis.intersection, arms: arme }, signalGroups: groups, conflicts: [], phases: [phase], program: { ...basis.program, phaseOrder: [phase.id] }, demands: mode === 'kfz' ? groups.map((g, i) => ({ signalGroupId: g.id, volume: 300 + ((i * 37) % 400), heavyVehicleShare: 0.05, })) : [], }; } function unterlage(project: Project, optionen: Partial = {}): Uint8Array { const plan = buildSignalPlan(project); return buildProjectPdf( project, plan, validateProject(project, plan, FEST), { ...DEFAULT_PDF_OPTIONS, ...optionen }, FEST, ); } /** Jeder Text unterhalb des Satzspiegels - ausser der Fusszeile selbst. */ function imFusssteg(bytes: Uint8Array): readonly string[] { return textstuecke(bytes) .filter((s) => s.y < SATZSPIEGEL_UNTEN && Math.abs(s.y - FUSSZEILE_Y) > 0.5) .map((s) => `Blatt ${s.blatt}, y ${s.y.toFixed(2)}: "${s.text}"`); } describe('Befunde 20 und 51 - Absaetze ueber mehrere Blaetter', () => { it('setzt den Furtregelabsatz bei 32 Fussgaengergruppen auf Folgeblaettern fort', () => { const bytes = unterlage(projekt(32, 'fuss', 200)); // Vorbedingung: Der Absatz steht im Dokument und ist wirklich lang. const zeilen = textstuecke(bytes).filter((s) => s.text.includes('Furtlänge unbekannt')); expect(zeilen.length, 'Vorbedingung: der Furtregelabsatz steht im Dokument').toBeGreaterThan( 30, ); expect(imFusssteg(bytes), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile').toEqual( [], ); // Gegenprobe: Der Mittelinselvermerk steht am Ende des Absatzes und fiel // deshalb als erstes vom Blatt. expect( textstuecke(bytes).filter((s) => s.text.includes('Mittelinsel')).length, 'der Schluss des Absatzes fehlt im Dokument', ).toBeGreaterThan(0); }); it('setzt den Fahrstreifensatz bei 40 Kfz-Gruppen ohne Signalgruppentabelle fort', () => { const bytes = unterlage(projekt(40, 'kfz', 200), { includeSignalGroups: false }); const zeilen = textstuecke(bytes).filter((s) => s.text.includes('Fahrstreifen')); expect(zeilen.length, 'Vorbedingung: der Fahrstreifensatz steht im Dokument').toBeGreaterThan( 10, ); expect(imFusssteg(bytes), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile').toEqual( [], ); }); });