lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain wegnachweis.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import * as actions from '@/app/actions'; |
| 3 | import { |
| 4 | leiteAb, |
| 5 | uebernimmSignalgruppen, |
| 6 | uebernimmWege, |
| 7 | wegSchluessel, |
| 8 | } from '@/domain/geometrie/ableitung'; |
| 9 | import { REGELBREITE } from '@/domain/geometrie/vermessung'; |
| 10 | import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; |
| 11 | import { |
| 12 | createConflict, |
| 13 | createEmptyProject, |
| 14 | createStandardIntersectionProject, |
| 15 | } from '@/domain/model/factory'; |
| 16 | import { loadProject } from '@/domain/model/migrate'; |
| 17 | import { parseProject } from '@/domain/model/schema'; |
| 18 | import { CURRENT_SCHEMA_VERSION, type Conflict, type Project } from '@/domain/model/project'; |
| 19 | |
| 20 | /** |
| 21 | * Herkunftsnachweis der Raeum- und Einfahrwege. |
| 22 | * |
| 23 | * DER FEHLER, GEGEN DEN DIESE TESTS STEHEN: Zuvor lag der Herkunftsvermerk im |
| 24 | * freien Bemerkungsfeld. `uebernimmWege` schrieb dort "Aus dem Lageplan |
| 25 | * vermessen", und der Satz blieb wortgleich stehen, wenn der Raeumweg danach |
| 26 | * von Hand verlaengert wurde. Der Ausdruck behauptete damit in einer Unterlage |
| 27 | * fuer ein verkehrsbehoerdliches Verfahren eine Vermessung, die es fuer diese |
| 28 | * Zahl nie gegeben hat. |
| 29 | * |
| 30 | * Der Nachweis ist nur so viel wert wie seine unguenstigste Stelle: Wird eine |
| 31 | * einzige Schreibstelle vergessen, behauptet der Ausdruck wieder etwas |
| 32 | * Falsches - und das ist schlimmer als gar kein Nachweis, weil man sich auf ihn |
| 33 | * verlaesst. Deshalb prueft der Abschnitt "Zuruecksetzen" jedes Feld einzeln. |
| 34 | */ |
| 35 | |
| 36 | const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); |
| 37 | const UEBERNAHME = new Date('2026-08-14T09:30:00Z'); |
| 38 | |
| 39 | /** Zwei sich kreuzende Fahrlinien; 1 Bildpunkt = 0,5 m. */ |
| 40 | function lageplanMitKreuzung(): Lageplan { |
| 41 | const linie = ( |
| 42 | id: string, |
| 43 | name: string, |
| 44 | punkte: readonly { x: number; y: number }[], |
| 45 | ): Planlinie => ({ |
| 46 | id, |
| 47 | name, |
| 48 | mode: 'kfz', |
| 49 | movement: 'geradeaus', |
| 50 | breiteMeter: REGELBREITE.kfz, |
| 51 | punkte, |
| 52 | haltlinieId: null, |
| 53 | startT: 0.5, |
| 54 | signalGroupId: null, |
| 55 | }); |
| 56 | |
| 57 | return { |
| 58 | arbeitsbereiche: [], |
| 59 | signalgeber: [], |
| 60 | haltlinien: [], |
| 61 | bild: { |
| 62 | datenUrl: 'data:image/png;base64,AAAA', |
| 63 | breite: 400, |
| 64 | hoehe: 400, |
| 65 | herkunft: 'Test', |
| 66 | geladenAm: FIXED_DATE.toISOString(), |
| 67 | }, |
| 68 | kalibrierung: { |
| 69 | von: { x: 0, y: 0 }, |
| 70 | bis: { x: 100, y: 0 }, |
| 71 | laengeMeter: 50, |
| 72 | herkunft: 'gemessen', |
| 73 | }, |
| 74 | linien: [ |
| 75 | linie('l-w', 'K1', [ |
| 76 | { x: 0, y: 100 }, |
| 77 | { x: 200, y: 100 }, |
| 78 | ]), |
| 79 | linie('l-s', 'K2', [ |
| 80 | { x: 100, y: 0 }, |
| 81 | { x: 100, y: 200 }, |
| 82 | ]), |
| 83 | ], |
| 84 | }; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Projekt mit genau einer Beziehung, deren Wege aus dem Lageplan stammen. |
| 89 | * Ausgangslage fuer alle Tests des Zuruecksetzens. |
| 90 | */ |
| 91 | function ausDemLageplan(): { project: Project; conflict: Conflict } { |
| 92 | const start = createEmptyProject('Nachweis', FIXED_DATE); |
| 93 | const { project: mitGruppen, lageplan } = uebernimmSignalgruppen(start, lageplanMitKreuzung(), [ |
| 94 | 'l-w', |
| 95 | 'l-s', |
| 96 | ]); |
| 97 | const ableitung = leiteAb(mitGruppen, lageplan); |
| 98 | const einer = ableitung.wege[0]; |
| 99 | expect(einer).toBeDefined(); |
| 100 | |
| 101 | const project = uebernimmWege( |
| 102 | mitGruppen, |
| 103 | lageplan, |
| 104 | ableitung, |
| 105 | [wegSchluessel(einer!)], |
| 106 | UEBERNAHME, |
| 107 | ).project; |
| 108 | const conflict = project.conflicts[0]; |
| 109 | expect(conflict).toBeDefined(); |
| 110 | return { project, conflict: conflict! }; |
| 111 | } |
| 112 | |
| 113 | describe('Modell und Vorlage', () => { |
| 114 | it('setzt den Wegnachweis ab Schemaversion 8 voraus', () => { |
| 115 | /* |
| 116 | * Bewusst "mindestens 8" und nicht "genau 8". Der Wegnachweis ist mit |
| 117 | * Schema 8 dazugekommen - das ist die Zusage, die hier festzuhalten ist. |
| 118 | * Die feste Zahl fiel dagegen beim naechsten Schemaaufstieg um, obwohl sich |
| 119 | * am Wegnachweis nichts geaendert hatte: Ein Test ueber die Herkunft der |
| 120 | * Wege soll nicht bei jeder fremden Modellaenderung rot werden. Die |
| 121 | * jeweils aktuelle Zahl prueft tests/domain/anlagenartDatei.test.ts an der |
| 122 | * Stelle, an die sie gehoert. |
| 123 | */ |
| 124 | expect(CURRENT_SCHEMA_VERSION).toBeGreaterThanOrEqual(8); |
| 125 | }); |
| 126 | |
| 127 | it('legt eine neue Beziehung ohne Herkunftsangabe an', () => { |
| 128 | // Beide Wege stehen auf 0. Diese 0 hat niemand vermessen und niemand |
| 129 | // eingetragen - "von Hand" waere hier bereits eine falsche Angabe. |
| 130 | const conflict = createConflict('a', 'b'); |
| 131 | expect(conflict.wegnachweis.herkunft).toBe('unbestimmt'); |
| 132 | expect(conflict.wegnachweis.uebernommenAm).toBeNull(); |
| 133 | }); |
| 134 | |
| 135 | it('weist die Masse des Beispielknotenpunkts nicht als eingetragen aus', () => { |
| 136 | const project = createStandardIntersectionProject('Vorlage', FIXED_DATE); |
| 137 | expect(project.conflicts.length).toBeGreaterThan(0); |
| 138 | for (const conflict of project.conflicts) { |
| 139 | expect(conflict.wegnachweis.herkunft).toBe('unbestimmt'); |
| 140 | } |
| 141 | }); |
| 142 | }); |
| 143 | |
| 144 | describe('Einlesen', () => { |
| 145 | it('liest einen Nachweis aus dem Lageplan samt Zeitpunkt wieder ein', () => { |
| 146 | const { project } = ausDemLageplan(); |
| 147 | const gelesen = parseProject(project, FIXED_DATE); |
| 148 | |
| 149 | expect(gelesen.project.conflicts[0]!.wegnachweis).toEqual({ |
| 150 | herkunft: 'lageplan', |
| 151 | uebernommenAm: UEBERNAHME.toISOString(), |
| 152 | }); |
| 153 | }); |
| 154 | |
| 155 | it('gibt einer Datei ohne Angabe "unbestimmt" und nicht "von Hand"', () => { |
| 156 | // Eine Datei aus Schema 7: Ueber die Herkunft steht darin nichts. Sie als |
| 157 | // Eingabe des Bearbeiters auszuweisen hiesse zu behaupten, jemand habe die |
| 158 | // Zahl geprueft. |
| 159 | const { project, issues } = parseProject({ |
| 160 | schemaVersion: 7, |
| 161 | signalGroups: [ |
| 162 | { id: 'a', name: 'K1', mode: 'kfz' }, |
| 163 | { id: 'b', name: 'K2', mode: 'kfz' }, |
| 164 | ], |
| 165 | conflicts: [{ fromId: 'a', toId: 'b', clearingDistance: 24, enteringDistance: 9 }], |
| 166 | }); |
| 167 | |
| 168 | expect(project.conflicts[0]!.wegnachweis.herkunft).toBe('unbestimmt'); |
| 169 | expect(project.conflicts[0]!.clearingDistance).toBe(24); |
| 170 | // Ein fehlendes Feld ist kein Mangel der Datei - es gab es damals nicht. |
| 171 | expect(issues.filter((i) => i.path.includes('wegnachweis'))).toHaveLength(0); |
| 172 | }); |
| 173 | |
| 174 | it('verwirft eine unbekannte Herkunftsstufe und meldet sie', () => { |
| 175 | const { project, issues } = parseProject({ |
| 176 | signalGroups: [ |
| 177 | { id: 'a', name: 'K1', mode: 'kfz' }, |
| 178 | { id: 'b', name: 'K2', mode: 'kfz' }, |
| 179 | ], |
| 180 | conflicts: [{ fromId: 'a', toId: 'b', wegnachweis: { herkunft: 'amtlich-vermessen' } }], |
| 181 | }); |
| 182 | |
| 183 | expect(project.conflicts[0]!.wegnachweis.herkunft).toBe('unbestimmt'); |
| 184 | expect(issues.some((i) => i.path.includes('wegnachweis'))).toBe(true); |
| 185 | }); |
| 186 | |
| 187 | it('uebernimmt kein erfundenes Vermassungsdatum', () => { |
| 188 | const { project, issues } = parseProject({ |
| 189 | signalGroups: [ |
| 190 | { id: 'a', name: 'K1', mode: 'kfz' }, |
| 191 | { id: 'b', name: 'K2', mode: 'kfz' }, |
| 192 | ], |
| 193 | conflicts: [ |
| 194 | { fromId: 'a', toId: 'b', wegnachweis: { herkunft: 'lageplan', uebernommenAm: 'neulich' } }, |
| 195 | ], |
| 196 | }); |
| 197 | |
| 198 | expect(project.conflicts[0]!.wegnachweis.herkunft).toBe('lageplan'); |
| 199 | expect(project.conflicts[0]!.wegnachweis.uebernommenAm).toBeNull(); |
| 200 | expect(issues.some((i) => i.path.includes('uebernommenAm'))).toBe(true); |
| 201 | }); |
| 202 | |
| 203 | it('haengt an "von Hand" keinen Zeitstempel an', () => { |
| 204 | const { project } = parseProject({ |
| 205 | signalGroups: [ |
| 206 | { id: 'a', name: 'K1', mode: 'kfz' }, |
| 207 | { id: 'b', name: 'K2', mode: 'kfz' }, |
| 208 | ], |
| 209 | conflicts: [ |
| 210 | { |
| 211 | fromId: 'a', |
| 212 | toId: 'b', |
| 213 | wegnachweis: { herkunft: 'handeingabe', uebernommenAm: '2026-05-05T00:00:00.000Z' }, |
| 214 | }, |
| 215 | ], |
| 216 | }); |
| 217 | |
| 218 | expect(project.conflicts[0]!.wegnachweis.uebernommenAm).toBeNull(); |
| 219 | }); |
| 220 | }); |
| 221 | |
| 222 | describe('Uebernahme von Altprojekten', () => { |
| 223 | const legacyProject = { |
| 224 | metadata: { version: '4.3.3' }, |
| 225 | projektdaten: { projektBezeichnung: 'Alt' }, |
| 226 | signalgruppen: [ |
| 227 | { id: 'SG-1', nummer: 'K1', typ: 'kfz' }, |
| 228 | { id: 'SG-2', nummer: 'F1', typ: 'fussgaenger' }, |
| 229 | ], |
| 230 | sperrmatrix: { |
| 231 | matrix: [ |
| 232 | [false, true], |
| 233 | [true, false], |
| 234 | ], |
| 235 | }, |
| 236 | zwischenzeiten: { parameter: { raeumweg: 24, einfahrweg: 9 } }, |
| 237 | }; |
| 238 | |
| 239 | it('weist die Wege eines Altprojekts als ungeklaert aus', () => { |
| 240 | // Das Altprogramm kannte nur einen global hinterlegten Weg fuer alle |
| 241 | // Beziehungen. Woher die Zahl stammt, sagt die Datei nicht. |
| 242 | const result = loadProject(legacyProject, FIXED_DATE); |
| 243 | |
| 244 | expect(result.project.conflicts.length).toBeGreaterThan(0); |
| 245 | for (const conflict of result.project.conflicts) { |
| 246 | expect(conflict.wegnachweis.herkunft).toBe('unbestimmt'); |
| 247 | expect(conflict.clearingDistance).toBe(24); |
| 248 | } |
| 249 | }); |
| 250 | |
| 251 | it('sagt beim Oeffnen einer Datei aus Schema 7, dass der Nachweis fehlt', () => { |
| 252 | const result = loadProject( |
| 253 | { |
| 254 | schemaVersion: 7, |
| 255 | signalGroups: [ |
| 256 | { id: 'a', name: 'K1', mode: 'kfz' }, |
| 257 | { id: 'b', name: 'K2', mode: 'kfz' }, |
| 258 | ], |
| 259 | conflicts: [{ fromId: 'a', toId: 'b', clearingDistance: 24, enteringDistance: 9 }], |
| 260 | }, |
| 261 | FIXED_DATE, |
| 262 | ); |
| 263 | |
| 264 | const hinweis = result.issues.find((i) => i.path === 'conflicts'); |
| 265 | expect(hinweis).toBeDefined(); |
| 266 | expect(hinweis!.severity).toBe('hinweis'); |
| 267 | expect(hinweis!.message).toContain('Herkunft'); |
| 268 | }); |
| 269 | |
| 270 | it('schweigt bei einer Datei, die den Nachweis bereits fuehrt', () => { |
| 271 | const { project } = ausDemLageplan(); |
| 272 | const result = loadProject(project, FIXED_DATE); |
| 273 | expect(result.issues.some((i) => i.path === 'conflicts')).toBe(false); |
| 274 | }); |
| 275 | }); |
| 276 | |
| 277 | describe('Uebernahme aus dem Lageplan', () => { |
| 278 | it('weist die uebernommenen Wege mit dem Zeitpunkt der Uebernahme aus', () => { |
| 279 | const { conflict } = ausDemLageplan(); |
| 280 | expect(conflict.wegnachweis.herkunft).toBe('lageplan'); |
| 281 | expect(conflict.wegnachweis.uebernommenAm).toBe(UEBERNAHME.toISOString()); |
| 282 | }); |
| 283 | |
| 284 | it('behauptet keine Vermessung, wo eine Zwischenzeit von Hand vorgegeben ist', () => { |
| 285 | // Die Wege stammen dann zwar aus dem Plan, die Zeile im Ausdruck aber |
| 286 | // nicht: Die vorgegebene Zwischenzeit ersetzt die Rechnung vollstaendig. |
| 287 | const start = createEmptyProject('Nachweis', FIXED_DATE); |
| 288 | const { project: mitGruppen, lageplan } = uebernimmSignalgruppen(start, lageplanMitKreuzung(), [ |
| 289 | 'l-w', |
| 290 | 'l-s', |
| 291 | ]); |
| 292 | const ableitung = leiteAb(mitGruppen, lageplan); |
| 293 | const einer = ableitung.wege[0]!; |
| 294 | const von = lageplan.linien.find((l) => l.id === einer.vonLinieId)!.signalGroupId!; |
| 295 | const nach = lageplan.linien.find((l) => l.id === einer.nachLinieId)!.signalGroupId!; |
| 296 | |
| 297 | const mitVorgabe = actions.setConflict(mitGruppen, von, nach, { manualIntergreen: 9 }); |
| 298 | const ergebnis = uebernimmWege( |
| 299 | mitVorgabe, |
| 300 | lageplan, |
| 301 | ableitung, |
| 302 | [wegSchluessel(einer)], |
| 303 | UEBERNAHME, |
| 304 | ).project; |
| 305 | |
| 306 | const conflict = ergebnis.conflicts.find((c) => c.fromId === von && c.toId === nach)!; |
| 307 | expect(conflict.clearingDistance).toBeCloseTo(einer.raeumweg, 2); |
| 308 | // Die bewusst gesetzte Vorgabe bleibt unangetastet ... |
| 309 | expect(conflict.manualIntergreen).toBe(9); |
| 310 | // ... der Nachweis wird dafuer nicht vergeben. |
| 311 | expect(conflict.wegnachweis.herkunft).toBe('handeingabe'); |
| 312 | }); |
| 313 | }); |
| 314 | |
| 315 | describe('Zuruecksetzen bei jeder Aenderung an den Wegen', () => { |
| 316 | /** Aendert ein Feld der uebernommenen Beziehung und gibt sie zurueck. */ |
| 317 | const nachAenderung = (patch: Parameters<typeof actions.setConflict>[3]): Conflict => { |
| 318 | const { project, conflict } = ausDemLageplan(); |
| 319 | const geaendert = actions.setConflict(project, conflict.fromId, conflict.toId, patch); |
| 320 | const treffer = geaendert.conflicts.find((c) => c.id === conflict.id); |
| 321 | expect(treffer).toBeDefined(); |
| 322 | return treffer!; |
| 323 | }; |
| 324 | |
| 325 | it('faellt zurueck, wenn der Raeumweg von Hand geaendert wird', () => { |
| 326 | // DER FEHLER, UM DEN ES GEHT: uebernehmen, dann den Raeumweg verlaengern. |
| 327 | // Zuvor blieb "Aus dem Lageplan vermessen" wortgleich stehen. |
| 328 | const conflict = nachAenderung({ clearingDistance: 41 }); |
| 329 | expect(conflict.clearingDistance).toBe(41); |
| 330 | expect(conflict.wegnachweis.herkunft).toBe('handeingabe'); |
| 331 | expect(conflict.wegnachweis.uebernommenAm).toBeNull(); |
| 332 | }); |
| 333 | |
| 334 | it('faellt zurueck, wenn der Einfahrweg von Hand geaendert wird', () => { |
| 335 | expect(nachAenderung({ enteringDistance: 3 }).wegnachweis.herkunft).toBe('handeingabe'); |
| 336 | }); |
| 337 | |
| 338 | it('faellt zurueck bei einer abweichenden Raeumgeschwindigkeit', () => { |
| 339 | expect(nachAenderung({ clearingSpeedOverride: 8 }).wegnachweis.herkunft).toBe('handeingabe'); |
| 340 | }); |
| 341 | |
| 342 | it('faellt zurueck bei einer abweichenden Einfahrgeschwindigkeit', () => { |
| 343 | expect(nachAenderung({ enteringSpeedOverride: 11 }).wegnachweis.herkunft).toBe('handeingabe'); |
| 344 | }); |
| 345 | |
| 346 | it('faellt zurueck bei einer abweichenden Ueberfahrzeit', () => { |
| 347 | expect(nachAenderung({ crossingTimeOverride: 2 }).wegnachweis.herkunft).toBe('handeingabe'); |
| 348 | }); |
| 349 | |
| 350 | it('faellt zurueck bei einer von Hand vorgegebenen Zwischenzeit', () => { |
| 351 | expect(nachAenderung({ manualIntergreen: 7 }).wegnachweis.herkunft).toBe('handeingabe'); |
| 352 | }); |
| 353 | |
| 354 | it('bleibt bestehen, wenn nur die Bemerkung geaendert wird', () => { |
| 355 | // Die Bemerkung geht in keine Rechnung ein; sie entwertet nichts. |
| 356 | const conflict = nachAenderung({ note: 'Vor Ort nachgesehen' }); |
| 357 | expect(conflict.note).toBe('Vor Ort nachgesehen'); |
| 358 | expect(conflict.wegnachweis.herkunft).toBe('lageplan'); |
| 359 | }); |
| 360 | |
| 361 | it('bleibt bestehen, wenn derselbe Wert noch einmal geschrieben wird', () => { |
| 362 | // Sonst verfiele der Nachweis beim blossen Durchtabben durch das Fenster, |
| 363 | // und nach einer Sitzung stuende ueberall "von Hand". |
| 364 | const { project, conflict } = ausDemLageplan(); |
| 365 | const gleich = actions.setConflict(project, conflict.fromId, conflict.toId, { |
| 366 | clearingDistance: conflict.clearingDistance, |
| 367 | enteringDistance: conflict.enteringDistance, |
| 368 | }); |
| 369 | |
| 370 | expect(gleich.conflicts[0]!.wegnachweis).toEqual(conflict.wegnachweis); |
| 371 | }); |
| 372 | |
| 373 | it('faellt beim Sammelbearbeiten aller Beziehungen zurueck', () => { |
| 374 | // Die Massenvorgabe war die zweite Stelle, an der der alte Vermerk stehen |
| 375 | // blieb, obwohl die Zahl darunter ausgetauscht wurde. |
| 376 | const { project } = ausDemLageplan(); |
| 377 | const gesetzt = actions.setzeAlleWege(project, 20, 10, false); |
| 378 | |
| 379 | expect(gesetzt.conflicts[0]!.clearingDistance).toBe(20); |
| 380 | expect(gesetzt.conflicts[0]!.wegnachweis.herkunft).toBe('handeingabe'); |
| 381 | }); |
| 382 | |
| 383 | it('laesst beim Sammelbearbeiten die bereits vermassten Beziehungen unberuehrt', () => { |
| 384 | const { project, conflict } = ausDemLageplan(); |
| 385 | const gesetzt = actions.setzeAlleWege(project, 20, 10, true); |
| 386 | |
| 387 | expect(gesetzt.conflicts[0]!.clearingDistance).toBe(conflict.clearingDistance); |
| 388 | expect(gesetzt.conflicts[0]!.wegnachweis.herkunft).toBe('lageplan'); |
| 389 | }); |
| 390 | |
| 391 | it('macht aus einer ungeklaerten Herkunft eine Eingabe, sobald jemand sie eintraegt', () => { |
| 392 | const start = createStandardIntersectionProject('Vorlage', FIXED_DATE); |
| 393 | const erster = start.conflicts[0]!; |
| 394 | const geaendert = actions.setConflict(start, erster.fromId, erster.toId, { |
| 395 | clearingDistance: 31, |
| 396 | }); |
| 397 | |
| 398 | expect(geaendert.conflicts[0]!.wegnachweis.herkunft).toBe('handeingabe'); |
| 399 | }); |
| 400 | |
| 401 | it('legt eine neu erfasste Beziehung mit ihrer Eingabe als Herkunft an', () => { |
| 402 | const start = createStandardIntersectionProject('Vorlage', FIXED_DATE); |
| 403 | const a = start.signalGroups[0]!; |
| 404 | const b = start.signalGroups[1]!; |
| 405 | const ohne = actions.removeConflict(actions.removeConflict(start, a.id, b.id), b.id, a.id); |
| 406 | |
| 407 | const angelegt = actions.setConflict(ohne, a.id, b.id, { clearingDistance: 18 }); |
| 408 | const conflict = angelegt.conflicts.find((c) => c.fromId === a.id && c.toId === b.id)!; |
| 409 | expect(conflict.wegnachweis.herkunft).toBe('handeingabe'); |
| 410 | |
| 411 | // Ohne Angabe bleibt sie ungeklaert - eine Beziehung ohne Wege ist keine |
| 412 | // Eingabe, sondern eine offene Aufgabe. |
| 413 | const leer = actions.setConflict(ohne, b.id, a.id, {}); |
| 414 | const leerer = leer.conflicts.find((c) => c.fromId === b.id && c.toId === a.id)!; |
| 415 | expect(leerer.wegnachweis.herkunft).toBe('unbestimmt'); |
| 416 | }); |
| 417 | }); |