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 { signalgeberBefunde } from '@/domain/validation/rules'; import * as actions from '@/app/actions'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import type { Project } from '@/domain/model/project'; import type { Signalgeber } from '@/domain/geometrie/lageplan'; /* * STUFE 4 DES SIGNALLAGEPLAN-ENTWURFS: DAS DRITTE BLATT UND DIE UMGEDREHTE * ZUSAGE. * * Die Zeile "Bemasster Signallageplan - NICHT Bestandteil" ist die Behebung * des Befundes C14 und die einzige Stelle, an der die Unterlage ueber sich * selbst die unangenehme Auskunft gibt. Wer sie zu einem "ja" macht, richtet * das Vertrauen eines Pruefers danach - und ein Signallageplan ohne Bauform, * Hoehe, Blendenmass, Sichtpruefung und Restbreitennachweis ist keiner. * * DESHALB WIRD SIE DREIWERTIG UND NICHT ZWEIWERTIG. Diese Datei bewacht genau * das: dass der Satz mit dem beginnt, was FEHLT, dass er die fuenf fehlenden * Angaben beim Namen nennt, und dass ohne erfasste Signalgeber alles bleibt, * wie es war. */ const DATUM = new Date('2026-09-12T11:00:00Z'); const GEBER: Signalgeber = { id: 'sg-1', name: 'K1', punkt: { x: 60, y: 40 }, ausrichtung: 0, signalGroupId: null, }; 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 stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) { teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')'))); } } return teile.join(' ').replace(/\s+/g, ' '); } /** Eine Engstelle aus dem gefuehrten Einstieg, wahlweise mit Signalgebern. */ function projekt(geber: readonly Signalgeber[] = []): Project { const basis = projektAusAngaben( { ...ASSISTENT_VORGABE, form: 'einstreifig' }, 'Arbeitsstelle Musterweg', DATUM, ); return geber.reduce((p, g) => actions.addSignalgeber(p, g), basis); } function ausdruck(p: Project): string { const plan = buildSignalPlan(p); return pdfText( buildProjectPdf(p, plan, validateProject(p, plan, DATUM), DEFAULT_PDF_OPTIONS, DATUM), ); } describe('Die Bestandteilezeile zum Signallageplan', () => { it('sagt ohne erfasste Signalgeber genau das, was sie vorher sagte', () => { const text = ausdruck(projekt()); expect(text).toContain('Bemaßter Signallageplan'); expect(text).toContain('NICHT Bestandteil dieser Unterlage'); expect(text).not.toContain('TEILWEISE'); }); it('wird mit erfassten Signalgebern dreiwertig und nennt die Zahl', () => { const text = ausdruck(projekt([GEBER])); expect(text).toContain('TEILWEISE'); expect(text).toContain('1 Signalgeberstandort'); }); it('nennt die fünf Angaben, die weiterhin fehlen', () => { /* * Der Kern. Ein "teilweise" ohne Aufzaehlung waere eine Andeutung; ein * Pruefer soll lesen koennen, WAS ihm fehlt, ohne es zu erraten. */ const text = ausdruck(projekt([GEBER])); for (const angabe of ['Bauform', 'Höhe', 'Blendenmaß', 'Sichtprüfung', 'Fahrbahnbreite']) { expect(text, `die Zeile verschweigt: ${angabe}`).toContain(angabe); } }); it('beginnt der Satz mit dem Fehlenden und nicht mit dem Vorhandenen', () => { // Wer die Zeile ueberfliegt, liest den Anfang. Stuende dort die Zahl der // Signalgeber, laese er "enthalten". const text = ausdruck(projekt([GEBER])); const zeile = text.slice(text.indexOf('TEILWEISE')); expect(zeile.indexOf('Nicht enthalten')).toBeLessThan(zeile.indexOf('Bauform') + 1); expect(text.indexOf('TEILWEISE')).toBeLessThan(text.indexOf('1 Signalgeberstandort') + 1); }); it('druckt das dritte Blatt nur, wenn Signalgeber erfasst sind', () => { expect(ausdruck(projekt())).not.toContain('Lageplan mit Signalgeberstandorten'); // Ohne Bildgrundlage gibt es gar kein Lageplanblatt; das Projekt des // gefuehrten Einstiegs fuehrt keines. Geprueft wird deshalb die // Bestandteilezeile, die es in jedem Fall gibt. expect(ausdruck(projekt([GEBER]))).toContain('TEILWEISE'); }); }); describe('Die Prüfregeln zu den Signalgeberstandorten', () => { it('schweigen, solange keiner erfasst ist', () => { expect(signalgeberBefunde(projekt())).toEqual([]); }); it('melden eine Signalgruppe ohne Signalgeber', () => { const befunde = signalgeberBefunde(projekt([GEBER])); const befund = befunde.find((b) => b.rule === 'lageplan.gruppe-ohne-signalgeber'); expect(befund, 'kein Befund zur Gruppe ohne Geber').toBeDefined(); expect(befund!.severity).toBe('hinweis'); }); it('melden einen Signalgeber ohne Signalgruppe', () => { const befund = signalgeberBefunde(projekt([GEBER])).find( (b) => b.rule === 'lageplan.signalgeber-ohne-gruppe', ); expect(befund).toBeDefined(); expect(befund!.message).toContain('K1'); }); it('warnen bei doppelter Kennung', () => { const befund = signalgeberBefunde( projekt([GEBER, { ...GEBER, id: 'sg-2', punkt: { x: 90, y: 40 } }]), ).find((b) => b.rule === 'lageplan.signalgeber-kennung-doppelt'); expect(befund, 'doppelte Kennung nicht gemeldet').toBeDefined(); expect(befund!.severity).toBe('warnung'); }); it('prüfen keinen Abstand zur Haltlinie', () => { /* * DIE REGEL, DIE ES NICHT GIBT, UND WARUM. Ein Sollabstand von 3 bis 5 m * ist in keinem vorliegenden Regelwerk nachgewiesen; wer ihn als Prüfung * liest und die Haltlinie naeher an den Geber setzt, verkuerzt an der * Engstelle den Raeumweg und damit die Zwischenzeit. Die gefaehrliche * Richtung. */ const kennungen = signalgeberBefunde(projekt([GEBER])).map((b) => b.rule); expect(kennungen).not.toContain('lageplan.signalgeber-abstand'); const texte = signalgeberBefunde(projekt([GEBER])) .map((b) => `${b.message} ${b.suggestion ?? ''}`) .join(' '); expect(texte, 'eine Abstandsspanne steht in der Meldung').not.toMatch( /3[,.]0?\s*(bis|–|-)\s*5/, ); }); it('sperren die Ausgabe nicht', () => { // Der Signalgeber geht in keine Rechnung ein; nichts an ihm kann einen // Plan falsch machen. Ein Fehler waere hier eine Anmassung. const schweren = signalgeberBefunde(projekt([GEBER, { ...GEBER, id: 'sg-2' }])).map( (b) => b.severity, ); expect(schweren).not.toContain('fehler'); }); });