lsa-planer

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

/ tests ui fokusschluessel.test.ts

11,2 KB Rohdatei
tests/ui/fokusschluessel.test.ts — 314 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { beschrifteTabelle, datenTabelle, el } from '@/ui/dom';
3 import { mitBewahrtemFokus } from '@/ui/fokus';
4
5 /*
6 * Der Fokusschluessel aus `benenneBedienelemente()` ist nicht eindeutig.
7 *
8 * Er lautete `${spalte}|${zeile}|${tagName}`. Zwei Faelle brechen ihn:
9 *
10 * - Eine Zelle enthaelt mehrere gleichartige Bedienelemente (drei Knoepfe am
11 * Zeilenende, Spalte ohne Kopftext, alle BUTTON: dreimal `|K1|BUTTON`).
12 * `mitBewahrtemFokus` nimmt den ersten Treffer in Dokumentreihenfolge - nach
13 * "nach unten" sass der Fokus auf "nach oben", der Schaltflaeche mit der
14 * Gegenwirkung.
15 * - Zwei Zeilen tragen denselben Namen. Bezeichnungen sind freier Text; dann
16 * stimmen bei JEDEM Feld beider Zeilen alle drei Teile ueberein, und der
17 * Fokus landet in der falschen Zeile.
18 *
19 * Die Behebung von Befund 50 zur Fassung 5.8.0 hat nur die drei Knoepfe der
20 * Signalgruppentabelle mit eigenen Schluesseln versorgt. Hier steht die
21 * allgemeine Wache: an `datenTabelle()` und `beschrifteTabelle()` selbst, also
22 * fuer jede Tabelle.
23 */
24
25 let aufraeumen: (() => void) | null = null;
26 afterEach(() => {
27 aufraeumen?.();
28 aufraeumen = null;
29 document.body.replaceChildren();
30 });
31
32 /** Alle vergebenen Fokusschluessel in Dokumentreihenfolge. */
33 const schluessel = (wurzel: HTMLElement): string[] =>
34 [...wurzel.querySelectorAll<HTMLElement>('[data-fokus]')].map(
35 (feld) => feld.getAttribute('data-fokus') ?? '',
36 );
37
38 /** Schaltflaeche mit eigenem Text - sie bringt ihren Namen selbst mit. */
39 function knopf(text: string, beiKlick?: () => void): HTMLButtonElement {
40 const node = el('button', { type: 'button', text });
41 if (beiKlick !== undefined) node.addEventListener('click', beiKlick);
42 return node;
43 }
44
45 /** Text der ersten Zelle der Zeile, in der das Element steht. */
46 const zeileVon = (element: Element | null): string =>
47 (element?.closest('tr')?.firstElementChild?.textContent ?? '').trim();
48
49 describe('Fokusschluessel einer Datentabelle', () => {
50 it('trennt mehrere gleichartige Bedienelemente derselben Zelle', () => {
51 const tabelle = datenTabelle<string>({
52 beschriftung: 'Signalgruppen',
53 spalten: [
54 { kopf: 'Bezeichnung', zelle: (name) => name },
55 // Die Spalte mit den Zeilenknoepfen hat keinen Kopftext - genau wie in
56 // der Signalgruppentabelle.
57 {
58 kopf: '',
59 zelle: () => el('div', {}, knopf('nach oben'), knopf('nach unten'), knopf('löschen')),
60 },
61 ],
62 zeilen: ['K1', 'K2'],
63 zeilenname: (name) => name,
64 });
65
66 const vergeben = schluessel(tabelle);
67 expect(vergeben.length, 'sechs Knoepfe erwartet').toBe(6);
68 expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(
69 vergeben.length,
70 );
71 });
72
73 it('trennt zwei gleich benannte Zeilen', () => {
74 // Bezeichnungen sind freier Text; zwei Zeilen duerfen denselben Namen
75 // tragen. Dann heissen auch die Felder gleich - der zugaengliche Name
76 // allein trennt sie nicht.
77 const tabelle = datenTabelle<{ kennung: string }>({
78 beschriftung: 'Signalgruppen',
79 spalten: [
80 { kopf: 'Kennung', zelle: (zeile) => zeile.kennung },
81 { kopf: 'Wert', zelle: () => el('input', { type: 'number' }) },
82 ],
83 zeilen: [{ kennung: 'A' }, { kennung: 'B' }],
84 zeilenname: () => 'K1',
85 });
86
87 const vergeben = schluessel(tabelle);
88 expect(vergeben.length, 'zwei Eingabefelder erwartet').toBe(2);
89 expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(2);
90 });
91 });
92
93 describe('Fokusschluessel einer nachtraeglich beschrifteten Tabelle', () => {
94 /** Von Hand gebaute Tabelle, wie sie die bestehenden Ansichten liefern. */
95 function baueTabelle(zeilennamen: readonly string[]): HTMLElement {
96 const koerper = el('tbody', {});
97 for (const name of zeilennamen) {
98 koerper.append(
99 el(
100 'tr',
101 {},
102 el('td', { text: name }),
103 el('td', {}, el('input', { type: 'number' })),
104 el('td', {}, knopf('nach oben'), knopf('nach unten')),
105 ),
106 );
107 }
108 return el(
109 'table',
110 {},
111 el('thead', {}, el('tr', {}, el('th', { text: 'Bezeichnung' }), el('th', { text: 'Wert' }))),
112 koerper,
113 );
114 }
115
116 it('trennt mehrere gleichartige Bedienelemente derselben Zelle', () => {
117 const tabelle = beschrifteTabelle(baueTabelle(['K1', 'K2']), {
118 beschriftung: 'Signalgruppen',
119 });
120
121 const vergeben = schluessel(tabelle);
122 expect(vergeben.length, 'zwei Felder und vier Knoepfe erwartet').toBe(6);
123 expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(6);
124 });
125
126 it('trennt zwei gleich benannte Zeilen', () => {
127 const tabelle = beschrifteTabelle(baueTabelle(['K1', 'K1']), {
128 beschriftung: 'Signalgruppen',
129 });
130
131 const vergeben = schluessel(tabelle);
132 expect(vergeben.length, 'zwei Felder und vier Knoepfe erwartet').toBe(6);
133 expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(6);
134 });
135 });
136
137 describe('Fokusschluessel ueber zwei Beschriftungsdurchgaenge', () => {
138 /*
139 * Die Ueberschrift von `fokusschluessel` sagt "eindeutig innerhalb seiner
140 * Tabelle" zu - nicht "innerhalb eines Durchgangs". `beschrifteTabelle()`
141 * darf also ein zweites Mal ueber dieselbe, inzwischen gewachsene Tabelle
142 * laufen, ohne dass ein Schluessel zweimal vergeben wird. Ohne die Vorbelegung
143 * aus den bereits gesetzten `data-fokus` beginnt die Zaehlung wieder bei null,
144 * und die neue Zeile bekommt die Grundform, die eine aeltere schon traegt.
145 */
146
147 /** Zeile mit Bezeichnung und einem Eingabefeld. */
148 const zeileMitFeld = (name: string): HTMLElement =>
149 el('tr', {}, el('td', { text: name }), el('td', {}, el('input', { type: 'number' })));
150
151 const beschrifte = (tabelle: HTMLElement): HTMLElement =>
152 beschrifteTabelle(tabelle, {
153 beschriftung: 'Signalgruppen',
154 zeilenname: (tr) => (tr.children[0]?.textContent ?? '').trim(),
155 });
156
157 it('teilt einen Schluessel nicht erneut aus, wenn die Tabelle zwischen zwei Durchgaengen waechst', () => {
158 const koerper = el('tbody', {}, zeileMitFeld('K1'));
159 const tabelle = el(
160 'table',
161 {},
162 el('thead', {}, el('tr', {}, el('th', { text: 'Bezeichnung' }), el('th', { text: 'Wert' }))),
163 koerper,
164 );
165
166 beschrifte(tabelle);
167 // Eine gleichnamige Zeile kommt hinzu; die Tabelle wird nicht neu gebaut,
168 // sondern erneut beschriftet.
169 koerper.append(zeileMitFeld('K1'));
170 beschrifte(tabelle);
171
172 const vergeben = schluessel(tabelle);
173 expect(vergeben.length, 'zwei Eingabefelder erwartet').toBe(2);
174 expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(2);
175 });
176 });
177
178 describe('Fokus nach dem Neuaufbau einer Datentabelle', () => {
179 /**
180 * Tabelle mit zwei Knoepfen je Zeile, die die Reihenfolge aendern - dieselbe
181 * Bedienung wie in der Signalgruppentabelle, aber an `datenTabelle()` selbst.
182 */
183 function bauePlatz(anfang: readonly string[]): {
184 wurzel: HTMLElement;
185 reihenfolge: () => readonly string[];
186 } {
187 const wurzel = document.createElement('div');
188 document.body.append(wurzel);
189 aufraeumen = () => wurzel.remove();
190
191 let namen: readonly string[] = [...anfang];
192
193 const verschiebe = (index: number, richtung: number): void => {
194 const ziel = index + richtung;
195 if (ziel < 0 || ziel >= namen.length) return;
196 const kopie = [...namen];
197 const gezogen = kopie.splice(index, 1)[0] ?? '';
198 kopie.splice(ziel, 0, gezogen);
199 namen = kopie;
200 mitBewahrtemFokus(wurzel, zeichne);
201 };
202
203 function zeichne(): void {
204 wurzel.replaceChildren(
205 datenTabelle<string>({
206 beschriftung: 'Signalgruppen',
207 spalten: [
208 { kopf: 'Bezeichnung', zelle: (name) => name },
209 {
210 kopf: '',
211 zelle: (_name, index) =>
212 el(
213 'div',
214 {},
215 knopf('nach oben', () => {
216 verschiebe(index, -1);
217 }),
218 knopf('nach unten', () => {
219 verschiebe(index, 1);
220 }),
221 ),
222 },
223 ],
224 zeilen: [...namen],
225 zeilenname: (name) => name,
226 }),
227 );
228 }
229
230 zeichne();
231 return { wurzel, reihenfolge: () => namen };
232 }
233
234 const knopfDerZeile = (wurzel: HTMLElement, zeile: string, text: string): HTMLButtonElement => {
235 const treffer = [...wurzel.querySelectorAll<HTMLButtonElement>('button')].filter(
236 (b) => (b.textContent ?? '').trim() === text && zeileVon(b) === zeile,
237 );
238 expect(treffer.length, `Kein Knopf "${text}" in Zeile "${zeile}"`).toBe(1);
239 return treffer[0] as HTMLButtonElement;
240 };
241
242 it('bleibt auf "nach unten" und nicht auf der Schaltflaeche mit der Gegenwirkung', () => {
243 const { wurzel, reihenfolge } = bauePlatz(['K1', 'K2', 'K3']);
244 const runter = knopfDerZeile(wurzel, 'K1', 'nach unten');
245 runter.focus();
246 expect(document.activeElement).toBe(runter);
247
248 runter.click();
249
250 expect(reihenfolge()).toEqual(['K2', 'K1', 'K3']);
251 const jetzt = document.activeElement as HTMLElement | null;
252 expect((jetzt?.textContent ?? '').trim()).toBe('nach unten');
253 expect(zeileVon(jetzt)).toBe('K1');
254 });
255
256 it('schiebt beim zweiten Tastendruck weiter, statt zwischen zwei Plaetzen zu pendeln', () => {
257 const { wurzel, reihenfolge } = bauePlatz(['K1', 'K2', 'K3']);
258 knopfDerZeile(wurzel, 'K1', 'nach unten').focus();
259
260 (document.activeElement as HTMLButtonElement | null)?.click();
261 (document.activeElement as HTMLButtonElement | null)?.click();
262
263 expect(reihenfolge()).toEqual(['K2', 'K3', 'K1']);
264 });
265
266 it('bleibt bei zwei gleich benannten Zeilen in der richtigen Zeile', () => {
267 const wurzel = document.createElement('div');
268 document.body.append(wurzel);
269 aufraeumen = () => wurzel.remove();
270
271 const werte = new Map<string, string>([
272 ['A', '10'],
273 ['B', '20'],
274 ]);
275 const zeichne = (): void => {
276 wurzel.replaceChildren(
277 datenTabelle<string>({
278 beschriftung: 'Signalgruppen',
279 spalten: [
280 { kopf: 'Kennung', zelle: (kennung) => kennung },
281 {
282 kopf: 'Wert',
283 zelle: (kennung) => {
284 const feld = el('input', { type: 'number', value: werte.get(kennung) ?? '' });
285 feld.addEventListener('change', () => {
286 werte.set(kennung, feld.value);
287 mitBewahrtemFokus(wurzel, zeichne);
288 });
289 return feld;
290 },
291 },
292 ],
293 zeilen: ['A', 'B'],
294 // Beide Zeilen heissen gleich - das laesst das Programm zu.
295 zeilenname: () => 'K1',
296 }),
297 );
298 };
299 zeichne();
300
301 const felder = [...wurzel.querySelectorAll<HTMLInputElement>('input')];
302 expect(felder.length).toBe(2);
303 const zweites = felder[1] as HTMLInputElement;
304 expect(zeileVon(zweites)).toBe('B');
305
306 zweites.focus();
307 zweites.value = '25';
308 zweites.dispatchEvent(new Event('change'));
309
310 expect(werte.get('B')).toBe('25');
311 const jetzt = document.activeElement as HTMLElement | null;
312 expect(zeileVon(jetzt), 'der Fokus muss in der bearbeiteten Zeile bleiben').toBe('B');
313 });
314 });