import { readFileSync } from 'node:fs'; import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { buildSignalPlan, intergreenKey, massgebendeRaeumbeziehung, } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { raeumbeziehungBefunde } from '@/domain/validation/rules'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { parseProject } from '@/domain/model/schema'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { toWinAnsi } from '@/render/pdfSurface'; import type { Conflict, Project } from '@/domain/model/project'; import * as fmt from '@/ui/format'; /* * Der massgebende Strom in der PLANUNTERLAGE. * * Verlangt war ausdruecklich dieser Fall: EINE Beziehung, an der Plan, * Pruefbericht und die gedruckte Zeile gegeneinander gehalten werden. Bis zu * diesem Befund liefen sie auseinander - der Plan raeumte mit der Fahrbeziehung * des massgebenden STROMS, die Aufstellung "Abweichungen von den Regelwerten" * zaehlte mit der Fahrbeziehung der SIGNALGRUPPE. Gedruckt stand dann: das * Merkmal "enger Innenradius" ist an einer Beziehung gesetzt und wirkt an * null - waehrend derselbe Ausdruck zwei Seiten davor 5,0 m/s in die Spalte * "vr" derselben Zeile schrieb. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const BEISPIEL = 'beispiele/vierarmiger-knotenpunkt.lsap'; /** 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/g)) { teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); } } return teile.join(' ').replace(/\s+/g, ' '); } const gedruckt = (text: string): string => toWinAnsi(text).replace(/\s+/g, ' '); /** * Standardknotenpunkt, an dem "K1" geradeaus UND rechts fuehrt: Die Gruppe * bleibt bei 'geradeaus', die Beziehung K1 -> K3 traegt die Fahrbeziehung * ihres massgebenden Stroms und das Merkmal "enger Innenradius". */ function sammelgruppe(): Project { const basis = createStandardIntersectionProject('Sammelgruppe', STICHTAG); const k1 = basis.signalGroups.find((g) => g.name === 'K1')!; const k3 = basis.signalGroups.find((g) => g.name === 'K3')!; return { ...basis, meta: { ...basis.meta, planner: 'O. Willerding' }, conflicts: basis.conflicts.map((c) => c.fromId === k1.id && c.toId === k3.id ? { ...c, raeumbeziehung: 'rechts' as const, engerRadius: true } : c, ), }; } function beziehung(project: Project): Conflict { return project.conflicts.find((c) => c.engerRadius === true)!; } function ausdruck(project: Project): string { const plan = buildSignalPlan(project); const bericht = validateProject(project, plan, STICHTAG); return pdfText(buildProjectPdf(project, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG)); } describe('N1/3: Plan, Pruefbericht und gedruckte Zeile legen dieselbe Fahrbeziehung zugrunde', () => { it('haelt die drei an derselben Beziehung gegeneinander', () => { const project = sammelgruppe(); const konflikt = beziehung(project); const from = project.signalGroups.find((g) => g.id === konflikt.fromId)!; const plan = buildSignalPlan(project); const bericht = validateProject(project, plan, STICHTAG); const text = pdfText(buildProjectPdf(project, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG)); // Die eine Entscheidung, an die sich alle drei zu halten haben. expect(from.movement).toBe('geradeaus'); expect(massgebendeRaeumbeziehung(from, konflikt)).toBe('rechts'); // PLAN: raeumt als Rechtsabbieger mit engem Innenradius. const gerechnet = plan.intergreens.get(intergreenKey(konflikt.fromId, konflikt.toId)); expect(gerechnet?.calculation?.clearingSpeed).toBe( RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius, ); // PRUEFBERICHT: meldet das Merkmal als wirksam, nicht als wirkungslos. const zurBeziehung = bericht.findings.filter( (f) => f.target?.kind === 'konflikt' && f.target.id === konflikt.id, ); expect(zurBeziehung.map((f) => f.rule)).toContain('zwischenzeiten.enger-radius'); expect(zurBeziehung.map((f) => f.rule)).not.toContain( 'zwischenzeiten.enger-radius-ohne-wirkung', ); // GEDRUCKTE ZEILE: dieselbe Raeumgeschwindigkeit in der Spalte "vr" ... expect(text).toContain( gedruckt(fmt.metersPerSecond(RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius, 1)), ); // ... und die Abweichungsaufstellung zaehlt die Beziehung als wirksam. // 16 Beziehungen seit 5.43.0: Die Vorlage erfasst jede Furt auch gegen den // Strom, der sie beim Ausfahren quert (K2/F2, K4/F1; Gruppe Furt). Bis // dahin waren es 12. Gezaehlt wird weiter die eine wirksame Beziehung. expect(text).toContain(gedruckt('an 1 von 16 Konfliktbeziehungen')); expect(text).toContain(gedruckt('an den gekennzeichneten Beziehungen.')); expect(text).not.toContain(gedruckt('an den 0 Beziehungen, an denen es wirkt')); }); it('zaehlt weiter nur die Beziehungen, an denen das Merkmal wirkt', () => { // Gegenprobe: dieselbe Gruppe ohne die Fahrbeziehung des massgebenden // Stroms. Dann raeumt der Plan geradeaus, das Merkmal wirkt nicht - und // die Aufstellung muss das sagen, sonst prueft der Fall oben nichts. const basis = sammelgruppe(); const project: Project = { ...basis, conflicts: basis.conflicts.map((c) => { const { raeumbeziehung: _weg, ...ohne } = c; return ohne; }), }; const text = ausdruck(project); expect(text).toContain(gedruckt('an den 0 Beziehungen, an denen es wirkt')); expect(text).not.toContain(gedruckt('an den gekennzeichneten Beziehungen.')); }); it('sagt im Erlaeuterungsabsatz, welche Fahrbeziehung ueber das Merkmal entscheidet', () => { /* * Ohne diesen Satz steht in der Unterlage eine Zeile mit 5,0 m/s neben * einer Signalgruppentabelle, die "geradeaus" fuehrt, und nichts erklaert * den Unterschied. Der Absatz nennt deshalb den Strom, dem der Raeumweg * dieser Zeile gehoert - nicht die Signalgruppe. */ const text = ausdruck(sammelgruppe()); expect(text).toContain( gedruckt( 'Ob ein Strom abbiegt, entscheidet dabei die Fahrbeziehung des Stroms, dem der Räumweg ' + 'dieser Zeile gehört, und nicht die der räumenden Signalgruppe', ), ); }); it('nennt im selben Absatz den Rueckfall auf die Fahrbeziehung der Signalgruppe', () => { /* * NACHTRAG (Fassung 5.11.0): Festgehalten wird der massgebende * Strom allein bei der Uebernahme aus dem Lageplan. In der mitgelieferten * Beispieldatei traegt keine der 72 Beziehungen das Feld; gedruckt stand * trotzdem unbedingt, ueber das Abbiegen entscheide der Strom und nicht * die Signalgruppe. Der Pruefer haelt die Zeile damit fuer nachgewiesen - * die gefaehrliche Richtung des Irrtums. */ expect(ausdruck(sammelgruppe())).toContain( gedruckt( 'Ist nicht festgehalten, welcher Strom den Räumweg gefahren ist, wird mit der ' + 'Fahrbeziehung der Signalgruppe gerechnet, und der Prüfbericht meldet das.', ), ); }); it('sagt den Rueckfall mit dem Wortlaut des Pruefberichts', () => { // Derselbe Sachverhalt, dieselben Worte: Der Bericht meldet ihn an der // Beispieldatei, die Unterlage erklaert ihn im Absatz zur Spalte. const gemeinsam = 'mit der Fahrbeziehung der Signalgruppe'; const beispiel = parseProject(JSON.parse(readFileSync(BEISPIEL, 'utf8')), STICHTAG).project; expect(raeumbeziehungBefunde(beispiel)[0]?.message).toContain(gemeinsam); expect(ausdruck(sammelgruppe())).toContain(gedruckt(gemeinsam)); }); });