lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export verkehrsstaerkeNull.test.ts
| 1 | import { inflateSync } from 'node:zlib'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; |
| 4 | import { signalGroupsCsv } from '@/services/export/csv'; |
| 5 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 6 | import { validateProject } from '@/domain/validation'; |
| 7 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 8 | import { toWinAnsi } from '@/render/pdfSurface'; |
| 9 | import type { Project, TrafficDemand } from '@/domain/model/project'; |
| 10 | |
| 11 | /* |
| 12 | * Fassung 5.9.0 - die Verkehrsstaerke 0 im Ausdruck und in der Tabellenausgabe. |
| 13 | * |
| 14 | * Seit den Befunden 12, 18 und 58 gilt im Fachkern durchgaengig: Ein DATENSATZ |
| 15 | * ist noch keine Zaehlung. `actions.setDemand` und `actions.setLastzugAnteil` |
| 16 | * legen einen Datensatz mit `volume: 0` an, sobald in der |
| 17 | * Signalgruppentabelle nur die Spalte "SV-Anteil" ausgefuellt wird; |
| 18 | * `erfassteVerkehrsstaerke` (signalPlan.ts) liest `volume <= 0` deshalb als |
| 19 | * "keine Angabe", der Bildschirm zeichnet das Feld leer, und der |
| 20 | * Auslastungsgrad steht als "-" da. |
| 21 | * |
| 22 | * Der Ausdruck zog nicht mit: Die Spalte "Verkehrsstärke" prueft nur |
| 23 | * `demand === undefined` und druckte "0 Fz/h" - waehrend die |
| 24 | * Schwerverkehrsspalte derselben Zeile (schwerverkehrZelle, `demand.volume |
| 25 | * <= 0`) einen Gedankenstrich setzt. In derselben Zeile stand damit eine |
| 26 | * gezaehlte 0 neben zwei Strichen, und Bildschirm und Unterlage sagten |
| 27 | * sichtbar Verschiedenes. Die Lage ist aelter als Befund 58: Sie entsteht |
| 28 | * schon, wenn nur der Schwerverkehrsanteil erfasst ist. |
| 29 | * |
| 30 | * UND DIE TABELLENAUSGABE ZOG NACH (Fassung 5.10.0): Sie schrieb weiter "0", |
| 31 | * waehrend der Ausdruck an allen drei Stellen einen Gedankenstrich setzte. |
| 32 | * Vorher sagten beide Ausgaben dasselbe (beide falsch), danach widersprachen |
| 33 | * sie einander - und nebeneinandergelegt werden sie, um verglichen zu werden. |
| 34 | * In der Tabellenausgabe bleibt die Zelle LEER: dieselbe Schreibweise wie bei |
| 35 | * jeder Groesse, die sie fuer eine Zeile nicht bildet. |
| 36 | * |
| 37 | * MUTATIONSNACHWEIS, nachgemessen an zwei zurueckgesetzten Staenden: |
| 38 | * |
| 39 | * - Beide Bedingungen des Ausdrucks auf `demand === undefined` |
| 40 | * zurueckgesetzt: 2 von 5 Faellen fallen - der erste und der zweite. |
| 41 | * - Die Zelle der Tabellenausgabe auf `nachfrage ? ... : ''` zurueckgesetzt: |
| 42 | * 1 von 5 faellt - der vierte. |
| 43 | * |
| 44 | * Der dritte ("lässt eine erfasste Verkehrsstärke unverändert stehen") und der |
| 45 | * fuenfte (die Rechenwegtabelle KANN nicht betroffen sein) sind in allen |
| 46 | * Staenden gruen; sie bewachen die Gegenrichtung. |
| 47 | * |
| 48 | * BERICHTIGT (Fassung 5.10.0): Hier stand "gegen den Altstand |
| 49 | * fallen die drei ersten Faelle" - gemessen fallen zwei; der dritte ist in |
| 50 | * beiden Staenden gruen. |
| 51 | */ |
| 52 | |
| 53 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 54 | const OHNE_ZAEHLUNG = 'K-NULL'; |
| 55 | |
| 56 | /** Sichtbaren Text aus dem PDF, Stueck fuer Stueck (eine Tabellenzelle je Stueck). */ |
| 57 | function pdfStuecke(bytes: Uint8Array): string[] { |
| 58 | const roh = new TextDecoder('latin1').decode(bytes); |
| 59 | const teile: string[] = []; |
| 60 | const suche = /stream\r?\n/g; |
| 61 | let treffer: RegExpExecArray | null; |
| 62 | |
| 63 | while ((treffer = suche.exec(roh)) !== null) { |
| 64 | const start = treffer.index + treffer[0].length; |
| 65 | const ende = roh.indexOf('endstream', start); |
| 66 | if (ende < 0) continue; |
| 67 | let inhalt: string; |
| 68 | try { |
| 69 | inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 70 | } catch { |
| 71 | continue; |
| 72 | } |
| 73 | for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { |
| 74 | teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); |
| 75 | } |
| 76 | } |
| 77 | return teile; |
| 78 | } |
| 79 | |
| 80 | function ausdruck(project: Project): string[] { |
| 81 | const plan = buildSignalPlan(project); |
| 82 | return pdfStuecke( |
| 83 | buildProjectPdf( |
| 84 | project, |
| 85 | plan, |
| 86 | validateProject(project, plan, STICHTAG), |
| 87 | DEFAULT_PDF_OPTIONS, |
| 88 | STICHTAG, |
| 89 | ), |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Knotenpunkt, an dessen erster Kfz-Gruppe NUR der Schwerverkehrsanteil erfasst |
| 95 | * ist - der Datensatz traegt `volume: 0`. Genau das legt die Oberflaeche an, |
| 96 | * wenn in der Signalgruppentabelle allein die Spalte "SV-Anteil" ausgefuellt |
| 97 | * wird. |
| 98 | */ |
| 99 | function nurSchwerverkehrsanteil(): Project { |
| 100 | const basis = createStandardIntersectionProject('Verkehrsstärke', STICHTAG); |
| 101 | return { |
| 102 | ...basis, |
| 103 | signalGroups: basis.signalGroups.map((g, index) => |
| 104 | index === 0 ? { ...g, name: OHNE_ZAEHLUNG } : g, |
| 105 | ), |
| 106 | demands: basis.demands.map((d, index): TrafficDemand => |
| 107 | index === 0 ? { ...d, volume: 0, heavyVehicleShare: 0.12 } : d, |
| 108 | ), |
| 109 | }; |
| 110 | } |
| 111 | |
| 112 | describe('Eine Verkehrsstärke von 0 gilt in Ausdruck und Tabellenausgabe nicht als Zählung', () => { |
| 113 | it('setzt in der Leistungstabelle einen Gedankenstrich statt "0 Fz/h"', () => { |
| 114 | const projekt = nurSchwerverkehrsanteil(); |
| 115 | expect(projekt.demands[0]!.volume, 'Vorbedingung: der Datensatz trägt 0').toBe(0); |
| 116 | const stuecke = ausdruck(projekt); |
| 117 | |
| 118 | // Die Zeile der Leistungstabelle: Name, Saettigungsverkehrsstaerke, |
| 119 | // Abflusszeit, Kapazitaet, Verkehrsstaerke. |
| 120 | const zeile = stuecke.findIndex( |
| 121 | (s, i) => s === OHNE_ZAEHLUNG && (stuecke[i + 1] ?? '').endsWith('Fz/h'), |
| 122 | ); |
| 123 | expect(zeile, 'Vorbedingung: die Gruppe steht in der Leistungstabelle').toBeGreaterThan(0); |
| 124 | // Der Gedankenstrich, wie ihn der Zeichenvorrat des PDF traegt. |
| 125 | expect(stuecke[zeile + 4]).toBe(toWinAnsi('–')); |
| 126 | }); |
| 127 | |
| 128 | it('druckt nirgends in der Unterlage "0 Fz/h"', () => { |
| 129 | const stuecke = ausdruck(nurSchwerverkehrsanteil()); |
| 130 | |
| 131 | // Auch die Signalgruppentabelle fuehrt eine Spalte "Verkehrsstärke"; sie |
| 132 | // ist dieselbe Aussage und derselbe Befund. |
| 133 | expect(stuecke).not.toContain('0 Fz/h'); |
| 134 | }); |
| 135 | |
| 136 | it('lässt eine erfasste Verkehrsstärke unverändert stehen', () => { |
| 137 | const projekt = nurSchwerverkehrsanteil(); |
| 138 | const stuecke = ausdruck(projekt); |
| 139 | |
| 140 | // Kein pauschales Ausblenden: Die uebrigen Stroeme behalten ihre Zahl. |
| 141 | const uebrige = projekt.demands.slice(1).filter((d) => d.volume > 0); |
| 142 | expect(uebrige.length, 'Vorbedingung: es gibt gezählte Ströme').toBeGreaterThan(0); |
| 143 | for (const d of uebrige) { |
| 144 | expect(stuecke, String(d.volume)).toContain( |
| 145 | `${new Intl.NumberFormat('de-DE').format(d.volume)} Fz/h`, |
| 146 | ); |
| 147 | } |
| 148 | }); |
| 149 | |
| 150 | it('lässt die Zelle auch in der Tabellenausgabe leer', () => { |
| 151 | /* |
| 152 | * Nachgezogen (Fassung 5.10.0): Der Ausdruck setzte seit der Behebung an |
| 153 | * allen drei Stellen einen Gedankenstrich, die Tabellenausgabe schrieb |
| 154 | * weiter "0". Vorher sagten beide dasselbe (beide falsch), danach |
| 155 | * widersprachen sie einander - und wer beide nebeneinanderlegt, tut das, um |
| 156 | * sie zu vergleichen. |
| 157 | * |
| 158 | * LEER UND NICHT "–": Dieselbe Schreibweise wie in der Nachbarspalte |
| 159 | * "Lastzuganteil ... (leer = nicht erfasst)" und wie bei jeder Groesse, |
| 160 | * die diese Ausgabe fuer eine Zeile nicht bildet. Ein Gedankenstrich waere |
| 161 | * in einer Zahlenspalte Text. |
| 162 | */ |
| 163 | const projekt = nurSchwerverkehrsanteil(); |
| 164 | const zeilen = signalGroupsCsv(projekt, buildSignalPlan(projekt)) |
| 165 | .replace(/^/, '') |
| 166 | .split('\r\n') |
| 167 | .filter((z) => z.length > 0) |
| 168 | .map((z) => (z.match(/("([^"]|"")*"|[^;]*)(;|$)/g) ?? []).map((f) => f.replace(/;$/, ''))); |
| 169 | const spalte = zeilen[0]!.indexOf('Verkehrsstärke [Fz/h]'); |
| 170 | |
| 171 | expect(spalte, 'Vorbedingung: die Spalte steht in der Ausgabe').toBeGreaterThan(0); |
| 172 | expect(zeilen.slice(1).find((z) => z[0] === OHNE_ZAEHLUNG)![spalte]).toBe(''); |
| 173 | // Kein pauschales Leeren: Die gezaehlten Stroeme behalten ihre Zahl. |
| 174 | const gezaehlt = projekt.demands.filter((d) => d.volume > 0); |
| 175 | expect(gezaehlt.length, 'Vorbedingung: es gibt gezählte Ströme').toBeGreaterThan(0); |
| 176 | for (const d of gezaehlt) { |
| 177 | const name = projekt.signalGroups.find((g) => g.id === d.signalGroupId)!.name; |
| 178 | const zeile = zeilen.slice(1).find((z) => z[0] === name); |
| 179 | if (zeile === undefined) continue; |
| 180 | expect(zeile[spalte], name).not.toBe(''); |
| 181 | } |
| 182 | }); |
| 183 | |
| 184 | it('führt in der Rechenwegtabelle nur Gruppen mit erfasster Verkehrsstärke', () => { |
| 185 | /* |
| 186 | * Zusammenhangspruefung zur zweiten Stelle: `drawDelayDetails` zeigt nur |
| 187 | * Gruppen mit `delay !== null`, und `delay` entsteht nur aus einer |
| 188 | * erfassten Verkehrsstaerke (signalPlan.ts, `verkehrsstaerke !== undefined |
| 189 | * && capacity !== null`). Die 0 kann dort also gar nicht ankommen - die |
| 190 | * Bedingung steht dort trotzdem, damit beide Tabellen dieselbe Aussage |
| 191 | * machen. |
| 192 | */ |
| 193 | const projekt = nurSchwerverkehrsanteil(); |
| 194 | const plan = buildSignalPlan(projekt); |
| 195 | const ohne = plan.groups.find((g) => g.name === OHNE_ZAEHLUNG)!; |
| 196 | |
| 197 | expect(ohne.delay).toBeNull(); |
| 198 | for (const gruppe of plan.groups.filter((g) => g.delay !== null)) { |
| 199 | const demand = projekt.demands.find((d) => d.signalGroupId === gruppe.groupId); |
| 200 | if (demand === undefined) continue; |
| 201 | expect(demand.volume, gruppe.name).toBeGreaterThan(0); |
| 202 | } |
| 203 | }); |
| 204 | }); |