import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { signalGroupsCsv } from '@/services/export/csv'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { toWinAnsi } from '@/render/pdfSurface'; import type { Project, TrafficDemand } from '@/domain/model/project'; /* * Fassung 5.9.0 - die Verkehrsstaerke 0 im Ausdruck und in der Tabellenausgabe. * * Seit den Befunden 12, 18 und 58 gilt im Fachkern durchgaengig: Ein DATENSATZ * ist noch keine Zaehlung. `actions.setDemand` und `actions.setLastzugAnteil` * legen einen Datensatz mit `volume: 0` an, sobald in der * Signalgruppentabelle nur die Spalte "SV-Anteil" ausgefuellt wird; * `erfassteVerkehrsstaerke` (signalPlan.ts) liest `volume <= 0` deshalb als * "keine Angabe", der Bildschirm zeichnet das Feld leer, und der * Auslastungsgrad steht als "-" da. * * Der Ausdruck zog nicht mit: Die Spalte "Verkehrsstärke" prueft nur * `demand === undefined` und druckte "0 Fz/h" - waehrend die * Schwerverkehrsspalte derselben Zeile (schwerverkehrZelle, `demand.volume * <= 0`) einen Gedankenstrich setzt. In derselben Zeile stand damit eine * gezaehlte 0 neben zwei Strichen, und Bildschirm und Unterlage sagten * sichtbar Verschiedenes. Die Lage ist aelter als Befund 58: Sie entsteht * schon, wenn nur der Schwerverkehrsanteil erfasst ist. * * UND DIE TABELLENAUSGABE ZOG NACH (Fassung 5.10.0): Sie schrieb weiter "0", * waehrend der Ausdruck an allen drei Stellen einen Gedankenstrich setzte. * Vorher sagten beide Ausgaben dasselbe (beide falsch), danach widersprachen * sie einander - und nebeneinandergelegt werden sie, um verglichen zu werden. * In der Tabellenausgabe bleibt die Zelle LEER: dieselbe Schreibweise wie bei * jeder Groesse, die sie fuer eine Zeile nicht bildet. * * MUTATIONSNACHWEIS, nachgemessen an zwei zurueckgesetzten Staenden: * * - Beide Bedingungen des Ausdrucks auf `demand === undefined` * zurueckgesetzt: 2 von 5 Faellen fallen - der erste und der zweite. * - Die Zelle der Tabellenausgabe auf `nachfrage ? ... : ''` zurueckgesetzt: * 1 von 5 faellt - der vierte. * * Der dritte ("lässt eine erfasste Verkehrsstärke unverändert stehen") und der * fuenfte (die Rechenwegtabelle KANN nicht betroffen sein) sind in allen * Staenden gruen; sie bewachen die Gegenrichtung. * * BERICHTIGT (Fassung 5.10.0): Hier stand "gegen den Altstand * fallen die drei ersten Faelle" - gemessen fallen zwei; der dritte ist in * beiden Staenden gruen. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const OHNE_ZAEHLUNG = 'K-NULL'; /** Sichtbaren Text aus dem PDF, Stueck fuer Stueck (eine Tabellenzelle je Stueck). */ function pdfStuecke(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; } function ausdruck(project: Project): string[] { const plan = buildSignalPlan(project); return pdfStuecke( buildProjectPdf( project, plan, validateProject(project, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, ), ); } /** * Knotenpunkt, an dessen erster Kfz-Gruppe NUR der Schwerverkehrsanteil erfasst * ist - der Datensatz traegt `volume: 0`. Genau das legt die Oberflaeche an, * wenn in der Signalgruppentabelle allein die Spalte "SV-Anteil" ausgefuellt * wird. */ function nurSchwerverkehrsanteil(): Project { const basis = createStandardIntersectionProject('Verkehrsstärke', STICHTAG); return { ...basis, signalGroups: basis.signalGroups.map((g, index) => index === 0 ? { ...g, name: OHNE_ZAEHLUNG } : g, ), demands: basis.demands.map((d, index): TrafficDemand => index === 0 ? { ...d, volume: 0, heavyVehicleShare: 0.12 } : d, ), }; } describe('Eine Verkehrsstärke von 0 gilt in Ausdruck und Tabellenausgabe nicht als Zählung', () => { it('setzt in der Leistungstabelle einen Gedankenstrich statt "0 Fz/h"', () => { const projekt = nurSchwerverkehrsanteil(); expect(projekt.demands[0]!.volume, 'Vorbedingung: der Datensatz trägt 0').toBe(0); const stuecke = ausdruck(projekt); // Die Zeile der Leistungstabelle: Name, Saettigungsverkehrsstaerke, // Abflusszeit, Kapazitaet, Verkehrsstaerke. const zeile = stuecke.findIndex( (s, i) => s === OHNE_ZAEHLUNG && (stuecke[i + 1] ?? '').endsWith('Fz/h'), ); expect(zeile, 'Vorbedingung: die Gruppe steht in der Leistungstabelle').toBeGreaterThan(0); // Der Gedankenstrich, wie ihn der Zeichenvorrat des PDF traegt. expect(stuecke[zeile + 4]).toBe(toWinAnsi('–')); }); it('druckt nirgends in der Unterlage "0 Fz/h"', () => { const stuecke = ausdruck(nurSchwerverkehrsanteil()); // Auch die Signalgruppentabelle fuehrt eine Spalte "Verkehrsstärke"; sie // ist dieselbe Aussage und derselbe Befund. expect(stuecke).not.toContain('0 Fz/h'); }); it('lässt eine erfasste Verkehrsstärke unverändert stehen', () => { const projekt = nurSchwerverkehrsanteil(); const stuecke = ausdruck(projekt); // Kein pauschales Ausblenden: Die uebrigen Stroeme behalten ihre Zahl. const uebrige = projekt.demands.slice(1).filter((d) => d.volume > 0); expect(uebrige.length, 'Vorbedingung: es gibt gezählte Ströme').toBeGreaterThan(0); for (const d of uebrige) { expect(stuecke, String(d.volume)).toContain( `${new Intl.NumberFormat('de-DE').format(d.volume)} Fz/h`, ); } }); it('lässt die Zelle auch in der Tabellenausgabe leer', () => { /* * Nachgezogen (Fassung 5.10.0): Der Ausdruck setzte seit der Behebung an * allen drei Stellen einen Gedankenstrich, die Tabellenausgabe schrieb * weiter "0". Vorher sagten beide dasselbe (beide falsch), danach * widersprachen sie einander - und wer beide nebeneinanderlegt, tut das, um * sie zu vergleichen. * * LEER UND NICHT "–": Dieselbe Schreibweise wie in der Nachbarspalte * "Lastzuganteil ... (leer = nicht erfasst)" und wie bei jeder Groesse, * die diese Ausgabe fuer eine Zeile nicht bildet. Ein Gedankenstrich waere * in einer Zahlenspalte Text. */ const projekt = nurSchwerverkehrsanteil(); const zeilen = signalGroupsCsv(projekt, buildSignalPlan(projekt)) .replace(/^/, '') .split('\r\n') .filter((z) => z.length > 0) .map((z) => (z.match(/("([^"]|"")*"|[^;]*)(;|$)/g) ?? []).map((f) => f.replace(/;$/, ''))); const spalte = zeilen[0]!.indexOf('Verkehrsstärke [Fz/h]'); expect(spalte, 'Vorbedingung: die Spalte steht in der Ausgabe').toBeGreaterThan(0); expect(zeilen.slice(1).find((z) => z[0] === OHNE_ZAEHLUNG)![spalte]).toBe(''); // Kein pauschales Leeren: Die gezaehlten Stroeme behalten ihre Zahl. const gezaehlt = projekt.demands.filter((d) => d.volume > 0); expect(gezaehlt.length, 'Vorbedingung: es gibt gezählte Ströme').toBeGreaterThan(0); for (const d of gezaehlt) { const name = projekt.signalGroups.find((g) => g.id === d.signalGroupId)!.name; const zeile = zeilen.slice(1).find((z) => z[0] === name); if (zeile === undefined) continue; expect(zeile[spalte], name).not.toBe(''); } }); it('führt in der Rechenwegtabelle nur Gruppen mit erfasster Verkehrsstärke', () => { /* * Zusammenhangspruefung zur zweiten Stelle: `drawDelayDetails` zeigt nur * Gruppen mit `delay !== null`, und `delay` entsteht nur aus einer * erfassten Verkehrsstaerke (signalPlan.ts, `verkehrsstaerke !== undefined * && capacity !== null`). Die 0 kann dort also gar nicht ankommen - die * Bedingung steht dort trotzdem, damit beide Tabellen dieselbe Aussage * machen. */ const projekt = nurSchwerverkehrsanteil(); const plan = buildSignalPlan(projekt); const ohne = plan.groups.find((g) => g.name === OHNE_ZAEHLUNG)!; expect(ohne.delay).toBeNull(); for (const gruppe of plan.groups.filter((g) => g.delay !== null)) { const demand = projekt.demands.find((d) => d.signalGroupId === gruppe.groupId); if (demand === undefined) continue; expect(demand.volume, gruppe.name).toBeGreaterThan(0); } }); });