lsa-planer

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

/ tests ui ueberFenster.test.ts

21,1 KB Rohdatei
tests/ui/ueberFenster.test.ts — 525 Zeilen
1 import { readFileSync } from 'node:fs';
2 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3 import { ladeSitzung, saveSession } from '@/services/storage';
4 import type { MenuCommand } from '@shared/ipc';
5 import { zeigeUeber } from '@/ui/ueber';
6
7 /**
8 * Fassung 5.14.0 - das "Über"-Fenster.
9 *
10 * Die Frage des Anwenders lautete: "Wo kann ich die Lizenzen im Programm
11 * ansehen?" Die Antwort war: gar nicht. Der Hilfemenueeintrag
12 * `Über LSA-Planer Professional 5.14.0` rief `oeffneSeite` auf dieselbe
13 * Internetseite wie der Eintrag darueber (electron/menu.ts, bis 5.14.0), es
14 * gab kein Fenster dahinter, und die Kurzhilfe sagt zu Lizenzen nichts. In
15 * `src/` kam die Zeichenkette "Lizenz" bis dahin an keiner einzigen Stelle
16 * vor.
17 *
18 * Geprueft wird am echten Einstiegspunkt und am geoeffneten Fenster, nicht an
19 * der Bindung: `src/main.ts` startet die ganze Anwendung, der Menuebefehl
20 * kommt ueber die ersetzte Desktop-Bruecke herein, und gelesen wird, was
21 * danach im Fenster steht. Dasselbe Verfahren wie in
22 * tests/ui/fenstertabellen.test.ts - `tests/ui/barrierefreiheit.test.ts`
23 * laeuft ueber die zwoelf Ansichten und erreicht Fenster nicht.
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 const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as {
37 version: string;
38 build: { copyright: string };
39 };
40 const LIZENZ_DEUTSCH = readFileSync('LICENSE.de.txt', 'utf8');
41 const LIZENZ_ENGLISCH = readFileSync('LICENSE', 'utf8');
42 const FREMDBESTANDTEILE = readFileSync('docs/fremdbestandteile.md', 'utf8');
43 const README = readFileSync('README.md', 'utf8');
44 const SEITE = readFileSync('src/index.html', 'utf8');
45
46 /** Titel des Fensters - zugleich sein Name fuer die Sprachausgabe. */
47 const TITEL = 'Über LSA-Planer Professional';
48
49 async function warte(ms = 0): Promise<void> {
50 await new Promise((fertig) => setTimeout(fertig, ms));
51 }
52
53 function alleFenster(): HTMLElement[] {
54 return [...document.querySelectorAll<HTMLElement>('.dialog-hintergrund')];
55 }
56
57 function titelDerFenster(): string[] {
58 return alleFenster().map((k) => k.querySelector('h2')?.textContent?.trim() ?? '');
59 }
60
61 function ueberFenster(): HTMLElement | null {
62 return alleFenster().find((k) => k.querySelector('h2')?.textContent?.trim() === TITEL) ?? null;
63 }
64
65 /** Wortlaut des offenen Fensters, mit Einfachabstaenden. */
66 function text(kasten: HTMLElement): string {
67 return (kasten.textContent ?? '').replace(/\s+/g, ' ');
68 }
69
70 function knopf(kasten: HTMLElement, beschriftung: string): HTMLButtonElement {
71 const gefunden = [...kasten.querySelectorAll('button')].find(
72 (b) => b.textContent?.trim() === beschriftung,
73 );
74 if (!(gefunden instanceof HTMLButtonElement)) {
75 throw new Error(`Schaltfläche "${beschriftung}" fehlt im Fenster.`);
76 }
77 return gefunden;
78 }
79
80 /** Die Absaetze des Inhaltsbereichs - ohne Kopfzeile und Schaltflaechen. */
81 function absaetze(kasten: HTMLElement): string[] {
82 return [...kasten.querySelectorAll('.dialog-inhalt p')].map((p) => p.textContent ?? '');
83 }
84
85 /** Nichtleere Zeilen einer Lizenzdatei - je eine wird zu einem Absatz. */
86 function zeilen(datei: string): string[] {
87 return datei.split('\n').filter((zeile) => zeile.trim() !== '');
88 }
89
90 /**
91 * Die WIRKSAME Sprachauszeichnung eines Elements: das naechste `lang` nach
92 * oben. Genau das wertet eine Sprachausgabe aus - ein fehlendes `lang` am
93 * Absatz ist kein "keine Angabe", sondern die Angabe des Elternelements.
94 */
95 function wirksameSprache(element: Element): string | null {
96 return element.closest('[lang]')?.getAttribute('lang') ?? null;
97 }
98
99 /** Der Absatz mit genau diesem Wortlaut - fuer Pruefungen an einem einzelnen. */
100 function absatzMit(kasten: HTMLElement, wortlaut: string): HTMLElement {
101 const gefunden = [...kasten.querySelectorAll<HTMLElement>('.dialog-inhalt p')].find(
102 (p) => p.textContent === wortlaut,
103 );
104 if (gefunden === undefined) throw new Error(`Absatz "${wortlaut}" fehlt im Fenster.`);
105 return gefunden;
106 }
107
108 function schliesseAlle(): void {
109 for (const kasten of alleFenster()) {
110 kasten.querySelector<HTMLButtonElement>('.dialog-schliessen')?.click();
111 }
112 }
113
114 /** Der Menuebefehl, so wie ihn der Hauptprozess schickt. */
115 let sendeMenuebefehl: (befehl: MenuCommand) => void = () => undefined;
116
117 beforeAll(async () => {
118 vi.mocked(ladeSitzung).mockResolvedValue({ art: 'leer' });
119 vi.mocked(saveSession).mockResolvedValue({ ok: true, message: '' });
120
121 /*
122 * `__APP_VERSION__` setzt sonst vite.config.ts aus package.json ein; unter
123 * Vitest setzt es niemand, und `src/main.ts` faellt dann auf
124 * "0.0.0-entwicklung" zurueck. Hier steht genau die Zahl aus package.json,
125 * damit sich pruefen laesst, dass sie im Fenster ankommt. Dass sie im Bau
126 * aus package.json stammt, haelt tests/electron/brueckeVersion.test.ts
127 * fest.
128 */
129 (globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = PAKET.version;
130
131 (window as unknown as { lsaPlaner: unknown }).lsaPlaner = {
132 platform: 'electron',
133 setDirty: () => undefined,
134 reportError: () => undefined,
135 logPath: () => Promise.resolve('C:/Protokoll/lsa-planer.log'),
136 onMenuCommand: (behandler: (befehl: MenuCommand) => void) => {
137 sendeMenuebefehl = behandler;
138 return () => undefined;
139 },
140 onBeforeClose: () => undefined,
141 };
142
143 /*
144 * Die Seite ist deutsch ausgezeichnet; unter Vitest baut niemand
145 * src/index.html auf, und das jsdom-Dokument kaeme ohne `lang` daher. Damit
146 * sich die WIRKSAME Sprache eines Absatzes pruefen laesst, wird dieselbe
147 * Auszeichnung hier gesetzt - und an der Datei festgehalten, damit die
148 * Pruefung mit ihr faellt statt an einer Annahme haengen zu bleiben.
149 */
150 expect(SEITE, 'src/index.html zeichnet die Seite nicht mehr als deutsch aus').toContain(
151 '<html lang="de">',
152 );
153 document.documentElement.setAttribute('lang', 'de');
154
155 document.body.replaceChildren();
156 const wurzel = document.createElement('div');
157 wurzel.id = 'anwendung';
158 document.body.append(wurzel);
159
160 await import('@/main');
161 await warte(50);
162 });
163
164 beforeEach(async () => {
165 schliesseAlle();
166 await warte(5);
167 });
168
169 async function oeffne(): Promise<HTMLElement> {
170 sendeMenuebefehl('ueber');
171 await warte(5);
172 const kasten = ueberFenster();
173 expect(kasten, 'der Menübefehl "ueber" hat kein Fenster geöffnet').not.toBeNull();
174 return kasten!;
175 }
176
177 describe('Hilfe > Über LSA-Planer Professional', () => {
178 it('öffnet auf den Menübefehl ein Fenster mit diesem Titel', async () => {
179 const kasten = await oeffne();
180 expect(titelDerFenster()).toEqual([TITEL]);
181 // Der Titel ist zugleich der Name des Fensters fuer die Sprachausgabe.
182 const dialog = kasten.querySelector('[role="dialog"]');
183 const benannt = dialog?.getAttribute('aria-labelledby');
184 expect(benannt, 'das Fenster trägt kein aria-labelledby').not.toBeNull();
185 expect(kasten.querySelector(`#${benannt!}`)?.textContent).toBe(TITEL);
186 });
187
188 it('nennt die Fassung aus package.json', async () => {
189 const kasten = await oeffne();
190 expect(text(kasten)).toContain(`LSA-Planer Professional, Fassung ${PAKET.version}`);
191 });
192
193 it('nimmt die Fassung entgegen, statt eine zweite Zahl zu führen', async () => {
194 /*
195 * Grenzwache zur vorigen Pruefung: Stuende die Zahl im Fenster fest,
196 * waere jene gruen, solange package.json dieselbe fuehrt - und beim
197 * naechsten Anheben still falsch. Hier wird `zeigeUeber` unmittelbar mit
198 * einer Zahl gerufen, die in package.json nicht vorkommt.
199 */
200 schliesseAlle();
201 await warte(5);
202 zeigeUeber('9.9.9-pruefstand');
203 await warte(5);
204 const kasten = ueberFenster()!;
205 expect(text(kasten)).toContain('LSA-Planer Professional, Fassung 9.9.9-pruefstand');
206 expect(text(kasten)).not.toContain(PAKET.version);
207 });
208
209 it('trägt den Urhebervermerk, der auch in der Programmdatei steht', async () => {
210 const kasten = await oeffne();
211 const vermerk = '© 2026 Olaf Willerding';
212 expect(text(kasten)).toContain(vermerk);
213 /*
214 * Zweite Stelle derselben Angabe: `build.copyright` in package.json, aus
215 * der electron-builder den Vermerk der Programmdatei bildet. Der Renderer
216 * kommt an package.json nicht heran; diese Zeile bindet die beiden
217 * aneinander.
218 */
219 expect(PAKET.build.copyright).toContain(vermerk);
220 });
221
222 it('trägt den Vermerk wörtlich, den die Lizenz selbst vorschreibt', async () => {
223 const kasten = await oeffne();
224 /*
225 * "Das Werk wird unter den Bedingungen dieser Lizenz zur Verfuegung
226 * gestellt, wenn der Lizenzgeber den folgenden Hinweis unmittelbar hinter
227 * dem Urheberrechtshinweis dieses Werks anbringt: Licensed under the
228 * EUPL" - LICENSE.de.txt, siebter Absatz. Woertlich und unmittelbar
229 * hinter dem Urheberrechtshinweis, deshalb beides zusammen.
230 */
231 expect(LIZENZ_ENGLISCH).toContain('Licensed under the EUPL');
232
233 const alle = absaetze(kasten);
234 const stelle = alle.indexOf('© 2026 Olaf Willerding');
235 expect(stelle, 'der Urheberrechtshinweis fehlt').toBeGreaterThanOrEqual(0);
236 expect(alle[stelle + 1], 'der Vermerk steht nicht unmittelbar dahinter').toBe(
237 'Licensed under the EUPL',
238 );
239 });
240
241 it('nennt die Lizenz und dass beide Sprachfassungen gleichermaßen gelten', async () => {
242 const inhalt = text(await oeffne());
243 expect(inhalt).toContain('Open-Source-Lizenz für die Europäische Union v. 1.2');
244 expect(inhalt).toContain('EUPL-1.2');
245 expect(inhalt).toContain('amtlich und gleichermaßen verbindlich');
246 // Die Zusage der Lizenz selbst, an ihrem Wortlaut geprueft.
247 expect(LIZENZ_DEUTSCH).toContain(
248 'Alle von der Europäischen Kommission anerkannten Sprachfassungen dieser Lizenz sind gleichwertig.',
249 );
250 });
251
252 it('nennt die Fremdbestandteile, ihre Lizenzen und wo die Texte liegen', async () => {
253 const inhalt = text(await oeffne());
254
255 for (const bestandteil of ['Electron', 'Chromium', 'Node.js', 'jsPDF']) {
256 expect(inhalt, `Fremdbestandteil "${bestandteil}" fehlt`).toContain(bestandteil);
257 }
258
259 /*
260 * Die Lizenznamen stehen nicht frei erfunden im Fenster, sondern so auch
261 * in docs/fremdbestandteile.md - dem Verzeichnis, das dem Paket als
262 * FREMDBESTANDTEILE.md beiliegt.
263 */
264 for (const lizenz of ['MIT', 'BSD-3-Clause', 'Apache-2.0', 'MPL-2.0']) {
265 expect(inhalt, `Lizenzname "${lizenz}" fehlt im Fenster`).toContain(lizenz);
266 expect(FREMDBESTANDTEILE, `Lizenzname "${lizenz}" fehlt im Verzeichnis`).toContain(lizenz);
267 }
268
269 // Die vier Dateien, die neben der Programmdatei liegen.
270 for (const datei of [
271 'DRITTANBIETER-LIZENZEN.txt',
272 'FREMDBESTANDTEILE.md',
273 'LICENSE.electron.txt',
274 'LICENSES.chromium.html',
275 ]) {
276 expect(inhalt, `Beiliegende Datei "${datei}" fehlt`).toContain(datei);
277 expect(FREMDBESTANDTEILE, `"${datei}" steht nicht im Verzeichnis`).toContain(datei);
278 }
279 });
280
281 it('behauptet nicht, die Lizenzpflichten seien erfüllt', async () => {
282 const inhalt = text(await oeffne());
283 expect(inhalt).toContain(
284 'Ob die Pflichten aus diesen Lizenzen erfüllt sind, sagt dieses Fenster nicht',
285 );
286 for (const behauptung of [
287 'sind erfüllt',
288 'ist erfüllt',
289 'erfüllt alle',
290 'vollständig erfüllt',
291 'rechtskonform',
292 'lizenzkonform',
293 ]) {
294 expect(inhalt, `Das Fenster behauptet "${behauptung}"`).not.toContain(behauptung);
295 }
296 });
297
298 it('nennt sich keine Rechtsauskunft und verweist auf den Wortlaut', async () => {
299 /*
300 * Der Vorbehalt gehoert zu dem Abschnitt, aus dem der Inhalt dieses
301 * Fensters stammt: README.md, "Rechtliches". Ohne ihn staende die
302 * Zusammenfassung der Lizenz als Auskunft ueber die Rechtslage da - und
303 * gerade er traegt die Zusage des Fensters, nichts zu behaupten, was es
304 * nicht weiss. Woertlich gehalten wie der Fall darueber.
305 */
306 const inhalt = text(await oeffne());
307 expect(inhalt).toContain('Keine Rechtsauskunft: Dieses Fenster legt die Lizenz nicht aus.');
308 expect(inhalt).toContain(
309 'Wo es sie zusammenfasst, ist allein ihr Wortlaut maßgeblich – die beiden Schaltflächen ' +
310 '„Lizenztext“ unten zeigen ihn.',
311 );
312 // Die Quelle des Vorbehalts, damit er nicht frei erfunden dasteht.
313 expect(README.replace(/\s+/g, ' '), 'der Vorbehalt fehlt in der README').toContain(
314 'Dieser Abschnitt beschreibt, was gilt; er legt die Lizenz nicht aus.',
315 );
316 });
317
318 it('gibt die Copyleft-Wirkung so wieder, wie Artikel 5 der Lizenz sie fasst', async () => {
319 const inhalt = text(await oeffne());
320
321 // "Copyleft"-Klausel: diese Lizenz ODER eine neuere Fassung.
322 expect(LIZENZ_DEUTSCH).toContain('oder einer neueren Version dieser Lizenz');
323 expect(inhalt).toContain('unter dieser Lizenz oder einer neueren Fassung davon weiter');
324
325 // "Bereitstellung des Quellcodes": mitliefern ODER Speicherort angeben.
326 expect(LIZENZ_DEUTSCH).toContain('oder einen Speicherort angeben');
327 expect(inhalt).toContain(
328 'liefert den Quellcode mit oder gibt an, wo er unentgeltlich zu bekommen ist',
329 );
330
331 /*
332 * "Kompatibilitaets-Klausel": Sie greift NICHT bei jeder Weitergabe,
333 * sondern nur fuer Bearbeitungen, die auf dem Werk UND einem anderen Werk
334 * unter einer kompatiblen Lizenz beruhen. Als freie Wahl dargestellt,
335 * fuehrte das Fenster weiter, als die Lizenz reicht.
336 */
337 expect(LIZENZ_DEUTSCH).toContain(
338 'Bearbeitungen, die auf dem Werk und einem anderen Werk, das unter einer kompatiblen ' +
339 'Lizenz lizenziert wurde, basieren',
340 );
341 expect(inhalt).toContain(
342 'Beruht eine Bearbeitung zugleich auf einem anderen Werk, das unter einer der im Anhang ' +
343 'der Lizenz genannten kompatiblen Lizenzen steht, darf sie unter jener Lizenz ' +
344 'weitergegeben werden.',
345 );
346 expect(inhalt, 'die kompatible Lizenz steht als freie Wahl bei jeder Weitergabe').not.toContain(
347 'unter derselben oder einer der im Anhang',
348 );
349 });
350
351 /*
352 * Die README fasst dieselbe Wirkung zusammen. Ohne den Absatz zur
353 * kompatiblen Lizenz stuende dort die Copyleft-Klausel ohne ihre Ausnahme,
354 * und wer nur die README liest, erfuehre weniger, als das Fenster sagt.
355 * Gelesen wird der Absatz am geoeffneten Fenster, damit beide Stellen
356 * woertlich dieselbe bleiben und nicht zwei Abschriften derselben Vorlage.
357 */
358 it('gibt die README den Absatz zur kompatiblen Lizenz wörtlich wie das Fenster wieder', async () => {
359 const kasten = await oeffne();
360 const kompatibel = absaetze(kasten).find((absatz) =>
361 absatz.startsWith('Beruht eine Bearbeitung zugleich'),
362 );
363 expect(kompatibel, 'der Absatz zur kompatiblen Lizenz fehlt im Fenster').toBeDefined();
364 const rechtliches = README.slice(README.indexOf('## Rechtliches')).replace(/\s+/g, ' ');
365 expect(rechtliches, 'die README laesst die kompatible Lizenz aus').toContain(kompatibel);
366 });
367 });
368
369 describe('Der Wortlaut der Lizenz steht im Fenster', () => {
370 it('zeigt die deutsche Fassung Zeile für Zeile', async () => {
371 const kasten = await oeffne();
372 knopf(kasten, 'Lizenztext (deutsch)').click();
373 await warte(5);
374
375 const gezeigt = absaetze(kasten);
376 // Der erste Absatz ist die Ueberschrift der Ansicht, nicht Lizenztext.
377 expect(kasten.querySelector('.dialog-inhalt h3')?.textContent).toBe(
378 'Wortlaut der Lizenz – deutsche Fassung',
379 );
380 expect(gezeigt).toEqual(zeilen(LIZENZ_DEUTSCH));
381 // Anker gegen einen Abgleich, der sich selbst prueft: Anfang, Mitte, Ende.
382 expect(gezeigt[0]).toBe('OPEN-SOURCE-LIZENZ FÜR DIE EUROPÄISCHE UNION v. 1.2');
383 expect(gezeigt).toContain('Lizenziert unter der EUPL');
384 expect(gezeigt[gezeigt.length - 1]).toContain(
385 'Alle sonstigen Änderungen oder Ergänzungen dieses Anhangs',
386 );
387 expect(gezeigt).toHaveLength(88);
388 });
389
390 it('zeigt die englische Fassung Zeile für Zeile', async () => {
391 const kasten = await oeffne();
392 knopf(kasten, 'Lizenztext (englisch)').click();
393 await warte(5);
394
395 const gezeigt = absaetze(kasten);
396 expect(gezeigt).toEqual(zeilen(LIZENZ_ENGLISCH));
397 expect(gezeigt[0]).toBe('EUROPEAN UNION PUBLIC LICENCE v. 1.2');
398 expect(gezeigt).toContain('Licensed under the EUPL');
399 expect(gezeigt).toHaveLength(88);
400 });
401
402 it('blättert im selben Fenster und führt zurück', async () => {
403 const kasten = await oeffne();
404 knopf(kasten, 'Lizenztext (deutsch)').click();
405 await warte(5);
406 // Kein zweites Fenster - ein Ortswechsel waere fuer eine Sprachausgabe
407 // ein Neuanfang und verwuerfe den Lesestand.
408 expect(titelDerFenster()).toEqual([TITEL]);
409
410 knopf(kasten, 'Zurück').click();
411 await warte(5);
412 expect(text(kasten)).toContain('Licensed under the EUPL');
413 expect(absaetze(kasten).length).toBeLessThan(88);
414 });
415
416 it('setzt den Fokus beim Blättern auf die Überschrift der neuen Ansicht', async () => {
417 /*
418 * `setActions` baut die Schaltflaechen neu auf; die gerade gedrueckte ist
419 * danach nicht mehr im Baum. Ohne Nachziehen faellt der Fokus auf den
420 * Seitenkoerper, und der Wechsel wird nirgends angesagt (EK 2.4.3).
421 */
422 const kasten = await oeffne();
423 knopf(kasten, 'Lizenztext (deutsch)').click();
424 await warte(5);
425
426 const ueberschrift = kasten.querySelector('.dialog-inhalt h3');
427 expect(document.activeElement).toBe(ueberschrift);
428 expect(ueberschrift?.getAttribute('tabindex')).toBe('-1');
429 });
430 });
431
432 describe('Sprache von Teilen (EK 3.1.2)', () => {
433 /*
434 * Die Seite ist deutsch ausgezeichnet (`<html lang="de">`, src/index.html,
435 * oben nachgehalten). Die englische Ansicht bringt 88 Absaetze englischen
436 * Text hinein; ohne eigenes `lang` gaelten sie als deutsch, und eine
437 * Sprachausgabe laese sie mit deutschen Ausspracheregeln. Geprueft wird
438 * deshalb nicht das Attribut am Absatz, sondern die WIRKSAME Sprache - das
439 * naechste `lang` nach oben, also genau das, was ausgewertet wird.
440 */
441 it('zeichnet die englische Fassung Absatz für Absatz als englisch aus', async () => {
442 const kasten = await oeffne();
443 knopf(kasten, 'Lizenztext (englisch)').click();
444 await warte(5);
445
446 const alle = [...kasten.querySelectorAll<HTMLElement>('.dialog-inhalt p')];
447 expect(alle).toHaveLength(88);
448 for (const absatz of alle) {
449 expect(wirksameSprache(absatz), `Absatz "${(absatz.textContent ?? '').slice(0, 48)}…"`).toBe(
450 'en',
451 );
452 }
453
454 // Die Ueberschrift der Ansicht ist deutscher Text und bleibt deutsch.
455 const ueberschrift = kasten.querySelector<HTMLElement>('.dialog-inhalt h3');
456 expect(ueberschrift?.textContent).toBe('Wortlaut der Lizenz – englische Fassung');
457 expect(wirksameSprache(ueberschrift!)).toBe('de');
458 });
459
460 it('lässt die deutsche Fassung deutsch', async () => {
461 const kasten = await oeffne();
462 knopf(kasten, 'Lizenztext (deutsch)').click();
463 await warte(5);
464
465 const alle = [...kasten.querySelectorAll<HTMLElement>('.dialog-inhalt p')];
466 expect(alle).toHaveLength(88);
467 for (const absatz of alle) {
468 expect(wirksameSprache(absatz), `Absatz "${(absatz.textContent ?? '').slice(0, 48)}…"`).toBe(
469 'de',
470 );
471 }
472 });
473
474 it('zeichnet den vorgeschriebenen Vermerk in der Übersicht als englisch aus', async () => {
475 /*
476 * "Licensed under the EUPL" ist ein englischer Satz in einer deutschen
477 * Seite. Ob er als vorgeschriebene Formel unter die Ausnahme des
478 * Kriteriums fuer Eigennamen und Fachbegriffe faellt, laesst sich
479 * vertreten; ausgezeichnet ist die Frage nicht mehr zu stellen, und am
480 * Wortlaut aendert die Auszeichnung nichts - der Fall darueber haelt ihn.
481 */
482 const kasten = await oeffne();
483 expect(wirksameSprache(absatzMit(kasten, 'Licensed under the EUPL'))).toBe('en');
484
485 // Der deutsche Text daneben bleibt deutsch - nicht die ganze Übersicht.
486 expect(wirksameSprache(absatzMit(kasten, '© 2026 Olaf Willerding'))).toBe('de');
487 expect(
488 wirksameSprache(absatzMit(kasten, `LSA-Planer Professional, Fassung ${PAKET.version}`)),
489 ).toBe('de');
490 });
491 });
492
493 describe('Waechter und Riegel', () => {
494 it('stapelt kein zweites Fenster, sondern holt den Fokus zurück', async () => {
495 const kasten = await oeffne();
496 knopf(kasten, 'Lizenztext (deutsch)').click();
497 await warte(5);
498 (document.activeElement as HTMLElement | null)?.blur();
499
500 sendeMenuebefehl('ueber');
501 await warte(5);
502
503 expect(titelDerFenster(), 'ein zweites Fenster wurde gestapelt').toEqual([TITEL]);
504 // Der Lesestand bleibt: Es wurde nicht neu aufgebaut.
505 expect(absaetze(kasten)).toHaveLength(88);
506 expect(kasten.contains(document.activeElement), 'der Fokus steht nicht im Fenster').toBe(true);
507 });
508
509 it('greift bei einem anderen offenen Fenster nicht durch', async () => {
510 /*
511 * Derselbe Riegel wie bei "Neu", "Öffnen", "Speichern" und der Ausgabe:
512 * Das Anwendungsmenue wird von der Stilllegung des Hintergrunds nicht
513 * erfasst.
514 */
515 sendeMenuebefehl('neu');
516 await warte(10);
517 expect(titelDerFenster()).toEqual(['Neues Projekt']);
518
519 sendeMenuebefehl('ueber');
520 await warte(10);
521 expect(titelDerFenster(), 'das "Über"-Fenster kam über die Vorlagenauswahl').toEqual([
522 'Neues Projekt',
523 ]);
524 });
525 });