lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui vorgabenRegelwert.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 4 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 5 | import { ANLAGENART_GRENZEN, RILSA_DEFAULTS } from '@/domain/rilsa/constants'; |
| 6 | import { resolveRilsaSettings, settingFields } from '@/domain/rilsa/settings'; |
| 7 | import { fundstellenkennwerte } from '@/services/export/bewertung'; |
| 8 | import { settingsView } from '@/ui/views/settingsView'; |
| 9 | import type { Anlagenart, Project } from '@/domain/model/project'; |
| 10 | import type { RilsaOverrides } from '@/domain/rilsa/settings'; |
| 11 | |
| 12 | /* |
| 13 | * Der letzte offene Rest von Befund 48 (tests/domain/regelbereichBezug.test.ts) |
| 14 | * und seiner ersten Behebung (tests/export/regelbereichFundstelle.test.ts). |
| 15 | * |
| 16 | * Die Abweichungsliste misst die beiden Regelbereichsfelder der Umlaufzeit |
| 17 | * seit Befund 48 gegen den Wert, der OHNE Eintrag fuer die ANLAGENART gilt, und |
| 18 | * das Fundstellenverzeichnis des Ausdrucks seit der ersten Behebung |
| 19 | * ebenfalls. Die VORGABENANSICHT am Bildschirm tat es nicht: Spalte |
| 20 | * "Regelwert", Platzhalter des leeren Feldes und die Sichtbarkeit des Knopfes |
| 21 | * "Zuruecksetzen" lasen `field.standard` aus `settingFields`, also den |
| 22 | * Knotenpunktwert. |
| 23 | * |
| 24 | * WAS DER ANWENDER SONST ERLEBT - an einer Fussgaengerschutzanlage, deren |
| 25 | * Regelbereich 40 bis 70 s betraegt und mit der der Plan auch rechnet: |
| 26 | * |
| 27 | * (a) Die Spalte "Regelwert" nennt 60 s und der Platzhalter des leeren |
| 28 | * Feldes ebenfalls - beides fuer einen Wert, den weder der Plan noch die |
| 29 | * Abweichungsliste noch der Ausdruck kennen. Wer den Platzhalter fuer den |
| 30 | * geltenden Wert haelt und ihn eintraegt, verschiebt den Regelbereich von |
| 31 | * 40 auf 60 s, ohne es zu merken. |
| 32 | * (b) Der Knopf "Zuruecksetzen" stand an der falschen Zeile: bei einem |
| 33 | * Eintrag von 40 s - dem Wert, der dort ohnehin gilt - erschien er, bei |
| 34 | * einem Eintrag von 60 s, der den Regelbereich wirklich verschiebt, |
| 35 | * nicht. Genau dort, wo etwas zurueckzunehmen war, fehlte er. |
| 36 | * |
| 37 | * DIESE WACHE HAELT DIE AUSGABEN GEGENEINANDER; ihr Auseinanderlaufen ist der |
| 38 | * Befund. Geprueft wird die Ansicht deshalb nicht gegen eine hier |
| 39 | * hingeschriebene Zahl, sondern gegen `plan.grenzen` (womit gerechnet wird), |
| 40 | * gegen `resolveRilsaSettings(...).deviations` (die gedruckte Aufstellung |
| 41 | * "Abweichungen von den Regelwerten") und gegen `fundstellenkennwerte` (das |
| 42 | * Fundstellenverzeichnis desselben Ausdrucks). |
| 43 | * |
| 44 | * GEGEN DEN ALTSTAND schlagen sechs der zehn Faelle fehl. Gruen bleiben dort |
| 45 | * die Vorbedingung, die Gegenprobe (Knotenpunkt und groesste Umlaufzeit) und |
| 46 | * die beiden Faelle zum Vermerk "wirksam N s". |
| 47 | * |
| 48 | * DER UMBAU VON `wirksamerWert` IST FUER SICH WIRKUNGSGLEICH, und das ist |
| 49 | * gemessen: Setzt man allein seinen Rumpf auf die fruehere Fassung zurueck |
| 50 | * (`bezug = eingetragen ?? feld.standard`) und laesst Spalte, Platzhalter und |
| 51 | * Knopf umgestellt, bleiben alle zehn Faelle gruen. Rot wird der Fall |
| 52 | * "behauptet am leeren Feld keinen abweichenden wirksamen Wert" erst gegen die |
| 53 | * HALBE Umstellung, bei der nur die BEZUGSSEITE des Vergleichs auf `regelwert` |
| 54 | * gezogen wird (`bezug = eingetragen ?? feld.regelwert`, `wirksam` |
| 55 | * unveraendert): Dann steht unter dem leeren Feld "wirksam 60 s", waehrend die |
| 56 | * Zeile daneben 40 s als Regelwert fuehrt - gemessen, 1 von 10 rot. Genau das |
| 57 | * bewacht der Fall. Der Fall daneben ("meldet den gekappten Wert weiterhin") |
| 58 | * ist die Gegenprobe dazu und bleibt in allen drei Staenden gruen. |
| 59 | */ |
| 60 | |
| 61 | const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); |
| 62 | |
| 63 | const VON = 'Regelbereich Umlaufzeit von'; |
| 64 | const BIS = 'Regelbereich Umlaufzeit bis'; |
| 65 | |
| 66 | afterEach(() => { |
| 67 | document.body.replaceChildren(); |
| 68 | }); |
| 69 | |
| 70 | function projekt(anlagenart: Anlagenart, rilsa: RilsaOverrides = {}): Project { |
| 71 | const roh = createStandardIntersectionProject('Vorgaben', FESTES_DATUM); |
| 72 | return { |
| 73 | ...roh, |
| 74 | anlagenart, |
| 75 | settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, ...rilsa } }, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | interface Ansicht { |
| 80 | readonly wurzel: HTMLElement; |
| 81 | readonly aufraeumen: () => void; |
| 82 | } |
| 83 | |
| 84 | function zeichne(project: Project): Ansicht { |
| 85 | const wurzel = document.createElement('div'); |
| 86 | document.body.append(wurzel); |
| 87 | const schliessen = settingsView.render(wurzel, { |
| 88 | store: new ProjectStore(project), |
| 89 | refresh: () => {}, |
| 90 | navigate: () => {}, |
| 91 | target: null, |
| 92 | }); |
| 93 | return { |
| 94 | wurzel, |
| 95 | aufraeumen: () => { |
| 96 | schliessen(); |
| 97 | wurzel.remove(); |
| 98 | }, |
| 99 | }; |
| 100 | } |
| 101 | |
| 102 | /** Zeile der Vorgabentabelle zu einem Kennwert. */ |
| 103 | function zeileZu(wurzel: ParentNode, label: string): HTMLTableRowElement { |
| 104 | const zeile = [...wurzel.querySelectorAll<HTMLTableRowElement>('tbody tr')].find( |
| 105 | (tr) => tr.children[0]?.textContent?.trim() === label, |
| 106 | ); |
| 107 | if (!zeile) throw new Error(`Keine Zeile "${label}"`); |
| 108 | return zeile; |
| 109 | } |
| 110 | |
| 111 | /** Was die Zeile in Spalte "Regelwert", im Platzhalter und am Knopf zeigt. */ |
| 112 | interface Zeilenbild { |
| 113 | readonly spalte: string; |
| 114 | readonly platzhalter: string; |
| 115 | readonly zuruecksetzen: boolean; |
| 116 | readonly text: string; |
| 117 | } |
| 118 | |
| 119 | function bild(wurzel: ParentNode, label: string): Zeilenbild { |
| 120 | const zeile = zeileZu(wurzel, label); |
| 121 | return { |
| 122 | spalte: zeile.children[2]?.textContent?.trim() ?? '', |
| 123 | platzhalter: zeile.querySelector('input')?.placeholder ?? '', |
| 124 | zuruecksetzen: [...zeile.querySelectorAll('button')].some( |
| 125 | (b) => b.textContent?.trim() === 'Zurücksetzen', |
| 126 | ), |
| 127 | text: zeile.textContent ?? '', |
| 128 | }; |
| 129 | } |
| 130 | |
| 131 | /** Der Bezugswert der gedruckten Aufstellung "Abweichungen von den Regelwerten". */ |
| 132 | function bezugDerAufstellung( |
| 133 | anlagenart: Anlagenart, |
| 134 | key: keyof RilsaOverrides, |
| 135 | eintrag: number, |
| 136 | ): number | undefined { |
| 137 | return resolveRilsaSettings({ [key]: eintrag }, RILSA_DEFAULTS, anlagenart).deviations.find( |
| 138 | (d) => d.key === key, |
| 139 | )?.standardValue; |
| 140 | } |
| 141 | |
| 142 | describe('Der Regelwert der Vorgabenzeile', () => { |
| 143 | it('haelt an der Fussgaengerschutzanlage die Zahlen der Anlagenart bereit', () => { |
| 144 | // Vorbedingung des Aufbaus, wie in regelbereichBezug: Die Anlagenart |
| 145 | // fuehrt einen anderen Regelbereich als der Knotenpunkt. |
| 146 | expect(ANLAGENART_GRENZEN.fussgaengerschutzanlage.cycleTime.recommendedMin).toBe(40); |
| 147 | expect(ANLAGENART_GRENZEN.fussgaengerschutzanlage.cycleTime.recommendedMax).toBe(70); |
| 148 | // Seit 5.27.0 der Richtwert der RiLSA (Abschnitt 2.6) statt der 60 s. |
| 149 | expect(RILSA_DEFAULTS.cycleTime.recommendedMin).toBe(30); |
| 150 | expect(RILSA_DEFAULTS.cycleTime.recommendedMax).toBe(90); |
| 151 | }); |
| 152 | |
| 153 | it('nennt in Spalte und Platzhalter die Zahl, mit der der Plan rechnet', () => { |
| 154 | const p = projekt('fussgaengerschutzanlage'); |
| 155 | // Womit gerechnet wird - nicht behauptet, sondern am Plan abgelesen. |
| 156 | const grenzen = buildSignalPlan(p).grenzen.cycleTime; |
| 157 | expect([grenzen.recommendedMin, grenzen.recommendedMax]).toEqual([40, 70]); |
| 158 | |
| 159 | const ansicht = zeichne(p); |
| 160 | try { |
| 161 | const von = bild(ansicht.wurzel, VON); |
| 162 | expect(von.spalte, 'Spalte "Regelwert"').toBe(String(grenzen.recommendedMin)); |
| 163 | expect(von.platzhalter, 'Platzhalter des leeren Feldes').toBe(String(grenzen.recommendedMin)); |
| 164 | const bis = bild(ansicht.wurzel, BIS); |
| 165 | expect(bis.spalte).toBe(String(grenzen.recommendedMax)); |
| 166 | expect(bis.platzhalter).toBe(String(grenzen.recommendedMax)); |
| 167 | } finally { |
| 168 | ansicht.aufraeumen(); |
| 169 | } |
| 170 | }); |
| 171 | |
| 172 | it('nennt dieselbe Zahl wie die Aufstellung "Abweichungen von den Regelwerten"', () => { |
| 173 | // Ein Eintrag von 60 s verschiebt den Regelbereich; die Aufstellung nennt |
| 174 | // dafuer ihren Bezugswert. Die Ansicht darf keinen anderen fuehren. |
| 175 | const bezug = bezugDerAufstellung('fussgaengerschutzanlage', 'cycleTimeRecommendedMin', 60); |
| 176 | expect(bezug, 'Vorbedingung: die Aufstellung fuehrt die Zeile').toBeDefined(); |
| 177 | |
| 178 | const ansicht = zeichne(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 60 })); |
| 179 | try { |
| 180 | expect(bild(ansicht.wurzel, VON).spalte).toBe(String(bezug)); |
| 181 | } finally { |
| 182 | ansicht.aufraeumen(); |
| 183 | } |
| 184 | }); |
| 185 | |
| 186 | it('nennt dieselbe Zahl wie das Fundstellenverzeichnis desselben Ausdrucks', () => { |
| 187 | // Bildschirm und Ausdruck bilden ihren Bezugswert nicht mehr unabhaengig |
| 188 | // voneinander - das war der Befund, dreimal hintereinander. |
| 189 | const gedruckt = fundstellenkennwerte({}, 'fussgaengerschutzanlage'); |
| 190 | const zeile = settingFields(RILSA_DEFAULTS, 'fussgaengerschutzanlage'); |
| 191 | for (const key of ['cycleTimeRecommendedMin', 'cycleTimeRecommendedMax'] as const) { |
| 192 | const feld = zeile.find((f) => f.key === key); |
| 193 | expect(feld?.regelwert, `Vorgabenzeile "${key}"`).toBe(gedruckt.get(key)?.regelwert); |
| 194 | } |
| 195 | |
| 196 | const ansicht = zeichne(projekt('fussgaengerschutzanlage')); |
| 197 | try { |
| 198 | expect(bild(ansicht.wurzel, VON).spalte).toBe( |
| 199 | String(gedruckt.get('cycleTimeRecommendedMin')?.regelwert), |
| 200 | ); |
| 201 | expect(bild(ansicht.wurzel, BIS).spalte).toBe( |
| 202 | String(gedruckt.get('cycleTimeRecommendedMax')?.regelwert), |
| 203 | ); |
| 204 | } finally { |
| 205 | ansicht.aufraeumen(); |
| 206 | } |
| 207 | }); |
| 208 | |
| 209 | it('bietet "Zuruecksetzen" bei der wirksamen Eingabe an', () => { |
| 210 | // 60 s verschieben den Regelbereich von 40 auf 60 - hier gibt es etwas |
| 211 | // zurueckzunehmen, und die Aufstellung fuehrt die Zeile. |
| 212 | expect(bezugDerAufstellung('fussgaengerschutzanlage', 'cycleTimeRecommendedMin', 60)).toBe(40); |
| 213 | |
| 214 | const ansicht = zeichne(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 60 })); |
| 215 | try { |
| 216 | expect(bild(ansicht.wurzel, VON).zuruecksetzen).toBe(true); |
| 217 | } finally { |
| 218 | ansicht.aufraeumen(); |
| 219 | } |
| 220 | }); |
| 221 | |
| 222 | it('bietet "Zuruecksetzen" bei der wirkungslosen Eingabe nicht an', () => { |
| 223 | // 40 s gelten dort ohnehin; die Aufstellung fuehrt die Zeile deshalb nicht. |
| 224 | expect( |
| 225 | bezugDerAufstellung('fussgaengerschutzanlage', 'cycleTimeRecommendedMin', 40), |
| 226 | ).toBeUndefined(); |
| 227 | |
| 228 | const ansicht = zeichne(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 40 })); |
| 229 | try { |
| 230 | expect(bild(ansicht.wurzel, VON).zuruecksetzen).toBe(false); |
| 231 | } finally { |
| 232 | ansicht.aufraeumen(); |
| 233 | } |
| 234 | }); |
| 235 | |
| 236 | it('behauptet am leeren Feld keinen abweichenden wirksamen Wert', () => { |
| 237 | /* |
| 238 | * Der Vermerk "wirksam N s" vergleicht gegen das, was die Zeile als geltend |
| 239 | * darstellt. Mit dem Regelwert der Anlagenart in Spalte und Platzhalter |
| 240 | * muss ihm der Vergleich AUF BEIDEN SEITEN folgen: `resolveRilsaSettings` |
| 241 | * fuehrt im aufgeloesten Kennwertsatz weiterhin die Knotenpunktzahl, weil |
| 242 | * der Regelbereich der Anlagenart erst in `anlagenartGrenzen` einsetzt - |
| 243 | * und zwar nur, solange nichts eingetragen ist. Zieht nur die Bezugsseite |
| 244 | * auf den Regelwert um, steht unter dem leeren Feld "wirksam 60 s", |
| 245 | * waehrend der Plan mit 40 s rechnet (gemessen; siehe Kopfkommentar). |
| 246 | */ |
| 247 | const ansicht = zeichne(projekt('fussgaengerschutzanlage')); |
| 248 | try { |
| 249 | expect(bild(ansicht.wurzel, VON).text).not.toContain('wirksam'); |
| 250 | expect(bild(ansicht.wurzel, BIS).text).not.toContain('wirksam'); |
| 251 | } finally { |
| 252 | ansicht.aufraeumen(); |
| 253 | } |
| 254 | }); |
| 255 | |
| 256 | it('meldet den gekappten Wert weiterhin, wo einer gekappt wird', () => { |
| 257 | // Gegenprobe zum Fall darueber: Ein Eintrag ueber der Schranke der |
| 258 | // Anlagenart wird auf 120 s gekappt, und das steht neben dem Feld |
| 259 | // (Befund 56 zur Fassung 5.9.0). |
| 260 | const aufgeloest = resolveRilsaSettings( |
| 261 | { cycleTimeRecommendedMin: 500 }, |
| 262 | RILSA_DEFAULTS, |
| 263 | 'fussgaengerschutzanlage', |
| 264 | ); |
| 265 | expect(aufgeloest.defaults.cycleTime.recommendedMin).toBe(120); |
| 266 | |
| 267 | const ansicht = zeichne(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 500 })); |
| 268 | try { |
| 269 | expect(bild(ansicht.wurzel, VON).text).toContain('wirksam 120 s'); |
| 270 | } finally { |
| 271 | ansicht.aufraeumen(); |
| 272 | } |
| 273 | }); |
| 274 | |
| 275 | it('folgt auch bei einstreifiger Verkehrsfuehrung der Anlagenart', () => { |
| 276 | // Nicht nur die Fussgaengerschutzanlage: Den Regelbereich der einstreifigen |
| 277 | // Verkehrsfuehrung (60 bis 180 s) setzt dieses Programm ebenso selbst. |
| 278 | const p = projekt('einstreifig'); |
| 279 | const grenzen = buildSignalPlan(p).grenzen.cycleTime; |
| 280 | expect([grenzen.recommendedMin, grenzen.recommendedMax]).toEqual([60, 180]); |
| 281 | |
| 282 | const ansicht = zeichne(p); |
| 283 | try { |
| 284 | expect(bild(ansicht.wurzel, BIS).spalte).toBe(String(grenzen.recommendedMax)); |
| 285 | expect(bild(ansicht.wurzel, BIS).platzhalter).toBe(String(grenzen.recommendedMax)); |
| 286 | } finally { |
| 287 | ansicht.aufraeumen(); |
| 288 | } |
| 289 | }); |
| 290 | |
| 291 | it('laesst Knotenpunkt und groesste Umlaufzeit unveraendert', () => { |
| 292 | /* |
| 293 | * Die Gegenproben, in beiden Staenden gruen. Am Knotenpunkt sind |
| 294 | * Anlagenart- und Regelwerkszahl dieselben. Und bei der kleinsten und |
| 295 | * groessten Umlaufzeit bleibt der Knotenpunktwert der Bezug, auch wo die |
| 296 | * Anlagenart mehr zulaesst: Er ist dort ein Regelwerkswert (30 bis 120 s, |
| 297 | * von der RiLSA nicht nach Anlagenart gestaffelt), waehrend die 300 s der |
| 298 | * einstreifigen Fuehrung dieses Programm setzt. |
| 299 | */ |
| 300 | const amKnoten = zeichne(projekt('knotenpunkt')); |
| 301 | try { |
| 302 | expect(bild(amKnoten.wurzel, VON).spalte).toBe('30'); |
| 303 | expect(bild(amKnoten.wurzel, VON).platzhalter).toBe('30'); |
| 304 | expect(bild(amKnoten.wurzel, BIS).spalte).toBe('90'); |
| 305 | } finally { |
| 306 | amKnoten.aufraeumen(); |
| 307 | } |
| 308 | |
| 309 | expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.max).toBe(300); |
| 310 | const einstreifig = zeichne(projekt('einstreifig')); |
| 311 | try { |
| 312 | const groesste = bild(einstreifig.wurzel, 'Größte Umlaufzeit'); |
| 313 | expect(groesste.spalte, 'Regelwerkswert, nicht die Schranke der Anlagenart').toBe('120'); |
| 314 | expect(groesste.platzhalter).toBe('120'); |
| 315 | } finally { |
| 316 | einstreifig.aufraeumen(); |
| 317 | } |
| 318 | }); |
| 319 | }); |