import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { signalGroupsCsv } from '@/services/export/csv'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { toWinAnsi } from '@/render/pdfSurface'; import type { Project } from '@/domain/model/project'; import * as fmt from '@/ui/format'; /* * Fassung 5.9.0 - die Hoechstfreigabezeit stand in keiner Unterlage. * * Seit den Befunden 36 und 42 wirkt eine je Signalgruppe eingetragene * Hoechstfreigabezeit (`SignalGroup.maxGreenOverride`) in der * Freigabezeitverteilung: `phaseMaxGreen` (signalPlan.ts) nimmt die KLEINSTE * Vorgabe der Signalgruppen einer Phase statt des Regelwerts. Gedruckt wurde * sie nirgends - weder im Ausdruck noch in der Tabellenausgabe. Damit stand in * der Planunterlage eine Phasendauer, die sich aus den gedruckten Groessen * nicht mehr herleiten laesst, und das ist der Zweck der Unterlage. * * DIE SPALTE NENNT DIE VORGABE, NICHT DIE GELTENDE SCHRANKE. Die erste Fassung schrieb * ohne Eintrag den Regelwert und sagte im Docblock zu, die Spalte nenne "die * Schranke, die gilt". Das hielt in drei Lagen nicht: bei einer festen * Freigabezeit der Phase (sie geht vor), bei einer Vorgabe unter der * Mindestfreigabezeit (dann gilt diese, und der Pruefbericht sagt es im selben * Dokument) und bei einer negativen Vorgabe, die das Einlesen durchlaesst. * Massgebend ist ohnehin nichts, was an EINER Signalgruppe steht: Die wirksame * obere Schranke bildet `phaseMaxGreen` je PHASE aus der kleinsten Vorgabe * ihrer Signalgruppen. Die Zelle weist deshalb den Eintrag als Eintrag aus, * "–" bzw. leer heisst "keiner", und der Absatz unter der Tabelle sagt, was * ueber die wirksame Schranke entscheidet. * * MUTATIONSNACHWEIS, nachgemessen gegen den Stand vor dieser Berichtigung * (Spaltenkopf "Höchstfreigabezeit [s]" bzw. "Min./Max.", Regelwert statt * leerer Zelle, kein Absatz unter der Tabelle): 7 von 8 Faellen fallen - die * ersten sieben. Der achte misst den Satzspiegel bei sechzehn Signalgruppen * mit langen Namen; er ist in beiden Staenden gruen und bewacht, dass die * Spalte den Satz nicht sprengt. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const VORGABE = 47; /** Feste Freigabezeit einer Phase, unter der Vorgabe der Signalgruppe. */ const FEST = 30; /** Vorgabe unter der Mindestfreigabezeit - sie kommt nicht zur Wirkung. */ const ZU_KLEIN = 1; const KOPF_MIN = 'Mindestfreigabezeit [s]'; const KOPF_MAX = 'Höchstfreigabezeit: Vorgabe der Signalgruppe [s] (leer = keine)'; /** * Der Spaltenkopf des Ausdrucks: Mindestfreigabezeit und Vorgabe teilen sich * dort eine Spalte. */ const SPALTE_PDF = 'Min./Vorgabe'; const BLATTBREITE = 841.89; const PAGE_MARGIN = 36; /** Knotenpunkt, dessen erste Signalgruppe eine Hoechstfreigabezeit traegt. */ function mitVorgabe(vorgabe: number = VORGABE): Project { const basis = createStandardIntersectionProject('Höchstfreigabezeit', STICHTAG); return { ...basis, signalGroups: basis.signalGroups.map((g, index) => index === 0 ? { ...g, maxGreenOverride: vorgabe } : g, ), }; } /** * Derselbe Knotenpunkt, dessen erste Phase zusaetzlich eine feste * Freigabezeit traegt - die geht der Vorgabe der Signalgruppen vor * (`phaseMaxGreen`, signalPlan.ts). */ function mitFesterFreigabezeit(): Project { const basis = mitVorgabe(); return { ...basis, phases: basis.phases.map((p, index) => (index === 0 ? { ...p, manualGreen: FEST } : p)), }; } // --- Tabellenausgabe -------------------------------------------------------- function zerlege(zeile: string): string[] { return (zeile.match(/("([^"]|"")*"|[^;]*)(;|$)/g) ?? []).map((feld) => feld.replace(/;$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'), ); } function tabelle(project: Project): string[][] { return signalGroupsCsv(project, buildSignalPlan(project)) .replace(/^/, '') .split('\r\n') .filter((z) => z.length > 0) .map(zerlege); } // --- Ausdruck --------------------------------------------------------------- interface Stueck { readonly blatt: number; readonly x: number; readonly y: number; readonly text: string; } /** Textstuecke mit ihrer Lage auf dem Blatt - eine Tabellenzeile teilt sich ein y. */ function stuecke(bytes: Uint8Array): Stueck[] { const roh = new TextDecoder('latin1').decode(bytes); const raus: Stueck[] = []; const suche = /stream\r?\n/g; let treffer: RegExpExecArray | null; let blatt = 0; 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; } if (!inhalt.includes('Td') && !inhalt.includes('Tj')) continue; blatt += 1; let x = 0; let y = 0; for (const zeile of inhalt.split('\n')) { const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile.trim()); if (td) { x = Number(td[1]); y = Number(td[2]); continue; } const tj = /^\((.*)\)\s*Tj$/.exec(zeile.trim()); if (tj && tj[1]!.trim() !== '') { raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); } } } return raus; } /** * Der ganze sichtbare Text der Unterlage in einer Zeichenkette. * * Neben `stuecke`, weil beide etwas anderes koennen: `stuecke` haelt die Lage * fest (daran haengt die Zeile der Signalgruppentabelle), erfasst aber nur * Textstuecke mit eigener Setzmarke - die Fliesstextzeilen der Absaetze setzt * jsPDF mit "T*" fort und fielen dort durch. */ function volltext(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 unterlage(project: Project): Uint8Array { const plan = buildSignalPlan(project); return buildProjectPdf( project, plan, validateProject(project, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, ); } function ausdruck(project: Project): Stueck[] { return stuecke(unterlage(project)); } /** * Die Zeile der Signalgruppentabelle zu einer Gruppe, Zelle fuer Zelle von * links nach rechts. * * Erkannt wird sie an der Zelle "V zul", die es nur in dieser Tabelle gibt; * Zellen mit mehreren Zeilen stehen mit ihrer ERSTEN Zeile auf derselben * Grundlinie wie der Name. */ function signalgruppenzeile(alle: readonly Stueck[], name: string): string[] { const anker = alle.find( (s) => s.text === name && alle.some((n) => n.blatt === s.blatt && n.y === s.y && n.text.endsWith('km/h')), ); expect(anker, `Zeile der Gruppe "${name}" steht in der Signalgruppentabelle`).toBeDefined(); return alle .filter((s) => s.blatt === anker!.blatt && s.y === anker!.y) .sort((a, b) => a.x - b.x) .map((s) => s.text); } describe('Die Höchstfreigabezeit steht in Ausdruck und Tabellenausgabe', () => { it('führt in der Tabellenausgabe eine Spalte neben der Mindestfreigabezeit', () => { const zeilen = tabelle(mitVorgabe()); const kopf = zeilen[0]!; const min = kopf.indexOf(KOPF_MIN); expect(min, 'Vorbedingung: die Mindestfreigabezeit steht in der Tabelle').toBeGreaterThan(0); expect(kopf[min + 1]).toBe(KOPF_MAX); }); it('schreibt in der Tabellenausgabe den Eintrag der Gruppe, sonst nichts', () => { const projekt = mitVorgabe(); const plan = buildSignalPlan(projekt); const zeilen = tabelle(projekt); const spalte = zeilen[0]!.indexOf(KOPF_MAX); expect(plan.defaults.maxGreen, 'Vorbedingung: es gibt einen Regelwert').toBeGreaterThan(0); for (const gruppe of projekt.signalGroups) { const zeile = zeilen.slice(1).find((z) => z[0] === gruppe.name)!; // Ohne Eintrag bleibt die Zelle LEER und nicht der Regelwert: Der gilt // nur, solange keine Signalgruppe DERSELBEN PHASE eine kleinere Vorgabe // traegt - eine Zelle je Gruppe kann das nicht sagen. expect(zeile[spalte], gruppe.name).toBe( gruppe.maxGreenOverride === null ? '' : fmt.numShort(gruppe.maxGreenOverride), ); } }); it('nennt sie im Ausdruck an der Stelle der Mindestfreigabezeit', () => { const alle = ausdruck(mitVorgabe()); /* * EINE SPALTE FUER BEIDE ANGABEN: Die Signalgruppentabelle des Ausdrucks * ist quer voll (siehe die Begruendung am Spaltenkopf in pdf.ts). Eine * vierzehnte Spalte nimmt den Nachbarn so viel Breite, dass * Signalgruppennamen und Fahrzeugarten umbrechen, die Zeilen hoeher werden * und die Tabelle frueher auf das naechste Blatt umbricht - nachgestellt * mit einer eigenen Spalte "Max. Freigabe" hinter "Min. Freigabe": Dabei * faellt tests/export/pdfSignalgruppenNummer.test.ts mit 3 Faellen (die * Ausgabe zeigt NaN statt der Nummern 6 und 10). tests/export/ * pdfBeschriftungen.test.ts und tests/export/pdfSatzspiegel.test.ts melden * den Fall NICHT - sie bleiben dabei gruen; der letzte Fall dieser Datei * misst deshalb selbst. */ expect(alle.map((s) => s.text)).toContain(SPALTE_PDF); }); it('druckt den Eintrag, sonst einen Gedankenstrich', () => { const projekt = mitVorgabe(); const plan = buildSignalPlan(projekt); const alle = ausdruck(projekt); for (const gruppe of projekt.signalGroups) { const zellen = signalgruppenzeile(alle, gruppe.name); const planned = plan.groups.find((g) => g.groupId === gruppe.id)!; // Nr., SG, Verkehrsart, Fahrbeziehung, V zul, Fahrstr., Fahrzeugart, // Furt, Rot-Gelb, Gelb, Min./Vorgabe, ... expect(zellen[10], `${gruppe.name}: ${SPALTE_PDF}`).toBe( `${fmt.seconds(planned.times.minGreen)} / ` + (gruppe.maxGreenOverride === null ? toWinAnsi('–') : fmt.seconds(gruppe.maxGreenOverride)), ); } }); it('sagt unter der Tabelle, was über die wirksame obere Schranke entscheidet', () => { const projekt = mitVorgabe(); const plan = buildSignalPlan(projekt); const text = volltext(unterlage(projekt)); // Der Absatz nennt den Regelwert dieses Plans - in der Zelle steht er // nicht mehr, und ohne ihn liesse sich die Phasendauer einer Gruppe ohne // Eintrag nicht herleiten. expect(text).toContain(toWinAnsi(`Regelwert von ${fmt.seconds(plan.defaults.maxGreen)}`)); // Und er sagt, dass die rechte Zahl der Zelle die Eingangsgroesse ist ... expect(text).toContain(toWinAnsi('nicht die im Plan wirksame obere Schranke')); // ... und wer ueber die wirksame entscheidet: die Phase. expect(text).toContain(toWinAnsi('kleinste Vorgabe ihrer Signalgruppen')); }); it('widerspricht dem Prüfbericht nicht, wenn die Vorgabe unter der Mindestfreigabezeit liegt', () => { /* * Die Lage, an der die erste Fassung scheiterte: Die Tabelle wies 1 s als * geltende Hoechstfreigabezeit aus - eine Obergrenze unter der Untergrenze * -, waehrend der Pruefbericht desselben Dokuments sagte, massgebend bleibe * die Mindestfreigabezeit. Jetzt weist die Spalte den Eintrag als Eintrag * aus, und der Absatz nennt den Pruefbericht als die Stelle, die den Fall * meldet. */ const projekt = mitVorgabe(ZU_KLEIN); const plan = buildSignalPlan(projekt); const gruppe = projekt.signalGroups[0]!; const bytes = unterlage(projekt); const alle = stuecke(bytes); const text = volltext(bytes); expect( plan.groups.find((g) => g.groupId === gruppe.id)!.times.minGreen, 'Vorbedingung: die Mindestfreigabezeit liegt über der Vorgabe', ).toBeGreaterThan(ZU_KLEIN); // Der Pruefbericht steht in derselben Datei und sagt, was gilt. expect(text).toContain(toWinAnsi('Maßgebend bleibt die Mindestfreigabezeit')); // Die Zelle behauptet daneben keine geltende Schranke mehr, sondern nennt // den Eintrag - und der Absatz sagt, wo der Fall gemeldet ist. Der ganze // Satzteil, nicht nur "meldet der Prüfbericht": Den Wortlaut fuehrt auch // der Absatz zum engen Innenradius, und ein Fall, der ihn dort findet, // bewacht nichts. expect(signalgruppenzeile(alle, gruppe.name)[10]).toBe( `${fmt.seconds(plan.groups.find((g) => g.groupId === gruppe.id)!.times.minGreen)} / ` + fmt.seconds(ZU_KLEIN), ); expect(text).toContain( toWinAnsi( 'die in ihrer Phase unter der Mindestfreigabezeit oder unter einer festen Freigabezeit ' + 'liegt, meldet der Prüfbericht', ), ); }); it('lässt bei fester Freigabezeit der Phase die feste Zeit gelten', () => { /* * Zweite Lage der ersten Fassung: Die Phase traegt eine feste Freigabezeit * von 30 s, die Gruppe eine Vorgabe von 47 s. Gedruckt wurde "5 s / 47 s" * als geltende Schranke, obwohl 30 s gelten. Die Vorgabe steht weiter da - * sie ist eingetragen -, und der Absatz sagt, dass die feste Freigabezeit * ihr vorgeht; die Phasentabelle nennt die 30 s. */ const projekt = mitFesterFreigabezeit(); const plan = buildSignalPlan(projekt); const bytes = unterlage(projekt); const alle = stuecke(bytes); const text = volltext(bytes); expect(plan.phases[0]!.duration, 'Vorbedingung: die feste Freigabezeit gilt').toBe(FEST); expect(text).toContain(toWinAnsi('feste Freigabezeit der Phase')); expect(signalgruppenzeile(alle, projekt.signalGroups[0]!.name)[10]).toContain( fmt.seconds(VORGABE), ); }); it('sprengt bei sechzehn Signalgruppen mit langen Namen den Satzspiegel nicht', () => { /* * Der Ausdruck hat feste Spaltenbreiten und Umbruchregeln; eine * zusaetzliche Spalte kann den Satz sprengen. Gemessen wird an demselben * Pruefstueck wie in tests/export/pdfSatzspiegel.test.ts: sechzehn * Signalgruppen mit langen Namen. */ const basis = projektAusAngaben({ ...ASSISTENT_VORGABE, form: 'kreuzung', fussgaenger: true, rad: true, linksabbieger: true, }); const gruppen = Array.from({ length: 16 }, (_, i) => ({ ...basis.signalGroups[i % basis.signalGroups.length]!, id: `sg-${i}`, name: `Signalgruppe ${i + 1} Nordwest Geradeaus und Rechtsabbieger`, maxGreenOverride: i === 0 ? VORGABE : null, })); const alle = ausdruck({ ...basis, signalGroups: gruppen, conflicts: [] }); expect(alle.length, 'Vorbedingung: die Unterlage trägt Text').toBeGreaterThan(100); const raus = alle.filter((s) => s.x < PAGE_MARGIN || s.x > BLATTBREITE - PAGE_MARGIN); expect( raus.length, raus.length ? `zuerst x=${raus[0]!.x}: "${raus[0]!.text.slice(0, 40)}"` : '', ).toBe(0); }); });