lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain schluesselAusKennungen.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { |
| 3 | leiteAb, |
| 4 | pruefeAufhebbarkeit, |
| 5 | uebernimmWege, |
| 6 | wegSchluessel, |
| 7 | type Ableitung, |
| 8 | type VertraeglichkeitVorschlag, |
| 9 | } from '@/domain/geometrie/ableitung'; |
| 10 | import type { Haltlinie, Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; |
| 11 | import { REGELBREITE } from '@/domain/geometrie/vermessung'; |
| 12 | import { createEmptyProject } from '@/domain/model/factory'; |
| 13 | import type { Project, SignalGroup } from '@/domain/model/project'; |
| 14 | |
| 15 | /* |
| 16 | * Folge aus Befund 61 (Fassung 5.10.0): zusammengesetzte Schluessel aus |
| 17 | * Signalgruppenkennungen in `ableitung.ts`. |
| 18 | * |
| 19 | * Eine Signalgruppenkennung kam ungeprueft aus der Projektdatei - das Einlesen |
| 20 | * beanstandete nur die leere und die doppelt vergebene Kennung, kein |
| 21 | * Zeichenrepertoire. Enthaelt eine Kennung ein "|", geht der zusammengeklebte |
| 22 | * Schluessel `${fromId}|${toId}` auf zwei Weisen kaputt: |
| 23 | * |
| 24 | * 1. Er ist MEHRDEUTIG. Die Paare ("a|b", "c") und ("a", "b|c") ergeben |
| 25 | * beide "a|b|c"; zwei sachlich verschiedene Beziehungen fallen auf |
| 26 | * denselben Eintrag, und eine von beiden verschwindet still. |
| 27 | * 2. Er ist NICHT UMKEHRBAR. `paar.split('|')` liefert fuer "sg|a|sg-b" die |
| 28 | * drei Stuecke "sg", "a", "sg-b"; uebernommen wurde die Beziehung |
| 29 | * "sg" -> "a" - zwei Kennungen, die es im Projekt gar nicht gibt. |
| 30 | * |
| 31 | * Beides trifft die gefaehrliche Richtung: Die gemeinte Beziehung bleibt ohne |
| 32 | * die vermessenen Wege stehen, ihre Zwischenzeit haengt weiter am alten (oder |
| 33 | * am Vorgabe-)Raeumweg, und der Anwender sieht im Lageplan eine Uebernahme, |
| 34 | * die woanders gelandet ist. |
| 35 | * |
| 36 | * WELCHE STELLEN ES HEUTE SIND (nachgezaehlt mit der Fassung 5.12.0; zuvor |
| 37 | * stand hier "die beiden verbliebenen"). `ableitung.ts` bildet an DREI Stellen |
| 38 | * einen Schluessel aus Kennungen: |
| 39 | * |
| 40 | * 1. die Verdichtung auf Signalgruppenpaare in `uebernimmWege` - seit |
| 41 | * Befund 61 verschachtelt gefuehrt, wie `parseConflicts`; |
| 42 | * 2. der ungerichtete Schluessel der Gruppenpaare in `pruefeAufhebbarkeit` - |
| 43 | * ebenso verschachtelt; |
| 44 | * 3. `wegSchluessel`, das Auswahlmerkmal eines Strompaares aus zwei |
| 45 | * PLANLINIENKENNUNGEN - seit Fassung 5.11.0 ueber |
| 46 | * `intergreenKey` maskiert statt mit "->" zusammengeklebt. |
| 47 | * |
| 48 | * Die dritte Stelle traegt jeder Fall dieser Datei mit: `wegeAufDieFurt` waehlt |
| 49 | * die zu uebernehmenden Wege ueber `wegSchluessel` aus. Eine eigene Wache dazu |
| 50 | * steht in `wegeschluessel.test.ts`. |
| 51 | * |
| 52 | * DIE QUELLE IST SEIT 5.11.0 GESCHLOSSEN: Das Einlesen prueft die Kennungen |
| 53 | * jetzt gegen ein Zeichenrepertoire (`ZULAESSIGE_KENNUNG` in |
| 54 | * src/domain/model/schema.ts) und ersetzt, was nicht hineinpasst. Eine Kennung |
| 55 | * mit "|" kann also nicht mehr aus einer Datei kommen. Die Faelle hier bleiben |
| 56 | * trotzdem: Sie bewachen die Schluesselbildung selbst - die zweite Reihe, und |
| 57 | * die einzige, die noch traegt, wenn das Repertoire eines Tages weiter gefasst |
| 58 | * wird. Sie bauen ihre Projekte deshalb unmittelbar auf, nicht ueber das |
| 59 | * Einlesen. |
| 60 | * |
| 61 | * GEGEN DEN ALTSTAND schlagen die drei Faelle mit "|" in der Kennung fehl; die |
| 62 | * beiden Gegenproben mit gewoehnlichen Kennungen bestehen dort ebenso. |
| 63 | */ |
| 64 | |
| 65 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 66 | |
| 67 | /** 1 Bildpunkt = 1 m, damit sich die Masse unmittelbar ablesen lassen. */ |
| 68 | const KALIBRIERUNG = { |
| 69 | von: { x: 0, y: 0 }, |
| 70 | bis: { x: 100, y: 0 }, |
| 71 | laengeMeter: 100, |
| 72 | herkunft: 'gemessen', |
| 73 | } as const; |
| 74 | |
| 75 | /** Zufahrt und Furt eines Kreuzungspaares, um `versatz` Meter nach Osten geschoben. */ |
| 76 | function haltlinienPaar(nummer: number, versatz: number): Haltlinie[] { |
| 77 | return [ |
| 78 | { |
| 79 | id: `hl-zufahrt-${nummer}`, |
| 80 | name: `Zufahrt ${nummer}`, |
| 81 | art: 'zufahrt', |
| 82 | armId: `arm-${nummer}`, |
| 83 | von: { x: versatz, y: 0 }, |
| 84 | bis: { x: versatz + 20, y: 0 }, |
| 85 | }, |
| 86 | { |
| 87 | id: `hl-furt-${nummer}`, |
| 88 | name: `Furt ${nummer}`, |
| 89 | art: 'querung', |
| 90 | armId: `arm-${nummer}`, |
| 91 | von: { x: versatz, y: 30 }, |
| 92 | bis: { x: versatz + 30, y: 30 }, |
| 93 | }, |
| 94 | ]; |
| 95 | } |
| 96 | |
| 97 | /** Geradeausstrom und kreuzende Furt; sie schneiden sich bei (versatz+10 | 30). */ |
| 98 | function linienPaar( |
| 99 | nummer: number, |
| 100 | versatz: number, |
| 101 | gruppeKfz: string, |
| 102 | gruppeFuss: string, |
| 103 | ): Planlinie[] { |
| 104 | return [ |
| 105 | { |
| 106 | id: `l-gerade-${nummer}`, |
| 107 | name: `L${nummer} geradeaus`, |
| 108 | mode: 'kfz', |
| 109 | movement: 'geradeaus', |
| 110 | breiteMeter: REGELBREITE.kfz, |
| 111 | punkte: [ |
| 112 | { x: versatz + 10, y: 0 }, |
| 113 | { x: versatz + 10, y: 60 }, |
| 114 | ], |
| 115 | haltlinieId: `hl-zufahrt-${nummer}`, |
| 116 | startT: 0.5, |
| 117 | signalGroupId: gruppeKfz, |
| 118 | }, |
| 119 | { |
| 120 | id: `l-furt-${nummer}`, |
| 121 | name: `L${nummer} Furt`, |
| 122 | mode: 'fuss', |
| 123 | movement: 'querung', |
| 124 | breiteMeter: REGELBREITE.fuss, |
| 125 | punkte: [ |
| 126 | { x: versatz, y: 30 }, |
| 127 | { x: versatz + 30, y: 30 }, |
| 128 | ], |
| 129 | haltlinieId: `hl-furt-${nummer}`, |
| 130 | startT: 0, |
| 131 | signalGroupId: gruppeFuss, |
| 132 | }, |
| 133 | ]; |
| 134 | } |
| 135 | |
| 136 | const gruppe = (id: string, patch: Partial<SignalGroup> = {}): SignalGroup => ({ |
| 137 | id, |
| 138 | name: id, |
| 139 | mode: 'kfz', |
| 140 | movement: 'geradeaus', |
| 141 | armId: null, |
| 142 | vZul: 50, |
| 143 | lanes: 1, |
| 144 | vehicleClass: 'pkw', |
| 145 | minGreenOverride: null, |
| 146 | maxGreenOverride: null, |
| 147 | reducedMobility: false, |
| 148 | color: '#2f6fd0', |
| 149 | ...patch, |
| 150 | }); |
| 151 | |
| 152 | const furtgruppe = (id: string): SignalGroup => |
| 153 | gruppe(id, { mode: 'fuss', movement: 'querung', vehicleClass: 'keine' }); |
| 154 | |
| 155 | /** |
| 156 | * Projekt und Lageplan mit `paare` Kreuzungen; je Kreuzung eine Kfz- und eine |
| 157 | * Fussgaengergruppe mit den uebergebenen Kennungen. |
| 158 | */ |
| 159 | function aufbau(paare: readonly (readonly [string, string])[]): { |
| 160 | project: Project; |
| 161 | lageplan: Lageplan; |
| 162 | } { |
| 163 | const haltlinien = paare.flatMap((_, i) => haltlinienPaar(i + 1, i * 100)); |
| 164 | const linien = paare.flatMap(([kfz, fuss], i) => linienPaar(i + 1, i * 100, kfz, fuss)); |
| 165 | const lageplan: Lageplan = { |
| 166 | bild: null, |
| 167 | kalibrierung: KALIBRIERUNG, |
| 168 | haltlinien, |
| 169 | linien, |
| 170 | arbeitsbereiche: [], |
| 171 | signalgeber: [], |
| 172 | }; |
| 173 | const leer = createEmptyProject('Kennungen mit Strich', DATUM); |
| 174 | return { |
| 175 | project: { |
| 176 | ...leer, |
| 177 | signalGroups: paare.flatMap(([kfz, fuss]) => [gruppe(kfz), furtgruppe(fuss)]), |
| 178 | lageplan, |
| 179 | }, |
| 180 | lageplan, |
| 181 | }; |
| 182 | } |
| 183 | |
| 184 | /** Die Wege der Geradeausstroeme auf ihre jeweilige Furt - je Kreuzung einer. */ |
| 185 | function wegeAufDieFurt(project: Project, lageplan: Lageplan, ableitung: Ableitung): string[] { |
| 186 | const gewaehlt = ableitung.wege |
| 187 | .filter((w) => w.vonLinieId.startsWith('l-gerade-') && w.nachLinieId.startsWith('l-furt-')) |
| 188 | .map((w) => wegSchluessel(w)); |
| 189 | // Vorbedingung jedes Falls: Ohne Wege prueft nichts darunter etwas. |
| 190 | expect(gewaehlt.length, 'die Ströme kreuzen sich im Plan nicht').toBeGreaterThan(0); |
| 191 | expect(project.signalGroups.length).toBeGreaterThan(0); |
| 192 | expect(lageplan.linien.length).toBe(project.signalGroups.length); |
| 193 | return gewaehlt; |
| 194 | } |
| 195 | |
| 196 | describe('R1.2 - Wegeuebernahme aus dem Lageplan bei "|" in der Signalgruppenkennung', () => { |
| 197 | it('schreibt die Beziehung der wirklich gemeinten Signalgruppen', () => { |
| 198 | // Eine einzige Kreuzung genuegt: Der Schluessel "sg|a|sg-b" laesst sich |
| 199 | // nicht mehr zerlegen, und uebernommen wurde "sg" -> "a". |
| 200 | const { project, lageplan } = aufbau([['sg|a', 'sg-b']]); |
| 201 | const ableitung = leiteAb(project, lageplan); |
| 202 | const ergebnis = uebernimmWege( |
| 203 | project, |
| 204 | lageplan, |
| 205 | ableitung, |
| 206 | wegeAufDieFurt(project, lageplan, ableitung), |
| 207 | ); |
| 208 | |
| 209 | expect(ergebnis.uebernommen).toBe(1); |
| 210 | expect(ergebnis.project.conflicts).toHaveLength(1); |
| 211 | const geschrieben = ergebnis.project.conflicts[0]!; |
| 212 | expect(geschrieben.fromId).toBe('sg|a'); |
| 213 | expect(geschrieben.toId).toBe('sg-b'); |
| 214 | expect(geschrieben.clearingDistance).toBeGreaterThan(0); |
| 215 | // Und keine Beziehung zwischen Kennungen, die es im Projekt nicht gibt. |
| 216 | const kennungen = new Set(project.signalGroups.map((g) => g.id)); |
| 217 | for (const c of ergebnis.project.conflicts) { |
| 218 | expect(kennungen.has(c.fromId), `fromId "${c.fromId}"`).toBe(true); |
| 219 | expect(kennungen.has(c.toId), `toId "${c.toId}"`).toBe(true); |
| 220 | } |
| 221 | }); |
| 222 | |
| 223 | it('haelt zwei Gruppenpaare auseinander, die denselben zusammengeklebten Schluessel ergaeben', () => { |
| 224 | // ("a|b", "c") und ("a", "b|c") ergeben beide "a|b|c". |
| 225 | const { project, lageplan } = aufbau([ |
| 226 | ['a|b', 'c'], |
| 227 | ['a', 'b|c'], |
| 228 | ]); |
| 229 | const ableitung = leiteAb(project, lageplan); |
| 230 | const ergebnis = uebernimmWege( |
| 231 | project, |
| 232 | lageplan, |
| 233 | ableitung, |
| 234 | wegeAufDieFurt(project, lageplan, ableitung), |
| 235 | ); |
| 236 | |
| 237 | expect(ergebnis.uebernommen).toBe(2); |
| 238 | const paare = ergebnis.project.conflicts.map((c) => `${c.fromId} -> ${c.toId}`).sort(); |
| 239 | expect(paare).toEqual(['a -> b|c', 'a|b -> c']); |
| 240 | }); |
| 241 | |
| 242 | it('uebernimmt bei gewoehnlichen Kennungen unveraendert', () => { |
| 243 | // Gegenprobe: Kennungen aus createId (a-z, 0-9, "-") sind von der |
| 244 | // Umstellung nicht betroffen. |
| 245 | const { project, lageplan } = aufbau([ |
| 246 | ['sg-k1', 'sg-f1'], |
| 247 | ['sg-k2', 'sg-f2'], |
| 248 | ]); |
| 249 | const ableitung = leiteAb(project, lageplan); |
| 250 | const ergebnis = uebernimmWege( |
| 251 | project, |
| 252 | lageplan, |
| 253 | ableitung, |
| 254 | wegeAufDieFurt(project, lageplan, ableitung), |
| 255 | ); |
| 256 | |
| 257 | expect(ergebnis.uebernommen).toBe(2); |
| 258 | const paare = ergebnis.project.conflicts.map((c) => `${c.fromId} -> ${c.toId}`).sort(); |
| 259 | expect(paare).toEqual(['sg-k1 -> sg-f1', 'sg-k2 -> sg-f2']); |
| 260 | }); |
| 261 | }); |
| 262 | |
| 263 | /** |
| 264 | * Ein vertraegliches, im Projekt erfasstes Strompaar - der Fall, in dem |
| 265 | * `pruefeAufhebbarkeit` etwas zu melden hat. |
| 266 | */ |
| 267 | function vertraeglich(aLinieId: string, bLinieId: string): VertraeglichkeitVorschlag { |
| 268 | return { |
| 269 | aLinieId, |
| 270 | bLinieId, |
| 271 | aName: aLinieId, |
| 272 | bName: bLinieId, |
| 273 | feindlich: false, |
| 274 | zuPruefen: false, |
| 275 | grund: 'kein-schnitt', |
| 276 | begruendung: 'Die Fahrlinien schneiden sich nicht.', |
| 277 | imProjekt: true, |
| 278 | abweichung: true, |
| 279 | }; |
| 280 | } |
| 281 | |
| 282 | /** Nur `vertraeglichkeit` wird von pruefeAufhebbarkeit gelesen. */ |
| 283 | function nurVertraeglichkeit(paare: readonly VertraeglichkeitVorschlag[]): Ableitung { |
| 284 | return { |
| 285 | auswertbar: true, |
| 286 | signalgruppen: [], |
| 287 | vertraeglichkeit: paare, |
| 288 | wege: [], |
| 289 | unterdrueckt: [], |
| 290 | hinweise: [], |
| 291 | }; |
| 292 | } |
| 293 | |
| 294 | describe('R1.2 - Aufhebbarkeit bei "|" in der Signalgruppenkennung', () => { |
| 295 | it('beurteilt zwei Gruppenpaare mit demselben zusammengeklebten Schluessel getrennt', () => { |
| 296 | const { lageplan } = aufbau([ |
| 297 | ['a|b', 'c'], |
| 298 | ['a', 'b|c'], |
| 299 | ]); |
| 300 | const ergebnis = pruefeAufhebbarkeit( |
| 301 | lageplan, |
| 302 | nurVertraeglichkeit([ |
| 303 | vertraeglich('l-gerade-1', 'l-furt-1'), |
| 304 | vertraeglich('l-gerade-2', 'l-furt-2'), |
| 305 | ]), |
| 306 | ); |
| 307 | |
| 308 | expect(ergebnis.gesperrt).toHaveLength(0); |
| 309 | const paare = ergebnis.aufhebbar.map((p) => `${p.ersteId} + ${p.zweiteId}`).sort(); |
| 310 | expect(paare).toEqual(['a + b|c', 'a|b + c']); |
| 311 | for (const eintrag of ergebnis.aufhebbar) { |
| 312 | expect(eintrag.paare, `${eintrag.ersteId} + ${eintrag.zweiteId}`).toHaveLength(1); |
| 313 | } |
| 314 | }); |
| 315 | |
| 316 | it('laesst ein feindliches Strompaar nur das eigene Gruppenpaar sperren', () => { |
| 317 | // Der zweite Vorschlag ist feindlich. Fielen beide auf denselben |
| 318 | // Schluessel, riss er das erste, vertraegliche Gruppenpaar mit in die |
| 319 | // Sperre - mit einer Begruendung, die zu ganz anderen Signalgruppen gehoert. |
| 320 | const { lageplan } = aufbau([ |
| 321 | ['a|b', 'c'], |
| 322 | ['a', 'b|c'], |
| 323 | ]); |
| 324 | const feindlichesPaar: VertraeglichkeitVorschlag = { |
| 325 | ...vertraeglich('l-gerade-2', 'l-furt-2'), |
| 326 | feindlich: true, |
| 327 | grund: 'kreuzung', |
| 328 | begruendung: 'Die Fahrlinien kreuzen sich.', |
| 329 | abweichung: false, |
| 330 | }; |
| 331 | const ergebnis = pruefeAufhebbarkeit( |
| 332 | lageplan, |
| 333 | nurVertraeglichkeit([vertraeglich('l-gerade-1', 'l-furt-1'), feindlichesPaar]), |
| 334 | ); |
| 335 | |
| 336 | expect(ergebnis.gesperrt).toHaveLength(0); |
| 337 | expect(ergebnis.aufhebbar).toHaveLength(1); |
| 338 | expect(ergebnis.aufhebbar[0]!.ersteId).toBe('a|b'); |
| 339 | expect(ergebnis.aufhebbar[0]!.zweiteId).toBe('c'); |
| 340 | }); |
| 341 | |
| 342 | it('fasst bei gewoehnlichen Kennungen weiter je Gruppenpaar zusammen', () => { |
| 343 | // Gegenprobe: Die Verdichtung auf Signalgruppenpaare bleibt - beide |
| 344 | // Strompaare derselben zwei Gruppen ergeben einen Eintrag. |
| 345 | const { lageplan } = aufbau([ |
| 346 | ['sg-k1', 'sg-f1'], |
| 347 | ['sg-k2', 'sg-f2'], |
| 348 | ]); |
| 349 | const ergebnis = pruefeAufhebbarkeit( |
| 350 | lageplan, |
| 351 | nurVertraeglichkeit([ |
| 352 | vertraeglich('l-gerade-1', 'l-furt-1'), |
| 353 | vertraeglich('l-furt-1', 'l-gerade-1'), |
| 354 | vertraeglich('l-gerade-2', 'l-furt-2'), |
| 355 | ]), |
| 356 | ); |
| 357 | |
| 358 | expect(ergebnis.aufhebbar).toHaveLength(2); |
| 359 | const nachPaar = new Map( |
| 360 | ergebnis.aufhebbar.map((p) => [`${p.ersteId} + ${p.zweiteId}`, p.paare.length]), |
| 361 | ); |
| 362 | expect(nachPaar.get('sg-f1 + sg-k1')).toBe(2); |
| 363 | expect(nachPaar.get('sg-f2 + sg-k2')).toBe(1); |
| 364 | }); |
| 365 | }); |