lsa-planer

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

/ tests domain programmbibliothek.test.ts

17,5 KB Rohdatei
tests/domain/programmbibliothek.test.ts — 401 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { createStandardIntersectionProject } from '@/domain/model/factory';
4 import {
5 ERSTES_PROGRAMM,
6 PROGRAMM_HOECHSTZAHL,
7 aktiverProgrammsatz,
8 legeProgrammAn,
9 loescheProgramm,
10 programmanzahl,
11 programmfolge,
12 programmplatz,
13 wechsleProgramm,
14 } from '@/domain/model/programme';
15 import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project';
16 import { loadProject } from '@/domain/model/migrate';
17 import { buildSignalPlan } from '@/domain/plan/signalPlan';
18 import { programmPruefungen, validateProject } from '@/domain/validation';
19
20 /**
21 * Fassung 5.33.0 - mehrere Tagesprogramme in einem Projekt.
22 *
23 * DIE EINE SICHERHEITSRELEVANTE BEDINGUNG DES BEFUNDS: Pruefung und
24 * Ausgabesperre laufen ueber ALLE Programme und nicht ueber das sichtbare.
25 * Sonst geht ein Nachtprogramm mit unterschrittener Mindestfreigabezeit
26 * ungeprueft in die Anordnungsunterlage. Sie steht in diesem Fall ganz unten
27 * und ist der Grund, aus dem alles Uebrige gebaut wurde.
28 */
29
30 const STICHTAG = new Date('2026-01-01T12:00:00Z');
31
32 function basis(): Project {
33 return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG);
34 }
35
36 /** Ein Programm, dessen Umlaufzeit die Schranke des Knotenpunkts reisst. */
37 function mitFehler(project: Project): Project {
38 return {
39 ...project,
40 program: { ...project.program, method: 'manuell', manualCycleTime: 300 },
41 };
42 }
43
44 describe('Programmbibliothek - das Umschalten traegt die Daten', () => {
45 it('hält die Verkehrsstärken jedes Programms auseinander', () => {
46 /*
47 * DIE KERNZUSAGE. Fruehspitze und Nacht beschreiben denselben Knotenpunkt
48 * mit verschiedenen Zaehlungen. Liefe eine Zahl in das andere Programm
49 * ueber, waere die Bemessung der Nacht die der Spitze - und die
50 * Freigabezeiten fielen fuer die falsche Stunde aus.
51 */
52 const tag = basis();
53 const k1 = tag.signalGroups[0]!.id;
54 const mitTag = actions.setDemand(tag, k1, { volume: 900 });
55
56 const nacht = legeProgrammAn(mitTag, 'Nacht');
57 expect(nacht.demands, 'ein neues Programm beginnt ohne Zählung').toEqual([]);
58 const mitNacht = actions.setDemand(nacht, k1, { volume: 120 });
59
60 const zurueck = wechsleProgramm(mitNacht, ERSTES_PROGRAMM);
61 expect(zurueck.demands.find((d) => d.signalGroupId === k1)?.volume).toBe(900);
62
63 const wiederNacht = wechsleProgramm(zurueck, mitNacht.program.id);
64 expect(wiederNacht.demands.find((d) => d.signalGroupId === k1)?.volume).toBe(120);
65 });
66
67 it('hält die festen Freigabezeiten jedes Programms auseinander', () => {
68 const tag = basis();
69 const phase = tag.phases[0]!.id;
70 const mitTag = actions.updatePhase(tag, phase, { manualGreen: 42 });
71
72 const nacht = legeProgrammAn(mitTag, 'Nacht');
73 expect(nacht.phases.find((p) => p.id === phase)?.manualGreen).toBeNull();
74 const mitNacht = actions.updatePhase(nacht, phase, { manualGreen: 12 });
75
76 const zurueck = wechsleProgramm(mitNacht, ERSTES_PROGRAMM);
77 expect(zurueck.phases.find((p) => p.id === phase)?.manualGreen).toBe(42);
78 expect(
79 wechsleProgramm(zurueck, mitNacht.program.id).phases.find((p) => p.id === phase)?.manualGreen,
80 ).toBe(12);
81 });
82
83 it('führt keine Angabe zweimal', () => {
84 /*
85 * Die Form dieses Modells - aktives Programm oben, uebrige in der
86 * Bibliothek - traegt nur, solange sich die beiden Seiten nicht
87 * ueberschneiden. Stuende das aktive Programm auch in der Bibliothek, gaebe
88 * es zwei Staende derselben Sache, und einer davon wuerde beim naechsten
89 * Umschalten der andere.
90 */
91 let projekt = basis();
92 projekt = legeProgrammAn(projekt, 'Nacht');
93 projekt = legeProgrammAn(projekt, 'Frühspitze');
94 for (const eintrag of programmfolge(projekt)) {
95 const gewechselt = wechsleProgramm(projekt, eintrag.satz.programm.id);
96 expect(
97 gewechselt.programmbibliothek.map((s) => s.programm.id),
98 `nach dem Wechsel auf ${eintrag.satz.programm.name}`,
99 ).not.toContain(gewechselt.program.id);
100 expect(programmanzahl(gewechselt)).toBe(3);
101 }
102 });
103
104 it('lässt die Reihenfolge stehen', () => {
105 // Ohne festgehaltenen Platz wanderte das bearbeitete Programm bei jedem
106 // Umschalten an den Anfang, und die Auswahlliste sortierte sich unter der
107 // Hand um - fuer den Bearbeiter sieht das aus, als habe er etwas
108 // verschoben.
109 let projekt = basis();
110 projekt = legeProgrammAn(projekt, 'Nacht');
111 projekt = legeProgrammAn(projekt, 'Frühspitze');
112 const namen = programmfolge(projekt).map((e) => e.satz.programm.name);
113 expect(namen).toEqual(['Programm 1', 'Nacht', 'Frühspitze']);
114
115 for (const eintrag of programmfolge(projekt)) {
116 const gewechselt = wechsleProgramm(projekt, eintrag.satz.programm.id);
117 expect(
118 programmfolge(gewechselt).map((e) => e.satz.programm.name),
119 `nach dem Wechsel auf ${eintrag.satz.programm.name}`,
120 ).toEqual(namen);
121 }
122 });
123
124 it('übernimmt Einstellungen, aber keine Zählungen', () => {
125 const roh = basis();
126 const tag: Project = {
127 ...roh,
128 program: { ...roh.program, method: 'manuell', manualCycleTime: 80, offset: 17 },
129 };
130 const nacht = legeProgrammAn(tag, 'Nacht');
131 expect(nacht.program.method).toBe('manuell');
132 expect(nacht.program.manualCycleTime).toBe(80);
133 expect(nacht.program.offset).toBe(17);
134 expect(nacht.program.phaseOrder).toEqual(tag.program.phaseOrder);
135 expect(nacht.demands).toEqual([]);
136 expect(aktiverProgrammsatz(nacht).festeFreigabezeiten).toEqual([]);
137 });
138
139 it('bleibt bei unbekannter Kennung und beim letzten Programm stehen', () => {
140 const projekt = basis();
141 expect(wechsleProgramm(projekt, 'gibt-es-nicht')).toBe(projekt);
142 expect(wechsleProgramm(projekt, projekt.program.id)).toBe(projekt);
143 // Ein Projekt ohne Programm hat keinen Signalzeitenplan.
144 expect(loescheProgramm(projekt, projekt.program.id)).toBe(projekt);
145 });
146
147 it('lässt beim Löschen den Nachbarn eintreten', () => {
148 let projekt = basis();
149 projekt = legeProgrammAn(projekt, 'Nacht');
150 projekt = legeProgrammAn(projekt, 'Frühspitze');
151 const nachtId = programmfolge(projekt)[1]!.satz.programm.id;
152
153 // Das aktive Programm loeschen: der Nachbar tritt an seine Stelle.
154 const ohneAktives = loescheProgramm(projekt, projekt.program.id);
155 expect(programmanzahl(ohneAktives)).toBe(2);
156 expect(ohneAktives.program.id).toBe(nachtId);
157
158 // Ein eingelagertes loeschen: das aktive bleibt aktiv.
159 const ohneNacht = loescheProgramm(projekt, nachtId);
160 expect(ohneNacht.program.id).toBe(projekt.program.id);
161 expect(programmfolge(ohneNacht).map((e) => e.satz.programm.name)).toEqual([
162 'Programm 1',
163 'Frühspitze',
164 ]);
165 });
166
167 it('nimmt nicht mehr als die Höchstzahl auf', () => {
168 let projekt = basis();
169 for (let i = 2; i <= PROGRAMM_HOECHSTZAHL; i += 1) {
170 projekt = legeProgrammAn(projekt, `Programm ${String(i)}`);
171 }
172 expect(programmanzahl(projekt)).toBe(PROGRAMM_HOECHSTZAHL);
173 expect(legeProgrammAn(projekt, 'eins zu viel')).toBe(projekt);
174 });
175
176 it('klemmt einen unmöglichen Platz, statt ein Loch zu lassen', () => {
177 const projekt = legeProgrammAn(basis(), 'Nacht');
178 for (const platz of [-3, 99, Number.NaN, 1.7]) {
179 const verbogen: Project = { ...projekt, programmplatz: platz };
180 expect(programmplatz(verbogen), String(platz)).toBeGreaterThanOrEqual(0);
181 expect(programmplatz(verbogen), String(platz)).toBeLessThanOrEqual(1);
182 expect(programmfolge(verbogen), String(platz)).toHaveLength(2);
183 }
184 });
185 });
186
187 describe('Programmbibliothek - die Aktionen der Oberfläche', () => {
188 it('reichen an den Fachkern durch, ohne etwas hinzuzutun', () => {
189 /*
190 * Die drei Aktionen sind Durchreichen und sollen es bleiben: Rechnen
191 * geschieht in `domain/`, und eine Aktion, die unterwegs etwas anderes
192 * tut als die Fachfunktion, waere eine zweite Fassung derselben Regel.
193 * Verglichen wird deshalb Ergebnis gegen Ergebnis.
194 */
195 const roh = basis();
196 // Beim Anlegen kann nur der Inhalt verglichen werden: Die Kennung des neuen
197 // Programms wird erzeugt und faellt bei jedem Aufruf anders aus.
198 const ueberAktion = actions.programmAnlegen(roh, 'Nacht');
199 const ueberFachkern = legeProgrammAn(roh, 'Nacht');
200 expect({ ...ueberAktion, program: { ...ueberAktion.program, id: '' } }).toEqual({
201 ...ueberFachkern,
202 program: { ...ueberFachkern.program, id: '' },
203 });
204
205 const zwei = legeProgrammAn(roh, 'Nacht');
206 expect(actions.programmWechseln(zwei, ERSTES_PROGRAMM)).toEqual(
207 wechsleProgramm(zwei, ERSTES_PROGRAMM),
208 );
209 expect(actions.programmEntfernen(zwei, zwei.program.id)).toEqual(
210 loescheProgramm(zwei, zwei.program.id),
211 );
212 expect(programmanzahl(actions.programmEntfernen(zwei, zwei.program.id))).toBe(1);
213 });
214 });
215
216 describe('Programmbibliothek - was allen Programmen gehört', () => {
217 it('hängt eine neue Phase an jede Programmreihenfolge', () => {
218 // Eine Phase, die in einem Programm nicht vorkommt, bekommt dort nie
219 // Freigabe. Das darf nicht davon abhaengen, welches Programm beim Anlegen
220 // gerade sichtbar war.
221 let projekt = legeProgrammAn(basis(), 'Nacht');
222 projekt = actions.addPhase(projekt);
223 const neue = projekt.phases[projekt.phases.length - 1]!.id;
224 for (const eintrag of programmfolge(projekt)) {
225 expect(eintrag.satz.programm.phaseOrder, eintrag.satz.programm.name).toContain(neue);
226 }
227 });
228
229 it('entfernt eine gelöschte Phase aus jedem Programm', () => {
230 let projekt = basis();
231 const phase = projekt.phases[0]!.id;
232 projekt = actions.updatePhase(projekt, phase, { manualGreen: 33 });
233 projekt = legeProgrammAn(projekt, 'Nacht');
234 projekt = actions.updatePhase(projekt, phase, { manualGreen: 11 });
235 projekt = actions.removePhase(projekt, phase);
236
237 for (const eintrag of programmfolge(projekt)) {
238 expect(eintrag.satz.programm.phaseOrder, eintrag.satz.programm.name).not.toContain(phase);
239 expect(
240 eintrag.satz.festeFreigabezeiten.map((f) => f.phaseId),
241 eintrag.satz.programm.name,
242 ).not.toContain(phase);
243 }
244 expect(projekt.phases.some((p) => p.id === phase)).toBe(false);
245 });
246
247 it('räumt eine gelöschte Signalgruppe aus jedem Programm', () => {
248 /*
249 * Sonst fuehrt das Nachtprogramm eine Verkehrsstaerke fuer einen Strom, den
250 * es nicht mehr gibt. Beim naechsten Umschalten stuende sie wieder im
251 * Projekt - genau der Fehler, an dem der Altbestand mit seiner ueber
252 * Feldindizes gefuehrten Konfliktmatrix scheiterte.
253 */
254 let projekt = basis();
255 const k1 = projekt.signalGroups[0]!.id;
256 projekt = legeProgrammAn(projekt, 'Nacht');
257 projekt = actions.setDemand(projekt, k1, { volume: 150 });
258 projekt = wechsleProgramm(projekt, ERSTES_PROGRAMM);
259 projekt = actions.removeSignalGroup(projekt, k1);
260
261 for (const eintrag of programmfolge(projekt)) {
262 expect(
263 eintrag.satz.demands.map((d) => d.signalGroupId),
264 eintrag.satz.programm.name,
265 ).not.toContain(k1);
266 }
267 });
268 });
269
270 describe('Programmbibliothek - die Prüfung läuft über alle Programme', () => {
271 it('sperrt die Ausgabe wegen eines Fehlers in einem nicht angezeigten Programm', () => {
272 /*
273 * DER FALL, UM DEN ES GEHT. Das sichtbare Programm ist in Ordnung, das
274 * eingelagerte nicht. Wer nur das sichtbare prueft, gibt eine
275 * Anordnungsunterlage aus, in der ein Programm steckt, das niemand geprueft
276 * hat. Eine Anordnung nach § 45 StVO gilt der ANLAGE, nicht einer
277 * Tageszeit.
278 */
279 const sauber = basis();
280 const sauberBericht = validateProject(sauber, buildSignalPlan(sauber), STICHTAG);
281 expect(sauberBericht.errorCount, 'Vorbedingung: das Grundprojekt ist fehlerfrei').toBe(0);
282 expect(sauberBericht.exportBlocked).toBe(false);
283
284 // Das Nachtprogramm bekommt den Fehler, danach zurueck auf das saubere.
285 const mitNacht = wechsleProgramm(mitFehler(legeProgrammAn(sauber, 'Nacht')), sauber.program.id);
286 expect(mitNacht.program.id, 'sichtbar ist das fehlerfreie Programm').toBe(sauber.program.id);
287
288 const bericht = validateProject(mitNacht, buildSignalPlan(mitNacht), STICHTAG);
289 expect(bericht.exportBlocked, 'die Ausgabe ist gesperrt').toBe(true);
290 const befund = bericht.findings.find((f) => f.rule === 'programm.fehler-in-anderem-programm');
291 expect(befund?.severity).toBe('fehler');
292 expect(befund?.message).toContain('Nacht');
293 });
294
295 it('meldet Warnungen eines anderen Programms als Warnung, nicht als Fehler', () => {
296 const sauber = basis();
297 // Ein Programm ohne Verkehrsstaerken: der Plan steht, aber die Bemessung
298 // fehlt - das ist eine Warnung und keine Sperre.
299 const mitLeerem = wechsleProgramm(legeProgrammAn(sauber, 'Nacht'), sauber.program.id);
300 const bericht = validateProject(mitLeerem, buildSignalPlan(mitLeerem), STICHTAG);
301 expect(bericht.findings.some((f) => f.rule === 'programm.fehler-in-anderem-programm')).toBe(
302 false,
303 );
304 const warnung = bericht.findings.find((f) => f.rule === 'programm.warnung-in-anderem-programm');
305 expect(warnung?.severity).toBe('warnung');
306 expect(bericht.exportBlocked).toBe(false);
307 });
308
309 it('zählt keinen Fehler doppelt und prüft nicht im Kreis', () => {
310 /*
311 * Ohne den Schalter `ohneProgrammpruefung` pruefte die Pruefung jedes
312 * Programms alle uebrigen mit: bei vier Programmen sechzehn Plaene, und
313 * jeder Fehler stuende viermal im Bericht. Die Spaltensumme der Uebersicht
314 * waere dann groesser als die Zahl der Fehler.
315 */
316 const mitNacht = wechsleProgramm(mitFehler(legeProgrammAn(basis(), 'Nacht')), ERSTES_PROGRAMM);
317 const pruefungen = programmPruefungen(mitNacht, STICHTAG);
318 expect(pruefungen).toHaveLength(2);
319 expect(pruefungen[0]?.aktiv).toBe(true);
320 expect(pruefungen[0]?.errorCount, 'das sichtbare Programm ist fehlerfrei').toBe(0);
321 expect(pruefungen[1]?.name).toBe('Nacht');
322 expect(pruefungen[1]?.errorCount).toBeGreaterThan(0);
323 expect(pruefungen[1]?.aktiv).toBe(false);
324 });
325
326 it('rechnet jedem Programm seine eigene Umlaufzeit', () => {
327 const roh = basis();
328 let projekt: Project = {
329 ...roh,
330 program: { ...roh.program, method: 'manuell', manualCycleTime: 60 },
331 };
332 projekt = legeProgrammAn(projekt, 'Nacht');
333 projekt = {
334 ...projekt,
335 program: { ...projekt.program, method: 'manuell', manualCycleTime: 90 },
336 };
337 const pruefungen = programmPruefungen(projekt, STICHTAG);
338 expect(pruefungen.map((p) => p.umlaufzeit)).toEqual([60, 90]);
339 expect(pruefungen.map((p) => p.verfahren)).toEqual(['manuell', 'manuell']);
340 });
341 });
342
343 describe('Programmbibliothek - in der Datei', () => {
344 it('trägt die Programme über Speichern und Laden', () => {
345 let projekt = basis();
346 const k1 = projekt.signalGroups[0]!.id;
347 const phase = projekt.phases[0]!.id;
348 projekt = legeProgrammAn(projekt, 'Nacht');
349 projekt = actions.setDemand(projekt, k1, { volume: 130 });
350 projekt = actions.updatePhase(projekt, phase, { manualGreen: 9 });
351 projekt = wechsleProgramm(projekt, ERSTES_PROGRAMM);
352
353 const geladen = loadProject(JSON.parse(JSON.stringify(projekt)) as unknown, STICHTAG);
354 expect(geladen.project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
355 expect(programmanzahl(geladen.project)).toBe(2);
356 const nacht = geladen.project.programmbibliothek[0];
357 expect(nacht?.programm.name).toBe('Nacht');
358 expect(nacht?.demands.find((d) => d.signalGroupId === k1)?.volume).toBe(130);
359 expect(nacht?.festeFreigabezeiten).toEqual([{ phaseId: phase, sekunden: 9 }]);
360 expect(geladen.issues.filter((i) => i.path.startsWith('programm'))).toEqual([]);
361 });
362
363 it('macht aus einer Datei ohne Bibliothek genau ein Programm', () => {
364 // Eine Datei bis Schema 15 kennt weder Kennung noch Bibliothek. Sie muss
365 // ohne Beanstandung als Einprogramm-Projekt ankommen - alles andere waere
366 // ein Vorwurf an eine Datei, die nichts falsch macht.
367 const roh = JSON.parse(JSON.stringify(basis())) as Record<string, unknown>;
368 roh['schemaVersion'] = 15;
369 delete roh['programmbibliothek'];
370 delete roh['programmplatz'];
371 delete (roh['program'] as Record<string, unknown>)['id'];
372
373 const geladen = loadProject(roh, STICHTAG);
374 expect(geladen.project.program.id).toBe(ERSTES_PROGRAMM);
375 expect(geladen.project.programmbibliothek).toEqual([]);
376 expect(geladen.project.programmplatz).toBe(0);
377 expect(geladen.issues.filter((i) => i.severity !== 'hinweis')).toEqual([]);
378 });
379
380 it('ersetzt eine doppelt vergebene Programmkennung, statt das Programm zu verwerfen', () => {
381 /*
382 * Zwei gleiche Kennungen liessen die Reihenfolge entscheiden, welches
383 * Programm gemeint ist - der Bearbeiter saehe die Verkehrsstaerken eines
384 * anderen. Ein ganzes Tagesprogramm stillschweigend fallen zu lassen waere
385 * aber der groessere Verlust; deshalb wird umbenannt und gemeldet.
386 */
387 const roh = JSON.parse(JSON.stringify(legeProgrammAn(basis(), 'Nacht'))) as Record<
388 string,
389 unknown
390 >;
391 const bibliothek = roh['programmbibliothek'] as Record<string, unknown>[];
392 const aktiveKennung = (roh['program'] as Record<string, unknown>)['id'];
393 (bibliothek[0]!['programm'] as Record<string, unknown>)['id'] = aktiveKennung;
394
395 const geladen = loadProject(roh, STICHTAG);
396 expect(programmanzahl(geladen.project)).toBe(2);
397 const kennungen = programmfolge(geladen.project).map((e) => e.satz.programm.id);
398 expect(new Set(kennungen).size, 'jede Kennung genau einmal').toBe(2);
399 expect(geladen.issues.some((i) => i.message.includes('mehrfach vergeben'))).toBe(true);
400 });
401 });