lsa-planer

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

/ tests ui koordinierungAnsicht.test.ts

8,6 KB Rohdatei
tests/ui/koordinierungAnsicht.test.ts — 240 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { ProjectStore } from '@/app/store';
4 import { createStandardIntersectionProject } from '@/domain/model/factory';
5 import { koordinierungView } from '@/ui/views/koordinierungView';
6 import type { Project } from '@/domain/model/project';
7
8 /**
9 * Die Ansicht "Koordinierung".
10 *
11 * DIE HAEUFIGSTE FEHLERWARTUNG an eine solche Ansicht ist, der Versatz
12 * verschiebe den eigenen Signalzeitenplan. Er tut es nicht - er gilt zwischen
13 * Anlagen. Das muss auf dem Schirm stehen, nicht nur im Quelltext.
14 */
15
16 const STICHTAG = new Date('2026-01-01T12:00:00Z');
17
18 function basis(): Project {
19 return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG);
20 }
21
22 function zeichne(project: Project): {
23 wurzel: HTMLElement;
24 store: ProjectStore;
25 aufraeumen: () => void;
26 } {
27 const wurzel = document.createElement('div');
28 document.body.append(wurzel);
29 const store = new ProjectStore(project);
30 const ende = koordinierungView.render(wurzel, {
31 store,
32 refresh: () => {},
33 navigate: () => {},
34 target: null,
35 });
36 return {
37 wurzel,
38 store,
39 aufraeumen: () => {
40 ende();
41 wurzel.remove();
42 },
43 };
44 }
45
46 /**
47 * Ein Zahlenfeld an seiner Beschriftung.
48 *
49 * ZWEI WEGE, WEIL ES ZWEI FORMEN GIBT: Ein Feld in einer Karte haengt an einem
50 * `label`, eines in einer Tabellenzelle traegt seine Beschriftung als
51 * `aria-label` - dort waere ein sichtbares Etikett je Zelle eine Wand aus
52 * Text. Beide Formen sind fuer eine Sprachausgabe benannt, und dieser Helfer
53 * findet beide.
54 */
55 function zahlenfeld(wurzel: ParentNode, beschriftung: string): HTMLInputElement {
56 const ueberEtikett = [...wurzel.querySelectorAll('label')].find((l) =>
57 l.textContent?.includes(beschriftung),
58 );
59 const feld =
60 ueberEtikett?.querySelector<HTMLInputElement>('input[type="number"]') ??
61 ueberEtikett?.parentElement?.querySelector<HTMLInputElement>('input[type="number"]') ??
62 [...wurzel.querySelectorAll<HTMLInputElement>('input[type="number"]')].find(
63 (i) => i.getAttribute('aria-label') === beschriftung,
64 );
65 if (!feld) throw new Error(`Zahlenfeld "${beschriftung}" nicht gefunden.`);
66 return feld;
67 }
68
69 function auswahl(wurzel: ParentNode, beschriftung: string): HTMLSelectElement {
70 const treffer = [...wurzel.querySelectorAll('label')].find((l) =>
71 l.textContent?.includes(beschriftung),
72 );
73 const feld = treffer?.querySelector('select') ?? treffer?.parentElement?.querySelector('select');
74 if (!feld) throw new Error(`Auswahl "${beschriftung}" nicht gefunden.`);
75 return feld;
76 }
77
78 /** Traegt einen Wert in ein Zahlenfeld ein, wie es die Bedienung tut. */
79 function trage(feld: HTMLInputElement, wert: string): void {
80 feld.value = wert;
81 feld.dispatchEvent(new Event('input', { bubbles: true }));
82 feld.dispatchEvent(new Event('change', { bubbles: true }));
83 feld.dispatchEvent(new FocusEvent('blur'));
84 }
85
86 function knopf(wurzel: ParentNode, text: string): HTMLButtonElement {
87 const treffer = [...wurzel.querySelectorAll('button')].find(
88 (b) => b.textContent?.trim() === text,
89 );
90 if (!treffer) throw new Error(`Schaltfläche "${text}" nicht gefunden.`);
91 return treffer;
92 }
93
94 /** Ein Knotenpunkt mit einer Nachbaranlage und fester Umlaufzeit. */
95 function mitKette(): Project {
96 const roh = basis();
97 let projekt: Project = {
98 ...roh,
99 program: { ...roh.program, method: 'manuell', manualCycleTime: 60 },
100 };
101 projekt = actions.setzeKoordinierung(projekt, {
102 hauptrichtung: roh.signalGroups[0]!.id,
103 progressionsgeschwindigkeit: 36,
104 platz: 0,
105 });
106 projekt = actions.koordinierungsknotenAnlegen(projekt, 'Weststraße');
107 const west = projekt.koordinierung!.nachbarn[0]!;
108 return actions.koordinierungsknotenAendern(projekt, west.id, {
109 abstand: 200,
110 freigabeBeginn: 20,
111 freigabeDauer: 25,
112 });
113 }
114
115 describe('Ansicht Koordinierung', () => {
116 it('bietet ohne Straßenzug das Anlegen an', () => {
117 const { wurzel, store, aufraeumen } = zeichne(basis());
118 try {
119 expect(wurzel.textContent).toContain('keine Koordinierung erfasst');
120 knopf(wurzel, 'Koordinierung anlegen').click();
121 expect(store.getProject().koordinierung).not.toBeNull();
122 // Und sie beginnt leer: Ein erfundener Nachbar waere eine Behauptung
123 // ueber die Oertlichkeit.
124 expect(store.getProject().koordinierung?.nachbarn).toEqual([]);
125 } finally {
126 aufraeumen();
127 }
128 });
129
130 it('sagt, dass der Versatz den eigenen Plan nicht verschiebt', () => {
131 const { wurzel, aufraeumen } = zeichne(mitKette());
132 try {
133 expect(wurzel.textContent).toContain('verschiebt den Signalzeitenplan dieses Knotenpunkts');
134 expect(wurzel.textContent).toContain('gilt zwischen den Anlagen');
135 } finally {
136 aufraeumen();
137 }
138 });
139
140 it('zeigt Versatzzeiten, Bandbreite und das Diagramm', () => {
141 const { wurzel, aufraeumen } = zeichne(mitKette());
142 try {
143 expect(wurzel.textContent).toContain('Empfohlener Versatz');
144 expect(wurzel.textContent).toContain('Bandbreite Hinrichtung');
145 expect(wurzel.querySelector('canvas.zeitweg')).not.toBeNull();
146 } finally {
147 aufraeumen();
148 }
149 });
150
151 it('benennt die fehlende Gegenrichtung', () => {
152 const { wurzel, aufraeumen } = zeichne(mitKette());
153 try {
154 expect(wurzel.textContent).toContain('nicht erfasst');
155 expect(wurzel.textContent).toContain('keines angenommen');
156 } finally {
157 aufraeumen();
158 }
159 });
160
161 it('trägt Abstand und Freigabezeiten der Nachbaranlage ein', () => {
162 const { wurzel, store, aufraeumen } = zeichne(mitKette());
163 try {
164 trage(zahlenfeld(wurzel, 'Abstand zur vorigen, Weststraße'), '420');
165 expect(store.getProject().koordinierung?.nachbarn[0]?.abstand).toBe(420);
166
167 trage(zahlenfeld(wurzel, 'Gegenrichtung ab, Weststraße'), '15');
168 trage(zahlenfeld(wurzel, 'Gegenrichtung Freigabezeit, Weststraße'), '20');
169 const nachbar = store.getProject().koordinierung?.nachbarn[0];
170 expect(nachbar?.gegenBeginn).toBe(15);
171 expect(nachbar?.gegenDauer).toBe(20);
172 } finally {
173 aufraeumen();
174 }
175 });
176
177 it('stellt Hauptrichtung, Geschwindigkeit und Platz um', () => {
178 const { wurzel, store, aufraeumen } = zeichne(mitKette());
179 try {
180 const gegen = auswahl(wurzel, 'Signalgruppe der Gegenrichtung');
181 const zweite = store.getProject().signalGroups[1]!.id;
182 gegen.value = zweite;
183 gegen.dispatchEvent(new Event('change'));
184 expect(store.getProject().koordinierung?.gegenrichtung).toBe(zweite);
185
186 trage(zahlenfeld(wurzel, 'Progressionsgeschwindigkeit'), '45');
187 expect(store.getProject().koordinierung?.progressionsgeschwindigkeit).toBe(45);
188
189 trage(zahlenfeld(wurzel, 'Platz dieses Knotenpunkts'), '1');
190 expect(store.getProject().koordinierung?.platz).toBe(1);
191
192 trage(zahlenfeld(wurzel, 'Abstand zur vorhergehenden Anlage'), '250');
193 expect(store.getProject().koordinierung?.eigenerAbstand).toBe(250);
194 } finally {
195 aufraeumen();
196 }
197 });
198
199 it('sagt, warum keine Grüne Welle gerechnet werden kann', () => {
200 // Ohne zugeordnete Hauptrichtung gibt es kein Freigabefenster und damit
201 // kein Band. Die Ansicht nennt den Grund, statt eine leere Tafel zu zeigen.
202 const ohneRichtung = actions.setzeKoordinierung(mitKette(), { hauptrichtung: null });
203 const { wurzel, aufraeumen } = zeichne(ohneRichtung);
204 try {
205 expect(wurzel.textContent).toContain('nicht festgelegt, welche Signalgruppe');
206 expect(wurzel.querySelector('canvas.zeitweg')).toBeNull();
207 } finally {
208 aufraeumen();
209 }
210 });
211
212 it('entfernt die Koordinierung erst nach Rückfrage', async () => {
213 const { wurzel, store, aufraeumen } = zeichne(mitKette());
214 try {
215 knopf(wurzel, 'Koordinierung entfernen').click();
216 const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
217 expect(dialog, 'Ohne Rückfrage wäre die Kette mit einem Klick fort').not.toBeNull();
218 const loeschen = [...dialog!.querySelectorAll('button')].find(
219 (b) => b.textContent?.trim() === 'Entfernen',
220 )!;
221 loeschen.click();
222 await new Promise((fertig) => setTimeout(fertig, 0));
223 expect(store.getProject().koordinierung).toBeNull();
224 } finally {
225 aufraeumen();
226 }
227 });
228
229 it('legt eine Nachbaranlage an und entfernt sie wieder', () => {
230 const { wurzel, store, aufraeumen } = zeichne(mitKette());
231 try {
232 knopf(wurzel, 'Nachbaranlage hinzufügen').click();
233 expect(store.getProject().koordinierung?.nachbarn).toHaveLength(2);
234 knopf(wurzel, 'Entfernen').click();
235 expect(store.getProject().koordinierung?.nachbarn).toHaveLength(1);
236 } finally {
237 aufraeumen();
238 }
239 });
240 });