lsa-planer

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

/ tests ui fokusReihenfolge.test.ts

7,0 KB Rohdatei
tests/ui/fokusReihenfolge.test.ts — 176 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { createStandardIntersectionProject } from '@/domain/model/factory';
4 import { mitBewahrtemFokus } from '@/ui/fokus';
5 import type { Project } from '@/domain/model/project';
6 import type { ViewContext } from '@/ui/shell';
7 import { signalGroupsView } from '@/ui/views/signalGroupsView';
8
9 /*
10 * Befund 50 zur Fassung 5.8.0.
11 *
12 * `benenneBedienelemente()` vergibt den Fokusschluessel aus Spaltenkopf,
13 * Zeilenname und Elementart. In der letzten Spalte der Signalgruppentabelle
14 * stehen drei Knoepfe in EINER Zelle, die Spalte hat keinen Kopftext, und alle
15 * drei sind BUTTON - sie trugen deshalb denselben Schluessel "|K1|BUTTON".
16 * `mitBewahrtemFokus` sucht ueber `.find()` und traf damit immer den ersten
17 * Treffer in Dokumentreihenfolge: Nach "nach unten" sass der Fokus auf "nach
18 * oben", also auf der Schaltflaeche mit der Gegenwirkung. Wer eine Signalgruppe
19 * mit der Tastatur um drei Plaetze verschieben wollte, pendelte zwischen zwei
20 * Plaetzen.
21 *
22 * Zusatzfall: Zwei Signalgruppen mit derselben Bezeichnung - die Bezeichnung
23 * ist freier Text - ergaben denselben Schluessel in verschiedenen Zeilen; der
24 * Fokus sprang dann in die falsche Zeile. Das trifft nicht nur die drei
25 * Knoepfe, sondern JEDES Bedienelement beider Zeilen: gleicher Spaltenkopf,
26 * gleicher Zeilenname, gleiche Elementart.
27 */
28
29 let aufraeumen: (() => void) | null = null;
30 afterEach(() => {
31 aufraeumen?.();
32 aufraeumen = null;
33 document.body.replaceChildren();
34 });
35
36 /** Ansicht mit demselben Neuaufbau, den die Shell fuer `refresh` vorsieht. */
37 function zeichne(project: Project): { wurzel: HTMLElement; store: ProjectStore } {
38 const wurzel = document.createElement('div');
39 document.body.append(wurzel);
40 const store = new ProjectStore(project);
41 let dispose: () => void = () => {};
42 const context: ViewContext = {
43 store,
44 refresh: () => {
45 mitBewahrtemFokus(wurzel, () => {
46 dispose();
47 wurzel.replaceChildren();
48 dispose = signalGroupsView.render(wurzel, context);
49 });
50 },
51 navigate: () => {},
52 target: null,
53 };
54 dispose = signalGroupsView.render(wurzel, context);
55 aufraeumen = () => {
56 dispose();
57 wurzel.remove();
58 };
59 return { wurzel, store };
60 }
61
62 /*
63 * Ueber die Werte vergleichen und nicht ueber einen Attributselektor: Der Name
64 * enthaelt die Bezeichnung der Signalgruppe in Anfuehrungszeichen, und die
65 * bricht jeden Selektor.
66 */
67 const knoepfeMitNamen = (wurzel: HTMLElement, name: string): HTMLButtonElement[] =>
68 [...wurzel.querySelectorAll<HTMLButtonElement>('button')].filter(
69 (b) => b.getAttribute('aria-label') === name,
70 );
71
72 const knopf = (wurzel: HTMLElement, name: string): HTMLButtonElement => {
73 const treffer = knoepfeMitNamen(wurzel, name);
74 expect(treffer.length, `Keine Schaltflaeche "${name}"`).toBe(1);
75 return treffer[0] as HTMLButtonElement;
76 };
77
78 const namen = (store: ProjectStore): string[] =>
79 store.getProject().signalGroups.map((gruppe) => gruppe.name);
80
81 /**
82 * Projekt mit zwei gleich benannten Signalgruppen: K2 heisst ebenfalls "K1".
83 * Die Bezeichnung ist freier Text, das Programm laesst das zu.
84 */
85 function projektMitZweiMalK1(): Project {
86 const basis = createStandardIntersectionProject();
87 return {
88 ...basis,
89 signalGroups: basis.signalGroups.map((g) => (g.name === 'K2' ? { ...g, name: 'K1' } : g)),
90 };
91 }
92
93 /** Zeile einer Signalgruppe - ueber die Kennung, nicht ueber die Bezeichnung. */
94 const zeileDerGruppe = (wurzel: HTMLElement, groupId: string): HTMLTableRowElement => {
95 const treffer = [...wurzel.querySelectorAll<HTMLTableRowElement>('tbody tr')].find(
96 (tr) => tr.id === `sg-${groupId}`,
97 );
98 expect(treffer, `Keine Zeile zur Signalgruppe ${groupId}`).toBeDefined();
99 return treffer as HTMLTableRowElement;
100 };
101
102 describe('Fokus nach dem Verschieben einer Signalgruppe (Befund 50)', () => {
103 it('bleibt auf "nach unten" und nicht auf der Schaltflaeche mit der Gegenwirkung', () => {
104 const { wurzel, store } = zeichne(createStandardIntersectionProject());
105 const runter = knopf(wurzel, 'Signalgruppe "K1" nach unten');
106 runter.focus();
107 expect(document.activeElement).toBe(runter);
108
109 runter.click();
110
111 expect(namen(store).slice(0, 2)).toEqual(['K2', 'K1']);
112 const jetzt = document.activeElement as HTMLElement | null;
113 expect(jetzt?.getAttribute('aria-label')).toBe('Signalgruppe "K1" nach unten');
114 });
115
116 it('schiebt beim zweiten Tastendruck weiter, statt zwischen zwei Plaetzen zu pendeln', () => {
117 const { wurzel, store } = zeichne(createStandardIntersectionProject());
118 const runter = knopf(wurzel, 'Signalgruppe "K1" nach unten');
119 runter.focus();
120
121 // Zweimal die Eingabetaste auf derselben Schaltflaeche - der Fokus wandert
122 // dazwischen nur, weil die Ansicht sich neu aufbaut.
123 runter.click();
124 (document.activeElement as HTMLButtonElement | null)?.click();
125
126 expect(namen(store).slice(0, 3)).toEqual(['K2', 'K3', 'K1']);
127 });
128
129 it('bleibt bei zwei gleich benannten Signalgruppen in der richtigen Zeile', () => {
130 // Die Bezeichnung ist freier Text; zwei Zeilen duerfen denselben Namen
131 // tragen. Ueber den Zeilennamen allein ist die Zeile dann nicht zu finden.
132 const projekt = projektMitZweiMalK1();
133 const erste = projekt.signalGroups[0];
134 expect(erste?.name).toBe('K1');
135
136 const { wurzel } = zeichne(projekt);
137 const knoepfe = knoepfeMitNamen(wurzel, 'Signalgruppe "K1" nach unten');
138 expect(knoepfe.length, 'Zwei gleich benannte Signalgruppen erwartet').toBe(2);
139
140 knoepfe[0]?.focus();
141 knoepfe[0]?.click();
142
143 const zeile = (document.activeElement as HTMLElement | null)?.closest('tr');
144 expect(zeile?.id).toBe(`sg-${erste?.id ?? ''}`);
145 });
146
147 /*
148 * Derselbe Zusatzfall an einem Feld, das kein Knopf ist: Wer in der ZWEITEN
149 * von zwei gleich benannten Zeilen die Fahrzeugart umstellt, findet den Fokus
150 * danach in der ERSTEN Zeile wieder - und stellt dort mit der naechsten
151 * Eingabe eine Signalgruppe um, die er gar nicht bearbeiten wollte.
152 */
153 it('bleibt nach einer Aenderung im Auswahlfeld Fahrzeugart in der richtigen Zeile', () => {
154 const projekt = projektMitZweiMalK1();
155 const zweite = projekt.signalGroups[1];
156 expect(zweite?.name).toBe('K1');
157 expect(zweite?.mode).toBe('kfz');
158 const zweiteId = zweite?.id ?? '';
159
160 const { wurzel, store } = zeichne(projekt);
161 const feld = zeileDerGruppe(wurzel, zweiteId).querySelector<HTMLSelectElement>(
162 'select[aria-label="Fahrzeugart, K1"]',
163 );
164 expect(feld, 'Kein Auswahlfeld "Fahrzeugart" in der zweiten Zeile').not.toBeNull();
165
166 feld?.focus();
167 if (feld !== null && feld !== undefined) feld.value = 'lkw';
168 feld?.dispatchEvent(new Event('change'));
169
170 // Die Aenderung greift an der zweiten Gruppe - der Fokus muss ihr folgen.
171 expect(store.getProject().signalGroups[1]?.vehicleClass).toBe('lkw');
172 const jetzt = document.activeElement as HTMLElement | null;
173 expect(jetzt?.closest('tr')?.id).toBe(`sg-${zweiteId}`);
174 expect(jetzt?.getAttribute('aria-label')).toBe('Fahrzeugart, K1');
175 });
176 });