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