lsa-planer

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

/ tests ui anlagenartBedienung.test.ts

10,0 KB Rohdatei
tests/ui/anlagenartBedienung.test.ts — 267 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
4 import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants';
5 import type { Anlagenart, Project } from '@/domain/model/project';
6 import type { ViewDefinition } from '@/ui/shell';
7 import { conflictsView } from '@/ui/views/conflictsView';
8 import { phasesView } from '@/ui/views/phasesView';
9 import { projectView } from '@/ui/views/projectView';
10
11 /**
12 * Die Anlagenart muss sich bis in die Eingabefelder durchschlagen.
13 *
14 * Der Schaden, um den es geht, ist keiner der Darstellung: Eine Baustellenampel
15 * an einer 400 m langen Engstelle braucht rund 128 s Umlaufzeit und einen
16 * Raeumweg von 400 m. Solange Umlaufzeitfeld und Raeumwegfeld ihre Schranken
17 * fest mitbringen (120 s, 500 m), kappen sie beides - und die daraus
18 * errechnete Zwischenzeit ist zu kurz.
19 */
20
21 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
22
23 function basisProjekt(anlagenart: Anlagenart): Project {
24 const basis = projektAusAngaben(
25 { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true },
26 FESTES_DATUM,
27 );
28 return { ...basis, anlagenart };
29 }
30
31 function zeichne(
32 view: ViewDefinition,
33 project: Project,
34 ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } {
35 const wurzel = document.createElement('div');
36 document.body.append(wurzel);
37 const store = new ProjectStore(project);
38 const aufraeumen = view.render(wurzel, {
39 store,
40 refresh: () => {},
41 navigate: () => {},
42 target: null,
43 });
44 return {
45 wurzel,
46 store,
47 aufraeumen: () => {
48 aufraeumen();
49 wurzel.remove();
50 },
51 };
52 }
53
54 /**
55 * Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf.
56 *
57 * `labelled()` haengt im Zwischenzeitfenster einen Knopf "?" in die
58 * Beschriftung; im `textContent` steht dann "Raeumweg?" und kein Vergleich
59 * trifft mehr.
60 */
61 function beschriftungstext(label: HTMLLabelElement): string {
62 return [...label.childNodes]
63 .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf')))
64 .map((knoten) => knoten.textContent ?? '')
65 .join('')
66 .trim();
67 }
68
69 /** Feld zu einer sichtbaren Beschriftung - ueber `for`, nicht ueber die Lage. */
70 function feldZu(wurzel: ParentNode, beschriftung: string): HTMLInputElement | null {
71 const label = [...wurzel.querySelectorAll('label')].find(
72 (l) => beschriftungstext(l) === beschriftung,
73 );
74 if (label === undefined) return null;
75 return wurzel.querySelector<HTMLInputElement>(`#${label.htmlFor}`);
76 }
77
78 function knopfMit(text: string): HTMLButtonElement | undefined {
79 return [...document.querySelectorAll('button')].find(
80 (b) => (b.textContent ?? '').trim() === text,
81 );
82 }
83
84 /** Laesst die Warteschlange der Mikroaufgaben leerlaufen. */
85 async function abwarten(): Promise<void> {
86 await new Promise((fertig) => setTimeout(fertig, 0));
87 }
88
89 afterEach(() => {
90 // Dialoge und Kurzmeldungen haengen an document.body, nicht an der Ansicht.
91 document.body.replaceChildren();
92 });
93
94 describe('Umlaufzeitfeld folgt der Anlagenart', () => {
95 for (const art of Object.keys(ANLAGENART_GRENZEN) as Anlagenart[]) {
96 it(`nennt bei "${art}" dieselben Grenzen im Feld und im sichtbaren Hinweis`, () => {
97 const basis = basisProjekt(art);
98 const projekt: Project = { ...basis, program: { ...basis.program, method: 'manuell' } };
99 const grenzen = ANLAGENART_GRENZEN[art].cycleTime;
100
101 const { wurzel, aufraeumen } = zeichne(phasesView, projekt);
102 try {
103 const feld = feldZu(wurzel, 'Umlaufzeit');
104 expect(feld, 'Kein Feld "Umlaufzeit" in der Ansicht').not.toBeNull();
105 expect(feld?.getAttribute('min')).toBe(String(grenzen.min));
106 expect(feld?.getAttribute('max')).toBe(String(grenzen.max));
107
108 // Der sichtbare Hinweis darf keine zweite Fassung der Zahlen sein.
109 const hinweis = wurzel.querySelector(`#${feld?.getAttribute('aria-describedby') ?? ''}`);
110 const text = hinweis?.textContent ?? '';
111 expect(text).toContain(String(grenzen.min));
112 expect(text).toContain(String(grenzen.max));
113 } finally {
114 aufraeumen();
115 }
116 });
117 }
118 });
119
120 describe('Raeumwegfeld folgt der Anlagenart', () => {
121 for (const art of Object.keys(ANLAGENART_GRENZEN) as Anlagenart[]) {
122 it(`laesst bei "${art}" Wege bis ${ANLAGENART_GRENZEN[art].maxClearingDistance} m zu`, () => {
123 const grenze = ANLAGENART_GRENZEN[art].maxClearingDistance;
124 const { wurzel, aufraeumen } = zeichne(conflictsView, basisProjekt(art));
125 try {
126 // Erste besetzte Zelle der Matrix oeffnet das Bearbeitungsfenster.
127 const zelle = wurzel.querySelector<HTMLButtonElement>(
128 '.matrix-zelle:not(.matrix-zelle-leer)',
129 );
130 expect(zelle, 'Die Matrix hat keine besetzte Zelle').not.toBeNull();
131 zelle?.click();
132
133 const fenster = document.querySelector('.dialog');
134 expect(fenster, 'Das Zwischenzeitfenster ist nicht offen').not.toBeNull();
135
136 for (const name of ['Räumweg', 'Einfahrweg']) {
137 const feld = feldZu(fenster as ParentNode, name);
138 expect(feld, `Kein Feld "${name}" im Fenster`).not.toBeNull();
139 expect(feld?.getAttribute('max'), `"${name}" haelt eine eigene Obergrenze`).toBe(
140 String(grenze),
141 );
142 }
143 } finally {
144 aufraeumen();
145 }
146 });
147 }
148
149 it('setzt dieselbe Obergrenze im Sammelbearbeiten-Fenster', () => {
150 const grenze = ANLAGENART_GRENZEN.einstreifig.maxClearingDistance;
151 const { aufraeumen } = zeichne(conflictsView, basisProjekt('einstreifig'));
152 try {
153 knopfMit('Alle Wege gemeinsam setzen')?.click();
154 const fenster = document.querySelector('.dialog');
155 expect(fenster, 'Das Sammelfenster ist nicht offen').not.toBeNull();
156
157 for (const name of ['Räumweg', 'Einfahrweg']) {
158 expect(feldZu(fenster as ParentNode, name)?.getAttribute('max')).toBe(String(grenze));
159 }
160 } finally {
161 aufraeumen();
162 }
163 });
164 });
165
166 describe('Wechsel der Anlagenart', () => {
167 /** Das Auswahlfeld der Anlagenart in der Projektansicht. */
168 function auswahl(wurzel: ParentNode): HTMLSelectElement {
169 const label = [...wurzel.querySelectorAll('label')].find(
170 (l) => (l.textContent ?? '').trim() === 'Anlagenart',
171 );
172 const feld = wurzel.querySelector<HTMLSelectElement>(`#${label?.htmlFor ?? ''}`);
173 expect(feld, 'Kein Auswahlfeld "Anlagenart" in der Projektansicht').not.toBeNull();
174 return feld as HTMLSelectElement;
175 }
176
177 it('bietet alle Anlagenarten an und steht vor den uebrigen Projektangaben', () => {
178 const { wurzel, aufraeumen } = zeichne(projectView, basisProjekt('knotenpunkt'));
179 try {
180 const feld = auswahl(wurzel);
181 expect([...feld.options].map((o) => o.value)).toEqual(Object.keys(ANLAGENART_GRENZEN));
182 expect(feld.value).toBe('knotenpunkt');
183
184 // Vor den uebrigen Angaben: Die Anlagenart bestimmt, woran alles Weitere
185 // bemessen wird, und gehoert deshalb nicht ans Ende des Formulars.
186 const felder = [...wurzel.querySelectorAll('input, select')];
187 expect(felder.indexOf(feld)).toBe(0);
188
189 // Der Hinweis muss die Folgen nennen, nicht nur den Namen wiederholen.
190 const hinweis = wurzel.querySelector(`#${feld.getAttribute('aria-describedby') ?? ''}`);
191 const text = hinweis?.textContent ?? '';
192 const grenzen = ANLAGENART_GRENZEN.knotenpunkt;
193 expect(text).toContain(String(grenzen.cycleTime.max));
194 expect(text).toContain(String(grenzen.maxClearingDistance));
195 } finally {
196 aufraeumen();
197 }
198 });
199
200 it('fragt nach, bevor eine Umstellung bestehende Werte ungueltig macht', async () => {
201 const basis = basisProjekt('einstreifig');
202 const projekt: Project = {
203 ...basis,
204 program: { ...basis.program, method: 'manuell', manualCycleTime: 200 },
205 };
206 const { wurzel, store, aufraeumen } = zeichne(projectView, projekt);
207 try {
208 const feld = auswahl(wurzel);
209 feld.value = 'knotenpunkt';
210 feld.dispatchEvent(new Event('change'));
211
212 const dialog = document.querySelector('.dialog');
213 expect(dialog, 'Die Umstellung geschah ohne Rueckfrage').not.toBeNull();
214 // Die betroffene Angabe wird beim Namen genannt - sonst weiss niemand,
215 // was nach dem Bestaetigen zu pruefen ist.
216 expect(dialog?.textContent ?? '').toContain('200');
217
218 knopfMit('Anlagenart behalten')?.click();
219 await abwarten();
220 expect(store.getProject().anlagenart, 'Abbrechen hat trotzdem umgestellt').toBe(
221 'einstreifig',
222 );
223 // Und vor allem: Die Umlaufzeit steht unveraendert da.
224 expect(store.getProject().program.manualCycleTime).toBe(200);
225 } finally {
226 aufraeumen();
227 }
228 });
229
230 it('kappt beim Bestaetigen nichts, sondern stellt nur die Anlagenart um', async () => {
231 const basis = basisProjekt('einstreifig');
232 const projekt: Project = {
233 ...basis,
234 program: { ...basis.program, method: 'manuell', manualCycleTime: 200 },
235 };
236 const { wurzel, store, aufraeumen } = zeichne(projectView, projekt);
237 try {
238 const feld = auswahl(wurzel);
239 feld.value = 'knotenpunkt';
240 feld.dispatchEvent(new Event('change'));
241 knopfMit('Umstellen')?.click();
242 await abwarten();
243
244 expect(store.getProject().anlagenart).toBe('knotenpunkt');
245 // Eine am Lageplan gemessene Zahl auf die neue Schranke zu ziehen waere
246 // eine stille Faelschung - sie bliebe plausibel und waere falsch.
247 expect(store.getProject().program.manualCycleTime).toBe(200);
248 } finally {
249 aufraeumen();
250 }
251 });
252
253 it('stellt ohne Konflikt sofort um', async () => {
254 const { wurzel, store, aufraeumen } = zeichne(projectView, basisProjekt('knotenpunkt'));
255 try {
256 const feld = auswahl(wurzel);
257 feld.value = 'einstreifig';
258 feld.dispatchEvent(new Event('change'));
259 await abwarten();
260
261 expect(document.querySelector('.dialog'), 'Rueckfrage ohne Anlass').toBeNull();
262 expect(store.getProject().anlagenart).toBe('einstreifig');
263 } finally {
264 aufraeumen();
265 }
266 });
267 });