lsa-planer

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

/ tests ui sprungziel.test.ts

13,5 KB Rohdatei
tests/ui/sprungziel.test.ts — 359 Zeilen
1 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { ProjectStore } from '@/app/store';
4 import type { FindingTarget, ProjektOrt } from '@/domain/validation';
5 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
6 import { Shell } 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 import { reportView } from '@/ui/views/reportView';
11 import { signalGroupsView } from '@/ui/views/signalGroupsView';
12
13 /**
14 * Der Sprung aus dem Pruefbericht (Befunde 51 und 63 zur Fassung 5.8.0).
15 *
16 * Jede Beanstandung mit Fundstelle traegt einen Knopf, dessen Beschriftung das
17 * Ziel namentlich nennt - 'Zu "K1"'. Er wechselte die Ansicht und liess den
18 * Anwender dort am Seitenanfang stehen: Das Sprungziel wurde von der Shell
19 * zwar in den Ansichtskontext gelegt, aber von keiner Stelle ausgewertet, und
20 * `main.scrollTop = 0` zog den Aufbau ausdruecklich an den Anfang.
21 *
22 * Zugesagt ist der Sprung schriftlich ("mit Sprung zum betroffenen Element",
23 * "zeigen auf ihre Konfliktbeziehung").
24 *
25 * WAS OHNE DIE BEHEBUNG ROT IST: die fuenf Faelle je Zielart (nichts wird
26 * aufgerollt, der Fokus steht auf der Ueberschrift der Ansicht), der Fall zum
27 * fehlenden Ziel (es gab keine Meldung darueber) und der Fall zum Seitenanfang
28 * (er wurde auch beim Sprung auf 0 gezogen). Die beiden letzten Faelle - der
29 * Wechsel ohne Ziel und der Neuaufbau - sind Wachen und keine Nachweise: Sie
30 * gehen auch gegen den Altstand durch und halten fest, dass der neue Weg das
31 * bisherige Verhalten nicht mitnimmt.
32 *
33 * `scrollIntoView` gibt es in jsdom nicht; es wird hier als Aufzeichner
34 * eingesetzt.
35 *
36 * NACHTRAG (Fassung 5.9.0): Die Zielart "projekt" entscheidet seither ueber
37 * ihren ORT, wohin sie fuehrt - dieselbe Zielart landet je nach Ort in einer
38 * anderen Ansicht ("Verkehrsstärken" bei den Signalgruppen, "Projektdaten" im
39 * Projekt). Ein Fall zu dieser Zielart muss den Ort deshalb mitnennen; die
40 * blosse Zielart benennt kein Ziel mehr. Je Ort eine Wache steht in
41 * `tests/ui/sprungorte.test.ts`; hier bleibt der eine Ort
42 * "projektdaten".
43 */
44
45 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
46
47 /** Elemente, auf denen `scrollIntoView` gerufen wurde - in der Reihenfolge. */
48 let angerollt: Element[] = [];
49 let vorherigesAufrollen: PropertyDescriptor | undefined;
50
51 beforeEach(() => {
52 angerollt = [];
53 vorherigesAufrollen = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView');
54 Object.defineProperty(Element.prototype, 'scrollIntoView', {
55 configurable: true,
56 writable: true,
57 value(this: Element) {
58 angerollt.push(this);
59 },
60 });
61 document.querySelector('.meldungen')?.remove();
62 });
63
64 afterEach(() => {
65 if (vorherigesAufrollen === undefined) {
66 delete (Element.prototype as Partial<Element>).scrollIntoView;
67 } else {
68 Object.defineProperty(Element.prototype, 'scrollIntoView', vorherigesAufrollen);
69 }
70 document.querySelector('.meldungen')?.remove();
71 });
72
73 interface Rahmen {
74 readonly wurzel: HTMLElement;
75 readonly shell: Shell;
76 readonly store: ProjectStore;
77 /** Der Inhaltsbereich - er fuehrt den Rollstand der Seite. */
78 readonly inhalt: HTMLElement;
79 aufraeumen(): void;
80 }
81
82 /**
83 * Rahmen mit den vier Zielansichten und dem Pruefbericht.
84 *
85 * Das Projekt wird so zugerichtet, dass der Bericht zu JEDER Zielart aus
86 * `FindingTarget` mindestens eine Beanstandung mit Fundstelle fuehrt: eine
87 * Signalgruppe ohne Bezeichnung, eine Konfliktbeziehung ohne Raeumweg und eine
88 * Phase ohne Signalgruppe. "projekt" und "programm" ergeben sich von selbst -
89 * das Schriftfeld ist leer, und die Verkehrsstaerken fehlen.
90 */
91 function baueRahmen(): Rahmen {
92 const project = projektAusAngaben(
93 { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true },
94 FESTES_DATUM,
95 );
96 const store = new ProjectStore(project);
97
98 // Die LETZTE Gruppe verliert ihren Namen, nicht die erste: Die erste ist der
99 // Raeumstrom der Konfliktbeziehung unten, und deren Beschriftung im Bericht
100 // setzt sich aus beiden Namen zusammen.
101 const letzteGruppe = project.signalGroups[project.signalGroups.length - 1];
102 const ersterKonflikt = project.conflicts[0];
103 if (letzteGruppe === undefined || ersterKonflikt === undefined) {
104 throw new Error('Das Prüfprojekt führt weder Signalgruppen noch Konfliktbeziehungen.');
105 }
106 store.update((p) => actions.updateSignalGroup(p, letzteGruppe.id, { name: '' }), {
107 label: 'Bezeichnung geleert',
108 });
109 store.update(
110 (p) =>
111 actions.setConflict(p, ersterKonflikt.fromId, ersterKonflikt.toId, { clearingDistance: 0 }),
112 { label: 'Räumweg geleert' },
113 );
114 store.update((p) => actions.addPhase(p), { label: 'Phase hinzugefügt' });
115
116 const wurzel = document.createElement('div');
117 document.body.append(wurzel);
118
119 const shell = new Shell(wurzel, store, '0.0.0-pruefung');
120 for (const ansicht of [projectView, signalGroupsView, conflictsView, phasesView, reportView]) {
121 shell.addView(ansicht);
122 }
123 shell.start('pruefung');
124
125 const inhalt = wurzel.querySelector('main');
126 if (inhalt === null) throw new Error('Kein Inhaltsbereich im Rahmen.');
127
128 return {
129 wurzel,
130 shell,
131 store,
132 inhalt,
133 aufraeumen: () => {
134 shell.stop();
135 wurzel.remove();
136 },
137 };
138 }
139
140 /**
141 * Sucht die erste Beanstandung dieser Zielart und drueckt ihren Sprungknopf.
142 *
143 * Fuer die Zielart "projekt" ist `ort` anzugeben: Sie fuehrt je nach Ort in eine
144 * andere Ansicht, die Zielart allein benennt also kein Ziel.
145 */
146 function springeAusDemBericht(
147 rahmen: Rahmen,
148 art: FindingTarget['kind'],
149 ort?: ProjektOrt,
150 ): FindingTarget {
151 const ziel = rahmen.store.getReport().findings.find((f) => {
152 const t = f.target;
153 if (t === null || t.kind !== art) return false;
154 return ort === undefined || (t.kind === 'projekt' && t.ort === ort);
155 })?.target;
156 if (ziel === undefined || ziel === null) {
157 throw new Error(`Keine Beanstandung mit der Zielart "${art}".`);
158 }
159 const beschriftung = `Zu "${ziel.label}"`;
160 const knopf = [...rahmen.wurzel.querySelectorAll('button')].find(
161 (b) => b.textContent === beschriftung,
162 );
163 if (knopf === undefined) throw new Error(`Kein Sprungknopf ${beschriftung} im Prüfbericht.`);
164 knopf.click();
165 return ziel;
166 }
167
168 /** Abschnittskarte mit diesem sichtbaren Titel. */
169 function karteMitTitel(rahmen: Rahmen, titel: string): Element | null {
170 return (
171 [...rahmen.wurzel.querySelectorAll('section.karte')].find(
172 (k) => k.querySelector('.abschnitt-titel')?.textContent === titel,
173 ) ?? null
174 );
175 }
176
177 describe('Sprung aus dem Pruefbericht - je Zielart', () => {
178 it('"signalgruppe" rollt die Zeile der Signalgruppe auf und fokussiert sie', () => {
179 const rahmen = baueRahmen();
180 try {
181 const ziel = springeAusDemBericht(rahmen, 'signalgruppe');
182 expect(rahmen.shell.aktiveAnsichtId).toBe('signalgruppen');
183 const zeile = rahmen.wurzel.querySelector(`#sg-${ziel.id}`);
184 expect(zeile, 'keine Zeile zu dieser Signalgruppe').not.toBeNull();
185 expect(angerollt).toEqual([zeile]);
186 expect(document.activeElement).toBe(zeile);
187 } finally {
188 rahmen.aufraeumen();
189 }
190 });
191
192 it('"phase" rollt die Karte der Phase auf und fokussiert sie', () => {
193 const rahmen = baueRahmen();
194 try {
195 const ziel = springeAusDemBericht(rahmen, 'phase');
196 expect(rahmen.shell.aktiveAnsichtId).toBe('phasen');
197 const karte = rahmen.wurzel.querySelector(`#phase-${ziel.id}`);
198 expect(karte, 'keine Karte zu dieser Phase').not.toBeNull();
199 expect(angerollt).toEqual([karte]);
200 expect(document.activeElement).toBe(karte);
201 } finally {
202 rahmen.aufraeumen();
203 }
204 });
205
206 it('"konflikt" rollt die Zelle der Konfliktbeziehung auf und fokussiert sie', () => {
207 const rahmen = baueRahmen();
208 try {
209 const ziel = springeAusDemBericht(rahmen, 'konflikt');
210 expect(rahmen.shell.aktiveAnsichtId).toBe('zwischenzeiten');
211
212 const project = rahmen.store.getProject();
213 const konflikt = project.conflicts.find((c) => c.id === ziel.id);
214 const von = project.signalGroups.find((g) => g.id === konflikt?.fromId);
215 const nach = project.signalGroups.find((g) => g.id === konflikt?.toId);
216 expect(von, 'räumende Signalgruppe fehlt').toBeDefined();
217 expect(nach, 'einfahrende Signalgruppe fehlt').toBeDefined();
218
219 expect(angerollt).toHaveLength(1);
220 const zelle = angerollt[0] ?? null;
221 expect(document.activeElement).toBe(zelle);
222 expect(zelle?.tagName).toBe('BUTTON');
223 // Ueber den zugaenglichen Namen und nicht ueber die Kennung: Er ist die
224 // Auskunft, die der Anwender nach dem Sprung bekommt.
225 expect(zelle?.getAttribute('aria-label') ?? '').toContain(
226 `${von?.name ?? ''} räumt, ${nach?.name ?? ''} fährt ein`,
227 );
228 } finally {
229 rahmen.aufraeumen();
230 }
231 });
232
233 it('"programm" rollt den Abschnitt "Signalprogramm" auf und fokussiert ihn', () => {
234 const rahmen = baueRahmen();
235 try {
236 springeAusDemBericht(rahmen, 'programm');
237 expect(rahmen.shell.aktiveAnsichtId).toBe('phasen');
238 const abschnitt = karteMitTitel(rahmen, 'Signalprogramm');
239 expect(abschnitt, 'kein Abschnitt "Signalprogramm"').not.toBeNull();
240 expect(angerollt).toEqual([abschnitt]);
241 expect(document.activeElement).toBe(abschnitt);
242 } finally {
243 rahmen.aufraeumen();
244 }
245 });
246
247 it('"projekt" rollt den Abschnitt "Projektdaten" auf und fokussiert ihn', () => {
248 const rahmen = baueRahmen();
249 try {
250 springeAusDemBericht(rahmen, 'projekt', 'projektdaten');
251 expect(rahmen.shell.aktiveAnsichtId).toBe('projekt');
252 const abschnitt = karteMitTitel(rahmen, 'Projektdaten');
253 expect(abschnitt, 'kein Abschnitt "Projektdaten"').not.toBeNull();
254 expect(angerollt).toEqual([abschnitt]);
255 expect(document.activeElement).toBe(abschnitt);
256 } finally {
257 rahmen.aufraeumen();
258 }
259 });
260 });
261
262 describe('Sprung aus dem Pruefbericht - Randfaelle', () => {
263 it('zieht einen geglueckten Sprung nicht an den Seitenanfang zurueck', () => {
264 const rahmen = baueRahmen();
265 try {
266 rahmen.inhalt.scrollTop = 400;
267 springeAusDemBericht(rahmen, 'signalgruppe');
268 expect(rahmen.inhalt.scrollTop).toBe(400);
269 } finally {
270 rahmen.aufraeumen();
271 }
272 });
273
274 it('meldet ein Ziel, das es nicht mehr gibt, und laesst den Fokus nicht ins Leere gehen', () => {
275 const rahmen = baueRahmen();
276 try {
277 rahmen.inhalt.scrollTop = 400;
278 rahmen.shell.navigate('signalgruppen', 'sg-inzwischen-geloescht');
279
280 expect(angerollt).toEqual([]);
281 const ueberschrift = rahmen.wurzel.querySelector('.ansicht-halter h1');
282 expect(document.activeElement, 'Fokus nicht auf der Ueberschrift').toBe(ueberschrift);
283 expect(rahmen.inhalt.scrollTop).toBe(0);
284
285 const meldung = document.querySelector('.meldungen .meldung-warnung');
286 expect(meldung, 'keine Meldung über das fehlende Ziel').not.toBeNull();
287 expect(meldung?.textContent ?? '').toContain('nicht mehr vorhanden');
288 } finally {
289 rahmen.aufraeumen();
290 }
291 });
292
293 // Wache, kein Nachweis: geht auch ohne die Behebung durch.
294 it('setzt einen Wechsel ohne Ziel weiterhin an den Seitenanfang', () => {
295 const rahmen = baueRahmen();
296 try {
297 rahmen.inhalt.scrollTop = 400;
298 rahmen.shell.navigate('phasen');
299 expect(angerollt).toEqual([]);
300 expect(rahmen.inhalt.scrollTop).toBe(0);
301 } finally {
302 rahmen.aufraeumen();
303 }
304 });
305
306 // Wache, kein Nachweis: Ein Neuaufbau ist kein Ortswechsel - er darf den
307 // Fokus nicht ein zweites Mal auf das Sprungziel reissen, waehrend der
308 // Anwender dort schon arbeitet.
309 it('springt beim Neuaufbau derselben Ansicht kein zweites Mal', () => {
310 const rahmen = baueRahmen();
311 try {
312 springeAusDemBericht(rahmen, 'signalgruppe');
313 angerollt.length = 0;
314 rahmen.shell.baueSichtbareAnsichtNeu();
315 expect(angerollt).toEqual([]);
316 } finally {
317 rahmen.aufraeumen();
318 }
319 });
320 });
321
322 /**
323 * Der Sprung darf die Tabulatorfolge nicht beschaedigen (WCAG 2.4.3).
324 *
325 * Vier der fuenf Zielarten sind von Haus aus nicht fokussierbar - Zeile, Karte,
326 * Abschnitt. Die Zielart "konflikt" landet dagegen auf einem Knopf, und der ist
327 * es. Ein `tabindex="-1"` darauf naehme ausgerechnet die beanstandete Zelle aus
328 * der Tabulatorfolge: Wer nach dem Sprung weitertabbt oder mit Umschalt und
329 * Tabulator zurueckgeht, uebersprang genau die Zelle, zu der der Knopf ihn
330 * geschickt hat.
331 */
332 describe('Sprung aus dem Pruefbericht - Tabulatorfolge', () => {
333 it('laesst eine angesprungene Konfliktzelle in der Tabulatorfolge', () => {
334 const rahmen = baueRahmen();
335 try {
336 springeAusDemBericht(rahmen, 'konflikt');
337 const zelle = document.activeElement as HTMLElement | null;
338 expect(zelle?.tagName).toBe('BUTTON');
339 expect(zelle?.tabIndex, 'die Zelle ist aus der Tabulatorfolge gefallen').toBe(0);
340 } finally {
341 rahmen.aufraeumen();
342 }
343 });
344
345 // Wache, kein Nachweis: geht auch ohne die Behebung durch. Sie haelt fest,
346 // dass die Vorkehrung fuer die nicht bedienbaren Ziele bestehen bleibt -
347 // ohne tabindex nimmt eine Tabellenzeile den Fokus gar nicht erst an.
348 it('macht eine nicht bedienbare Zeile programmatisch erreichbar', () => {
349 const rahmen = baueRahmen();
350 try {
351 springeAusDemBericht(rahmen, 'signalgruppe');
352 const zeile = document.activeElement as HTMLElement | null;
353 expect(zeile?.tagName).toBe('TR');
354 expect(zeile?.getAttribute('tabindex')).toBe('-1');
355 } finally {
356 rahmen.aufraeumen();
357 }
358 });
359 });