import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { RILSA_ENGSTELLE } from '@/domain/rilsa/constants'; import { validateProject } from '@/domain/validation'; import { toWinAnsi } from '@/render/pdfSurface'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import type { Project } from '@/domain/model/project'; import type { PdfOptions } from '@/services/export/pdf'; /** * Fassung 5.43.0: Der Satz unter der Zwischenzeitenmatrix behauptete einen * Rechenansatz, der nicht gerechnet hat. * * DER BEFUND. Unter der Matrix stand unbedingt "Ueberfahrzeit des * Kraftfahrzeugverkehrs: Gerechnet wurde mit dem Ansatz ..." * (`ueberfahrzeitAnsatzSatz` aus services/export/bewertung.ts) - also auch an * einer EINSTREIFIGEN Verkehrsfuehrung. Dort geht der Ansatz in keine Zahl der * Matrix ein: `buildSignalPlan` waehlt fuer jeden raeumenden Kfz-Strom das * Verfahren der Engstellensignalisierung (`engstelle && from.mode === 'kfz'`), * und dieses setzt die Ueberfahrzeit mit RILSA_ENGSTELLE.ueberfahrzeit fest * (RiLSA 2015, Abschnitt 5.2.2). Die Unterlage nannte damit eine Rechnung, die * nicht stattgefunden hat - und zwar neben den Zahlen, die der Pruefer * nachrechnet. * * WARUM NICHT EINFACH WEGLASSEN: Der Satz steht aus einem Grund unter der Matrix * und nicht nur im Rechenweg (Fassung 5.5.0, Frage E2) - der * Rechenweg ist abwaehlbar, und eine Matrix ohne jede Angabe zur Ueberfahrzeit * laesst sich nicht nachrechnen. An seine Stelle tritt deshalb das Verfahren, * das wirklich gerechnet hat. Geprueft wird beides mit ABGEWAEHLTEM Rechenweg, * damit die Fussnote dort den Befund nicht verdeckt. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); /** Ausdruck ohne Rechenweg - die Lage, fuer die der Satz gedacht ist. */ const NUR_MATRIX: PdfOptions = { ...DEFAULT_PDF_OPTIONS, includeCalculationDetails: false }; /** 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/gu)) { teile.push(t[1]!.replace(/\\([()\\])/gu, '$1')); } } return teile.join(' ').replace(/\s+/gu, ' '); } const gedruckt = (text: string): string => toWinAnsi(text).replace(/\s+/gu, ' '); function unterlage(projekt: Project, options: PdfOptions = NUR_MATRIX): string { const plan = buildSignalPlan(projekt); const bericht = validateProject(projekt, plan, STICHTAG); return pdfText(buildProjectPdf(projekt, plan, bericht, options, STICHTAG)); } /** Zwei Richtungen einer Baustellenampel, 200 m Engstelle, 50 km/h. */ function baustelle(): Project { const basis = createEmptyProject('Baustelle', STICHTAG, 'einstreifig'); const a = createSignalGroup({ name: 'K1', mode: 'kfz', vZul: 50, index: 0 }); const b = createSignalGroup({ name: 'K2', mode: 'kfz', vZul: 50, index: 1 }); const p1 = createPhase('Seite A', [a.id]); const p2 = createPhase('Seite B', [b.id]); const beziehung = (von: string, nach: string) => ({ ...createConflict(von, nach), clearingDistance: 200, enteringDistance: 0, }); return { ...basis, signalGroups: [a, b], conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)], phases: [p1, p2], program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, }; } /** Jede Beziehung mit raeumendem Kraftfahrzeug von Hand vorgegeben. */ function mitHandvorgaben(projekt: Project): Project { return { ...projekt, conflicts: projekt.conflicts.map((c) => projekt.signalGroups.find((g) => g.id === c.fromId)?.mode === 'kfz' ? { ...c, manualIntergreen: 8 } : c, ), }; } function mitFestemAnsatz(projekt: Project): Project { return { ...projekt, settings: { ...projekt.settings, ueberfahrzeitAnsatz: 'fest' } }; } describe('Die Matrix behauptet keinen Rechenansatz, der nicht gerechnet hat', () => { it('Vorbedingung: an der Engstelle rechnet wirklich das zweite Verfahren', () => { const plan = buildSignalPlan(baustelle()); expect( [...plan.intergreens.values()].every((i) => i.calculation?.verfahren === 'engstelle'), 'jede Beziehung nach Abschnitt 5.2.2', ).toBe(true); // Und die feste Ueberfahrzeit stammt nicht aus dem Ansatz. expect([...plan.intergreens.values()].every((i) => i.calculation?.crossingTime === 4)).toBe( true, ); }); it('nennt unter der Matrix nicht den Kfz-Ansatz, wo er in keine Zahl eingeht', () => { const text = unterlage(baustelle()); expect(text, 'die Matrix steht in dieser Ausgabe').toContain(gedruckt('Angaben in Sekunden')); expect(text, 'der Satz, der eine Rechnung behauptet').not.toContain( gedruckt('Gerechnet wurde mit dem Ansatz'), ); }); it('nennt dafuer das Verfahren, das gerechnet hat', () => { const text = unterlage(baustelle()); expect(text).toContain(gedruckt('Verfahren der Engstellensignalisierung')); expect(text).toContain(gedruckt('RiLSA 2015, Abschnitt 5.2.2')); expect(text, 'die feste Ueberfahrzeit des Abschnitts').toContain( gedruckt(`tü = ${RILSA_ENGSTELLE.ueberfahrzeit} s`), ); }); it('laesst den Knotenpunkt unveraendert', () => { // Die Gegenprobe: Dort geht der Ansatz in jede Zwischenzeit eines // raeumenden Kraftfahrzeugs ein, und dort gehoert der Satz hin. const text = unterlage(createStandardIntersectionProject('Knotenpunkt', STICHTAG)); expect(text).toContain(gedruckt('Überfahrzeit des Kraftfahrzeugverkehrs: Gerechnet wurde')); expect(text, 'kein Verfahren, das dort nicht gilt').not.toContain( gedruckt('Verfahren der Engstellensignalisierung'), ); }); it('nennt keinen der beiden Saetze, wo jede Kfz-Zwischenzeit vorgegeben ist', () => { /* * DER DRITTE FALL, den keine Anlagenart hergibt: Am Knotenpunkt gilt die * Formel des Abschnitts 2, aber sie ist an keiner Beziehung gelaufen - * `plan.intergreens` fuehrt dort `calculation: null`. Bis 5.42.1 stand auch * hier der Ansatzsatz. */ const projekt = mitHandvorgaben(createStandardIntersectionProject('Vorgaben', STICHTAG)); const plan = buildSignalPlan(projekt); expect( [...plan.intergreens.values()].some( (i) => i.source === 'vorgegeben' && i.calculation === null, ), 'Vorbedingung: die Vorgabe ersetzt die Rechnung', ).toBe(true); const text = unterlage(projekt); expect(text).not.toContain(gedruckt('Gerechnet wurde mit dem Ansatz')); expect(text, 'und kein Verfahren, das hier nicht gilt').not.toContain( gedruckt('Verfahren der Engstellensignalisierung'), ); expect(text, 'die Sternfussnote sagt, woher die Zahlen kommen').toContain( gedruckt('Mit Stern gekennzeichnete Werte sind von Hand vorgegeben'), ); }); it('haelt beide Aussagen auch im vollen Ausdruck auseinander', () => { // Mit Rechenweg steht die Fussnote zum Engstellenverfahren ohnehin da; der // Kfz-Ansatz darf auch dann nirgends als gerechnet gelten. const text = unterlage(baustelle(), DEFAULT_PDF_OPTIONS); expect(text).not.toContain(gedruckt('Gerechnet wurde mit dem Ansatz')); expect(text, 'die Fussnote des Rechenwegs bleibt').toContain( gedruckt('gilt für den Kraftfahrzeugverkehr das eigene Verfahren'), ); }); }); /** * Dieselbe Frage wird an drei Stellen EINES Ausdrucks gestellt, und sie darf * nicht dreierlei beantwortet werden. * * Die Eingrenzung des Matrixabsatzes widerlegte zwei Zusagen, die stehen * blieben: die Deckblattzeile "Überfahrzeit Kfz (Rechenansatz)", die den Ansatz * unbedingt nannte und fuer die Begruendung auf genau den Absatz verwies, den * die Unterlage nun nicht mehr druckt, und die Tafel "Abweichungen von den * Regelwerten", die fuer eine Wahl ohne Wirkung samt Richtungssatz eine * Begruendung verlangte - waehrend ihr eigener Einleitungsabsatz seit Befund C20 * festhaelt, dass ein Kennwert ohne Wirkung keine zu begruendende Abweichung * ist. */ describe('Deckblatt und Abweichungstafel sagen dasselbe wie die Matrix', () => { it('setzt an der Engstelle den Wirkungsvermerk neben die Deckblattzeile', () => { // EIN Ausdruck, beide Stellen: Deckblatt und Matrixabschnitt duerfen nicht // Verschiedenes sagen - das war der Befund. const text = unterlage(baustelle(), DEFAULT_PDF_OPTIONS); expect(text, 'die Zeile bleibt - die Einstellung steht ja da').toContain( gedruckt('Überfahrzeit Kfz (Rechenansatz)'), ); expect(text, 'und sagt, dass sie hier nichts bewirkt').toContain( gedruckt('ohne Wirkung an dieser Anlage'), ); expect(text, 'unter der Matrix steht dasselbe').toContain( gedruckt('Verfahren der Engstellensignalisierung'), ); expect(text, 'und nirgends eine Rechnung, die nicht stattfand').not.toContain( gedruckt('Gerechnet wurde mit dem Ansatz'), ); }); it('laesst die Deckblattzeile am Knotenpunkt unvermerkt', () => { const text = unterlage( createStandardIntersectionProject('Knotenpunkt', STICHTAG), DEFAULT_PDF_OPTIONS, ); expect(text).toContain(gedruckt('Überfahrzeit Kfz (Rechenansatz)')); expect(text, 'dort wirkt der Ansatz in jede Kfz-Zwischenzeit').not.toContain( gedruckt('ohne Wirkung an dieser Anlage'), ); }); it('nennt die Handvorgabe als Grund, wo sie jede Kfz-Zwischenzeit ersetzt', () => { const text = unterlage( mitHandvorgaben(createStandardIntersectionProject('Vorgaben', STICHTAG)), DEFAULT_PDF_OPTIONS, ); expect(text).toContain(gedruckt('ohne Wirkung an dieser Anlage: Kfz-Zwischenzeiten von Hand')); }); it('verlangt an der Engstelle keine Begruendung fuer den festen Ansatz', () => { const text = unterlage(mitFestemAnsatz(baustelle()), DEFAULT_PDF_OPTIONS); expect(text, 'die Tafel fuehrt die Wahl weiter auf').toContain( gedruckt('Überfahrzeit des Kraftfahrzeugverkehrs (Rechenansatz, kein Kennwert)'), ); expect(text).toContain(gedruckt('ohne Wirkung an dieser Anlage')); expect(text, 'eine Fehlerrichtung ohne Rechnung gibt es nicht').not.toContain( gedruckt('VERKÜRZT die Zwischenzeiten'), ); }); it('nennt am Knotenpunkt weiter die Fehlerrichtung des festen Ansatzes', () => { const text = unterlage( mitFestemAnsatz(createStandardIntersectionProject('Knotenpunkt', STICHTAG)), DEFAULT_PDF_OPTIONS, ); expect(text).toContain(gedruckt('VERKÜRZT die Zwischenzeiten')); expect(text).not.toContain(gedruckt('ohne Wirkung an dieser Anlage')); }); });