lsa-planer

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

/ tests ui sprungorte.test.ts

10,1 KB Rohdatei
tests/ui/sprungorte.test.ts — 317 Zeilen
1 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import type { Lageplan } from '@/domain/geometrie/lageplan';
4 import { createStandardIntersectionProject } from '@/domain/model/factory';
5 import type { Project } from '@/domain/model/project';
6 import { Shell } from '@/ui/shell';
7 import { lageplanView } from '@/ui/views/lageplanView';
8 import { phasesView } from '@/ui/views/phasesView';
9 import { projectView } from '@/ui/views/projectView';
10 import { reportView } from '@/ui/views/reportView';
11 import { settingsView } from '@/ui/views/settingsView';
12 import { signalGroupsView } from '@/ui/views/signalGroupsView';
13
14 /**
15 * Der Sprung fuer die Zielart "projekt" trifft den Ort.
16 *
17 * Diese Zielart traegt an allen Fundstellen dieselbe Kennung (die des
18 * Projekts) und unterscheidet neun Orte allein ueber den Anzeigetext. Der
19 * Knopf hiess deshalb 'Zu "Verkehrsstärken"' und landete auf dem Abschnitt
20 * "Projektdaten" der Ansicht "Projekt" - fuer acht der neun Orte war die
21 * Beschriftung eine Zusage, die der Sprung nicht einloest.
22 *
23 * WAS OHNE DIE BEHEBUNG ROT IST: alle neun Ortsfaelle - fuenf landen in der
24 * falschen Ansicht, die uebrigen vier auf dem falschen Abschnitt derselben
25 * Ansicht - und der Fall, der zwei Orte gegeneinander stellt. Er ist der
26 * eigentliche Befund: Zwei Orte, die verschiedene Dinge meinen, kamen am
27 * selben Anker heraus.
28 *
29 * Geprueft wird ueber die Oberflaeche und nicht ueber das Sprungziel-Attribut:
30 * Angesteuert werden soll der Abschnitt, den der Anwender sucht - dass er ein
31 * Attribut traegt, ist der Weg dorthin und nicht die Zusage.
32 *
33 * `scrollIntoView` gibt es in jsdom nicht; es wird hier als Aufzeichner
34 * eingesetzt.
35 */
36
37 const FESTES_DATUM = new Date('2026-01-01T00:00:00.000Z');
38
39 /** Elemente, auf denen `scrollIntoView` gerufen wurde - in der Reihenfolge. */
40 let angerollt: Element[] = [];
41 let vorherigesAufrollen: PropertyDescriptor | undefined;
42
43 beforeEach(() => {
44 angerollt = [];
45 vorherigesAufrollen = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView');
46 Object.defineProperty(Element.prototype, 'scrollIntoView', {
47 configurable: true,
48 writable: true,
49 value(this: Element) {
50 angerollt.push(this);
51 },
52 });
53 document.querySelector('.meldungen')?.remove();
54 });
55
56 afterEach(() => {
57 if (vorherigesAufrollen === undefined) {
58 delete (Element.prototype as Partial<Element>).scrollIntoView;
59 } else {
60 Object.defineProperty(Element.prototype, 'scrollIntoView', vorherigesAufrollen);
61 }
62 document.querySelector('.meldungen')?.remove();
63 });
64
65 function basisProjekt(): Project {
66 return createStandardIntersectionProject('Prueffall', FESTES_DATUM);
67 }
68
69 /**
70 * Lageplan mit georeferenziertem Massstab und grober Bodenaufloesung.
71 *
72 * 2000 m auf 7000 Bildpunkte sind 28,6 cm je Bildpunkt - genug fuer
73 * Kfz-Beziehungen, zu grob fuer die Furten des Prueffalls. Genau daraus
74 * entsteht die Beanstandung, die auf den Lageplan zeigt.
75 */
76 function groberLageplan(): Lageplan {
77 return {
78 arbeitsbereiche: [],
79 signalgeber: [],
80 bild: null,
81 kalibrierung: {
82 von: { x: 0, y: 0 },
83 bis: { x: 7000, y: 0 },
84 laengeMeter: 2000,
85 herkunft: 'georeferenziert',
86 },
87 haltlinien: [],
88 linien: [],
89 };
90 }
91
92 interface Rahmen {
93 readonly wurzel: HTMLElement;
94 readonly shell: Shell;
95 aufraeumen(): void;
96 }
97
98 function baueRahmen(projekt: Project): Rahmen {
99 const wurzel = document.createElement('div');
100 document.body.append(wurzel);
101
102 const shell = new Shell(wurzel, new ProjectStore(projekt), '0.0.0-pruefung');
103 for (const ansicht of [
104 projectView,
105 signalGroupsView,
106 phasesView,
107 lageplanView,
108 settingsView,
109 reportView,
110 ]) {
111 shell.addView(ansicht);
112 }
113 shell.start('pruefung');
114
115 return {
116 wurzel,
117 shell,
118 aufraeumen: () => {
119 shell.stop();
120 wurzel.remove();
121 },
122 };
123 }
124
125 /** Drueckt den Sprungknopf, dessen Beschriftung diesen Ort nennt. */
126 function springeZumOrt(rahmen: Rahmen, ort: string): void {
127 const beschriftung = `Zu "${ort}"`;
128 const knopf = [...rahmen.wurzel.querySelectorAll('button')].find(
129 (b) => b.textContent === beschriftung,
130 );
131 if (knopf === undefined) throw new Error(`Kein Sprungknopf ${beschriftung} im Prüfbericht.`);
132 knopf.click();
133 }
134
135 /** Abschnittskarte mit diesem sichtbaren Titel. */
136 function karteMitTitel(rahmen: Rahmen, titel: string): Element | null {
137 return (
138 [...rahmen.wurzel.querySelectorAll('section.karte')].find((k) =>
139 (k.querySelector('.abschnitt-titel')?.textContent ?? '').startsWith(titel),
140 ) ?? null
141 );
142 }
143
144 /** Das Feld (Beschriftung samt Auswahl) mit dieser Beschriftung. */
145 function feldMitBeschriftung(rahmen: Rahmen, beschriftung: string): Element | null {
146 const marke = [...rahmen.wurzel.querySelectorAll('.feld > label')].find(
147 (l) => l.textContent === beschriftung,
148 );
149 return marke?.parentElement ?? null;
150 }
151
152 /** Hinweisbalken mit dieser Ueberschrift. */
153 function balkenMitTitel(rahmen: Rahmen, titel: string): Element | null {
154 return (
155 [...rahmen.wurzel.querySelectorAll('.hinweisbalken')].find(
156 (b) => b.querySelector('strong')?.textContent === titel,
157 ) ?? null
158 );
159 }
160
161 /** Spaltenkopf, dessen Text so beginnt - der Hilfeknopf haengt hinten dran. */
162 function spaltenkopfMit(rahmen: Rahmen, text: string): Element | null {
163 return (
164 [...rahmen.wurzel.querySelectorAll('th')].find((th) =>
165 (th.textContent ?? '').startsWith(text),
166 ) ?? null
167 );
168 }
169
170 interface Ortsfall {
171 /** Anzeigetext des Sprungknopfs - zugleich der Ort. */
172 readonly ort: string;
173 /** Projekt, dessen Pruefbericht eine Beanstandung zu diesem Ort fuehrt. */
174 readonly projekt: () => Project;
175 readonly ansicht: string;
176 readonly anker: (rahmen: Rahmen) => Element | null;
177 }
178
179 const FAELLE: readonly Ortsfall[] = [
180 {
181 ort: 'Projektdaten',
182 projekt: basisProjekt,
183 ansicht: 'projekt',
184 anker: (r) => karteMitTitel(r, 'Projektdaten'),
185 },
186 {
187 ort: 'Knotenpunkt',
188 projekt: () => {
189 const basis = basisProjekt();
190 return { ...basis, intersection: { ...basis.intersection, vZul: 80 } };
191 },
192 ansicht: 'projekt',
193 anker: (r) => karteMitTitel(r, 'Knotenpunkt'),
194 },
195 {
196 ort: 'Anlagenart',
197 projekt: () => ({ ...basisProjekt(), anlagenart: 'einstreifig' }),
198 ansicht: 'projekt',
199 anker: (r) => feldMitBeschriftung(r, 'Anlagenart'),
200 },
201 {
202 ort: 'Bewertungsverfahren',
203 projekt: () => {
204 const basis = basisProjekt();
205 return { ...basis, settings: { ...basis.settings, serviceLevelScale: 'HCM' } };
206 },
207 ansicht: 'projekt',
208 anker: (r) => feldMitBeschriftung(r, 'Bewertungsverfahren'),
209 },
210 {
211 ort: 'Signalgruppen',
212 projekt: () => {
213 const basis = basisProjekt();
214 return {
215 ...basis,
216 signalGroups: [],
217 conflicts: [],
218 demands: [],
219 phases: basis.phases.map((phase) => ({ ...phase, signalGroupIds: [] })),
220 };
221 },
222 ansicht: 'signalgruppen',
223 anker: (r) => karteMitTitel(r, 'Signalgruppen'),
224 },
225 {
226 ort: 'Verkehrsstärken',
227 projekt: () => ({ ...basisProjekt(), demands: [] }),
228 ansicht: 'signalgruppen',
229 anker: (r) => spaltenkopfMit(r, 'Verkehrsstärke'),
230 },
231 {
232 ort: 'Phasen',
233 projekt: () => {
234 const basis = basisProjekt();
235 const erste = basis.phases[0]!;
236 return {
237 ...basis,
238 phases: [erste],
239 program: { ...basis.program, phaseOrder: [erste.id] },
240 };
241 },
242 ansicht: 'phasen',
243 anker: (r) => karteMitTitel(r, 'Phasenfolge'),
244 },
245 {
246 ort: 'Vorgaben',
247 projekt: () => {
248 const basis = basisProjekt();
249 return { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' } };
250 },
251 ansicht: 'vorgaben',
252 anker: (r) => balkenMitTitel(r, 'Es gelten durchgängig die Regelwerte.'),
253 },
254 {
255 ort: 'Lageplan',
256 projekt: () => ({ ...basisProjekt(), lageplan: groberLageplan() }),
257 ansicht: 'lageplan',
258 anker: (r) => balkenMitTitel(r, 'Nur für Kfz-Beziehungen genau genug'),
259 },
260 ];
261
262 describe('Sprung aus dem Pruefbericht - Ort der Zielart "projekt"', () => {
263 for (const fall of FAELLE) {
264 it(`"${fall.ort}" landet in der Ansicht "${fall.ansicht}" auf dem zugehörigen Anker`, () => {
265 const rahmen = baueRahmen(fall.projekt());
266 try {
267 springeZumOrt(rahmen, fall.ort);
268 expect(rahmen.shell.aktiveAnsichtId).toBe(fall.ansicht);
269 const anker = fall.anker(rahmen);
270 expect(anker, `kein Anker für den Ort "${fall.ort}"`).not.toBeNull();
271 expect(angerollt).toEqual([anker]);
272 expect(document.activeElement).toBe(anker);
273 } finally {
274 rahmen.aufraeumen();
275 }
276 });
277 }
278 });
279
280 describe('Sprung aus dem Pruefbericht - zwei Orte, zwei Anker', () => {
281 /*
282 * Der eigentliche Befund. Beide Beanstandungen stehen in demselben Bericht,
283 * beide tragen die Kennung des Projekts, und beide zeigen auf dieselbe
284 * Ansicht - unterschieden sind sie allein durch den Ort. Landen sie auf
285 * demselben Element, ist eine der beiden Beschriftungen unwahr.
286 */
287 /**
288 * Titel des Abschnitts, auf dem der Fokus nach dem Sprung steht.
289 *
290 * Je Sprung ein eigener Rahmen: Beide Orte liegen in derselben Ansicht, und
291 * verglichen wird der sichtbare Titel und nicht der Knoten - zwei Rahmen
292 * fuehren zwei verschiedene Knoten, auch wenn sie dasselbe zeigen.
293 */
294 function angesteuerterAbschnitt(projekt: Project, ort: string): string {
295 const rahmen = baueRahmen(projekt);
296 try {
297 springeZumOrt(rahmen, ort);
298 const ziel = document.activeElement;
299 expect(ziel, `kein Sprungziel für "${ort}"`).not.toBeNull();
300 return ziel?.querySelector('.abschnitt-titel')?.textContent ?? '';
301 } finally {
302 rahmen.aufraeumen();
303 }
304 }
305
306 it('führt "Projektdaten" und "Knotenpunkt" auf verschiedene Abschnitte', () => {
307 const basis = basisProjekt();
308 const projekt: Project = { ...basis, intersection: { ...basis.intersection, vZul: 80 } };
309
310 const projektdaten = angesteuerterAbschnitt(projekt, 'Projektdaten');
311 const knotenpunkt = angesteuerterAbschnitt(projekt, 'Knotenpunkt');
312
313 expect(projektdaten).toBe('Projektdaten');
314 expect(knotenpunkt).toBe('Knotenpunkt');
315 expect(knotenpunkt).not.toBe(projektdaten);
316 });
317 });