lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui erscheinungsbildwechsel.test.ts
| 1 | import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; |
| 2 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 3 | import { DARK_THEME, LIGHT_THEME } from '@/render/signalPlanDrawing'; |
| 4 | import { ladeSitzung, saveSession } from '@/services/storage'; |
| 5 | |
| 6 | /** |
| 7 | * Wechselt das Erscheinungsbild im Betrieb, werden die Zeichenflaechen neu |
| 8 | * gezeichnet (Befund 46). |
| 9 | * |
| 10 | * `applyTheme` setzt das Attribut `data-erscheinungsbild` am Wurzelelement. |
| 11 | * Alle Farben der Oberflaeche haengen an CSS-Variablen und folgen sofort - die |
| 12 | * Zeichenflaechen aber nicht: `planView`, `simulationView` und `lageplanView` |
| 13 | * lesen das Attribut im Augenblick des Zeichnens und behalten sonst die alten |
| 14 | * Farben, bis irgendetwas anderes ein Neuzeichnen ausloest. Der Anwender sieht |
| 15 | * dann eine weisse Zeichnung mitten in einer dunklen Oberflaeche. |
| 16 | * |
| 17 | * Der Wechsel kommt aus zwei Richtungen: ueber die Kopfzeile und aus dem |
| 18 | * Betriebssystem. Geprueft wird hier der zweite Weg, weil ihn niemand sonst |
| 19 | * beantwortet - die Kopfzeile zeichnete bisher als einzige selbst nach. |
| 20 | * |
| 21 | * Geprueft wird am echten Einstiegspunkt: `src/main.ts` wird eingelesen und |
| 22 | * startet die ganze Anwendung; ersetzt sind nur der Sitzungsspeicher, die |
| 23 | * Medienabfragen und der Zeichenkontext. |
| 24 | */ |
| 25 | |
| 26 | vi.mock('@/services/storage', async (importOriginal) => { |
| 27 | const echt = await importOriginal<typeof import('@/services/storage')>(); |
| 28 | return { |
| 29 | ...echt, |
| 30 | ladeSitzung: vi.fn(), |
| 31 | saveSession: vi.fn(), |
| 32 | addRecoveryPoint: vi.fn(), |
| 33 | }; |
| 34 | }); |
| 35 | |
| 36 | /** |
| 37 | * Zeichenkontext, der nur die Fuellfarben mitschreibt. |
| 38 | * |
| 39 | * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nur eine |
| 40 | * einzige Farbe: Der Grund des Signalzeitenplans wird als erstes Rechteck mit |
| 41 | * `theme.surface` gefuellt. |
| 42 | */ |
| 43 | class Farbschreiber { |
| 44 | readonly fuellungen: string[] = []; |
| 45 | fillStyle = ''; |
| 46 | strokeStyle = ''; |
| 47 | lineWidth = 0; |
| 48 | font = ''; |
| 49 | textAlign = 'left'; |
| 50 | textBaseline = 'top'; |
| 51 | |
| 52 | save(): void {} |
| 53 | restore(): void {} |
| 54 | clearRect(): void {} |
| 55 | setTransform(): void {} |
| 56 | setLineDash(): void {} |
| 57 | beginPath(): void {} |
| 58 | moveTo(): void {} |
| 59 | lineTo(): void {} |
| 60 | stroke(): void {} |
| 61 | strokeRect(): void {} |
| 62 | fillText(): void {} |
| 63 | |
| 64 | measureText(): { width: number } { |
| 65 | return { width: 24 }; |
| 66 | } |
| 67 | |
| 68 | fillRect(): void { |
| 69 | this.fuellungen.push(this.fillStyle); |
| 70 | } |
| 71 | |
| 72 | leeren(): void { |
| 73 | this.fuellungen.length = 0; |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | const stift = new Farbschreiber(); |
| 78 | |
| 79 | interface Abfrage { |
| 80 | matches: boolean; |
| 81 | empfaenger: (() => void)[]; |
| 82 | } |
| 83 | |
| 84 | const abfragen = new Map<string, Abfrage>(); |
| 85 | |
| 86 | function stelleMedienabfragen(): void { |
| 87 | abfragen.clear(); |
| 88 | abfragen.set('(forced-colors: active)', { matches: false, empfaenger: [] }); |
| 89 | abfragen.set('(prefers-color-scheme: dark)', { matches: false, empfaenger: [] }); |
| 90 | |
| 91 | Object.defineProperty(window, 'matchMedia', { |
| 92 | configurable: true, |
| 93 | writable: true, |
| 94 | value: (text: string) => { |
| 95 | const zustand = abfragen.get(text) ?? { matches: false, empfaenger: [] }; |
| 96 | abfragen.set(text, zustand); |
| 97 | return { |
| 98 | get matches() { |
| 99 | return zustand.matches; |
| 100 | }, |
| 101 | media: text, |
| 102 | addEventListener: (_art: string, empfaenger: () => void) => { |
| 103 | zustand.empfaenger.push(empfaenger); |
| 104 | }, |
| 105 | removeEventListener: (_art: string, empfaenger: () => void) => { |
| 106 | const stelle = zustand.empfaenger.indexOf(empfaenger); |
| 107 | if (stelle >= 0) zustand.empfaenger.splice(stelle, 1); |
| 108 | }, |
| 109 | }; |
| 110 | }, |
| 111 | }); |
| 112 | } |
| 113 | |
| 114 | /** Stellt das Betriebssystem um - so, wie es im Betrieb geschieht. */ |
| 115 | function schalte(abfrage: string, matches: boolean): void { |
| 116 | const zustand = abfragen.get(abfrage); |
| 117 | if (zustand === undefined) throw new Error(`Abfrage ${abfrage} wurde nie gestellt.`); |
| 118 | zustand.matches = matches; |
| 119 | for (const empfaenger of [...zustand.empfaenger]) empfaenger(); |
| 120 | } |
| 121 | |
| 122 | async function warte(ms = 0): Promise<void> { |
| 123 | await new Promise((fertig) => setTimeout(fertig, ms)); |
| 124 | } |
| 125 | |
| 126 | /** Die Kopfzeilenschaltflaeche fuer das Erscheinungsbild. */ |
| 127 | function erscheinungsbildKnopf(): HTMLButtonElement { |
| 128 | const treffer = [...document.querySelectorAll('button')].find((b) => |
| 129 | b.textContent?.startsWith('Ansicht:'), |
| 130 | ); |
| 131 | if (!treffer) throw new Error('Schaltfläche "Ansicht: ..." nicht gefunden.'); |
| 132 | return treffer; |
| 133 | } |
| 134 | |
| 135 | /** Wechselt ueber die Navigationsleiste in eine Ansicht. */ |
| 136 | function gehZu(beschriftung: string): void { |
| 137 | const treffer = [...document.querySelectorAll<HTMLButtonElement>('.nav-knopf')].find( |
| 138 | (b) => b.querySelector('.nav-beschriftung')?.textContent === beschriftung, |
| 139 | ); |
| 140 | if (!treffer) throw new Error(`Ansicht "${beschriftung}" nicht gefunden.`); |
| 141 | treffer.click(); |
| 142 | } |
| 143 | |
| 144 | beforeAll(async () => { |
| 145 | vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( |
| 146 | () => stift as unknown as CanvasRenderingContext2D, |
| 147 | ); |
| 148 | stelleMedienabfragen(); |
| 149 | |
| 150 | vi.mocked(ladeSitzung).mockResolvedValue({ |
| 151 | art: 'geladen', |
| 152 | stand: { |
| 153 | project: createStandardIntersectionProject(), |
| 154 | issues: [], |
| 155 | repaired: false, |
| 156 | migrated: false, |
| 157 | sourceVersion: '13', |
| 158 | intergreenComparison: [], |
| 159 | }, |
| 160 | }); |
| 161 | vi.mocked(saveSession).mockResolvedValue({ ok: true, message: '' }); |
| 162 | |
| 163 | localStorage.clear(); |
| 164 | document.body.replaceChildren(); |
| 165 | const wurzel = document.createElement('div'); |
| 166 | wurzel.id = 'anwendung'; |
| 167 | document.body.append(wurzel); |
| 168 | |
| 169 | await import('@/main'); |
| 170 | await warte(20); |
| 171 | }); |
| 172 | |
| 173 | afterAll(() => { |
| 174 | vi.restoreAllMocks(); |
| 175 | Reflect.deleteProperty(window, 'matchMedia'); |
| 176 | }); |
| 177 | |
| 178 | describe('Wechsel des Erscheinungsbilds im laufenden Betrieb', () => { |
| 179 | it('zeichnet den Signalzeitenplan mit den neuen Farben neu', () => { |
| 180 | gehZu('Signalzeitenplan'); |
| 181 | // Ausgangslage: helles Erscheinungsbild, heller Grund. |
| 182 | expect(document.documentElement.dataset['erscheinungsbild']).toBe('hell'); |
| 183 | expect(stift.fuellungen).toContain(LIGHT_THEME.surface); |
| 184 | stift.leeren(); |
| 185 | |
| 186 | schalte('(prefers-color-scheme: dark)', true); |
| 187 | |
| 188 | // Das Attribut folgt bereits - nur die Leinwand blieb stehen. |
| 189 | expect(document.documentElement.dataset['erscheinungsbild']).toBe('dunkel'); |
| 190 | expect(stift.fuellungen).toContain(DARK_THEME.surface); |
| 191 | }); |
| 192 | |
| 193 | it('zeichnet auch über die Kopfzeile neu, ohne es ein zweites Mal zu regeln', () => { |
| 194 | // Die Kopfzeile zeichnete den Wechsel bisher selbst nach. Seit beide Wege |
| 195 | // an derselben Stelle beantwortet werden, muss dieser hier mitgeprueft |
| 196 | // werden: Vom System steht das Erscheinungsbild auf "dunkel", die |
| 197 | // Einstellung springt im Rundlauf von "System" auf "Hell". |
| 198 | expect(document.documentElement.dataset['erscheinungsbild']).toBe('dunkel'); |
| 199 | stift.leeren(); |
| 200 | |
| 201 | erscheinungsbildKnopf().click(); |
| 202 | |
| 203 | expect(document.documentElement.dataset['erscheinungsbild']).toBe('hell'); |
| 204 | expect(stift.fuellungen).toContain(LIGHT_THEME.surface); |
| 205 | }); |
| 206 | }); |
| 207 | |
| 208 | /** |
| 209 | * Der Neuaufbau nach einem Erscheinungsbildwechsel ist KEIN Ortswechsel. |
| 210 | * |
| 211 | * Der Wechsel kommt hier aus dem Betriebssystem - die Windows-Umschaltung bei |
| 212 | * Sonnenuntergang oder das Einschalten des Kontrastdesigns. Der Anwender hat |
| 213 | * nichts angefordert; er tippt gerade. Wird die Ansicht ueber den |
| 214 | * Ortswechselweg neu gezeichnet, setzt `zeichneAnsicht` den Fokus auf die h1 |
| 215 | * der Ansicht und den Inhaltsbereich an den Anfang zurueck. Genau die Folgen, |
| 216 | * die der Kommentar an `Shell.navigate` als Grund dafuer aufzaehlt, dass ein |
| 217 | * Neuaufbau den Fokus bewahrt: Wer ein Feld ausfuellt, verliert mitten in der |
| 218 | * Eingabe die Schreibstelle - ausgeloest von etwas, das er nicht getan hat. |
| 219 | */ |
| 220 | describe('Erscheinungsbildwechsel aus dem Betriebssystem', () => { |
| 221 | it('laesst den Fokus im Eingabefeld stehen, statt auf die Überschrift zu springen', () => { |
| 222 | gehZu('Projekt'); |
| 223 | |
| 224 | const feld = document.querySelector<HTMLInputElement>('.ansicht-halter input[type="text"]'); |
| 225 | if (feld === null) throw new Error('Kein Textfeld in der Ansicht "Projekt" gefunden.'); |
| 226 | feld.focus(); |
| 227 | // Die Schreibstelle steht mitten im Wort - sie gehoert mit bewahrt. |
| 228 | feld.setSelectionRange(3, 3); |
| 229 | expect(document.activeElement).toBe(feld); |
| 230 | |
| 231 | // Windows schaltet das Kontrastdesign ein. Das Erscheinungsbild richtet |
| 232 | // sich dann nach der Systempalette (theme.ts, effektivesErscheinungsbild) |
| 233 | // und wechselt von "hell" auf "dunkel". |
| 234 | schalte('(forced-colors: active)', true); |
| 235 | expect(document.documentElement.dataset['erscheinungsbild']).toBe('dunkel'); |
| 236 | |
| 237 | const danach = document.activeElement; |
| 238 | expect(danach).toBeInstanceOf(HTMLInputElement); |
| 239 | expect(danach?.tagName).toBe('INPUT'); |
| 240 | expect((danach as HTMLInputElement).type).toBe('text'); |
| 241 | }); |
| 242 | }); |