lsa-planer

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

/ tests ui fuehrungPlatzUndZiel.test.ts

19,9 KB Rohdatei
tests/ui/fuehrungPlatzUndZiel.test.ts — 477 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { Shell } from '@/ui/shell';
3 import { ProjectStore } from '@/app/store';
4 import { createEmptyProject, createSignalGroup } from '@/domain/model/factory';
5 import { naechsterSchritt } from '@/ui/naechsterSchritt';
6 import type { AppState } from '@/app/store';
7
8 /**
9 * Der Fuehrungshinweis: wo er steht und wohin er zeigt.
10 *
11 * DREI BEFUNDE AUS DER DURCHSICHT DER OBERFLAECHE, alle am selben Kasten:
12 *
13 * 1. ER STAND UEBER DER ANSICHT. Das Erste auf einer Seite sagt, WO MAN IST,
14 * nicht, wo man hin soll - die Ueberschrift der Ansicht ist der Anker.
15 * Wer mit Sprachausgabe arbeitet, begann jede Ansicht mit einem Satz ueber
16 * eine ANDERE Ansicht. Er steht jetzt darunter, dort, wo die Arbeit dieser
17 * Seite endet.
18 *
19 * 2. ER ZEIGTE AUF DIE SEITE, AUF DER MAN STAND. `naechsterSchritt` bekommt
20 * nur den Zustand und kennt die offene Ansicht nicht; keine Bedingung
21 * unterdrueckte den Sprungknopf auf seinem eigenen Ziel. Sieben der elf
22 * Ansichten koennen sich selbst als Ziel nennen, und kein Test beruehrte
23 * den Fall. Der Knopf war dabei nicht bloss wirkungslos: `navigate`
24 * zeichnet die Ansicht vollstaendig neu, setzt den Fokus auf die
25 * Ueberschrift, den Rollbalken auf null und verwirft den ansichtseigenen
26 * Zustand - wer im Lageplan gezeichnet hatte, verlor seine Stelle.
27 *
28 * 3. DIE LEISTE TRUG DIE AUSKUNFT NICHT. Sie kannte "aktiv" und
29 * "beanstandet", und ein Abzeichen bilden ueberhaupt nur 6 der 11
30 * Eintraege - gerade in den ersten beiden Schritten blieb sie stumm,
31 * waehrend der Kasten dorthin zeigte.
32 *
33 * WAS HIER NICHT GEPRUEFT WIRD: ob der Kasten schoen aussieht. Geprueft wird
34 * die Reihenfolge im Baum, das Vorhandensein des Knopfes und die Marke - alles
35 * Dinge, die sich messen lassen.
36 */
37
38 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
39
40 let laufend: Shell | null = null;
41
42 afterEach(() => {
43 laufend?.stop();
44 laufend = null;
45 document.body.replaceChildren();
46 });
47
48 /**
49 * Eine Shell mit den beiden Ansichten, die der erste Schritt betrifft.
50 *
51 * Ein leeres Projekt hat keine Signalgruppe; `naechsterSchritt` zeigt dann auf
52 * 'signalgruppen' (naechsterSchritt.ts, erster Zweig). Mit einer zweiten
53 * Ansicht daneben laesst sich beides pruefen: der Fall "ich stehe auf dem Ziel"
54 * und der Fall "ich stehe woanders".
55 */
56 function baueShell(start: string): {
57 wurzel: HTMLElement;
58 shell: Shell;
59 store: ProjectStore;
60 } {
61 const wurzel = document.createElement('div');
62 document.body.append(wurzel);
63
64 const store = new ProjectStore(createEmptyProject('Musterkreuzung', FESTES_DATUM));
65 const shell = new Shell(wurzel, store, '5.17.0');
66 const ansicht = (id: string, label: string) => ({
67 id,
68 label,
69 group: 'Erfassung',
70 description: `Nur zum Aufbau der Shell: ${label}`,
71 // Dieselbe Zusage wie die echte Signalgruppenansicht - hier reicht sie fuer
72 // alle drei, weil nur EINE davon sie je erfuellt: Ohne Signalgruppe ist sie
73 // ueberall falsch, mit einer ueberall wahr, und der Fall unten prueft
74 // beides.
75 fertig: (zustand: AppState) => zustand.project.signalGroups.length > 0,
76 render: () => () => {},
77 });
78 shell.addView(ansicht('signalgruppen', 'Signalgruppen'));
79 shell.addView(ansicht('projekt', 'Projekt'));
80 // Die dritte Ansicht ist das Ziel, sobald eine Signalgruppe da ist - ohne
81 // sie liesse sich nur zeigen, dass die Marke verschwindet, nicht, dass sie
82 // wandert.
83 shell.addView(ansicht('phasen', 'Phasen'));
84 shell.start(start);
85 laufend = shell;
86 return { wurzel, shell, store };
87 }
88
89 /** Der Kasten selbst. */
90 function fuehrung(wurzel: HTMLElement): HTMLElement {
91 const knoten = wurzel.querySelector<HTMLElement>('.fuehrung');
92 expect(knoten, 'der Fuehrungshinweis fehlt ganz').not.toBeNull();
93 return knoten as HTMLElement;
94 }
95
96 describe('Der Fuehrungshinweis steht unter der Ansicht', () => {
97 it('zeigt ein leeres Projekt ueberhaupt auf die Signalgruppen', () => {
98 /*
99 * Sachstand zuerst. Zeigte der erste Zweig eines Tages woandershin, prueften
100 * die Faelle darunter etwas anderes als das, was sie zu pruefen glauben -
101 * und blieben womoeglich gruen, ohne es zu merken.
102 */
103 const store = new ProjectStore(createEmptyProject('Musterkreuzung', FESTES_DATUM));
104 expect(naechsterSchritt(store.getState()).ansicht, 'Ziel bei leerem Projekt').toBe(
105 'signalgruppen',
106 );
107 });
108
109 it('steht er im Baum HINTER dem Ansichtsbehaelter', () => {
110 const { wurzel } = baueShell('projekt');
111 const halter = wurzel.querySelector('.fuehrung-halter');
112 const ansichten = wurzel.querySelector('.ansicht-halter');
113 expect(halter, 'der Halter des Hinweises fehlt').not.toBeNull();
114 expect(ansichten, 'der Ansichtsbehaelter fehlt').not.toBeNull();
115
116 /*
117 * `compareDocumentPosition` statt eines Vergleichs der Kindindizes: Der
118 * Vergleich gilt auch dann, wenn zwischen beiden eines Tages etwas
119 * Drittes steht. DOCUMENT_POSITION_FOLLOWING (4) heisst: der Halter kommt
120 * NACH dem Behaelter.
121 */
122 const lage = (ansichten as Element).compareDocumentPosition(halter as Element);
123 expect(
124 lage & Node.DOCUMENT_POSITION_FOLLOWING,
125 'der Hinweis steht vor der Ansicht statt dahinter',
126 ).toBeGreaterThan(0);
127 });
128
129 it('ist er nicht das erste Element des Inhaltsbereichs', () => {
130 // Die Gegenprobe zum Fall darueber, an derselben Stelle von der anderen
131 // Seite: Wer den Inhalt liest, trifft zuerst auf die Ansicht.
132 const { wurzel } = baueShell('projekt');
133 const inhalt = wurzel.querySelector('main.inhalt');
134 expect(inhalt, 'der Inhaltsbereich fehlt').not.toBeNull();
135 expect(
136 (inhalt as HTMLElement).firstElementChild?.className,
137 'der Hinweis steht an erster Stelle im Inhalt',
138 ).not.toContain('fuehrung');
139 });
140 });
141
142 describe('Der Fuehrungshinweis zeigt nie auf die eigene Seite', () => {
143 it('traegt er auf einer anderen Ansicht einen Sprungknopf', () => {
144 // Sachstand: Ohne diesen Fall koennte der Knopf ueberall fehlen, und der
145 // Fall darunter waere gruen, ohne etwas zu bewachen.
146 const { wurzel } = baueShell('projekt');
147 const knopf = fuehrung(wurzel).querySelector('button');
148 expect(knopf, 'auf einer fremden Ansicht fehlt der Sprungknopf').not.toBeNull();
149 expect(knopf?.textContent, 'Beschriftung des Sprungknopfes').toContain('Signalgruppen');
150 });
151
152 it('traegt er auf der Zielansicht KEINEN Sprungknopf', () => {
153 const { wurzel } = baueShell('signalgruppen');
154 expect(
155 fuehrung(wurzel).querySelector('button'),
156 'der Hinweis schickt den Anwender dorthin, wo er steht',
157 ).toBeNull();
158 });
159
160 it('bleibt der Satz auf der Zielansicht stehen', () => {
161 /*
162 * Der Knopf faellt weg, die Anweisung nicht: Auf der Signalgruppenansicht
163 * ist "Legen Sie die Signalgruppen an." genau richtig - nur der Weg dorthin
164 * ist keiner. Ohne diesen Fall waere das Beheben durch blosses Ausblenden
165 * des ganzen Kastens ununterscheidbar.
166 */
167 const { wurzel } = baueShell('signalgruppen');
168 const text = fuehrung(wurzel).textContent ?? '';
169 expect(text, 'die Anweisung ist mit dem Knopf verschwunden').toContain('Signalgruppen');
170 expect(text, 'die Marke kuendigt einen Schritt an, der hier zu tun ist').toContain('Hier');
171 expect(text, 'die Marke behauptet weiter einen naechsten Schritt').not.toContain(
172 'Nächster Schritt',
173 );
174 });
175
176 it('wechselt er mit der Ansicht', () => {
177 // Derselbe Zustand, zwei Ansichten, zwei Ergebnisse - das ist der Kern:
178 // Der Kasten haengt nicht mehr allein am Projekt, sondern auch am Standort.
179 const { wurzel, shell } = baueShell('projekt');
180 expect(fuehrung(wurzel).querySelector('button'), 'Ausgangslage').not.toBeNull();
181 shell.navigate('signalgruppen');
182 expect(fuehrung(wurzel).querySelector('button'), 'nach dem Wechsel auf das Ziel').toBeNull();
183 shell.navigate('projekt');
184 expect(fuehrung(wurzel).querySelector('button'), 'nach dem Wechsel zurueck').not.toBeNull();
185 });
186 });
187
188 describe('Die Leiste markiert den naechsten Schritt', () => {
189 /** Der Navigationsknopf zu einer Ansicht. */
190 function navKnopf(wurzel: HTMLElement, label: string): HTMLElement {
191 const knoepfe = [...wurzel.querySelectorAll<HTMLElement>('.nav-knopf')];
192 const treffer = knoepfe.find(
193 (k) => k.querySelector('.nav-beschriftung')?.textContent === label,
194 );
195 expect(treffer, `Navigationseintrag "${label}"`).toBeDefined();
196 return treffer as HTMLElement;
197 }
198
199 it('markiert sie den Eintrag, auf den der Hinweis zeigt', () => {
200 const { wurzel } = baueShell('projekt');
201 const marke = navKnopf(wurzel, 'Signalgruppen').querySelector<HTMLElement>('.nav-naechster');
202 expect(marke, 'die Marke fehlt im Eintrag').not.toBeNull();
203 expect(marke?.hidden, 'die Marke ist verborgen, obwohl der Eintrag dran ist').toBe(false);
204 });
205
206 it('sagt sie es auch der Sprachausgabe', () => {
207 /*
208 * Der Punkt traegt `aria-hidden` - Farbe und Zeichen allein sind fuer eine
209 * Sprachausgabe nichts. Wer nur den Punkt umschaltet, zeigt die Marke dem
210 * Auge und verschweigt sie dem Ohr.
211 */
212 const knoten = navKnopf(baueShell('projekt').wurzel, 'Signalgruppen');
213 const text = knoten.querySelector<HTMLElement>('.nav-naechster-text');
214 expect(text, 'der vorgelesene Zusatz fehlt').not.toBeNull();
215 expect(text?.hidden, 'der Zusatz bleibt verborgen, waehrend der Punkt sichtbar ist').toBe(
216 false,
217 );
218 expect(text?.textContent, 'Wortlaut des Zusatzes').toContain('als Nächstes dran');
219 });
220
221 it('markiert sie einen anderen Eintrag nicht', () => {
222 const knoten = navKnopf(baueShell('projekt').wurzel, 'Projekt');
223 expect(
224 knoten.querySelector<HTMLElement>('.nav-naechster')?.hidden,
225 'ein Eintrag ohne Anlass traegt die Marke',
226 ).toBe(true);
227 });
228
229 it('zieht sie mit, wenn sich der Zustand ohne Ansichtswechsel aendert', () => {
230 /*
231 * DIESE LUECKE HAT DER MUTATIONSNACHWEIS GEFUNDEN. Die Faelle darueber
232 * pruefen nur nach `start()` und `navigate()` - beide laufen ueber
233 * `zeichneAnsicht`, wo `markiereNaechsten` ein zweites Mal steht. Nimmt man
234 * den Aufruf aus `updateChrome` heraus, blieben sie alle gruen. GEMESSEN
235 * mit genau dieser Mutation.
236 *
237 * Der ungeprueft gebliebene Weg ist zugleich der haeufige: Wer eine
238 * Signalgruppe anlegt, wechselt dabei die Ansicht nicht - der naechste
239 * Schritt ist danach trotzdem ein anderer.
240 */
241 const { wurzel, store } = baueShell('projekt');
242 expect(
243 navKnopf(wurzel, 'Signalgruppen').querySelector<HTMLElement>('.nav-naechster')?.hidden,
244 'Ausgangslage: die Marke steht bei den Signalgruppen',
245 ).toBe(false);
246
247 store.update(
248 (projekt) => ({
249 ...projekt,
250 signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })],
251 }),
252 { label: 'Signalgruppe angelegt' },
253 );
254
255 // Mit einer Signalgruppe und ohne Phase zeigt die Kette auf die Phasen.
256 expect(naechsterSchritt(store.getState()).ansicht, 'Ziel nach der Aenderung').toBe('phasen');
257 expect(
258 navKnopf(wurzel, 'Signalgruppen').querySelector<HTMLElement>('.nav-naechster')?.hidden,
259 'die Marke steht noch bei den Signalgruppen',
260 ).toBe(true);
261 expect(
262 navKnopf(wurzel, 'Phasen').querySelector<HTMLElement>('.nav-naechster')?.hidden,
263 'die Marke ist den Phasen nicht gefolgt',
264 ).toBe(false);
265 });
266
267 it('markiert sie nicht die Ansicht, auf der man steht', () => {
268 /*
269 * Dort sagt `aria-current` bereits, wo man ist. "Hier" und "als Naechstes
270 * hierhin" nebeneinander waere dieselbe Verdopplung, die der Sprungknopf
271 * auf der eigenen Seite war.
272 */
273 const knoten = navKnopf(baueShell('signalgruppen').wurzel, 'Signalgruppen');
274 expect(
275 knoten.querySelector<HTMLElement>('.nav-naechster')?.hidden,
276 'die Leiste kuendigt an, wohin man schon gegangen ist',
277 ).toBe(true);
278 expect(
279 knoten.querySelector<HTMLElement>('.nav-naechster-text')?.hidden,
280 'der vorgelesene Zusatz bleibt stehen',
281 ).toBe(true);
282 });
283 });
284
285 describe('Der Haken "erledigt" in der Leiste', () => {
286 /** Der Navigationsknopf zu einer Ansicht. */
287 function navKnopf(wurzel: HTMLElement, label: string): HTMLElement {
288 const knoepfe = [...wurzel.querySelectorAll<HTMLElement>('.nav-knopf')];
289 const treffer = knoepfe.find(
290 (k) => k.querySelector('.nav-beschriftung')?.textContent === label,
291 );
292 expect(treffer, `Navigationseintrag "${label}"`).toBeDefined();
293 return treffer as HTMLElement;
294 }
295
296 it('steht am leeren Projekt an keinem Eintrag', () => {
297 // Sachstand: Ohne Signalgruppe ist die Zusage der Testansichten ueberall
298 // falsch. Stuende hier ein Haken, waere der Fall darunter wertlos.
299 const { wurzel } = baueShell('projekt');
300 for (const label of ['Projekt', 'Signalgruppen', 'Phasen']) {
301 expect(
302 navKnopf(wurzel, label).querySelector<HTMLElement>('.nav-fertig')?.hidden,
303 `Haken an "${label}" am leeren Projekt`,
304 ).toBe(true);
305 }
306 });
307
308 it('erscheint er, sobald die Zusage erfuellt ist', () => {
309 const { wurzel, store } = baueShell('projekt');
310 store.update(
311 (projekt) => ({
312 ...projekt,
313 signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })],
314 }),
315 { label: 'Signalgruppe angelegt' },
316 );
317 const knoten = navKnopf(wurzel, 'Signalgruppen');
318 expect(knoten.querySelector<HTMLElement>('.nav-fertig')?.hidden, 'Haken sichtbar').toBe(false);
319 expect(
320 knoten.querySelector<HTMLElement>('.nav-fertig-text')?.hidden,
321 'der vorgelesene Zusatz bleibt verborgen',
322 ).toBe(false);
323 expect(
324 knoten.querySelector<HTMLElement>('.nav-fertig-text')?.textContent,
325 'Wortlaut des Zusatzes',
326 ).toContain('erledigt');
327 });
328
329 it('schweigt er an dem Eintrag, der als Naechstes dran ist', () => {
330 /*
331 * "Erledigt" und "als Nächstes dran" am selben Eintrag waeren ein
332 * Widerspruch. Der Haken ist die schwaechere der beiden Aussagen und
333 * weicht.
334 *
335 * Gebaut wird die Lage ueber die Phasen: Mit einer Signalgruppe zeigt die
336 * Kette dorthin, und die Testansicht "Phasen" traegt dieselbe Zusage wie
337 * alle - sie waere also ohne diese Regel zugleich erledigt und dran.
338 */
339 const { wurzel, store } = baueShell('projekt');
340 store.update(
341 (projekt) => ({
342 ...projekt,
343 signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })],
344 }),
345 { label: 'Signalgruppe angelegt' },
346 );
347 expect(naechsterSchritt(store.getState()).ansicht, 'Ziel nach der Aenderung').toBe('phasen');
348
349 const knoten = navKnopf(wurzel, 'Phasen');
350 expect(
351 knoten.querySelector<HTMLElement>('.nav-naechster')?.hidden,
352 'die Marke fehlt am naechsten Schritt',
353 ).toBe(false);
354 expect(
355 knoten.querySelector<HTMLElement>('.nav-fertig')?.hidden,
356 'derselbe Eintrag traegt zugleich einen Haken',
357 ).toBe(true);
358 });
359 });
360
361 describe('Aendert sich der naechste Schritt, sagt es die Sprachausgabe', () => {
362 /** Der Ansageraum der Shell. */
363 function ansage(wurzel: HTMLElement): HTMLElement {
364 const knoten = wurzel.querySelector<HTMLElement>('[role="status"]');
365 expect(knoten, 'der Ansageraum fehlt').not.toBeNull();
366 return knoten as HTMLElement;
367 }
368
369 it('nennt sie den Schritt beim ersten Aufbau', () => {
370 const { wurzel } = baueShell('projekt');
371 expect(ansage(wurzel).textContent, 'Ansage beim Start').toContain(
372 'Nächster Schritt: Legen Sie die Signalgruppen an.',
373 );
374 });
375
376 it('nennt sie den neuen Schritt, wenn er sich aendert', () => {
377 /*
378 * DER BEFUND, GEGEN DEN DIESER FALL GESCHRIEBEN IST: Der naechste Schritt
379 * aenderte sich stumm. Wer eine Signalgruppe anlegte, hoerte, dass zwei
380 * Fehler dazugekommen sind, aber nicht, dass jetzt die Phasen dran sind -
381 * die sichtbarste Auskunft der Oberflaeche war die einzige, die nie
382 * angesagt wurde.
383 */
384 const { wurzel, store } = baueShell('projekt');
385 store.update(
386 (projekt) => ({
387 ...projekt,
388 signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })],
389 }),
390 { label: 'Signalgruppe angelegt' },
391 );
392 expect(ansage(wurzel).textContent, 'Ansage nach der Aenderung').toContain(
393 'Nächster Schritt: Bilden Sie die Phasen.',
394 );
395 });
396
397 it('wiederholt sie den Schritt nicht, wenn nur der Zustand sich aendert', () => {
398 /*
399 * Der Grund fuer zwei getrennte Staende. Haengte der Schritt an derselben
400 * Zeichenkette wie der Zustand, laese die Sprachausgabe ihn bei jeder
401 * geaenderten Zahl noch einmal mit vor - und wer ihn hoeren will, hoerte
402 * ihn im Rauschen nicht.
403 */
404 const { wurzel, store } = baueShell('projekt');
405 /*
406 * GEMESSEN WIRD, OB NEU GESCHRIEBEN WURDE, nicht, was dasteht. Ein
407 * Ansageraum behaelt seinen Text, solange niemand ihn tauscht - eine
408 * Sprachausgabe liest ihn aber nur bei der AENDERUNG vor. Der erste Anlauf
409 * dieses Falles suchte im Text nach "Nächster Schritt:" und fand die
410 * Ansage vom Programmstart wieder; er war rot, obwohl nichts gesagt worden
411 * war.
412 */
413 expect(ansage(wurzel).textContent, 'Ausgangslage: es wurde schon etwas gesagt').not.toBe('');
414
415 /*
416 * GEMESSEN WIRD DER SCHREIBVORGANG, nicht der Wert. Der zweite Anlauf
417 * dieses Falles verglich den Text vorher und nachher - und blieb gruen, als
418 * die Mutation "alles bei jeder Aenderung wiederholen" den Ansageraum
419 * unbedingt neu beschrieb: Derselbe Text, geschrieben wie ungeschrieben,
420 * sieht gleich aus. Eine Sprachausgabe liest ihn aber vor, sobald er
421 * gesetzt wird.
422 *
423 * `textContent = ...` ersetzt die Kindknoten. Der Textknoten selbst ist
424 * damit der Zeuge: Ist es derselbe wie vorher, wurde nicht geschrieben.
425 */
426 const knoten = ansage(wurzel).firstChild;
427 expect(knoten, 'der Ansageraum hat keinen Textknoten').not.toBeNull();
428
429 // Der Projektname geht weder in den Zustandssatz noch in den Schritt ein.
430 store.update((projekt) => ({ ...projekt, meta: { ...projekt.meta, name: 'Anders' } }), {
431 label: 'Name geaendert',
432 });
433 expect(ansage(wurzel).firstChild, 'der Ansageraum wurde neu beschrieben').toBe(knoten);
434 });
435
436 it('sagt sie beim Schrittwechsel den Schritt und nicht die halbe Oberflaeche', () => {
437 // Die Gegenprobe zum Fall darueber: Dass NICHTS gesagt wird, ist nur dann
438 // ein Verdienst, wenn im richtigen Fall etwas gesagt wird.
439 const { wurzel, store } = baueShell('projekt');
440 const vorher = ansage(wurzel).textContent ?? '';
441 store.update(
442 (projekt) => ({
443 ...projekt,
444 signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })],
445 }),
446 { label: 'Signalgruppe angelegt' },
447 );
448 expect(ansage(wurzel).textContent, 'die Ansage blieb stehen').not.toBe(vorher);
449 expect(ansage(wurzel).textContent, 'der neue Schritt fehlt').toContain(
450 'Nächster Schritt: Bilden Sie die Phasen.',
451 );
452 });
453 });
454
455 describe('Der Fokus ueberlebt eine Zustandsaenderung auf dem Sprungknopf', () => {
456 it('bleibt er auf dem Knopf, wenn sich der Zustand aendert', () => {
457 /*
458 * Der Hinweis wird bei JEDER Zustandsaenderung neu gebaut - auch beim
459 * Tippen in einem Feld. Ohne Fokusrettung ersetzt `render` die Kinder, und
460 * der Fokus faellt auf den Seitenkoerper zurueck: Wer den Sprungknopf mit
461 * der Tastatur erreicht hatte, stand danach wieder am Anfang.
462 */
463 const { wurzel, store } = baueShell('projekt');
464 const knopf = wurzel.querySelector<HTMLButtonElement>('.fuehrung button');
465 expect(knopf, 'der Sprungknopf fehlt').not.toBeNull();
466 (knopf as HTMLButtonElement).focus();
467 expect(document.activeElement, 'Ausgangslage: der Knopf hat den Fokus').toBe(knopf);
468
469 store.update((projekt) => ({ ...projekt, meta: { ...projekt.meta, name: 'Anders' } }), {
470 label: 'Name geaendert',
471 });
472
473 const danach = wurzel.querySelector<HTMLButtonElement>('.fuehrung button');
474 expect(danach, 'nach der Aenderung fehlt der Knopf').not.toBeNull();
475 expect(document.activeElement, 'der Fokus ist vom Knopf gefallen').toBe(danach);
476 });
477 });