lsa-planer

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

/ tests ui erscheinungsbildwechsel.test.ts

8,4 KB Rohdatei
tests/ui/erscheinungsbildwechsel.test.ts — 242 Zeilen
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 });