lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui massgebenderStromKonfliktfenster.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, intergreenKey } from '@/domain/plan/signalPlan'; |
| 5 | import { raeumbeziehungSatz } from '@/domain/rilsa/ansaetze'; |
| 6 | import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; |
| 7 | import type { Conflict, Project } from '@/domain/model/project'; |
| 8 | import type { ViewDefinition } from '@/ui/shell'; |
| 9 | import { conflictsView } from '@/ui/views/conflictsView'; |
| 10 | |
| 11 | /* |
| 12 | * Der massgebende Strom im KONFLIKTFENSTER. |
| 13 | * |
| 14 | * Das Fenster bildete die Entscheidung, mit welcher Fahrbeziehung eine |
| 15 | * Beziehung raeumt, ein zweites Mal nach und nahm dabei `from.movement`. An |
| 16 | * einer Sammelsignalgruppe "geradeaus UND rechts" widersprach es damit dem |
| 17 | * Rechenweg, den es selbst zwei Zeilen tiefer zeigt: Ueber der Zeile |
| 18 | * "Räumgeschwindigkeit vr 7,00 m/s" stand "Regelwert 10 m/s". |
| 19 | */ |
| 20 | |
| 21 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 22 | |
| 23 | function zeichne( |
| 24 | view: ViewDefinition, |
| 25 | project: Project, |
| 26 | ): { wurzel: HTMLElement; aufraeumen: () => void } { |
| 27 | const wurzel = document.createElement('div'); |
| 28 | document.body.append(wurzel); |
| 29 | const abmelden = view.render(wurzel, { |
| 30 | store: new ProjectStore(project), |
| 31 | refresh: () => {}, |
| 32 | navigate: () => {}, |
| 33 | target: null, |
| 34 | }); |
| 35 | return { |
| 36 | wurzel, |
| 37 | aufraeumen: () => { |
| 38 | abmelden(); |
| 39 | wurzel.remove(); |
| 40 | }, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | /** Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf. */ |
| 45 | function beschriftungstext(label: HTMLLabelElement): string { |
| 46 | return [...label.childNodes] |
| 47 | .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf'))) |
| 48 | .map((knoten) => knoten.textContent ?? '') |
| 49 | .join('') |
| 50 | .trim(); |
| 51 | } |
| 52 | |
| 53 | function hinweisZu(wurzel: ParentNode, beschriftung: string): string { |
| 54 | const label = [...wurzel.querySelectorAll('label')].find( |
| 55 | (l) => beschriftungstext(l) === beschriftung, |
| 56 | ); |
| 57 | const id = label?.getAttribute('for'); |
| 58 | expect(id, `Das Feld "${beschriftung}" fehlt im Fenster`).toBeTruthy(); |
| 59 | const feld = wurzel.querySelector<HTMLElement>(`[id="${id ?? ''}"]`); |
| 60 | const beschrieben = feld?.getAttribute('aria-describedby') ?? null; |
| 61 | if (beschrieben === null) return ''; |
| 62 | return beschrieben |
| 63 | .split(' ') |
| 64 | .map((teil) => wurzel.querySelector(`[id="${teil}"]`)?.textContent ?? '') |
| 65 | .join(' '); |
| 66 | } |
| 67 | |
| 68 | /** Erlaeuterungsspalte einer Zeile des Rechenwegs. */ |
| 69 | function rechenwegZeile(fenster: ParentNode, bezeichnung: string): string { |
| 70 | const zeile = [...fenster.querySelectorAll('tbody tr')].find( |
| 71 | (tr) => tr.children[0]?.textContent?.trim() === bezeichnung, |
| 72 | ); |
| 73 | if (!zeile) throw new Error(`Zeile "${bezeichnung}" fehlt im Rechenweg`); |
| 74 | return zeile.children[1]?.textContent?.trim() ?? ''; |
| 75 | } |
| 76 | |
| 77 | /** Der ganze sichtbare Text des Fensters. */ |
| 78 | function fenstertext(fenster: ParentNode): string { |
| 79 | return ((fenster as HTMLElement).textContent ?? '').replace(/\s+/g, ' '); |
| 80 | } |
| 81 | |
| 82 | function oeffneBeziehung(wurzel: HTMLElement, project: Project, konflikt: Conflict): ParentNode { |
| 83 | const zeile = project.signalGroups.findIndex((g) => g.id === konflikt.fromId); |
| 84 | const spalte = project.signalGroups.findIndex((g) => g.id === konflikt.toId); |
| 85 | const zellen = [...wurzel.querySelectorAll<HTMLButtonElement>('.matrix-zelle')]; |
| 86 | const n = project.signalGroups.length; |
| 87 | const versatz = spalte > zeile ? spalte - 1 : spalte; |
| 88 | zellen[zeile * (n - 1) + versatz]?.click(); |
| 89 | const fenster = document.querySelector('.dialog'); |
| 90 | expect(fenster, 'Das Konfliktfenster ist nicht offen').not.toBeNull(); |
| 91 | return fenster as ParentNode; |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Standardknotenpunkt, an dem "K1" geradeaus UND rechts fuehrt: Die Gruppe |
| 96 | * bleibt bei 'geradeaus', die Beziehung K1 -> K3 traegt die Fahrbeziehung |
| 97 | * ihres massgebenden Stroms. |
| 98 | */ |
| 99 | function sammelgruppe(zusatz: Partial<Conflict> = {}): Project { |
| 100 | const basis = createStandardIntersectionProject('Sammelgruppe', DATUM); |
| 101 | const k1 = basis.signalGroups.find((g) => g.name === 'K1')!; |
| 102 | const k3 = basis.signalGroups.find((g) => g.name === 'K3')!; |
| 103 | return { |
| 104 | ...basis, |
| 105 | conflicts: basis.conflicts.map((c) => |
| 106 | c.fromId === k1.id && c.toId === k3.id |
| 107 | ? { ...c, raeumbeziehung: 'rechts' as const, ...zusatz } |
| 108 | : c, |
| 109 | ), |
| 110 | }; |
| 111 | } |
| 112 | |
| 113 | function beziehung(project: Project): Conflict { |
| 114 | return project.conflicts.find((c) => c.raeumbeziehung !== undefined)!; |
| 115 | } |
| 116 | |
| 117 | afterEach(() => { |
| 118 | document.body.replaceChildren(); |
| 119 | }); |
| 120 | |
| 121 | describe('N1/4: Das Konfliktfenster nennt den Regelwert des massgebenden Stroms', () => { |
| 122 | it('beschriftet die Raeumgeschwindigkeit mit dem Wert, mit dem der Plan rechnet', () => { |
| 123 | const project = sammelgruppe(); |
| 124 | const konflikt = beziehung(project); |
| 125 | const gerechnet = buildSignalPlan(project).intergreens.get( |
| 126 | intergreenKey(konflikt.fromId, konflikt.toId), |
| 127 | ); |
| 128 | expect(gerechnet?.calculation?.clearingSpeed).toBe(RILSA_DEFAULTS.clearingSpeed.kfzTurning); |
| 129 | |
| 130 | const { wurzel, aufraeumen } = zeichne(conflictsView, project); |
| 131 | try { |
| 132 | const fenster = oeffneBeziehung(wurzel, project, konflikt); |
| 133 | expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('Regelwert 7 m/s'); |
| 134 | expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).not.toContain('Regelwert 10 m/s'); |
| 135 | // Und der Rechenweg im selben Fenster sagt dasselbe. |
| 136 | expect(rechenwegZeile(fenster, 'Räumgeschwindigkeit vr')).toContain('7,00 m/s'); |
| 137 | } finally { |
| 138 | aufraeumen(); |
| 139 | } |
| 140 | }); |
| 141 | |
| 142 | it('zeigt den engen Innenradius als wirksam, wo der Plan mit ihm rechnet', () => { |
| 143 | const project = sammelgruppe({ engerRadius: true }); |
| 144 | const konflikt = beziehung(project); |
| 145 | const gerechnet = buildSignalPlan(project).intergreens.get( |
| 146 | intergreenKey(konflikt.fromId, konflikt.toId), |
| 147 | ); |
| 148 | expect(gerechnet?.calculation?.clearingSpeed).toBe( |
| 149 | RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius, |
| 150 | ); |
| 151 | |
| 152 | const { wurzel, aufraeumen } = zeichne(conflictsView, project); |
| 153 | try { |
| 154 | const fenster = oeffneBeziehung(wurzel, project, konflikt); |
| 155 | expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('Angesetzt 5 m/s'); |
| 156 | expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('enger Innenradius'); |
| 157 | // Das Kaestchen darf nicht behaupten, der Strom biege nicht ab. |
| 158 | expect(fenstertext(fenster)).not.toContain('ist kein abbiegender Kraftfahrzeugstrom'); |
| 159 | } finally { |
| 160 | aufraeumen(); |
| 161 | } |
| 162 | }); |
| 163 | |
| 164 | it('nennt beim Ansatz "feste Ueberfahrzeit" den Wert des abbiegenden Stroms', () => { |
| 165 | const basis = sammelgruppe({ crossingTimeOverride: 4 }); |
| 166 | const project: Project = { |
| 167 | ...basis, |
| 168 | settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' }, |
| 169 | }; |
| 170 | const { wurzel, aufraeumen } = zeichne(conflictsView, project); |
| 171 | try { |
| 172 | const fenster = oeffneBeziehung(wurzel, project, beziehung(project)); |
| 173 | const hinweis = hinweisZu(fenster, 'Überfahrzeit'); |
| 174 | expect(hinweis).toContain('Angesetzt 4 s'); |
| 175 | // "Regelwert" seit Fassung 5.43.0 (Befund 17): Der feste |
| 176 | // Ansatz ist der der RiLSA 2015. Hier stand "ohne diesen Eintrag". |
| 177 | expect(hinweis).toContain(`Regelwert ${RILSA_DEFAULTS.crossingTime.kfzAbbiegend} s`); |
| 178 | expect(hinweis).not.toContain(`Regelwert ${RILSA_DEFAULTS.crossingTime.kfzGeradeaus} s`); |
| 179 | } finally { |
| 180 | aufraeumen(); |
| 181 | } |
| 182 | }); |
| 183 | |
| 184 | it('sagt im Kopf des Fensters, welcher Strom den Raeumweg faehrt', () => { |
| 185 | /* |
| 186 | * Sonst steht "K1 (Kfz, geradeaus) räumt" ueber einer Zeile mit 7 m/s, |
| 187 | * und nichts im Fenster erklaert den Unterschied. |
| 188 | */ |
| 189 | const project = sammelgruppe(); |
| 190 | const { wurzel, aufraeumen } = zeichne(conflictsView, project); |
| 191 | try { |
| 192 | const fenster = oeffneBeziehung(wurzel, project, beziehung(project)); |
| 193 | expect(fenstertext(fenster)).toContain( |
| 194 | 'Den Räumweg dieser Beziehung fährt ein Strom der Signalgruppe „K1“ mit der ' + |
| 195 | 'Fahrbeziehung „rechts abbiegend“; geräumt wird mit ihr.', |
| 196 | ); |
| 197 | } finally { |
| 198 | aufraeumen(); |
| 199 | } |
| 200 | }); |
| 201 | |
| 202 | it('setzt denselben Satz in den Kopf, den der Pruefbericht fuehrt', () => { |
| 203 | /* |
| 204 | * NACHTRAG (Fassung 5.11.0): Der Satz steht einmal (`raeumbeziehungSatz`, |
| 205 | * domain/rilsa/ansaetze.ts) und wird von Konfliktfenster und Pruefbericht |
| 206 | * geteilt. Zwei Fassungen desselben Sachverhalts liefen beim naechsten Wort |
| 207 | * auseinander - genau das war der Befund, aus dem diese Gruppe entstanden |
| 208 | * ist. |
| 209 | */ |
| 210 | const project = sammelgruppe(); |
| 211 | const konflikt = beziehung(project); |
| 212 | const from = project.signalGroups.find((g) => g.id === konflikt.fromId)!; |
| 213 | const satz = raeumbeziehungSatz(from, konflikt); |
| 214 | |
| 215 | expect(satz).not.toBe(''); |
| 216 | const { wurzel, aufraeumen } = zeichne(conflictsView, project); |
| 217 | try { |
| 218 | expect(fenstertext(oeffneBeziehung(wurzel, project, konflikt))).toContain(satz); |
| 219 | } finally { |
| 220 | aufraeumen(); |
| 221 | } |
| 222 | }); |
| 223 | |
| 224 | it('bleibt ohne eigene Fahrbeziehung beim Regelwert der Signalgruppe', () => { |
| 225 | // Gegenprobe: Ohne `raeumbeziehung` gilt die Fahrbeziehung der Gruppe. |
| 226 | const basis = sammelgruppe(); |
| 227 | const konflikt = beziehung(basis); |
| 228 | const project: Project = { |
| 229 | ...basis, |
| 230 | conflicts: basis.conflicts.map((c) => { |
| 231 | const { raeumbeziehung: _weg, ...ohne } = c; |
| 232 | return ohne; |
| 233 | }), |
| 234 | }; |
| 235 | const { wurzel, aufraeumen } = zeichne(conflictsView, project); |
| 236 | try { |
| 237 | const fenster = oeffneBeziehung(wurzel, project, konflikt); |
| 238 | expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('Regelwert 10 m/s'); |
| 239 | expect(fenstertext(fenster)).not.toContain('Den Räumweg dieser Beziehung fährt'); |
| 240 | } finally { |
| 241 | aufraeumen(); |
| 242 | } |
| 243 | }); |
| 244 | }); |