lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui vertraeglichkeit.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import type { Haltlinie, Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; |
| 4 | import { REGELBREITE } from '@/domain/geometrie/vermessung'; |
| 5 | import { createEmptyProject } from '@/domain/model/factory'; |
| 6 | import { |
| 7 | WEGNACHWEIS_UNBESTIMMT, |
| 8 | type Conflict, |
| 9 | type Project, |
| 10 | type SignalGroup, |
| 11 | } from '@/domain/model/project'; |
| 12 | import { lageplanView } from '@/ui/views/lageplanView'; |
| 13 | |
| 14 | /** |
| 15 | * Folge aus Befund 1: Die Ansicht zaehlte und benannte auf |
| 16 | * Strompaar-Ebene, waehrend das Aufheben auf Signalgruppenpaar-Ebene |
| 17 | * entscheidet. |
| 18 | * |
| 19 | * WAS DER ANWENDER SONST ERLEBT |
| 20 | * |
| 21 | * Signalgruppe A schaltet geradeaus UND rechts aus derselben Zufahrt - der |
| 22 | * Regelfall eines Signalgebers. Rechts kreuzt die Furt der Gruppe B, geradeaus |
| 23 | * nicht. Das Aufheben laesst die Beziehung A/B deshalb zu Recht stehen: Zwischen |
| 24 | * Stroemen einer Gruppe gibt es keine Zwischenzeit, der Rechtsabbieger verloere |
| 25 | * seine Sicherung. |
| 26 | * |
| 27 | * Der Warnbalken zaehlte trotzdem "1 erfasste Konflikte kreuzen sich im Plan |
| 28 | * nicht", der Bestaetigungstext nannte "geradeaus und Furt", und danach meldete |
| 29 | * das Programm "0 Konflikt(e) aufgehoben" - ohne Grund. Der Balken blieb stehen |
| 30 | * und liess sich ueber diesen Weg nie abtragen. |
| 31 | * |
| 32 | * Beide Seiten benutzen jetzt dieselbe Verdichtung aus `ableitung.ts`. Was |
| 33 | * nicht aufhebbar ist, steht mit seinem Grund in einem eigenen Abschnitt. |
| 34 | */ |
| 35 | |
| 36 | const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); |
| 37 | |
| 38 | /** 1 Bildpunkt = 1 m, damit sich die Masse unmittelbar ablesen lassen. */ |
| 39 | const KALIBRIERUNG = { |
| 40 | von: { x: 0, y: 0 }, |
| 41 | bis: { x: 100, y: 0 }, |
| 42 | laengeMeter: 100, |
| 43 | herkunft: 'gemessen', |
| 44 | } as const; |
| 45 | |
| 46 | const HL_NORD = 'hl-nord'; |
| 47 | const HL_FURT = 'hl-furt'; |
| 48 | const HL_WEST = 'hl-west'; |
| 49 | |
| 50 | const HALTLINIEN: readonly Haltlinie[] = [ |
| 51 | { |
| 52 | id: HL_NORD, |
| 53 | name: 'Zufahrt Nord', |
| 54 | art: 'zufahrt', |
| 55 | armId: 'arm-nord', |
| 56 | von: { x: 0, y: 0 }, |
| 57 | bis: { x: 70, y: 0 }, |
| 58 | }, |
| 59 | { |
| 60 | id: HL_FURT, |
| 61 | name: 'Querung Ost', |
| 62 | art: 'querung', |
| 63 | armId: 'arm-ost', |
| 64 | von: { x: 60, y: 5 }, |
| 65 | bis: { x: 60, y: 30 }, |
| 66 | }, |
| 67 | { |
| 68 | id: HL_WEST, |
| 69 | name: 'Zufahrt West', |
| 70 | art: 'zufahrt', |
| 71 | armId: 'arm-west', |
| 72 | von: { x: 200, y: 0 }, |
| 73 | bis: { x: 240, y: 0 }, |
| 74 | }, |
| 75 | ]; |
| 76 | |
| 77 | const linie = ( |
| 78 | id: string, |
| 79 | name: string, |
| 80 | punkte: readonly { x: number; y: number }[], |
| 81 | patch: Partial<Planlinie> = {}, |
| 82 | ): Planlinie => ({ |
| 83 | id, |
| 84 | name, |
| 85 | mode: 'kfz', |
| 86 | movement: 'geradeaus', |
| 87 | breiteMeter: REGELBREITE.kfz, |
| 88 | punkte, |
| 89 | haltlinieId: HL_NORD, |
| 90 | startT: 0.5, |
| 91 | signalGroupId: null, |
| 92 | ...patch, |
| 93 | }); |
| 94 | |
| 95 | /** Geradeaus aus der Nordzufahrt - kreuzt die Querung im Osten nicht. */ |
| 96 | const GERADEAUS = linie( |
| 97 | 'l-gerade', |
| 98 | 'Nord geradeaus', |
| 99 | [ |
| 100 | { x: 35, y: 0 }, |
| 101 | { x: 35, y: 40 }, |
| 102 | ], |
| 103 | { signalGroupId: 'sg-a' }, |
| 104 | ); |
| 105 | |
| 106 | /** Rechtsabbieger aus DERSELBEN Zufahrt und DERSELBEN Gruppe - kreuzt die Querung. */ |
| 107 | const RECHTS = linie( |
| 108 | 'l-rechts', |
| 109 | 'Nord rechts', |
| 110 | [ |
| 111 | { x: 35, y: 0 }, |
| 112 | { x: 38, y: 12 }, |
| 113 | { x: 70, y: 16 }, |
| 114 | ], |
| 115 | { movement: 'rechts', signalGroupId: 'sg-a' }, |
| 116 | ); |
| 117 | |
| 118 | /** Fussgaengerfurt ueber den Ausfahrtarm Ost. */ |
| 119 | const FURT = linie( |
| 120 | 'l-furt', |
| 121 | 'Querung Ost', |
| 122 | [ |
| 123 | { x: 60, y: 5 }, |
| 124 | { x: 60, y: 30 }, |
| 125 | ], |
| 126 | { |
| 127 | mode: 'fuss', |
| 128 | movement: 'querung', |
| 129 | breiteMeter: REGELBREITE.fuss, |
| 130 | haltlinieId: HL_FURT, |
| 131 | signalGroupId: 'sg-b', |
| 132 | }, |
| 133 | ); |
| 134 | |
| 135 | /** |
| 136 | * Ein Strom weit ab vom Knotenpunkt, allein in seiner Gruppe. |
| 137 | * |
| 138 | * Er ist die Gegenprobe: Sein Gruppenpaar mit der Querung ist wirklich |
| 139 | * aufhebbar, und nur er darf gezaehlt und im Bestaetigungstext genannt werden. |
| 140 | */ |
| 141 | const WEST = linie( |
| 142 | 'l-west', |
| 143 | 'West geradeaus', |
| 144 | [ |
| 145 | { x: 220, y: 0 }, |
| 146 | { x: 220, y: 40 }, |
| 147 | ], |
| 148 | { haltlinieId: HL_WEST, signalGroupId: 'sg-c' }, |
| 149 | ); |
| 150 | |
| 151 | function plan(linien: readonly Planlinie[]): Lageplan { |
| 152 | return { |
| 153 | bild: null, |
| 154 | kalibrierung: KALIBRIERUNG, |
| 155 | haltlinien: HALTLINIEN, |
| 156 | linien, |
| 157 | arbeitsbereiche: [], |
| 158 | signalgeber: [], |
| 159 | }; |
| 160 | } |
| 161 | |
| 162 | const gruppe = (id: string, patch: Partial<SignalGroup> = {}): SignalGroup => ({ |
| 163 | id, |
| 164 | name: id, |
| 165 | mode: 'kfz', |
| 166 | movement: 'geradeaus', |
| 167 | armId: null, |
| 168 | vZul: 50, |
| 169 | lanes: 1, |
| 170 | vehicleClass: 'pkw', |
| 171 | minGreenOverride: null, |
| 172 | maxGreenOverride: null, |
| 173 | reducedMobility: false, |
| 174 | color: '#2f6fd0', |
| 175 | ...patch, |
| 176 | }); |
| 177 | |
| 178 | const konflikt = (id: string, fromId: string, toId: string): Conflict => ({ |
| 179 | id, |
| 180 | fromId, |
| 181 | toId, |
| 182 | clearingDistance: 25, |
| 183 | enteringDistance: 5, |
| 184 | clearingSpeedOverride: null, |
| 185 | enteringSpeedOverride: null, |
| 186 | crossingTimeOverride: null, |
| 187 | manualIntergreen: null, |
| 188 | wegnachweis: WEGNACHWEIS_UNBESTIMMT, |
| 189 | note: '', |
| 190 | }); |
| 191 | |
| 192 | function projekt(lageplan: Lageplan, conflicts: readonly Conflict[]): Project { |
| 193 | const leer = createEmptyProject('Verdichtung', FIXED_DATE); |
| 194 | return { |
| 195 | ...leer, |
| 196 | signalGroups: [ |
| 197 | gruppe('sg-a'), |
| 198 | gruppe('sg-b', { mode: 'fuss', movement: 'querung', vehicleClass: 'keine' }), |
| 199 | gruppe('sg-c'), |
| 200 | ], |
| 201 | conflicts: [...conflicts], |
| 202 | lageplan, |
| 203 | }; |
| 204 | } |
| 205 | |
| 206 | interface Ansicht { |
| 207 | readonly store: ProjectStore; |
| 208 | readonly wurzel: HTMLElement; |
| 209 | readonly aufraeumen: () => void; |
| 210 | } |
| 211 | |
| 212 | function baueAnsicht(project: Project): Ansicht { |
| 213 | const store = new ProjectStore(project); |
| 214 | const wurzel = document.createElement('div'); |
| 215 | document.body.append(wurzel); |
| 216 | const schliessen = lageplanView.render(wurzel, { |
| 217 | store, |
| 218 | refresh: () => {}, |
| 219 | navigate: () => {}, |
| 220 | target: null, |
| 221 | }); |
| 222 | |
| 223 | return { |
| 224 | store, |
| 225 | wurzel, |
| 226 | aufraeumen: () => { |
| 227 | schliessen(); |
| 228 | wurzel.remove(); |
| 229 | }, |
| 230 | }; |
| 231 | } |
| 232 | |
| 233 | afterEach(() => { |
| 234 | document.body.replaceChildren(); |
| 235 | }); |
| 236 | |
| 237 | /** Laesst die angestossenen Zusagen der Reihe nach ablaufen. */ |
| 238 | async function ruhe(): Promise<void> { |
| 239 | for (let i = 0; i < 6; i += 1) { |
| 240 | await new Promise((erfuellen) => setTimeout(erfuellen, 0)); |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | function knopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement | undefined { |
| 245 | return [...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('button')].find( |
| 246 | (b) => (b.textContent ?? '').trim() === beschriftung, |
| 247 | ); |
| 248 | } |
| 249 | |
| 250 | function text(ansicht: Ansicht): string { |
| 251 | return ansicht.wurzel.textContent ?? ''; |
| 252 | } |
| 253 | |
| 254 | function dialogtext(): string { |
| 255 | return document.querySelector('.dialog .dialog-text')?.textContent ?? ''; |
| 256 | } |
| 257 | |
| 258 | function knopfImFenster(beschriftung: string): HTMLButtonElement { |
| 259 | const fenster = document.querySelector<HTMLElement>('.dialog'); |
| 260 | if (fenster === null) throw new Error('Es steht kein Fenster offen.'); |
| 261 | const treffer = [...fenster.querySelectorAll<HTMLButtonElement>('button')].find( |
| 262 | (b) => (b.textContent ?? '').trim() === beschriftung, |
| 263 | ); |
| 264 | if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}" im Fenster`); |
| 265 | return treffer; |
| 266 | } |
| 267 | |
| 268 | function letzteWarnung(): string { |
| 269 | const meldungen = [...document.querySelectorAll('.meldung-warnung')]; |
| 270 | return meldungen[meldungen.length - 1]?.textContent ?? ''; |
| 271 | } |
| 272 | |
| 273 | describe('Ableitung: Warnbalken und Bestätigungstext auf Signalgruppenpaar-Ebene', () => { |
| 274 | it('zeigt keinen Warnbalken, wenn ein anderer Strom derselben Gruppen kreuzt', () => { |
| 275 | const lageplan = plan([GERADEAUS, RECHTS, FURT]); |
| 276 | const ansicht = baueAnsicht( |
| 277 | projekt(lageplan, [konflikt('k-hin', 'sg-a', 'sg-b'), konflikt('k-rueck', 'sg-b', 'sg-a')]), |
| 278 | ); |
| 279 | try { |
| 280 | // Das Aufheben laesst die Beziehung stehen. Ein Balken, der ein Aufheben |
| 281 | // ankuendigt, das nicht stattfindet, ist die Aufforderung zu einem |
| 282 | // Bedienweg, der immer bei "0 Konflikt(e) aufgehoben" endet. |
| 283 | expect(text(ansicht), 'der Warnbalken zählt noch Strompaare').not.toContain( |
| 284 | 'kreuzen sich im Plan nicht', |
| 285 | ); |
| 286 | expect(knopf(ansicht, 'Konflikte prüfen und aufheben')).toBeUndefined(); |
| 287 | } finally { |
| 288 | ansicht.aufraeumen(); |
| 289 | } |
| 290 | }); |
| 291 | |
| 292 | it('nennt den Grund, warum die verträgliche Beziehung trotzdem bestehen bleibt', () => { |
| 293 | const lageplan = plan([GERADEAUS, RECHTS, FURT]); |
| 294 | const ansicht = baueAnsicht( |
| 295 | projekt(lageplan, [konflikt('k-hin', 'sg-a', 'sg-b'), konflikt('k-rueck', 'sg-b', 'sg-a')]), |
| 296 | ); |
| 297 | try { |
| 298 | const gelesen = text(ansicht); |
| 299 | expect(gelesen).toContain('Nord geradeaus und Querung Ost'); |
| 300 | expect(gelesen).toContain('ein anderer Strom derselben Signalgruppen kreuzt'); |
| 301 | } finally { |
| 302 | ansicht.aufraeumen(); |
| 303 | } |
| 304 | }); |
| 305 | |
| 306 | it('zählt und nennt nur das Gruppenpaar, das wirklich aufgehoben wird', async () => { |
| 307 | const lageplan = plan([GERADEAUS, RECHTS, FURT, WEST]); |
| 308 | const ansicht = baueAnsicht( |
| 309 | projekt(lageplan, [ |
| 310 | konflikt('k-hin', 'sg-a', 'sg-b'), |
| 311 | konflikt('k-rueck', 'sg-b', 'sg-a'), |
| 312 | konflikt('k-west', 'sg-c', 'sg-b'), |
| 313 | ]), |
| 314 | ); |
| 315 | try { |
| 316 | expect(text(ansicht)).toContain('1 erfasste Konflikte kreuzen sich im Plan nicht'); |
| 317 | |
| 318 | const aufheben = knopf(ansicht, 'Konflikte prüfen und aufheben'); |
| 319 | expect(aufheben, 'ohne Knopf lässt sich der Balken nicht abtragen').toBeDefined(); |
| 320 | aufheben?.click(); |
| 321 | await ruhe(); |
| 322 | |
| 323 | const gefragt = dialogtext(); |
| 324 | expect(gefragt).toContain('Querung Ost und West geradeaus'); |
| 325 | // Der Kern der Aenderung: Das Paar, das nicht aufgehoben wird, darf im |
| 326 | // Bestaetigungstext nicht stehen - er kuendigt sonst etwas an, was nicht |
| 327 | // geschieht. |
| 328 | expect(gefragt, 'der Text nennt ein Paar, das bestehen bleibt').not.toContain( |
| 329 | 'Nord geradeaus', |
| 330 | ); |
| 331 | |
| 332 | knopfImFenster('Aufheben').click(); |
| 333 | await ruhe(); |
| 334 | |
| 335 | expect(letzteWarnung()).toContain('1 Konflikt(e) aufgehoben'); |
| 336 | expect( |
| 337 | ansicht.store |
| 338 | .getProject() |
| 339 | .conflicts.map((c) => c.id) |
| 340 | .sort(), |
| 341 | ).toEqual(['k-hin', 'k-rueck']); |
| 342 | // Der Balken ist abgetragen - genau das ging ueber diesen Weg nie. |
| 343 | expect(text(ansicht)).not.toContain('kreuzen sich im Plan nicht'); |
| 344 | } finally { |
| 345 | ansicht.aufraeumen(); |
| 346 | } |
| 347 | }); |
| 348 | }); |