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