lsa-planer

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

/ tests domain variantenvergleich.test.ts

9,5 KB Rohdatei
tests/domain/variantenvergleich.test.ts — 235 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { createStandardIntersectionProject } from '@/domain/model/factory';
3 import { legeProgrammAn } from '@/domain/model/programme';
4 import { buildSignalPlan } from '@/domain/plan/signalPlan';
5 import { vergleicheVarianten, type Vergleichsseite } from '@/domain/plan/vergleich';
6 import type { Project } from '@/domain/model/project';
7
8 /**
9 * Fassung 5.34.0 - zwei Planfaelle nebeneinander.
10 *
11 * SEIN EIGENTLICHER WERT IST SICHERHEITSTECHNISCH: Wer einen Raeumweg oder
12 * einen Kennwert aendert, sieht heute nur den neuen Stand. Welche Zwischenzeit
13 * dabei KUERZER geworden ist, sagt ihm niemand - und das ist die gefaehrliche
14 * Richtung. Diese Faelle bewachen, dass der Vergleich sie zaehlt und benennt.
15 */
16
17 const STICHTAG = new Date('2026-01-01T12:00:00Z');
18
19 /*
20 * EIN Grundprojekt je Fall, und beide Seiten davon abgeleitet.
21 *
22 * `createStandardIntersectionProject` erzeugt bei jedem Aufruf FRISCHE
23 * Kennungen. Zwei Aufrufe ergaeben zwei Projekte ohne eine einzige gemeinsame
24 * Kennung - der Vergleich fiele auf die Zuordnung ueber den Namen zurueck, und
25 * ein Fall, der die Zuordnung ueber die Kennung pruefen will, prueft dann das
26 * Gegenteil. (Derselbe Fehler ist in 5.30.0 schon einmal unterlaufen.)
27 */
28 function basis(): Project {
29 return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG);
30 }
31
32 function seite(
33 name: string,
34 project: Project,
35 mehr: Partial<Vergleichsseite> = {},
36 ): Vergleichsseite {
37 return { name, project, plan: buildSignalPlan(project), ...mehr };
38 }
39
40 /** Derselbe Knotenpunkt, aber jeder Raeumweg um 5 m kuerzer. */
41 function mitKuerzerenWegen(project: Project): Project {
42 return {
43 ...project,
44 conflicts: project.conflicts.map((c) => ({
45 ...c,
46 clearingDistance: Math.max(0, c.clearingDistance - 5),
47 })),
48 };
49 }
50
51 describe('Variantenvergleich - die Verkürzungen werden gezählt', () => {
52 it('zählt jede Zwischenzeit, die rechts kürzer ist', () => {
53 /*
54 * DIE KERNZUSAGE. Der rechte Planfall hat kuerzere Raeumwege, also
55 * kuerzere Zwischenzeiten. Genau das muss die Zahl oben sagen - und zwar
56 * bevor jemand eine Tabelle durchsieht.
57 */
58 const roh = basis();
59 const vergleich = vergleicheVarianten(
60 seite('Bestand', roh),
61 seite('Entwurf', mitKuerzerenWegen(roh)),
62 );
63
64 expect(vergleich.verkuerzteZwischenzeiten).toBeGreaterThan(0);
65 expect(vergleich.verkuerzteZwischenzeiten).toBe(
66 vergleich.zwischenzeiten.filter((z) => z.zeit.richtung === 'kuerzer').length,
67 );
68 for (const zeile of vergleich.zwischenzeiten) {
69 if (zeile.zeit.richtung !== 'kuerzer') continue;
70 expect(zeile.zeit.differenz, `${zeile.von} → ${zeile.nach}`).toBeLessThan(0);
71 }
72 });
73
74 it('nennt keine Verkürzung, wo beide Seiten gleich sind', () => {
75 const roh = basis();
76 const vergleich = vergleicheVarianten(seite('links', roh), seite('rechts', roh));
77 expect(vergleich.verkuerzteZwischenzeiten).toBe(0);
78 expect(vergleich.zwischenzeiten.every((z) => z.zeit.richtung === 'gleich')).toBe(true);
79 expect(vergleich.umlaufzeit.richtung).toBe('gleich');
80 expect(vergleich.umlaufzeit.differenz).toBe(0);
81 });
82
83 it('rechnet die Differenz als rechts minus links', () => {
84 const roh = basis();
85 const links = seite('kurz', {
86 ...roh,
87 program: { ...roh.program, method: 'manuell', manualCycleTime: 60 },
88 });
89 const rechts = seite('lang', {
90 ...roh,
91 program: { ...roh.program, method: 'manuell', manualCycleTime: 90 },
92 });
93 const vergleich = vergleicheVarianten(links, rechts);
94 expect(vergleich.umlaufzeit.links).toBe(60);
95 expect(vergleich.umlaufzeit.rechts).toBe(90);
96 expect(vergleich.umlaufzeit.differenz).toBe(30);
97 expect(vergleich.umlaufzeit.richtung).toBe('laenger');
98 });
99
100 it('ändert an beiden Seiten nichts', () => {
101 // Der Vergleich ist eine Gegenueberstellung und kein Eingriff. Waere er
102 // einer, traege er eine Zahl aus einem fremden Zusammenhang in diese
103 // Planung - ohne dass ihre Wege dazu passen muessten.
104 const linkesProjekt = basis();
105 const rechtesProjekt = mitKuerzerenWegen(linkesProjekt);
106 const vorher = JSON.stringify([linkesProjekt, rechtesProjekt]);
107 vergleicheVarianten(seite('a', linkesProjekt), seite('b', rechtesProjekt));
108 expect(JSON.stringify([linkesProjekt, rechtesProjekt])).toBe(vorher);
109 });
110 });
111
112 describe('Variantenvergleich - die Zuordnung', () => {
113 it('ordnet über die Kennung zu, wo es eine gemeinsame gibt', () => {
114 const roh = basis();
115 const vergleich = vergleicheVarianten(seite('a', roh), seite('b', mitKuerzerenWegen(roh)));
116 expect(vergleich.gruppen.every((g) => g.art === 'kennung')).toBe(true);
117 expect(vergleich.vorbehalte.join(' ')).not.toContain('über ihren NAMEN');
118 });
119
120 it('ordnet über den Namen zu, wo die Kennungen auseinandergehen – und sagt es', () => {
121 /*
122 * Zwei unabhaengig erfasste Projekte haben keine gemeinsame Kennung. Dann
123 * bleibt nur der Name, und der ist die schwaechere Auskunft: Er sagt
124 * nicht, dass es derselbe Strom ist. Deshalb steht die Art an der Zeile
125 * UND ein Vorbehalt darueber.
126 */
127 const roh = basis();
128 const umbenannt: Project = {
129 ...roh,
130 signalGroups: roh.signalGroups.map((g) => ({ ...g, id: `neu-${g.id}` })),
131 conflicts: roh.conflicts.map((c) => ({
132 ...c,
133 id: `neu-${c.id}`,
134 fromId: `neu-${c.fromId}`,
135 toId: `neu-${c.toId}`,
136 })),
137 phases: roh.phases.map((p) => ({
138 ...p,
139 signalGroupIds: p.signalGroupIds.map((id) => `neu-${id}`),
140 })),
141 demands: roh.demands.map((d) => ({ ...d, signalGroupId: `neu-${d.signalGroupId}` })),
142 };
143 const vergleich = vergleicheVarianten(seite('a', roh), seite('b', umbenannt));
144 expect(vergleich.gruppen.every((g) => g.art === 'name')).toBe(true);
145 expect(vergleich.vorbehalte.join(' ')).toContain('über ihren NAMEN');
146 });
147
148 it('ordnet einen doppelt vergebenen Namen gar nicht zu', () => {
149 // Lieber zwei Zeilen "nur links" und "nur rechts" als eine geratene
150 // Gegenueberstellung: Welcher der beiden gleichnamigen Ströme gemeint ist,
151 // steht nirgends.
152 const roh = basis();
153 const doppelt: Project = {
154 ...roh,
155 signalGroups: roh.signalGroups.map((g, i) => ({
156 ...g,
157 id: `neu-${g.id}`,
158 name: i < 2 ? 'K1' : g.name,
159 })),
160 conflicts: roh.conflicts.map((c) => ({
161 ...c,
162 id: `neu-${c.id}`,
163 fromId: `neu-${c.fromId}`,
164 toId: `neu-${c.toId}`,
165 })),
166 phases: roh.phases.map((p) => ({
167 ...p,
168 signalGroupIds: p.signalGroupIds.map((id) => `neu-${id}`),
169 })),
170 demands: roh.demands.map((d) => ({ ...d, signalGroupId: `neu-${d.signalGroupId}` })),
171 };
172 const mitDoppel: Project = {
173 ...roh,
174 signalGroups: roh.signalGroups.map((g, i) => ({ ...g, name: i < 2 ? 'K1' : g.name })),
175 };
176 const vergleich = vergleicheVarianten(seite('a', mitDoppel), seite('b', doppelt));
177 const k1 = vergleich.gruppen.filter((g) => g.name === 'K1');
178 expect(k1).toHaveLength(4);
179 expect(k1.filter((g) => g.art === 'nur-links')).toHaveLength(2);
180 expect(k1.filter((g) => g.art === 'nur-rechts')).toHaveLength(2);
181 expect(vergleich.unpaarig).toBeGreaterThan(0);
182 expect(vergleich.vorbehalte.join(' ')).toContain('nur auf einer Seite');
183 });
184
185 it('führt eine Signalgruppe, die es nur auf einer Seite gibt, mit einem Strich', () => {
186 const roh = basis();
187 const ohneLetzte: Project = { ...roh, signalGroups: roh.signalGroups.slice(0, -1) };
188 const vergleich = vergleicheVarianten(seite('voll', roh), seite('knapp', ohneLetzte));
189 const fehlende = vergleich.gruppen.filter((g) => g.art === 'nur-links');
190 expect(fehlende).toHaveLength(1);
191 expect(fehlende[0]?.freigabezeit.rechts).toBeNull();
192 expect(fehlende[0]?.freigabezeit.differenz).toBeNull();
193 expect(fehlende[0]?.freigabezeit.richtung).toBe('unbestimmt');
194 });
195 });
196
197 describe('Variantenvergleich - die Vorbehalte', () => {
198 it('warnt, wo eine Seite aus einer älteren Fassung hochgezogen wurde', () => {
199 /*
200 * DER FALLSTRICK DES BEFUNDS. Beim Einlesen eines aelteren Projekts werden
201 * die Zwischenzeiten NEU GERECHNET. Der Vergleich zeigt dann Unterschiede,
202 * die von der Umstellung kommen und nicht von der Planung - wer das nicht
203 * liest, haelt sie fuer eine Planungsentscheidung.
204 */
205 const roh = basis();
206 const vergleich = vergleicheVarianten(
207 seite('Altprojekt', roh, { migriert: true, herkunftsfassung: '4.3.3' }),
208 seite('heute', roh),
209 );
210 const satz = vergleich.vorbehalte.join(' ');
211 expect(satz).toContain('NEU GERECHNET');
212 expect(satz).toContain('4.3.3');
213 });
214
215 it('sagt, dass nur das geöffnete Tagesprogramm verglichen wird', () => {
216 // Die uebersehene Abhaengigkeit aus dem Befund: Seit Fassung 5.33.0 traegt ein
217 // Projekt mehrere Programme, und dann ist nicht mehr von selbst bestimmt,
218 // was verglichen wird.
219 const roh = basis();
220 const vergleich = vergleicheVarianten(
221 seite('a', legeProgrammAn(roh, 'Nacht')),
222 seite('b', roh),
223 );
224 expect(vergleich.vorbehalte.join(' ')).toContain('2 Tagesprogramme');
225 expect(vergleich.vorbehalte.join(' ')).toContain('Verglichen wird nur das geöffnete');
226 expect(vergleich.programme.links).toBe('Nacht');
227 expect(vergleich.programme.rechts).toBe('Programm 1');
228 });
229
230 it('schweigt, wo es nichts einzuschränken gibt', () => {
231 const roh = basis();
232 const vergleich = vergleicheVarianten(seite('a', roh), seite('b', roh));
233 expect(vergleich.vorbehalte).toEqual([]);
234 });
235 });