lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import { readFileSync, readdirSync } from 'node:fs'; |
| 2 | import { join } from 'node:path'; |
| 3 | import { describe, expect, it } from 'vitest'; |
| 4 | import { HILFE, glossarEintraege } from '@/ui/help/texte'; |
| 5 | import type { HilfeSchluessel, Hilfetext } from '@/ui/help/texte'; |
| 6 | import { parseProject } from '@/domain/model/schema'; |
| 7 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 8 | import { compareCycleTimeMethods } from '@/domain/rilsa/cycle'; |
| 9 | import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; |
| 10 | |
| 11 | /* |
| 12 | * Fassung 5.10.0 - Hilfetexte sind Zusagen. |
| 13 | * |
| 14 | * Die Sammlung in src/ui/help/texte.ts wird dem Anwender am Feld und im |
| 15 | * Glossar ausgegeben; sie leitet seine Handmessung an. Ein Satz, der etwas |
| 16 | * anderes sagt als der Fachkern rechnet, ist deshalb kein Schoenheitsfehler, |
| 17 | * sondern eine falsche Anweisung. Diese Datei bewacht fuenf solcher Saetze. |
| 18 | */ |
| 19 | |
| 20 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 21 | const BEISPIELDATEI = 'beispiele/vierarmiger-knotenpunkt.lsap'; |
| 22 | |
| 23 | const SCHLUESSEL = Object.keys(HILFE) as HilfeSchluessel[]; |
| 24 | |
| 25 | /** Ein Eintrag als Hilfetext - so sind auch die freiwilligen Felder lesbar. */ |
| 26 | function eintrag(schluessel: HilfeSchluessel): Hilfetext { |
| 27 | return HILFE[schluessel]; |
| 28 | } |
| 29 | |
| 30 | /** Alle Textfelder eines Eintrags in einer Zeichenkette. */ |
| 31 | function ganzerText(schluessel: HilfeSchluessel): string { |
| 32 | const e = eintrag(schluessel); |
| 33 | return [e.wasIstDas, e.woher ?? '', e.ueblich ?? '', e.wennFalsch ?? ''].join(' '); |
| 34 | } |
| 35 | |
| 36 | describe('Furt mit Mittelinsel: nur der Raeumweg geht ueber die ganze Breite (Befund 15)', () => { |
| 37 | /* |
| 38 | * intergreen.ts rechnet raw = tue + tr - te mit te = se / ve. Ein zu gross |
| 39 | * angesetzter Einfahrweg VERKUERZT die Zwischenzeit - die gefaehrliche |
| 40 | * Richtung. Der Einfahrweg endet an der vorderen Kante des |
| 41 | * Konfliktbereichs (types.ts, vermessung.ts), an einer Furt also nach null |
| 42 | * bis wenigen Metern. |
| 43 | */ |
| 44 | it('weist den Einfahrweg nicht ueber die ganze Furtbreite an', () => { |
| 45 | const woher = HILFE.mittelinsel.woher; |
| 46 | expect(woher).not.toContain('Einfahrweg werden über die ganze Furtbreite'); |
| 47 | expect(woher).toContain('Räumweg wird über die ganze Furtbreite'); |
| 48 | }); |
| 49 | |
| 50 | it('sagt, wo der Einfahrweg des Querenden endet', () => { |
| 51 | expect(HILFE.mittelinsel.woher).toContain('vorderen Kante des Konfliktbereichs'); |
| 52 | }); |
| 53 | |
| 54 | it('beschraenkt auch die uebliche Angabe auf den Raeumweg', () => { |
| 55 | // "Fuer die Eingabe bleibt es bei der vollen Furtbreite" schloss den |
| 56 | // Einfahrweg mit ein und nahm die Berichtigung im Feld darueber zurueck. |
| 57 | expect(HILFE.mittelinsel.ueblich).not.toContain('Für die Eingabe bleibt es'); |
| 58 | expect(HILFE.mittelinsel.ueblich).toContain('Räumweg'); |
| 59 | }); |
| 60 | |
| 61 | it('laesst in der ganzen Sammlung keinen Satz stehen, der beides zusammenwirft', () => { |
| 62 | // Die Gegenprobe steht im Eintrag "Einfahrweg" selbst: "Im Zweifel also |
| 63 | // knapp messen." Kein zweiter Eintrag darf das Gegenteil anweisen. |
| 64 | expect(HILFE.einfahrweg.wennFalsch).toContain('knapp messen'); |
| 65 | for (const schluessel of SCHLUESSEL) { |
| 66 | for (const satz of ganzerText(schluessel).split(/(?<=[.!?])\s+/)) { |
| 67 | if (!satz.includes('Einfahrweg')) continue; |
| 68 | expect(satz, schluessel).not.toMatch(/ganze[nr]? Furtbreite/); |
| 69 | } |
| 70 | } |
| 71 | }); |
| 72 | }); |
| 73 | |
| 74 | describe('Zusatzeinrichtung fuer Blinde: was eine aeltere Projektdatei erwartet (Befund 43)', () => { |
| 75 | it('sagt einer aelteren Projektdatei keine Unveraenderlichkeit zu', () => { |
| 76 | const ueblich = HILFE.blindenzusatz.ueblich; |
| 77 | expect(ueblich).not.toContain('ändert sich also nicht'); |
| 78 | expect(ueblich).not.toContain('so, wie es vor diesem'); |
| 79 | }); |
| 80 | |
| 81 | it('nennt die Verschiebung, die das Einlesen bewirkt', () => { |
| 82 | const ueblich = HILFE.blindenzusatz.ueblich; |
| 83 | expect(ueblich).toContain('längere Mindestfreigabezeiten'); |
| 84 | expect(ueblich).toContain('Umlaufzeit'); |
| 85 | }); |
| 86 | |
| 87 | it('deckt sich mit dem, was die Beispieldatei aus Schema 7 wirklich ergibt', () => { |
| 88 | // Belegstelle, nicht Wache: Sie zeigt, dass die neue Aussage stimmt. |
| 89 | const { project } = parseProject(JSON.parse(readFileSync(BEISPIELDATEI, 'utf8')), DATUM); |
| 90 | for (const gruppe of project.signalGroups.filter((g) => g.mode === 'fuss')) { |
| 91 | expect(gruppe.blindenzusatz).toBeUndefined(); |
| 92 | } |
| 93 | const fussgruppen = buildSignalPlan(project).groups.filter((g) => g.mode === 'fuss'); |
| 94 | expect(fussgruppen.length).toBeGreaterThan(0); |
| 95 | for (const gruppe of fussgruppen) { |
| 96 | expect(gruppe.times.minGreen).toBeGreaterThan(RILSA_DEFAULTS.minGreen.fuss); |
| 97 | } |
| 98 | }); |
| 99 | }); |
| 100 | |
| 101 | describe('Kein Hilfeeintrag ohne Zugang (Befund 62)', () => { |
| 102 | /** Alle Quelltextdateien ausser der Sammlung selbst. */ |
| 103 | function quelltext(verzeichnis: string, gesammelt: string[] = []): string[] { |
| 104 | for (const eintragImOrdner of readdirSync(verzeichnis, { withFileTypes: true })) { |
| 105 | const pfad = join(verzeichnis, eintragImOrdner.name); |
| 106 | if (eintragImOrdner.isDirectory()) quelltext(pfad, gesammelt); |
| 107 | else if (pfad.endsWith('.ts') && !pfad.replace(/\\/g, '/').endsWith('src/ui/help/texte.ts')) |
| 108 | gesammelt.push(pfad); |
| 109 | } |
| 110 | return gesammelt; |
| 111 | } |
| 112 | |
| 113 | it('verlinkt die feste Freigabezeit einer Phase vom Eintrag Freigabezeit aus', () => { |
| 114 | expect(HILFE.freigabezeit.siehe).toContain('festeFreigabezeit'); |
| 115 | }); |
| 116 | |
| 117 | it('erreicht jeden Eintrag ueber Glossar, Verweis oder Bedienfeld', () => { |
| 118 | const quellen = quelltext('src') |
| 119 | .map((pfad) => readFileSync(pfad, 'utf8')) |
| 120 | .join('\n'); |
| 121 | const verlinkt = new Set<string>(); |
| 122 | for (const schluessel of SCHLUESSEL) { |
| 123 | for (const verweis of eintrag(schluessel).siehe ?? []) { |
| 124 | if (verweis !== schluessel) verlinkt.add(verweis); |
| 125 | } |
| 126 | } |
| 127 | const imGlossar = new Set<string>(glossarEintraege().map((e) => e.schluessel)); |
| 128 | |
| 129 | for (const schluessel of SCHLUESSEL) { |
| 130 | const amBedienfeld = quellen.includes(`'${schluessel}'`); |
| 131 | expect( |
| 132 | imGlossar.has(schluessel) || verlinkt.has(schluessel) || amBedienfeld, |
| 133 | `${schluessel} ist von keiner Stelle des Programms aus erreichbar`, |
| 134 | ).toBe(true); |
| 135 | } |
| 136 | }); |
| 137 | }); |
| 138 | |
| 139 | describe('Rechtsgrundlagen: wo die eigene Fundstelle eingetragen wird (Befund 63)', () => { |
| 140 | it('schickt nicht in das Fundstellenverzeichnis, das nur anzeigt', () => { |
| 141 | expect(HILFE.rechtsgrundlagen.woher).not.toContain('tragen Sie dort ein'); |
| 142 | }); |
| 143 | |
| 144 | it('nennt den Ort, an dem das Eingabefeld wirklich steht', () => { |
| 145 | const woher = HILFE.rechtsgrundlagen.woher; |
| 146 | expect(woher).toContain('Vorgaben'); |
| 147 | expect(woher).toContain('Herkunft'); |
| 148 | }); |
| 149 | }); |
| 150 | |
| 151 | describe('Verfahren zur Umlaufzeitermittlung: Einleitung und Aufzaehlung (Befund 64)', () => { |
| 152 | const ZAHLWOERTER: Readonly<Record<string, number>> = { |
| 153 | zwei: 2, |
| 154 | drei: 3, |
| 155 | vier: 4, |
| 156 | fünf: 5, |
| 157 | sechs: 6, |
| 158 | }; |
| 159 | |
| 160 | function punkte(): readonly string[] { |
| 161 | return HILFE.verfahren.wasIstDas.split('\n').filter((zeile) => zeile.startsWith('• ')); |
| 162 | } |
| 163 | |
| 164 | it('nennt in der Einleitung keine Zahl, die von der Aufzaehlung abweicht', () => { |
| 165 | const einleitung = HILFE.verfahren.wasIstDas.split('\n')[0] ?? ''; |
| 166 | for (const [wort, zahl] of Object.entries(ZAHLWOERTER)) { |
| 167 | if (!new RegExp(`\\b${wort}\\b`, 'i').test(einleitung)) continue; |
| 168 | expect(zahl, `Einleitung nennt "${wort}", aufgezählt sind ${punkte().length}`).toBe( |
| 169 | punkte().length, |
| 170 | ); |
| 171 | } |
| 172 | }); |
| 173 | |
| 174 | it('fuehrt jeden Rechenansatz des Fachkerns als eigenen Punkt, dazu die feste Vorgabe', () => { |
| 175 | // Die Ansaetze kommen aus dem Fachkern, nicht aus einer zweiten Liste: |
| 176 | // Wer dort ein Verfahren ergaenzt, bringt diesen Fall zu Fall. |
| 177 | const beschriftung: Readonly<Record<string, string>> = { |
| 178 | webster: 'Webster', |
| 179 | akcelik: 'Akçelik', |
| 180 | hbs: 'HBS', |
| 181 | hcm: 'HCM', |
| 182 | }; |
| 183 | const ansaetze = Object.keys( |
| 184 | compareCycleTimeMethods({ lostTime: 12, criticalFlowRatios: [0.3, 0.3] }).results, |
| 185 | ); |
| 186 | for (const ansatz of ansaetze) { |
| 187 | expect(HILFE.verfahren.wasIstDas).toContain(`• ${beschriftung[ansatz] ?? ansatz}`); |
| 188 | } |
| 189 | expect(HILFE.verfahren.wasIstDas).toContain('• Feste Vorgabe'); |
| 190 | expect(punkte()).toHaveLength(ansaetze.length + 1); |
| 191 | }); |
| 192 | }); |