lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui umsortierFokus.test.ts
| 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 | }); |