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 } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { parseProject } from '@/domain/model/schema'; import { CURRENT_SCHEMA_VERSION } from '@/domain/model/project'; /** * Die mitgelieferte Beispieldatei - die ganze Kette bis zum Ausdruck. * * DIE LUECKE, GEMESSEN: Zehn Testdateien nennen * `beispiele/vierarmiger-knotenpunkt.lsap`, neun lesen sie wirklich ein. Drei * der vier Stufen laufen darin - Einlesen samt Schemaaufstieg, `buildSignalPlan` * und `validateProject`. Die vierte lief nirgends: Die Datei ist nie GEDRUCKT * worden. Und ihr Pruefbericht wurde nie als Ganzes beurteilt, sondern immer * nur nach je einer Regel gefiltert - was sonst noch darin steht, hat niemand * angesehen. * * WARUM GERADE DIESE DATEI. Sie ist das einzige vollstaendige Projekt, das das * Programm mitbringt, und zugleich das Pruefstueck fuer den Altdatei-Pfad: Sie * traegt auf der Platte Schemastand 7, waehrend das Programm bei 18 steht * (bewusst so). Was hier gedruckt wird, ist also nicht irgendein * Projekt, sondern das Ergebnis einer Migration ueber elf Schemastaende - * der Weg, den die Datei eines Anwenders geht, der ein altes Projekt oeffnet * und daraus eine Anordnungsunterlage macht. * * WAS DABEI HERAUSKAM, gehoert benannt, denn es ist mehr, als der erste Blick * vermuten laesst. Die 15 Warnungen verteilen sich so: vier * Raeumbeziehungen ohne Nachweis der Herkunft, die eine * Altdatei nicht mitbringen kann; drei Projektangaben, die ein Beispiel nicht * fuehren kann (Projektnummer, Auftraggeber, Bearbeiter) - und **acht, die * den vier Fussgaengerfurten gelten**: Jede von ihnen wartet laengstens 94 s, * und das ist nach der HBS-Tafel die Qualitaetsstufe F. * * Vier Furten der Stufe F in dem Beispiel, das dem Programm beiliegt: Das ist * keine Aussage dieses Tests darueber, ob die Planung taugt - sie ist eine * eingefrorene Altfassung und als Pruefstueck gedacht, nicht als Vorbild. Es * ist eine Aussage darueber, was ein Anwender sieht, der sie oeffnet. Dass sie * hier steht, ist der ganze Ertrag der Beurteilung "als Ganzes"; die frueheren * Pruefungen an derselben Datei filterten immer nach einer einzelnen Regel und * haetten es nie gezeigt. * * SEIT FASSUNG 5.43.0 SPERRT DER BERICHT DEN AUSDRUCK. Die Datei ist * eingefroren und traegt die Wege, die die damalige Vermessung aus ihrem * Lageplan uebernahm - jede Furt allein in ihrer Zeichenrichtung gemessen. Die * Regel `zwischenzeiten.kuerzer-als-lageplan` haelt jede gespeicherte * Zwischenzeit gegen die, die derselbe Lageplan heute ergibt, und meldet 36 * Beziehungen als zu kurz: 28 Furtbeziehungen - die acht Nullen werden 6 s, "F * Nord" nach "K Nord" wird aus 3 s 12 s - und acht Kfz-Beziehungen, deren * massgebender Strom (Rechtsabbieger, Gleichstand) erst spaetere Fassungen * richtig waehlen. Die Wege sind nach dem Einlesen "unbestimmt" (Schemastand 7 * kennt keinen Herkunftsnachweis), deshalb Fehler und nicht Warnung. Das ist * die richtige Auskunft ueber diese Datei. Wer sie oeffnet und die Wege im * Lageplan neu uebernimmt, bekommt einen fehlerfreien Plan mit 110 s Umlauf. * * WOHER DIE 28 FURTBEZIEHUNGEN STAMMEN (nachgemessen am 17.09.2026 mit einer * Vermessung, die nur die Huelle beider Richtungen bildet): 20 aus der * Zeichenrichtung. Die uebrigen 8 aus den beiden Annahmen, * die das Programm fuer den Fussgaenger trifft - viermal "K x links" nach * "F x" (gemessen Einfahrweg 3,74 m, angesetzt 0 m; der Linksabbieger faehrt * im eigenen Fahrstreifen der Zufahrt, und dort ist 0 m eine Verschaerfung * dieses Programms) und viermal "F x" nach "K y links" (gemessen Raeumweg * 10,26 m, angesetzt die ganze Furt 14 m; im inneren Fahrstreifen der * Ausfahrt gezeichnet). * * DIE ACHT WARNUNGEN `zwischenzeiten.null` SIND FORT (seit dem * 17.09.2026): Bis 5.42.1 erklaerte die Regel die acht Nullen fuer * zulaessig; sie sind Artefakte der Zeichenrichtung. An einer Beziehung, die * `zwischenzeiten.kuerzer-als-lageplan` schon als Fehler meldet, schweigt die * Warnung - dieselbe Beziehung stuende sonst zweimal im Bericht. Aus 23 * Warnungen werden 15. */ const DATEI = 'beispiele/vierarmiger-knotenpunkt.lsap'; const STICHTAG = new Date('2026-01-01T12:00:00Z'); /** 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 stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) { teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')'))); } } return teile.join(' ').replace(/\s+/g, ' '); } const roh: unknown = JSON.parse(readFileSync(DATEI, 'utf8')); const eingelesen = parseProject(roh, STICHTAG); const plan = buildSignalPlan(eingelesen.project); const bericht = validateProject(eingelesen.project, plan, STICHTAG); const bytes = buildProjectPdf(eingelesen.project, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG); describe('Die Beispieldatei laeuft durch alle vier Stufen', () => { it('liegt als Schemastand 7 auf der Platte und kommt als aktueller Stand heraus', () => { /* * Die Datei wird nicht "aktualisiert" - sie ist mit Absicht eingefroren * (bewusst so). Genau deshalb ist sie das Pruefstueck: Was hier gedruckt * wird, hat die Migration hinter sich. */ expect((roh as { schemaVersion?: unknown }).schemaVersion, 'die Datei ist nicht mehr alt').toBe( 7, ); expect(eingelesen.project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); expect(eingelesen.issues, 'das Einlesen beanstandet etwas').toEqual([]); }); it('rechnet zu einem vollstaendigen Plan', () => { expect(eingelesen.project.signalGroups).toHaveLength(12); expect(eingelesen.project.phases).toHaveLength(5); expect(plan.cycleTime).toBe(100); expect(plan.feasible, 'der Plan ist nicht schaltbar').toBe(true); }); }); describe('Der Pruefbericht der Beispieldatei - als Ganzes', () => { /** Jede Regel mit der Zahl ihrer Beanstandungen, alphabetisch. */ function nachRegel(schwere: 'fehler' | 'warnung' | 'hinweis'): readonly string[] { const zaehler = new Map(); for (const befund of bericht.findings.filter((f) => f.severity === schwere)) { zaehler.set(befund.rule, (zaehler.get(befund.rule) ?? 0) + 1); } return [...zaehler.entries()].map(([regel, zahl]) => `${regel} x${zahl}`).sort(); } it('meldet die zu kurz gespeicherten Zwischenzeiten als Fehler', () => { /* * NACHGEZOGEN MIT FASSUNG 5.43.0 (Gruppe Furt). Hier stand "meldet keinen * einzigen Fehler" mit der Begruendung, ein Beispiel, das beanstandet * wird, waere ein schlechtes Beispiel. Das bleibt wahr - aber die Datei * ist eingefroren, und ihre Wege sind die der fehlerhaften Vermessung. * Schwiege der Bericht, waere er es, der schlecht ist. Siehe Kopf. */ expect(nachRegel('fehler')).toEqual(['zwischenzeiten.kuerzer-als-lageplan x36']); expect(bericht.exportBlocked, 'der Ausdruck ist gesperrt').toBe(true); const furt = bericht.findings.find( (f) => f.rule === 'zwischenzeiten.kuerzer-als-lageplan' && f.target?.label === 'F Nord nach K Nord', ); expect(furt?.message).toContain('Zwischenzeit von 3 s'); expect(furt?.message).toContain('damit 12 s'); }); it('meldet genau diese Warnungen und keine anderen', () => { /* * DIE EIGENTLICHE NEUERUNG DIESER DATEI. Bisher wurde der Bericht der * Beispieldatei immer nur nach einer einzelnen Regel gefiltert; was sonst * darin stand, sah niemand. Hier steht das ganze Bild. */ expect( bericht.findings.filter((f) => f.severity === 'warnung'), 'Zahl der Warnungen', ).toHaveLength(15); // Bis zum 17.09.2026 stand hier 'zwischenzeiten.null x8' // und die Zahl 23 - siehe Kopf. expect(nachRegel('warnung')).toEqual([ 'leistungsfaehigkeit.qualitaetsstufe x4', 'projektdaten.client x1', 'projektdaten.planner x1', 'projektdaten.projectNumber x1', 'signalplan.wartezeit-lang x4', 'zwischenzeiten.raeumbeziehung-nicht-nachgewiesen x4', ]); }); it('meldet genau diese Hinweise und keine anderen', () => { expect(nachRegel('hinweis')).toEqual([ 'leistungsfaehigkeit.abbiegerfaktor-pauschal x4', 'leistungsfaehigkeit.verkehrsstaerke-ohne-wirkung x1', 'projektdaten.betriebsverantwortlicher x1', 'projektdaten.einsatzzeiten x1', 'signalplan.mindestfreigabezeit-aus-furtlaenge x4', 'signalplan.umlaufzeit-ausserhalb-regelbereich x1', ]); }); }); describe('Der Ausdruck der Beispieldatei', () => { const text = pdfText(bytes); it('entsteht als vollstaendige PDF-Datei', () => { expect(new TextDecoder('latin1').decode(bytes.subarray(0, 5)), 'kein PDF-Kopf').toBe('%PDF-'); expect(bytes.length, 'die Datei ist verdaechtig klein').toBeGreaterThan(50_000); }); it('traegt die Angaben dieser Datei und nicht die einer Vorlage', () => { /* * Die Beispieldatei ist eine ANDERE Planung als * `createStandardIntersectionProject`, mit dem die uebrigen Ausdruckstests * arbeiten: zwoelf Signalgruppen mit eigenen Namen statt sechs mit K1..K4, * fuenf Phasen statt zwei, 100 s Umlauf. Steht das im Ausdruck, ist es * wirklich diese Datei, die gedruckt wurde. */ expect(text).toContain('Musterkreuzung'); expect(text).toContain('Anzahl Signalgruppen 12'); expect(text).toContain('Anzahl Phasen 5'); expect(text).toContain('Umlaufzeit 100 s'); for (const name of ['K Nord links', 'F West']) { expect(text, `die Signalgruppe ${name} fehlt im Ausdruck`).toContain(name); } }); it('druckt den Rechenweg der Furt mit den Wegen dieser Datei', () => { /* * Die Furten der Beispieldatei raeumen bis zu 14,39 m - die Vorlage der * uebrigen Tests kommt auf 12 m. Steht die Zahl im Ausdruck, kommt sie aus * der migrierten Datei und nicht aus einer Vorlage. * * DIESELBE ZAHL STEHT SCHON IN `tests/domain/beispielprojekt.test.ts`, und * das ist Absicht: Dort wird sie am PLAN geprueft, hier am AUSDRUCK. Genau * diese Strecke - vom gerechneten Wert bis auf das Papier - war fuer diese * Datei bisher unbewacht. */ expect(text).toContain('14,39 m'); expect(text).toContain('7,20 m / 1,2 m/s = 5,996 s, aufgerundet 6 s'); }); it('nennt das Ergebnis der Pruefung mit der Zahl, die der Bericht fuehrt', () => { /* * Die Zahl steht hier ausgeschrieben und nicht als Ausdruck aus demselben * Bericht: Beide kaemen sonst aus derselben Quelle, und der Fall pruefte * eine Groesse gegen sich selbst. Ausgeschrieben haelt er zweierlei - dass * das Deckblatt zaehlt, was der Bericht auffuehrt, UND dass es 15 sind. * Seit 5.43.0 stehen daneben 36 Fehler, und aus 23 Warnungen sind 15 * geworden (siehe Kopf). */ expect(text).toContain('36 Fehler und 15 Warnungen'); }); it('fuehrt die vier Furten mit der Stufe F auf', () => { /* * Die Aussage, um derentwillen der Bericht als Ganzes beurteilt wird. Sie * steht nicht nur im Bericht, sondern auch im Ausdruck - wer die Datei * druckt, gibt eine Unterlage aus der Hand, in der viermal die * schlechteste Qualitaetsstufe steht. */ expect(text).toContain('Qualitätsstufe F'); for (const furt of ['F Nord', 'F Ost', 'F Sued', 'F West']) { const befund = bericht.findings.find( (f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe' && f.target?.label === furt, ); expect(befund, `keine Stufe fuer ${furt}`).toBeDefined(); expect(befund?.message, `${furt} steht nicht auf F`).toContain('Qualitätsstufe F'); expect(befund?.message, `${furt} wartet nicht 94 s`).toContain('94 s'); } }); });