import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { freigabefensterZelle } from '@/services/export/csv'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createPhase, createStandardIntersectionProject } from '@/domain/model/factory'; import { toWinAnsi } from '@/render/pdfSurface'; import * as fmt from '@/ui/format'; import type { Project } from '@/domain/model/project'; /** * Die Koordinierung in der Unterlage. * * DER SATZ, AUF DEN ES ANKOMMT: Der Versatz ist eine Empfehlung fuer die * NACHBARANLAGEN und verschiebt den Signalzeitenplan dieses Knotenpunkts * nicht. Wer das nicht liest, sucht die Wirkung im Zeitdiagramm daneben - und * findet sie nicht. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); 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(' ').replace(/\s+/g, ' '); } function ausdruck(project: Project): string { const plan = buildSignalPlan(project); return pdfText( buildProjectPdf( project, plan, validateProject(project, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, ), ); } function ohneKette(): Project { return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG); } function mitKette( optionen: { gegenrichtung?: boolean; beginn?: number; dauer?: number } = {}, ): Project { const roh = ohneKette(); const k1 = roh.signalGroups[0]!.id; const k2 = roh.signalGroups[1]!.id; let projekt: Project = { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 60 }, }; projekt = actions.setzeKoordinierung(projekt, { hauptrichtung: k1, ...(optionen.gegenrichtung === true ? { gegenrichtung: k2 } : {}), progressionsgeschwindigkeit: 36, platz: 0, }); projekt = actions.koordinierungsknotenAnlegen(projekt, 'Weststraße'); const west = projekt.koordinierung!.nachbarn[0]!; return actions.koordinierungsknotenAendern(projekt, west.id, { abstand: 200, freigabeBeginn: optionen.beginn ?? 20, freigabeDauer: optionen.dauer ?? 25, ...(optionen.gegenrichtung === true ? { gegenBeginn: 20, gegenDauer: 25 } : {}), }); } /** Der Ausdruck vom Kopf der Koordinierungstabelle bis zur Ueberschrift des Zeit-Weg-Diagramms. */ function koordinierungstabelle(text: string): string { const anfang = text.indexOf(toWinAnsi('Freigabe Hauptrichtung')); expect(anfang).toBeGreaterThanOrEqual(0); const ende = text.indexOf(toWinAnsi('Zeit-Weg-Diagramm'), anfang); expect(ende).toBeGreaterThan(anfang); return text.slice(anfang, ende); } describe('Planunterlage - Koordinierung', () => { it('druckt den Abschnitt mit Entfernung, Reisezeit und Versatz', () => { const text = ausdruck(mitKette()); expect(text).toContain(toWinAnsi('Koordinierung')); expect(text).toContain(toWinAnsi('Weststraße')); expect(text).toContain(toWinAnsi('Empfohlener Versatz')); expect(text).toContain(toWinAnsi('Zeit-Weg-Diagramm')); expect(text).toContain(toWinAnsi('(dieser Knotenpunkt)')); }); it('sagt, dass der Versatz den eigenen Plan nicht verschiebt', () => { const text = ausdruck(mitKette()); expect(text).toContain(toWinAnsi('DER VERSATZ IST EINE EMPFEHLUNG FÜR DIE NACHBARANLAGEN')); expect(text).toContain(toWinAnsi('gilt zwischen Anlagen, nicht innerhalb einer')); }); it('sagt, dass die Nachbaranlagen hier nicht geprüft sind', () => { /* * Die wichtigste Abgrenzung des Abschnitts. Eine Behoerde, die eine * Bandbreite und Versatzzeiten fuer drei Anlagen sieht, koennte den * Ausdruck fuer die Unterlage des ganzen Strassenzugs halten. */ const text = ausdruck(mitKette()); expect(text).toContain(toWinAnsi('weder wiedergegeben noch geprüft')); expect(text).toContain(toWinAnsi('gemeinsame Umlaufzeit')); }); it('benennt die fehlende Gegenrichtung, statt sie anzunehmen', () => { const ohne = ausdruck(mitKette()); expect(ohne).toContain(toWinAnsi('ein zweites Band wird deshalb NICHT gerechnet')); const mit = ausdruck(mitKette({ gegenrichtung: true })); expect(mit).toContain(toWinAnsi('Bandbreite in der Gegenrichtung')); expect(mit).not.toContain(toWinAnsi('ein zweites Band wird deshalb NICHT gerechnet')); }); it('führt eine Zeile in der Bestandteileübersicht - auch ohne Straßenzug', () => { const mit = ausdruck(mitKette()); expect(mit).toContain(toWinAnsi('Koordinierung im Straßenzug (Grüne Welle)')); expect(mit).toContain(toWinAnsi('ja – Abschnitt „Koordinierung"')); const ohne = ausdruck(ohneKette()); expect(ohne).toContain(toWinAnsi('kein Straßenzug erfasst')); // Ohne Kette gibt es den Abschnitt nicht - und damit keinen seiner Sätze. expect(ohne).not.toContain(toWinAnsi('Zeit-Weg-Diagramm')); expect(ohne).not.toContain(toWinAnsi('Empfohlener Versatz')); }); }); /* * FASSUNG 5.43.0, BEFUND 13: Die Tabelle des Abschnitts nannte das * Freigabefenster als "Beginn bis Beginn + Dauer", ohne Faltung - "91 s bis * 139 s" bei 95 s Umlaufzeit, waehrend die Signalzeitentabelle derselben * Unterlage "91–44" fuehrt und das Diagramm darunter (seit derselben Pruefung) * mit dem gefalteten Fenster zeichnet. */ describe('Planunterlage - Freigabefenster der Koordinierungstabelle', () => { it('nennt ein einzelnes eigenes Fenster mit derselben Zeichenkette wie die Signalzeitentabelle - auch über das Umlaufende', () => { // Eine dritte Phase fuer Nord/Sued: K1 ist in der letzten und der ersten // Phase frei, sein Fenster laeuft ueber das Umlaufende. const basis = mitKette(); const k1 = basis.signalGroups[0]!; const k2 = basis.signalGroups[1]!; const phase3 = createPhase('Phase 3 - Nord/Süd', [k1.id, k2.id]); const projekt: Project = { ...basis, phases: [...basis.phases, phase3], program: { ...basis.program, phaseOrder: [...basis.program.phaseOrder, phase3.id] }, }; const plan = buildSignalPlan(projekt); const fenster = plan.groups.find((g) => g.groupId === k1.id)!.greens; expect(fenster).toHaveLength(1); expect(fenster[0]!.start + fenster[0]!.duration).toBeGreaterThan(plan.cycleTime); const zelle = freigabefensterZelle(fenster, plan.cycleTime); const tabelle = koordinierungstabelle(ausdruck(projekt)); expect(tabelle).toContain(toWinAnsi(zelle)); expect(tabelle).not.toContain( toWinAnsi(`bis ${fmt.seconds(fenster[0]!.start + fenster[0]!.duration)}`), ); }); it.each([ { beginn: 50, dauer: 20, soll: '50–10', alt: 'bis 70 s' }, // "Freigabe ab" darf die Umlaufzeit uebersteigen; gerechnet wird gefaltet. { beginn: 70, dauer: 20, soll: '10–30', alt: 'bis 90 s' }, ])( 'faltet das Fenster einer Nachbaranlage in den Umlauf, wie die Koordinierung rechnet (Beginn $beginn s)', ({ beginn, dauer, soll, alt }) => { const tabelle = koordinierungstabelle(ausdruck(mitKette({ beginn, dauer }))); expect(tabelle).toContain(toWinAnsi(soll)); expect(tabelle).not.toContain(toWinAnsi(alt)); }, ); it('sagt, dass ein Fenster über den Umlaufbeginn hinaus vor seinem Beginn endet', () => { const tabelle = koordinierungstabelle(ausdruck(mitKette({ beginn: 50, dauer: 20 }))); expect(tabelle).toContain(toWinAnsi('in den Umlauf gefaltet')); expect(tabelle).toContain(toWinAnsi('liegt sein Ende vor seinem Beginn')); }); });