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 { ANLAGENART_GRENZEN, RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { toWinAnsi } from '@/render/pdfSurface'; import type { Anlagenart, Project } from '@/domain/model/project'; import type { RilsaOverrides } from '@/domain/rilsa/settings'; /* * Offener Rest von Befund 48 (tests/domain/regelbereichBezug.test.ts). * * Befund 48 hat den Bezugswert der Abweichungsliste berichtigt: Bei den beiden * Regelbereichsfeldern der Umlaufzeit misst `resolveRilsaSettings` seither * gegen den Wert, der OHNE Eintrag fuer die Anlagenart gilt (40 bis 70 s an der * Fussgaengerschutzanlage), nicht mehr gegen die Knotenpunktzahl. Das * Fundstellenverzeichnis bildete seine Angabe aber weiter selbst, aus einem * Wertvergleich gegen `settingFields(...).standard`. Gemessen am erzeugten PDF * einer Fussgaengerschutzanlage sagte dieselbe Unterlage damit zweierlei: * * Eintrag 40 s - also genau der Wert, der dort ohnehin gilt: * "Abweichungen von den Regelwerten": keine Zeile. * Fundstellenverzeichnis: "Regelbereich Umlaufzeit von (angesetzt: 40 s, * abweichend vom Regelwert)" neben der Regelwertspalte "60 s". * * Eintrag 60 s - die wirksame Verschiebung des Regelbereichs: * "Abweichungen von den Regelwerten": "Regelbereich Umlaufzeit von | 40 | * 60 | s", und der Pruefbericht daneben "Angesetzt: 60 s, Regelwert: 40 s." * Fundstellenverzeichnis: kein Vermerk, Regelwertspalte "60 s". * * Vor der Behoerde stehen damit zwei Regelwerte fuer denselben Kennwert in * einer Anordnungsunterlage - und einmal eine Begruendungspflicht fuer eine * Eingabe, die an keiner Zahl des Plans etwas aendert. * * DIESER TEST HAELT BEIDE AUFSTELLUNGEN DESSELBEN PDF GEGENEINANDER; genau ihr * Auseinanderlaufen ist der Befund, und ein Test der neuen Stelle fuer sich * allein wuerde ihn nicht fassen. * * GEGEN DEN ALTSTAND schlagen vier der sechs Faelle fehl: die drei zur * Fussgaengerschutzanlage und der zur einstreifigen Verkehrsfuehrung, deren * Regelbereich (60 bis 180 s) dieses Programm ebenfalls selbst setzt. Gruen * bleiben dort die Vorbedingung und der Knotenpunkt. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const PAGE_MARGIN = 36; const CONTENT_WIDTH = 841.89 - 2 * PAGE_MARGIN; /* * Die Spalten des Fundstellenverzeichnisses, aus den festen Breiten des * Ausdrucks gerechnet: Regelwerk 70, Kennwert (Rest), Regelwert 62, * Sachgebiet 175, Fundstelle 110, Pruefstand 62 - wie in * tests/export/fundstellenRegelwert.test.ts. */ const KENNWERT_X = PAGE_MARGIN + 70; const KENNWERT_BREITE = CONTENT_WIDTH - (70 + 62 + 175 + 110 + 62); const REGELWERT_X = KENNWERT_X + KENNWERT_BREITE; const REGELWERT_ENDE = REGELWERT_X + 62; /** Innenabstand der Tabellenzellen dieses Verzeichnisses. */ const ZELLENRAND = 2.5; interface Stueck { readonly blatt: number; readonly x: 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 x = 0; 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) { x = Number(td[1]); 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, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); } } return raus; } function projekt(anlagenart: Anlagenart, rilsa: RilsaOverrides = {}): Project { const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); return { ...basis, anlagenart, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, ...rilsa } }, }; } function unterlage(p: Project): readonly Stueck[] { const plan = buildSignalPlan(p); return textstuecke( buildProjectPdf(p, plan, validateProject(p, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG), ); } /** Die Kennwertzelle des Fundstellenverzeichnisses zu einem Kennwert. */ function verzeichnisKennwert(stuecke: readonly Stueck[], label: string): Stueck { const treffer = stuecke.filter( (s) => Math.abs(s.x - (KENNWERT_X + ZELLENRAND)) < 0.5 && s.text.startsWith(toWinAnsi(label)), ); expect(treffer.length, `Vorbedingung: Zeile "${label}" im Fundstellenverzeichnis`).toBe(1); return treffer[0]!; } /** Die Zelle "Regelwert" des Fundstellenverzeichnisses zu einem Kennwert. */ function verzeichnisRegelwert(stuecke: readonly Stueck[], label: string): readonly string[] { const zeile = verzeichnisKennwert(stuecke, label); return stuecke .filter((s) => s.blatt === zeile.blatt && Math.abs(s.y - zeile.y) < 0.5) .filter((s) => s.x >= REGELWERT_X && s.x < REGELWERT_ENDE) .map((s) => s.text); } /** * Die Zeile der Aufstellung "Abweichungen von den Regelwerten" zu einem * Kennwert - Kennwert, Regelwert, Angesetzt, Einheit -, oder null, wo die * Aufstellung den Kennwert nicht fuehrt. Ihre erste Spalte beginnt am linken * Satzspiegel und steht damit links der Kennwertspalte des Verzeichnisses. */ function aufstellungZeile(stuecke: readonly Stueck[], label: string): readonly string[] | null { const treffer = stuecke.filter((s) => s.x < KENNWERT_X && s.text === toWinAnsi(label)); if (treffer.length === 0) return null; expect(treffer.length, `Zeile "${label}" in "Abweichungen von den Regelwerten"`).toBe(1); const zeile = treffer[0]!; return stuecke .filter((s) => s.blatt === zeile.blatt && Math.abs(s.y - zeile.y) < 0.5) .sort((a, b) => a.x - b.x) .map((s) => s.text); } const VON = 'Regelbereich Umlaufzeit von'; const BIS = 'Regelbereich Umlaufzeit bis'; describe('Regelwerte beider Aufstellungen desselben PDF', () => { it('haelt an der Fussgaengerschutzanlage die Zahlen der Anlagenart bereit', () => { // Voraussetzung des Aufbaus, wie in regelbereichBezug: Die // Anlagenart fuehrt einen anderen Regelbereich als der Knotenpunkt. expect(ANLAGENART_GRENZEN.fussgaengerschutzanlage.cycleTime.recommendedMin).toBe(40); // Seit 5.27.0 der Richtwert der RiLSA (Abschnitt 2.6) statt der 60 s. expect(RILSA_DEFAULTS.cycleTime.recommendedMin).toBe(30); }); it('nennt bei einer wirksamen Vorgabe in beiden Aufstellungen denselben Regelwert', () => { const stuecke = unterlage(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 60 })); // 60 s verschieben den Regelbereich von 40 bis 70 auf 60 bis 70. expect(aufstellungZeile(stuecke, VON)).toEqual([toWinAnsi(VON), '40', '60', 's']); expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']); expect(verzeichnisKennwert(stuecke, VON).text).toBe( toWinAnsi(`${VON} (angesetzt: 60 s, abweichend vom Regelwert)`), ); }); it('macht aus einer wirkungslosen Vorgabe auch im Verzeichnis keine Abweichung', () => { const stuecke = unterlage(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 40 })); // 40 s gelten dort ohnehin - keine der beiden Aufstellungen darf dafuer // eine Begruendung verlangen. expect(aufstellungZeile(stuecke, VON)).toBeNull(); expect(verzeichnisKennwert(stuecke, VON).text).toBe(toWinAnsi(VON)); expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']); }); it('haengt die Spalte "Regelwert" an der Anlagenart und nicht am Eintrag', () => { // Ohne Eintrag dieselbe Zahl wie mit den beiden Eintraegen darueber: Eine // Regelwertspalte, die zwischen 40 und 60 s wechselt, je nachdem ob jemand // etwas eingetragen hat, waere derselbe Widerspruch in einer Zeile. const stuecke = unterlage(projekt('fussgaengerschutzanlage')); expect(aufstellungZeile(stuecke, VON)).toBeNull(); expect(verzeichnisKennwert(stuecke, VON).text).toBe(toWinAnsi(VON)); expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']); expect(verzeichnisRegelwert(stuecke, BIS)).toEqual(['70 s']); }); it('misst am Knotenpunkt gegen den Richtwert der RiLSA', () => { // 40 s weichen seit 5.27.0 vom Regelwert 30 s ab - zuvor lag der bei 60 s, // und dieselbe Eingabe war eine Abweichung in die andere Richtung. const stuecke = unterlage(projekt('knotenpunkt', { cycleTimeRecommendedMin: 40 })); expect(aufstellungZeile(stuecke, VON)).toEqual([toWinAnsi(VON), '30', '40', 's']); expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['30 s']); expect(verzeichnisKennwert(stuecke, VON).text).toBe( toWinAnsi(`${VON} (angesetzt: 40 s, abweichend vom Regelwert)`), ); }); it('misst die groesste Umlaufzeit weiterhin gegen den Regelwerkswert', () => { /* * Die Fortsetzung der Gegenprobe aus regelbereichBezug im Ausdruck: * Bei der kleinsten und groessten Umlaufzeit bleibt der Knotenpunktwert der * Bezug, weil er dort ein Regelwerkswert ist (30 bis 120 s, nicht nach * Anlagenart gestaffelt) - die beiden Zeilen zur groessten Umlaufzeit sind * deshalb in beiden Staenden gruen. Nur die beiden Regelbereichsfelder * folgen der Anlagenart; bei einstreifiger Verkehrsfuehrung setzt dieses * Programm 60 bis 180 s, und der Altstand druckte dort die 90 s des * Knotenpunkts. */ const stuecke = unterlage(projekt('einstreifig', { cycleTimeMax: 250 })); expect(aufstellungZeile(stuecke, 'Größte Umlaufzeit')).toEqual([ toWinAnsi('Größte Umlaufzeit'), '120', '250', 's', ]); expect(verzeichnisRegelwert(stuecke, 'Größte Umlaufzeit')).toEqual(['120 s']); expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['60 s']); expect(verzeichnisRegelwert(stuecke, BIS)).toEqual(['180 s']); }); });