lsa-planer

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

/ tests ui projektangaben.test.ts

9,9 KB Rohdatei
tests/ui/projektangaben.test.ts — 265 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import * as actions from '@/app/actions';
4 import { createStandardIntersectionProject } from '@/domain/model/factory';
5 import type { Project } from '@/domain/model/project';
6 import { buildSignalPlan } from '@/domain/plan/signalPlan';
7 import { mitBewahrtemFokus } from '@/ui/fokus';
8 import type { ViewContext } from '@/ui/shell';
9 import { projectView } from '@/ui/views/projectView';
10
11 /**
12 * Fassung 5.10.0 - Ansicht "Projekt".
13 *
14 * - Befund 23 (mittel): Unter der zulaessigen Hoechstgeschwindigkeit des
15 * KNOTENPUNKTS stand "Bestimmt die Gelbzeit: ...". Die Gelbzeit folgt aber
16 * ausschliesslich `SignalGroup.vZul`; `intersection.vZul` ist die
17 * Vorbelegung neuer Zufahrten und Signalgruppen, Grundlage der
18 * 70-km/h-Warnung und eine Zeile im Ausdruck - eine Rechnung liest ihn
19 * nicht. Wer ihn hochsetzt, um laengere Gelb- und damit Ueberfahrzeiten zu
20 * bekommen, behaelt die kuerzeren: die unsichere Richtung.
21 *
22 * - Befund 24 (mittel): Der Hinweis am Auswahlfeld "Anlagenart" las die
23 * Rohschranken aus `ANLAGENART_GRENZEN`. Bemessen und beurteilt wird gegen
24 * `anlagenartGrenzen(project, defaults)`, das eine ausdruecklich
25 * eingetragene Vorgabe verengt. Dieselbe Rohschranke stand in der
26 * Kurzmeldung nach dem Wechsel und in `grenzverletzungen()`, das vor dem
27 * Wechsel warnt.
28 *
29 * - Befund 54 (gering): "Zufahrt hinzufügen" bildete den Vorgabenamen aus der
30 * ANZAHL der Zufahrten. Nach einem Loeschen entstand ein bereits vergebener
31 * Name - die Auswahlliste der Signalgruppentabelle fuehrte zwei
32 * gleichlautende Eintraege, und der Name der Loeschschaltflaeche benannte
33 * nicht mehr eindeutig.
34 */
35
36 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
37
38 let aufraeumen: (() => void) | null = null;
39 afterEach(() => {
40 aufraeumen?.();
41 aufraeumen = null;
42 document.body.replaceChildren();
43 });
44
45 interface Ansicht {
46 readonly wurzel: HTMLElement;
47 readonly store: ProjectStore;
48 }
49
50 function zeichne(project: Project): Ansicht {
51 const wurzel = document.createElement('div');
52 document.body.append(wurzel);
53 const store = new ProjectStore(project);
54 let abmelden: () => void = () => {};
55 const context: ViewContext = {
56 store,
57 refresh: () => {
58 mitBewahrtemFokus(wurzel, () => {
59 abmelden();
60 wurzel.replaceChildren();
61 abmelden = projectView.render(wurzel, context);
62 });
63 },
64 navigate: () => {},
65 target: null,
66 };
67 abmelden = projectView.render(wurzel, context);
68 aufraeumen = () => {
69 abmelden();
70 wurzel.remove();
71 };
72 return { wurzel, store };
73 }
74
75 async function abwarten(): Promise<void> {
76 await new Promise((fertig) => setTimeout(fertig, 0));
77 }
78
79 /** Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf daneben. */
80 function beschriftungstext(label: HTMLLabelElement): string {
81 return [...label.childNodes]
82 .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf')))
83 .map((knoten) => knoten.textContent ?? '')
84 .join('')
85 .trim();
86 }
87
88 /** Der vorgelesene Hinweis eines Feldes - ueber `aria-describedby`. */
89 function hinweisZu(wurzel: HTMLElement, beschriftung: string): string {
90 const label = [...wurzel.querySelectorAll('label')].find(
91 (l) => beschriftungstext(l) === beschriftung,
92 );
93 if (label === undefined) throw new Error(`Kein Feld "${beschriftung}"`);
94 const feld = wurzel.querySelector(`#${label.htmlFor}`);
95 const kennung = feld?.getAttribute('aria-describedby') ?? '';
96 return kennung
97 .split(' ')
98 .map((id) => wurzel.querySelector(`#${id}`)?.textContent ?? '')
99 .join(' ');
100 }
101
102 function knopfMit(text: string): HTMLButtonElement | undefined {
103 return [...document.querySelectorAll('button')].find(
104 (b) => (b.textContent ?? '').trim() === text,
105 );
106 }
107
108 describe('Befund 23 - Feldhinweis an der Hoechstgeschwindigkeit des Knotenpunkts', () => {
109 /*
110 * Die Messung, an der der Feldhinweis zu pruefen ist: Der Knotenpunktwert
111 * bewegt keine Gelbzeit. Sie steht hier und nicht nur im Text, damit die
112 * Aussage des Hinweises an einem Ergebnis haengt und nicht an einer
113 * Behauptung.
114 */
115 it('bewegt keine Gelbzeit, wenn er geaendert wird', () => {
116 const vorher = createStandardIntersectionProject('Gelbzeit', FESTES_DATUM);
117 const nachher = actions.setIntersection(vorher, { vZul: 70 });
118
119 expect(nachher.intersection.vZul).toBe(70);
120 expect(nachher.signalGroups.map((g) => g.vZul)).toEqual(vorher.signalGroups.map((g) => g.vZul));
121 expect(buildSignalPlan(nachher).groups.map((g) => g.times.yellow)).toEqual(
122 buildSignalPlan(vorher).groups.map((g) => g.times.yellow),
123 );
124 });
125
126 it('schreibt die Gelbzeit nicht dem Knotenpunktwert zu', () => {
127 const { wurzel } = zeichne(createStandardIntersectionProject('Hinweis', FESTES_DATUM));
128 const text = hinweisZu(wurzel, 'Zulässige Höchstgeschwindigkeit');
129
130 expect(
131 text,
132 'Der Hinweis schreibt dem Knotenpunktwert eine Wirkung zu, die er nicht hat',
133 ).not.toContain('Bestimmt die Gelbzeit');
134 // Was er tatsaechlich tut, und wo die Gelbzeit haengt.
135 expect(text).toContain('Vorbelegung');
136 expect(text).toContain('Signalgruppe');
137 // Die 70-km/h-Warnung bleibt am Feld - sie haengt wirklich an ihm.
138 expect(text).toContain('70');
139 });
140 });
141
142 describe('Befund 24 - Anlagenart-Hinweis nennt die wirksamen Schranken', () => {
143 /** Knotenpunkt mit eingetragenen Umlaufzeitvorgaben 45 / 100 / 80. */
144 function mitVorgaben(): Project {
145 const basis = createStandardIntersectionProject('Vorgaben', FESTES_DATUM);
146 return {
147 ...basis,
148 settings: {
149 ...basis.settings,
150 rilsa: {
151 ...basis.settings.rilsa,
152 cycleTimeMin: 45,
153 cycleTimeMax: 100,
154 cycleTimeRecommendedMax: 80,
155 },
156 },
157 };
158 }
159
160 it('nennt am Auswahlfeld die Schranken, gegen die bemessen wird', () => {
161 const projekt = mitVorgaben();
162 const grenzen = buildSignalPlan(projekt).grenzen.cycleTime;
163 expect([grenzen.min, grenzen.max, grenzen.recommendedMax]).toEqual([45, 100, 80]);
164
165 const { wurzel } = zeichne(projekt);
166 const text = hinweisZu(wurzel, 'Anlagenart');
167
168 expect(text).toContain(`Umlaufzeit ${grenzen.min} bis ${grenzen.max} s`);
169 expect(text).toContain(`empfohlen ${grenzen.recommendedMin} bis ${grenzen.recommendedMax} s`);
170 // Die Rohschranken der Anlagenart gelten hier gerade nicht.
171 expect(text, 'Der Hinweis nennt einen weiteren Bereich, als tatsächlich gilt').not.toContain(
172 'Umlaufzeit 30 bis 120 s',
173 );
174 // Unverengt und deshalb unveraendert: fuer sie gibt es keine Vorgabenfelder.
175 expect(text).toContain('Räumweg bis 500 m');
176 });
177
178 it('warnt vor einem Wechsel, den erst die eingetragene Vorgabe unzulaessig macht', async () => {
179 const basis = mitVorgaben();
180 const projekt: Project = {
181 ...basis,
182 anlagenart: 'einstreifig',
183 program: { ...basis.program, method: 'manuell', manualCycleTime: 110 },
184 };
185 const { wurzel, store } = zeichne(projekt);
186
187 const auswahl = wurzel.querySelector<HTMLSelectElement>('select');
188 expect(auswahl?.value).toBe('einstreifig');
189 auswahl!.value = 'knotenpunkt';
190 auswahl!.dispatchEvent(new Event('change'));
191
192 const dialog = document.querySelector('.dialog');
193 expect(
194 dialog,
195 'Die Umstellung geschah ohne Rückfrage, obwohl 110 s über der wirksamen Schranke von 100 s liegen',
196 ).not.toBeNull();
197 expect(dialog?.textContent ?? '').toContain('100');
198
199 knopfMit('Umstellen')?.click();
200 await abwarten();
201 expect(store.getProject().anlagenart).toBe('knotenpunkt');
202
203 // Die Kurzmeldung nach dem Wechsel nennt dieselben Schranken.
204 const meldung = [...document.querySelectorAll('.meldung')]
205 .map((m) => m.textContent ?? '')
206 .join(' ');
207 expect(meldung).toContain('100');
208 expect(meldung, 'Die Kurzmeldung nennt die Rohschranke der Anlagenart').not.toContain('120');
209 });
210 });
211
212 describe('Befund 54 - "Zufahrt hinzufügen" vergibt keinen bereits belegten Namen', () => {
213 function ohneZufahrten(): Project {
214 const basis = createStandardIntersectionProject('Zufahrten', FESTES_DATUM);
215 return { ...basis, intersection: { ...basis.intersection, arms: [] } };
216 }
217
218 it('zaehlt an den vergebenen Namen weiter, nicht an der Anzahl', () => {
219 const { wurzel, store } = zeichne(ohneZufahrten());
220 const hinzufuegen = (): void => {
221 const knopf = [...wurzel.querySelectorAll('button')].find(
222 (b) => (b.textContent ?? '').trim() === 'Zufahrt hinzufügen',
223 );
224 if (knopf === undefined) throw new Error('Keine Schaltfläche "Zufahrt hinzufügen"');
225 knopf.click();
226 };
227
228 hinzufuegen();
229 hinzufuegen();
230 expect(store.getProject().intersection.arms.map((a) => a.name)).toEqual([
231 'Zufahrt 1',
232 'Zufahrt 2',
233 ]);
234
235 const erste = store.getProject().intersection.arms[0]!;
236 store.update((p) => actions.removeArm(p, erste.id), { label: 'Zufahrt gelöscht' });
237 hinzufuegen();
238
239 const namen = store.getProject().intersection.arms.map((a) => a.name);
240 expect(new Set(namen).size, `Zwei Zufahrten heißen gleich: ${namen.join(' | ')}`).toBe(
241 namen.length,
242 );
243 });
244
245 it('haelt die Namen der Loeschschaltflaechen unterscheidbar', () => {
246 const { wurzel, store } = zeichne(ohneZufahrten());
247 const hinzufuegen = (): void => {
248 [...wurzel.querySelectorAll('button')]
249 .find((b) => (b.textContent ?? '').trim() === 'Zufahrt hinzufügen')
250 ?.click();
251 };
252
253 hinzufuegen();
254 hinzufuegen();
255 const erste = store.getProject().intersection.arms[0]!;
256 store.update((p) => actions.removeArm(p, erste.id), { label: 'Zufahrt gelöscht' });
257 hinzufuegen();
258
259 const namen = [...wurzel.querySelectorAll('button')]
260 .map((b) => b.getAttribute('aria-label') ?? '')
261 .filter((n) => n.startsWith('Zufahrt "'));
262 expect(namen).toHaveLength(2);
263 expect(new Set(namen).size, 'Die Namen sind nicht unterscheidbar').toBe(namen.length);
264 });
265 });