lsa-planer

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

/ tests ui umsortierFokus.test.ts

8,3 KB Rohdatei
tests/ui/umsortierFokus.test.ts — 208 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { createPhase, createStandardIntersectionProject } from '@/domain/model/factory';
4 import { mitBewahrtemFokus } from '@/ui/fokus';
5 import { orderedPhases, type Project } from '@/domain/model/project';
6 import type { ViewContext, ViewDefinition } from '@/ui/shell';
7 import { phasesView } from '@/ui/views/phasesView';
8 import { signalGroupsView } from '@/ui/views/signalGroupsView';
9
10 /**
11 * Befund 47 zur Fassung 5.10.0 - der Fokus nach dem Umsortieren.
12 *
13 * Ein Druck auf "Signalgruppe \"K2\" nach oben" vertauscht zwei Zeilen und baut
14 * die Ansicht neu auf. `mitBewahrtemFokus` findet den bestaendigen Schluessel
15 * `signalgruppe|<id>|hoch` zwar wieder, verwirft ihn aber, weil die
16 * Schaltflaeche in der nun ersten Zeile gesperrt ist (ui/fokus.ts - und diese
17 * Regel ist richtig: ein Fokus auf etwas Unbedienbarem ist schlechter als
18 * keiner). Der Rueckfall ist der Strukturweg, und der zeigt auf dieselbe
19 * DOM-Stelle - dort steht jetzt die Zeile, die gerade heruntergerutscht ist.
20 * Der Fokus sass danach auf der GLEICHNAMIGEN Schaltflaeche der Nachbarzeile:
21 * Derselbe Tastendruck ein zweites Mal nahm die eben ausgefuehrte Verschiebung
22 * wieder zurueck, ohne dass sich am Namen unter dem Finger etwas geaendert
23 * haette. Ueber Platz 1 kam man mit der Tastatur nicht hinaus.
24 *
25 * Derselbe Schaden, den Befund 50 zur Fassung 5.8.0 fuer diese Tabelle bereits
26 * als Fehler eingestuft hat ("nach unten" hinterliess den Fokus auf "nach
27 * oben") - nur ueber den Rueckfallpfad.
28 *
29 * In der Phasenansicht wog es schwerer: Dort trugen die Schaltflaechen ↑, ↓ und
30 * × ueberhaupt keinen `data-fokus`. Es griff von vornherein nur der
31 * Strukturweg, und der Fokus landete auf der Nachbarkarte - nicht nur am Rand,
32 * sondern bei JEDER Verschiebung.
33 */
34
35 const STICHTAG = new Date('2026-01-01T00:00:00Z');
36
37 let aufraeumen: (() => void) | null = null;
38 afterEach(() => {
39 aufraeumen?.();
40 aufraeumen = null;
41 document.body.replaceChildren();
42 });
43
44 /** Ansicht mit demselben Neuaufbau, den die Huelle fuer `refresh` vorsieht. */
45 function zeichne(view: ViewDefinition, project: Project): { wurzel: HTMLElement } {
46 const wurzel = document.createElement('div');
47 document.body.append(wurzel);
48 const store = new ProjectStore(project);
49 let dispose: () => void = () => {};
50 const context: ViewContext = {
51 store,
52 refresh: () => {
53 mitBewahrtemFokus(wurzel, () => {
54 dispose();
55 wurzel.replaceChildren();
56 dispose = view.render(wurzel, context);
57 });
58 },
59 navigate: () => {},
60 target: null,
61 };
62 dispose = view.render(wurzel, context);
63 aufraeumen = () => {
64 dispose();
65 wurzel.remove();
66 };
67 return { wurzel };
68 }
69
70 /*
71 * Ueber die Werte vergleichen und nicht ueber einen Attributselektor: Der Name
72 * enthaelt die Bezeichnung in Anfuehrungszeichen, und die bricht jeden
73 * Selektor.
74 */
75 function knopf(wurzel: HTMLElement, name: string): HTMLButtonElement {
76 const treffer = [...wurzel.querySelectorAll<HTMLButtonElement>('button')].filter(
77 (b) => b.getAttribute('aria-label') === name,
78 );
79 expect(treffer.length, `Keine Schaltflaeche "${name}"`).toBe(1);
80 return treffer[0] as HTMLButtonElement;
81 }
82
83 /** Name des Elements, das den Fokus hat. */
84 const fokusname = (): string =>
85 (document.activeElement as HTMLElement | null)?.getAttribute('aria-label') ?? '';
86
87 /** Druckt die Schaltflaeche so, wie es die Tastatur tut: mit Fokus darauf. */
88 function druecke(taste: HTMLButtonElement): void {
89 taste.focus();
90 expect(document.activeElement).toBe(taste);
91 taste.click();
92 }
93
94 describe('Befund 47 - Signalgruppen umsortieren', () => {
95 it('haelt den Fokus bei der verschobenen Gruppe, wenn "nach oben" gesperrt wird', () => {
96 const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG));
97 druecke(knopf(wurzel, 'Signalgruppe "K2" nach oben'));
98
99 // Nicht die gleichnamige Schaltflaeche der Nachbarzeile - die machte den
100 // Schritt beim naechsten Druck unbemerkt rueckgaengig.
101 expect(fokusname()).not.toBe('Signalgruppe "K1" nach oben');
102 expect(fokusname()).toBe('Signalgruppe "K2" nach unten');
103 });
104
105 it('haelt ihn ebenso am unteren Ende', () => {
106 const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG));
107 druecke(knopf(wurzel, 'Signalgruppe "F1" nach unten'));
108
109 expect(fokusname()).not.toBe('Signalgruppe "F2" nach unten');
110 expect(fokusname()).toBe('Signalgruppe "F1" nach oben');
111 });
112
113 it('laesst den Fokus abseits der Raender auf derselben Schaltflaeche', () => {
114 // Unveraendert - das sichert schon tests/ui/fokusReihenfolge;
115 // hier steht es als Gegenprobe, dass die Ausweichregel nur am Rand greift.
116 const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG));
117 druecke(knopf(wurzel, 'Signalgruppe "K1" nach unten'));
118 expect(fokusname()).toBe('Signalgruppe "K1" nach unten');
119 });
120
121 it('nimmt einer Maus ohne Fokus den Fokus nicht weg', () => {
122 // Ein Mausklick fokussiert in jsdom nichts. Dann darf die Ausweichregel
123 // auch nichts fokussieren - sonst risse ein Klick den Fokus aus dem Feld,
124 // in dem der Anwender gerade steht.
125 const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG));
126 knopf(wurzel, 'Signalgruppe "K2" nach oben').click();
127 expect(document.activeElement).toBe(document.body);
128 });
129 });
130
131 describe('Befund 47 - Phasen umsortieren', () => {
132 /** Drei Phasen, damit es eine Verschiebung ohne Randlage gibt. */
133 function dreiPhasen(): Project {
134 const roh = createStandardIntersectionProject('Drei Phasen', STICHTAG);
135 const dritte = createPhase('Phase 3 - Linksabbieger', []);
136 return {
137 ...roh,
138 phases: [...roh.phases, dritte],
139 program: { ...roh.program, phaseOrder: [...roh.program.phaseOrder, dritte.id] },
140 };
141 }
142
143 it('haelt den Fokus bei der verschobenen Phase, wenn "nach vorne" gesperrt wird', () => {
144 const { wurzel } = zeichne(phasesView, dreiPhasen());
145 druecke(knopf(wurzel, '2. Phase "Phase 2 - Ost/West" nach vorne'));
146
147 expect(fokusname()).not.toBe('1. Phase "Phase 1 - Nord/Süd" nach vorne');
148 expect(fokusname()).toBe('1. Phase "Phase 2 - Ost/West" nach hinten');
149 });
150
151 it('haelt ihn abseits der Raender auf derselben Schaltflaeche', () => {
152 // Ohne `data-fokus` griff hier nur der Strukturweg - und der zeigte nach
153 // dem Tausch auf die Nachbarkarte.
154 const { wurzel } = zeichne(phasesView, dreiPhasen());
155 druecke(knopf(wurzel, '1. Phase "Phase 1 - Nord/Süd" nach hinten'));
156
157 expect(fokusname()).toBe('2. Phase "Phase 1 - Nord/Süd" nach hinten');
158 });
159
160 it('schiebt beim zweiten Tastendruck weiter, statt zwischen zwei Plaetzen zu pendeln', () => {
161 const { wurzel } = zeichne(phasesView, dreiPhasen());
162 druecke(knopf(wurzel, '1. Phase "Phase 1 - Nord/Süd" nach hinten'));
163 (document.activeElement as HTMLButtonElement | null)?.click();
164
165 // Nur die Ueberschriften der Phasenkarten. Seit die Ansicht unter der
166 // Auswahlliste die vier Rechenansaetze gegenueberstellt, steht dort eine
167 // weitere h3, und die gehoert zu keiner Phase.
168 const namen = [...wurzel.querySelectorAll('[id^="phase-"] h3')].map((h) => h.textContent);
169 expect(namen).toEqual([
170 '1. Phase 2 - Ost/West',
171 '2. Phase 3 - Linksabbieger',
172 '3. Phase 1 - Nord/Süd',
173 ]);
174 });
175 });
176
177 describe('Befund 47 - die Reihenfolge im Speicher stimmt weiter', () => {
178 it('vertauscht genau zwei Phasen je Druck', () => {
179 const roh = createStandardIntersectionProject('Reihenfolge', STICHTAG);
180 const wurzel = document.createElement('div');
181 document.body.append(wurzel);
182 const store = new ProjectStore(roh);
183 let dispose: () => void = () => {};
184 const context: ViewContext = {
185 store,
186 refresh: () => {
187 mitBewahrtemFokus(wurzel, () => {
188 dispose();
189 wurzel.replaceChildren();
190 dispose = phasesView.render(wurzel, context);
191 });
192 },
193 navigate: () => {},
194 target: null,
195 };
196 dispose = phasesView.render(wurzel, context);
197 aufraeumen = () => {
198 dispose();
199 wurzel.remove();
200 };
201
202 druecke(knopf(wurzel, '2. Phase "Phase 2 - Ost/West" nach vorne'));
203 expect(orderedPhases(store.getProject()).map((p) => p.name)).toEqual([
204 'Phase 2 - Ost/West',
205 'Phase 1 - Nord/Süd',
206 ]);
207 });
208 });