lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests domain wegnachweis.test.ts

15,6 KB Rohdatei
tests/domain/wegnachweis.test.ts — 417 Zeilen
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 });