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 type { Project, TrafficDemand } from '@/domain/model/project'; /* * Befund 54 zur Fassung 5.9.0, ein Rest. * * Die beiden Prozentspalten der Tabellenausgabe trugen ihre Zahlen ohne jeden * Massstab: "Schwerverkehrsanteil" und "Lastzuganteil am Schwerverkehr * (leer = nicht erfasst)". In der Zeile steht 12 - und daneben, in der Spalte * "Auslastungsgrad", eine Zahl zwischen 0 und 1, die ebenfalls ein Anteil ist. * Der Ausdruck schreibt an derselben Stelle "12 %" (pdf.ts, * schwerverkehrZelle). Wer beide Unterlagen nebeneinanderlegt - und dafuer sind * sie da -, liest in der einen einen Anteil von 12 % und in der anderen eine * blanke 12. * * Behoben ist es im KOPF, nicht am Wert: Eine Tabellenkalkulation liest "12 %" * je nach Einstellung als Text oder als 0,12; die Spalte waere in einer * Auswertung unbrauchbar. Die uebrigen Spalten dieser Ausgabe fuehren ihre * Einheit ebenso in eckigen Klammern ("V zul [km/h]", "Rot-Gelb [s]", * "Verkehrsstärke [Fz/h]"). * * Die Behebung unterblieb zunaechst, weil die beiden Koepfe in drei * Testdateien als exakte Suchschluessel fuer den Spaltenindex dienen; sie sind * mit dieser Aenderung nachgezogen. * * MUTATIONSNACHWEIS (nachgemessen: beide Koepfe im Altstand zurueckgesetzt, * diese Datei dagegen gelaufen): 2 von 3 Faellen fallen - die beiden ersten. * Der dritte haelt fest, dass die WERTE ohne Einheit bleiben; er ist in beiden * Staenden gruen und bewacht die Gegenrichtung dieser Aenderung. * * BERICHTIGT (Fassung 5.10.0): Hier stand dieselbe Aussage, sie * traf aber nicht zu - der dritte Fall suchte seine Spalte an ihrem KOPF und * fiel gegen den Altstand mit; gemessen fielen 3 von 3. Er sucht sie jetzt * ueber die Nachbarspalte "Schwerverkehrsfaktor fSV", die diese Aenderung nicht * angefasst hat, und ist damit wirklich unabhaengig. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const KOPF_ANTEIL = 'Schwerverkehrsanteil [%]'; const KOPF_LASTZUG = 'Lastzuganteil am Schwerverkehr [%] (leer = nicht erfasst)'; /** Knotenpunkt mit erfasstem Schwerverkehrsanteil und erfasster Aufteilung. */ function mitAufteilung(): Project { const basis = createStandardIntersectionProject('Spaltenköpfe', STICHTAG); return { ...basis, demands: basis.demands.map((d): TrafficDemand => ({ ...d, heavyVehicleShare: 0.12, lastzugAnteil: 0.4, })), }; } /** 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, ' '); } /** * Eine Zeile in ihre Felder zerlegen - mit Anfuehrungszeichen: Die Spalte * "Freigabefenster" traegt bei mehreren Fenstern ein Semikolon, das die * Ausgabe ordnungsgemaess in Anfuehrungszeichen setzt. */ function zerlege(zeile: string): string[] { return (zeile.match(/("([^"]|"")*"|[^;]*)(;|$)/g) ?? []).map((feld) => feld.replace(/;$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'), ); } function tabelle(project: Project): string[][] { const zeilen = signalGroupsCsv(project, buildSignalPlan(project)) .replace(/^/, '') .split('\r\n') .filter((z) => z.length > 0); return zeilen.map(zerlege); } function kopfzeile(project: Project): string[] { return tabelle(project)[0]!; } /** * Der Wert einer der beiden Prozentspalten - gefunden ueber die NACHBARSPALTE. * * Nicht ueber den eigenen Kopf: Ein Fall, der die Spalte an ihrem Kopf sucht, * faellt mit jeder Aenderung des Kopfes und bewacht die Gegenrichtung deshalb * nicht. Massgebend ist hier der Kopf "Schwerverkehrsfaktor fSV", den diese * Aenderung nicht angefasst hat: Zwei Spalten davor steht der Anteil, eine * davor die Aufteilung. */ function wertNebenFsv(project: Project, versatz: number, gruppe: string): string { const zeilen = tabelle(project); const fsv = zeilen[0]!.indexOf('Schwerverkehrsfaktor fSV'); expect( fsv, 'Vorbedingung: die Spalte "Schwerverkehrsfaktor fSV" steht in der Ausgabe', ).toBeGreaterThan(0); const zeile = zeilen.slice(1).find((z) => z[0] === gruppe)!; return zeile[fsv + versatz]!; } describe('Befund 54 - die Prozentspalten der Tabellenausgabe nennen ihre Einheit', () => { it('führt die Einheit im Kopf beider Spalten', () => { const kopf = kopfzeile(mitAufteilung()); expect(kopf).toContain(KOPF_ANTEIL); expect(kopf).toContain(KOPF_LASTZUG); // Und nicht mehr ohne: Ein Kopf ohne Massstab neben dem Auslastungsgrad // war der Befund. expect(kopf).not.toContain('Schwerverkehrsanteil'); expect(kopf).not.toContain('Lastzuganteil am Schwerverkehr (leer = nicht erfasst)'); }); it('nimmt dasselbe Einheitenzeichen wie der Ausdruck und dieselbe Klammerform wie die Nachbarspalten', () => { const projekt = mitAufteilung(); const kopf = kopfzeile(projekt); const plan = buildSignalPlan(projekt); const bericht = validateProject(projekt, plan, STICHTAG); const gedruckt = pdfText( buildProjectPdf(projekt, plan, bericht, DEFAULT_PDF_OPTIONS, STICHTAG), ); // So schreibt der Ausdruck den Anteil an den Wert (pdf.ts, // schwerverkehrZelle) - Zahl, Leerzeichen, Prozentzeichen. expect(gedruckt, 'Vorbedingung: der Ausdruck nennt den Anteil mit Einheit').toContain('12 %'); // Dasselbe Zeichen steht in der Tabelle im Kopf, in der Klammerform der // uebrigen Einheitenspalten dieser Ausgabe. for (const spalte of kopf.filter( (s) => s.startsWith('Schwerverkehrsanteil') || s.startsWith('Lastzuganteil'), )) { expect(spalte, spalte).toContain('[%]'); } expect(kopf, 'Vorbedingung: die Klammerform ist die dieser Ausgabe').toContain('V zul [km/h]'); expect(kopf).toContain('Verkehrsstärke [Fz/h]'); }); it('lässt die Werte ohne Einheit - die Spalte bleibt eine Zahlenspalte', () => { const projekt = mitAufteilung(); const gruppe = projekt.signalGroups[0]!.name; // 12,0 % und 40,0 % als blanke Zahlen: Mit angehaengtem Prozentzeichen // laese eine Tabellenkalkulation die Zelle als Text. Gesucht ueber die // Nachbarspalte fSV, damit dieser Fall vom Kopf unabhaengig ist und die // Gegenrichtung wirklich bewacht. expect(wertNebenFsv(projekt, -2, gruppe)).toBe('12'); expect(wertNebenFsv(projekt, -1, gruppe)).toBe('40'); }); });