lsa-planer

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

/ tests ui fokusUeberNeuaufbau.test.ts

8,9 KB Rohdatei
tests/ui/fokusUeberNeuaufbau.test.ts — 245 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { mitBewahrtemFokus } from '@/ui/fokus';
4 import { conflictsView } from '@/ui/views/conflictsView';
5 import { lageplanView } from '@/ui/views/lageplanView';
6 import { createStandardIntersectionProject } from '@/domain/model/factory';
7 import type { Project } from '@/domain/model/project';
8 import type { Lageplan } from '@/domain/geometrie/lageplan';
9
10 /*
11 * Ein Neuaufbau ist kein Ortswechsel: Der Fokus bleibt, wo er war.
12 *
13 * Die Shell konnte das laengst, Ansichten mit eigenem Neuaufbau nicht - der
14 * Lageplan ruft seinen Aufbau sechsundzwanzigmal auf und context.refresh() kein
15 * einziges Mal. Im Konfliktfenster warf die Fokusfalle den naechsten Tabulator
16 * auf "Schliessen", genau dort, wo Raeumweg, Einfahrweg und beide
17 * Geschwindigkeiten nacheinander einzutragen sind.
18 *
19 * Im gesamten Testbestand gab es dafuer bis hierher keine Zusicherung: kein
20 * Treffer fuer .focus(), activeElement oder tabindex.
21 */
22
23 const STICHTAG = new Date('2026-01-01T00:00:00Z');
24
25 let aufraeumen: (() => void) | null = null;
26 afterEach(() => {
27 aufraeumen?.();
28 aufraeumen = null;
29 document.body.replaceChildren();
30 });
31
32 /** Baut eine kleine Ansicht mit zwei Feldern in einer Tabelle. */
33 function bauePlatz(mitFokusschluessel: boolean): { wurzel: HTMLElement; neu: () => void } {
34 const wurzel = document.createElement('div');
35 document.body.append(wurzel);
36
37 const neu = (): void => {
38 wurzel.replaceChildren();
39 const tabelle = document.createElement('table');
40 const koerper = document.createElement('tbody');
41 for (const name of ['K1 nach K3', 'K2 nach K4']) {
42 const zeile = document.createElement('tr');
43 for (const spalte of ['Räumweg', 'Einfahrweg']) {
44 const zelle = document.createElement('td');
45 const feld = document.createElement('input');
46 feld.type = 'text';
47 // Die Kennung wechselt bei jedem Aufbau - genau wie im Programm, wo
48 // field() fortlaufend hochzaehlt. Ein Wiederfinden ueber die Kennung
49 // kann deshalb nicht funktionieren.
50 feld.id = `feld-${Math.floor(performance.now() * 1000) % 100000}-${name}-${spalte}`;
51 feld.value = '22';
52 if (mitFokusschluessel) feld.setAttribute('data-fokus', `${name}|${spalte}`);
53 zelle.append(feld);
54 zeile.append(zelle);
55 }
56 koerper.append(zeile);
57 }
58 tabelle.append(koerper);
59 wurzel.append(tabelle);
60 };
61
62 neu();
63 aufraeumen = () => wurzel.remove();
64 return { wurzel, neu };
65 }
66
67 const feldMit = (wurzel: HTMLElement, schluessel: string): HTMLInputElement =>
68 wurzel.querySelector<HTMLInputElement>(`[data-fokus="${schluessel}"]`)!;
69
70 describe('Fokus ueber einen Neuaufbau', () => {
71 it('findet dieselbe Tabellenzelle ueber ihren fachlichen Schluessel wieder', () => {
72 const { wurzel, neu } = bauePlatz(true);
73 const vorher = feldMit(wurzel, 'K2 nach K4|Einfahrweg');
74 vorher.focus();
75 expect(document.activeElement).toBe(vorher);
76
77 mitBewahrtemFokus(wurzel, neu);
78
79 const nachher = document.activeElement as HTMLElement;
80 expect(nachher).not.toBe(vorher);
81 expect(nachher.getAttribute('data-fokus')).toBe('K2 nach K4|Einfahrweg');
82 });
83
84 it('traegt die Schreibmarke mit, statt ans Ende zu springen', () => {
85 const { wurzel, neu } = bauePlatz(true);
86 const vorher = feldMit(wurzel, 'K1 nach K3|Räumweg');
87 vorher.value = '1234';
88 vorher.focus();
89 vorher.setSelectionRange(2, 2);
90
91 mitBewahrtemFokus(wurzel, neu);
92
93 const nachher = document.activeElement as HTMLInputElement;
94 expect(nachher.getAttribute('data-fokus')).toBe('K1 nach K3|Räumweg');
95 expect(nachher.selectionStart, 'Schreibmarke mitten in der Zahl').toBe(2);
96 });
97
98 it('findet das Feld auch ohne Schluessel ueber seinen Platz im Aufbau', () => {
99 const { wurzel, neu } = bauePlatz(false);
100 const felder = [...wurzel.querySelectorAll('input')];
101 const vorher = felder[2]!;
102 vorher.focus();
103
104 mitBewahrtemFokus(wurzel, neu);
105
106 const nachher = document.activeElement as HTMLElement;
107 expect([...wurzel.querySelectorAll('input')].indexOf(nachher as HTMLInputElement)).toBe(2);
108 });
109
110 it('laesst einen Fokus ausserhalb des neu gezeichneten Bereichs in Ruhe', () => {
111 const { wurzel, neu } = bauePlatz(true);
112 const draussen = document.createElement('button');
113 document.body.append(draussen);
114 draussen.focus();
115
116 mitBewahrtemFokus(wurzel, neu);
117
118 expect(document.activeElement).toBe(draussen);
119 draussen.remove();
120 });
121
122 it('springt nicht auf etwas, das sich nicht bedienen laesst', () => {
123 // Aendert sich der Aufbau, kann der Strukturweg auf einen Absatz zeigen.
124 // Dort zu landen waere schlechter als gar nichts.
125 const wurzel = document.createElement('div');
126 document.body.append(wurzel);
127 aufraeumen = () => wurzel.remove();
128
129 const feld = document.createElement('input');
130 wurzel.append(feld);
131 feld.focus();
132
133 mitBewahrtemFokus(wurzel, () => {
134 wurzel.replaceChildren(document.createElement('p'));
135 });
136
137 expect(document.activeElement).not.toBe(wurzel.firstElementChild);
138 expect((document.activeElement as HTMLElement).tagName).not.toBe('P');
139 });
140 });
141
142 describe('Konfliktfenster behaelt den Fokus beim Eintragen', () => {
143 function oeffneFenster(): { wurzel: HTMLElement; store: ProjectStore } {
144 const projekt: Project = createStandardIntersectionProject('Fokus', STICHTAG);
145 const wurzel = document.createElement('div');
146 document.body.append(wurzel);
147 const store = new ProjectStore(projekt);
148 const schliesse = conflictsView.render(wurzel, {
149 store,
150 refresh: () => {},
151 navigate: () => {},
152 target: null,
153 });
154 aufraeumen = () => {
155 schliesse();
156 wurzel.remove();
157 };
158
159 // Die erste besetzte Zelle der Matrix anklicken.
160 const zelle = [...document.querySelectorAll<HTMLButtonElement>('.matrix-zelle')].find(
161 (b) => !b.classList.contains('matrix-zelle-leer'),
162 );
163 expect(zelle, 'die Matrix muss eine besetzte Zelle haben').toBeDefined();
164 zelle!.click();
165 return { wurzel, store };
166 }
167
168 it('bleibt nach einer Eingabe im selben Feld stehen', () => {
169 oeffneFenster();
170
171 const felder = [...document.querySelectorAll<HTMLInputElement>('[role="dialog"] input')];
172 expect(felder.length, 'das Fenster muss Eingabefelder haben').toBeGreaterThan(1);
173
174 const feld = felder[0]!;
175 feld.focus();
176 const platz = felder.indexOf(feld);
177
178 feld.value = '31';
179 feld.dispatchEvent(new Event('change', { bubbles: true }));
180
181 const jetzt = document.activeElement as HTMLElement;
182 // Nicht auf den Fensterrumpf und nicht auf "Schliessen".
183 expect(jetzt.tagName).toBe('INPUT');
184 const nachher = [...document.querySelectorAll<HTMLInputElement>('[role="dialog"] input')];
185 expect(nachher.indexOf(jetzt as HTMLInputElement)).toBe(platz);
186 });
187 });
188
189 describe('Lageplan behaelt den Fokus beim Eintragen', () => {
190 it('bleibt nach einer Eingabe im selben Feld stehen', () => {
191 // Ohne Lageplan zeigt die Ansicht nur einen Leerzustand; die Eingabefelder
192 // haengen an der Vermassung.
193 const lageplan: Lageplan = {
194 arbeitsbereiche: [],
195 signalgeber: [],
196 bild: null,
197 kalibrierung: {
198 von: { x: 0, y: 0 },
199 bis: { x: 1000, y: 0 },
200 laengeMeter: 60,
201 herkunft: 'georeferenziert',
202 },
203 haltlinien: [],
204 linien: [],
205 };
206 const wurzel = document.createElement('div');
207 document.body.append(wurzel);
208 const store = new ProjectStore({
209 ...createStandardIntersectionProject('Lageplan', STICHTAG),
210 lageplan,
211 });
212 const schliesse = lageplanView.render(wurzel, {
213 store,
214 refresh: () => {},
215 navigate: () => {},
216 target: null,
217 });
218 aufraeumen = () => {
219 schliesse();
220 wurzel.remove();
221 };
222
223 // Das Werkzeug "Ansehen" ist ohne Bild als einziges bedienbar - die
224 // uebrigen Werkzeuge sind gesperrt, solange weder Bild noch Vermassung
225 // vorliegen, und eine gesperrte Schaltflaeche nimmt ohnehin keinen Fokus an.
226 const knoepfe = [...wurzel.querySelectorAll<HTMLButtonElement>('button')];
227 const werkzeug = knoepfe.find((b) => (b.textContent ?? '').trim() === 'Ansehen');
228 expect(werkzeug, 'das Werkzeug "Ansehen" muss es geben').toBeDefined();
229
230 const platz = knoepfe.indexOf(werkzeug!);
231 werkzeug!.focus();
232 expect(document.activeElement).toBe(werkzeug);
233
234 werkzeug!.click();
235
236 // Der Aufbau ist wirklich durchgelaufen - sonst prueft der Test nichts.
237 expect(werkzeug!.isConnected, 'die Ansicht muss neu gezeichnet worden sein').toBe(false);
238
239 const jetzt = document.activeElement as HTMLElement;
240 expect(jetzt.tagName, 'der Fokus darf nicht auf den Seitenkoerper fallen').toBe('BUTTON');
241 const nachher = [...wurzel.querySelectorAll<HTMLButtonElement>('button')];
242 expect(nachher.indexOf(jetzt as HTMLButtonElement)).toBe(platz);
243 expect((jetzt.textContent ?? '').trim()).toBe('Ansehen');
244 });
245 });