import { readFileSync } from 'node:fs'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { ladeSitzung, saveSession } from '@/services/storage'; import type { MenuCommand } from '@shared/ipc'; import { zeigeUeber } from '@/ui/ueber'; /** * Fassung 5.14.0 - das "Über"-Fenster. * * Die Frage des Anwenders lautete: "Wo kann ich die Lizenzen im Programm * ansehen?" Die Antwort war: gar nicht. Der Hilfemenueeintrag * `Über LSA-Planer Professional 5.14.0` rief `oeffneSeite` auf dieselbe * Internetseite wie der Eintrag darueber (electron/menu.ts, bis 5.14.0), es * gab kein Fenster dahinter, und die Kurzhilfe sagt zu Lizenzen nichts. In * `src/` kam die Zeichenkette "Lizenz" bis dahin an keiner einzigen Stelle * vor. * * Geprueft wird am echten Einstiegspunkt und am geoeffneten Fenster, nicht an * der Bindung: `src/main.ts` startet die ganze Anwendung, der Menuebefehl * kommt ueber die ersetzte Desktop-Bruecke herein, und gelesen wird, was * danach im Fenster steht. Dasselbe Verfahren wie in * tests/ui/fenstertabellen.test.ts - `tests/ui/barrierefreiheit.test.ts` * laeuft ueber die zwoelf Ansichten und erreicht Fenster nicht. */ vi.mock('@/services/storage', async (importOriginal) => { const echt = await importOriginal(); return { ...echt, ladeSitzung: vi.fn(), saveSession: vi.fn(), addRecoveryPoint: vi.fn(), }; }); const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string; build: { copyright: string }; }; const LIZENZ_DEUTSCH = readFileSync('LICENSE.de.txt', 'utf8'); const LIZENZ_ENGLISCH = readFileSync('LICENSE', 'utf8'); const FREMDBESTANDTEILE = readFileSync('docs/fremdbestandteile.md', 'utf8'); const README = readFileSync('README.md', 'utf8'); const SEITE = readFileSync('src/index.html', 'utf8'); /** Titel des Fensters - zugleich sein Name fuer die Sprachausgabe. */ const TITEL = 'Über LSA-Planer Professional'; async function warte(ms = 0): Promise { await new Promise((fertig) => setTimeout(fertig, ms)); } function alleFenster(): HTMLElement[] { return [...document.querySelectorAll('.dialog-hintergrund')]; } function titelDerFenster(): string[] { return alleFenster().map((k) => k.querySelector('h2')?.textContent?.trim() ?? ''); } function ueberFenster(): HTMLElement | null { return alleFenster().find((k) => k.querySelector('h2')?.textContent?.trim() === TITEL) ?? null; } /** Wortlaut des offenen Fensters, mit Einfachabstaenden. */ function text(kasten: HTMLElement): string { return (kasten.textContent ?? '').replace(/\s+/g, ' '); } function knopf(kasten: HTMLElement, beschriftung: string): HTMLButtonElement { const gefunden = [...kasten.querySelectorAll('button')].find( (b) => b.textContent?.trim() === beschriftung, ); if (!(gefunden instanceof HTMLButtonElement)) { throw new Error(`Schaltfläche "${beschriftung}" fehlt im Fenster.`); } return gefunden; } /** Die Absaetze des Inhaltsbereichs - ohne Kopfzeile und Schaltflaechen. */ function absaetze(kasten: HTMLElement): string[] { return [...kasten.querySelectorAll('.dialog-inhalt p')].map((p) => p.textContent ?? ''); } /** Nichtleere Zeilen einer Lizenzdatei - je eine wird zu einem Absatz. */ function zeilen(datei: string): string[] { return datei.split('\n').filter((zeile) => zeile.trim() !== ''); } /** * Die WIRKSAME Sprachauszeichnung eines Elements: das naechste `lang` nach * oben. Genau das wertet eine Sprachausgabe aus - ein fehlendes `lang` am * Absatz ist kein "keine Angabe", sondern die Angabe des Elternelements. */ function wirksameSprache(element: Element): string | null { return element.closest('[lang]')?.getAttribute('lang') ?? null; } /** Der Absatz mit genau diesem Wortlaut - fuer Pruefungen an einem einzelnen. */ function absatzMit(kasten: HTMLElement, wortlaut: string): HTMLElement { const gefunden = [...kasten.querySelectorAll('.dialog-inhalt p')].find( (p) => p.textContent === wortlaut, ); if (gefunden === undefined) throw new Error(`Absatz "${wortlaut}" fehlt im Fenster.`); return gefunden; } function schliesseAlle(): void { for (const kasten of alleFenster()) { kasten.querySelector('.dialog-schliessen')?.click(); } } /** Der Menuebefehl, so wie ihn der Hauptprozess schickt. */ let sendeMenuebefehl: (befehl: MenuCommand) => void = () => undefined; beforeAll(async () => { vi.mocked(ladeSitzung).mockResolvedValue({ art: 'leer' }); vi.mocked(saveSession).mockResolvedValue({ ok: true, message: '' }); /* * `__APP_VERSION__` setzt sonst vite.config.ts aus package.json ein; unter * Vitest setzt es niemand, und `src/main.ts` faellt dann auf * "0.0.0-entwicklung" zurueck. Hier steht genau die Zahl aus package.json, * damit sich pruefen laesst, dass sie im Fenster ankommt. Dass sie im Bau * aus package.json stammt, haelt tests/electron/brueckeVersion.test.ts * fest. */ (globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = PAKET.version; (window as unknown as { lsaPlaner: unknown }).lsaPlaner = { platform: 'electron', setDirty: () => undefined, reportError: () => undefined, logPath: () => Promise.resolve('C:/Protokoll/lsa-planer.log'), onMenuCommand: (behandler: (befehl: MenuCommand) => void) => { sendeMenuebefehl = behandler; return () => undefined; }, onBeforeClose: () => undefined, }; /* * Die Seite ist deutsch ausgezeichnet; unter Vitest baut niemand * src/index.html auf, und das jsdom-Dokument kaeme ohne `lang` daher. Damit * sich die WIRKSAME Sprache eines Absatzes pruefen laesst, wird dieselbe * Auszeichnung hier gesetzt - und an der Datei festgehalten, damit die * Pruefung mit ihr faellt statt an einer Annahme haengen zu bleiben. */ expect(SEITE, 'src/index.html zeichnet die Seite nicht mehr als deutsch aus').toContain( '', ); document.documentElement.setAttribute('lang', 'de'); document.body.replaceChildren(); const wurzel = document.createElement('div'); wurzel.id = 'anwendung'; document.body.append(wurzel); await import('@/main'); await warte(50); }); beforeEach(async () => { schliesseAlle(); await warte(5); }); async function oeffne(): Promise { sendeMenuebefehl('ueber'); await warte(5); const kasten = ueberFenster(); expect(kasten, 'der Menübefehl "ueber" hat kein Fenster geöffnet').not.toBeNull(); return kasten!; } describe('Hilfe > Über LSA-Planer Professional', () => { it('öffnet auf den Menübefehl ein Fenster mit diesem Titel', async () => { const kasten = await oeffne(); expect(titelDerFenster()).toEqual([TITEL]); // Der Titel ist zugleich der Name des Fensters fuer die Sprachausgabe. const dialog = kasten.querySelector('[role="dialog"]'); const benannt = dialog?.getAttribute('aria-labelledby'); expect(benannt, 'das Fenster trägt kein aria-labelledby').not.toBeNull(); expect(kasten.querySelector(`#${benannt!}`)?.textContent).toBe(TITEL); }); it('nennt die Fassung aus package.json', async () => { const kasten = await oeffne(); expect(text(kasten)).toContain(`LSA-Planer Professional, Fassung ${PAKET.version}`); }); it('nimmt die Fassung entgegen, statt eine zweite Zahl zu führen', async () => { /* * Grenzwache zur vorigen Pruefung: Stuende die Zahl im Fenster fest, * waere jene gruen, solange package.json dieselbe fuehrt - und beim * naechsten Anheben still falsch. Hier wird `zeigeUeber` unmittelbar mit * einer Zahl gerufen, die in package.json nicht vorkommt. */ schliesseAlle(); await warte(5); zeigeUeber('9.9.9-pruefstand'); await warte(5); const kasten = ueberFenster()!; expect(text(kasten)).toContain('LSA-Planer Professional, Fassung 9.9.9-pruefstand'); expect(text(kasten)).not.toContain(PAKET.version); }); it('trägt den Urhebervermerk, der auch in der Programmdatei steht', async () => { const kasten = await oeffne(); const vermerk = '© 2026 Olaf Willerding'; expect(text(kasten)).toContain(vermerk); /* * Zweite Stelle derselben Angabe: `build.copyright` in package.json, aus * der electron-builder den Vermerk der Programmdatei bildet. Der Renderer * kommt an package.json nicht heran; diese Zeile bindet die beiden * aneinander. */ expect(PAKET.build.copyright).toContain(vermerk); }); it('trägt den Vermerk wörtlich, den die Lizenz selbst vorschreibt', async () => { const kasten = await oeffne(); /* * "Das Werk wird unter den Bedingungen dieser Lizenz zur Verfuegung * gestellt, wenn der Lizenzgeber den folgenden Hinweis unmittelbar hinter * dem Urheberrechtshinweis dieses Werks anbringt: Licensed under the * EUPL" - LICENSE.de.txt, siebter Absatz. Woertlich und unmittelbar * hinter dem Urheberrechtshinweis, deshalb beides zusammen. */ expect(LIZENZ_ENGLISCH).toContain('Licensed under the EUPL'); const alle = absaetze(kasten); const stelle = alle.indexOf('© 2026 Olaf Willerding'); expect(stelle, 'der Urheberrechtshinweis fehlt').toBeGreaterThanOrEqual(0); expect(alle[stelle + 1], 'der Vermerk steht nicht unmittelbar dahinter').toBe( 'Licensed under the EUPL', ); }); it('nennt die Lizenz und dass beide Sprachfassungen gleichermaßen gelten', async () => { const inhalt = text(await oeffne()); expect(inhalt).toContain('Open-Source-Lizenz für die Europäische Union v. 1.2'); expect(inhalt).toContain('EUPL-1.2'); expect(inhalt).toContain('amtlich und gleichermaßen verbindlich'); // Die Zusage der Lizenz selbst, an ihrem Wortlaut geprueft. expect(LIZENZ_DEUTSCH).toContain( 'Alle von der Europäischen Kommission anerkannten Sprachfassungen dieser Lizenz sind gleichwertig.', ); }); it('nennt die Fremdbestandteile, ihre Lizenzen und wo die Texte liegen', async () => { const inhalt = text(await oeffne()); for (const bestandteil of ['Electron', 'Chromium', 'Node.js', 'jsPDF']) { expect(inhalt, `Fremdbestandteil "${bestandteil}" fehlt`).toContain(bestandteil); } /* * Die Lizenznamen stehen nicht frei erfunden im Fenster, sondern so auch * in docs/fremdbestandteile.md - dem Verzeichnis, das dem Paket als * FREMDBESTANDTEILE.md beiliegt. */ for (const lizenz of ['MIT', 'BSD-3-Clause', 'Apache-2.0', 'MPL-2.0']) { expect(inhalt, `Lizenzname "${lizenz}" fehlt im Fenster`).toContain(lizenz); expect(FREMDBESTANDTEILE, `Lizenzname "${lizenz}" fehlt im Verzeichnis`).toContain(lizenz); } // Die vier Dateien, die neben der Programmdatei liegen. for (const datei of [ 'DRITTANBIETER-LIZENZEN.txt', 'FREMDBESTANDTEILE.md', 'LICENSE.electron.txt', 'LICENSES.chromium.html', ]) { expect(inhalt, `Beiliegende Datei "${datei}" fehlt`).toContain(datei); expect(FREMDBESTANDTEILE, `"${datei}" steht nicht im Verzeichnis`).toContain(datei); } }); it('behauptet nicht, die Lizenzpflichten seien erfüllt', async () => { const inhalt = text(await oeffne()); expect(inhalt).toContain( 'Ob die Pflichten aus diesen Lizenzen erfüllt sind, sagt dieses Fenster nicht', ); for (const behauptung of [ 'sind erfüllt', 'ist erfüllt', 'erfüllt alle', 'vollständig erfüllt', 'rechtskonform', 'lizenzkonform', ]) { expect(inhalt, `Das Fenster behauptet "${behauptung}"`).not.toContain(behauptung); } }); it('nennt sich keine Rechtsauskunft und verweist auf den Wortlaut', async () => { /* * Der Vorbehalt gehoert zu dem Abschnitt, aus dem der Inhalt dieses * Fensters stammt: README.md, "Rechtliches". Ohne ihn staende die * Zusammenfassung der Lizenz als Auskunft ueber die Rechtslage da - und * gerade er traegt die Zusage des Fensters, nichts zu behaupten, was es * nicht weiss. Woertlich gehalten wie der Fall darueber. */ const inhalt = text(await oeffne()); expect(inhalt).toContain('Keine Rechtsauskunft: Dieses Fenster legt die Lizenz nicht aus.'); expect(inhalt).toContain( 'Wo es sie zusammenfasst, ist allein ihr Wortlaut maßgeblich – die beiden Schaltflächen ' + '„Lizenztext“ unten zeigen ihn.', ); // Die Quelle des Vorbehalts, damit er nicht frei erfunden dasteht. expect(README.replace(/\s+/g, ' '), 'der Vorbehalt fehlt in der README').toContain( 'Dieser Abschnitt beschreibt, was gilt; er legt die Lizenz nicht aus.', ); }); it('gibt die Copyleft-Wirkung so wieder, wie Artikel 5 der Lizenz sie fasst', async () => { const inhalt = text(await oeffne()); // "Copyleft"-Klausel: diese Lizenz ODER eine neuere Fassung. expect(LIZENZ_DEUTSCH).toContain('oder einer neueren Version dieser Lizenz'); expect(inhalt).toContain('unter dieser Lizenz oder einer neueren Fassung davon weiter'); // "Bereitstellung des Quellcodes": mitliefern ODER Speicherort angeben. expect(LIZENZ_DEUTSCH).toContain('oder einen Speicherort angeben'); expect(inhalt).toContain( 'liefert den Quellcode mit oder gibt an, wo er unentgeltlich zu bekommen ist', ); /* * "Kompatibilitaets-Klausel": Sie greift NICHT bei jeder Weitergabe, * sondern nur fuer Bearbeitungen, die auf dem Werk UND einem anderen Werk * unter einer kompatiblen Lizenz beruhen. Als freie Wahl dargestellt, * fuehrte das Fenster weiter, als die Lizenz reicht. */ expect(LIZENZ_DEUTSCH).toContain( 'Bearbeitungen, die auf dem Werk und einem anderen Werk, das unter einer kompatiblen ' + 'Lizenz lizenziert wurde, basieren', ); expect(inhalt).toContain( 'Beruht eine Bearbeitung zugleich auf einem anderen Werk, das unter einer der im Anhang ' + 'der Lizenz genannten kompatiblen Lizenzen steht, darf sie unter jener Lizenz ' + 'weitergegeben werden.', ); expect(inhalt, 'die kompatible Lizenz steht als freie Wahl bei jeder Weitergabe').not.toContain( 'unter derselben oder einer der im Anhang', ); }); /* * Die README fasst dieselbe Wirkung zusammen. Ohne den Absatz zur * kompatiblen Lizenz stuende dort die Copyleft-Klausel ohne ihre Ausnahme, * und wer nur die README liest, erfuehre weniger, als das Fenster sagt. * Gelesen wird der Absatz am geoeffneten Fenster, damit beide Stellen * woertlich dieselbe bleiben und nicht zwei Abschriften derselben Vorlage. */ it('gibt die README den Absatz zur kompatiblen Lizenz wörtlich wie das Fenster wieder', async () => { const kasten = await oeffne(); const kompatibel = absaetze(kasten).find((absatz) => absatz.startsWith('Beruht eine Bearbeitung zugleich'), ); expect(kompatibel, 'der Absatz zur kompatiblen Lizenz fehlt im Fenster').toBeDefined(); const rechtliches = README.slice(README.indexOf('## Rechtliches')).replace(/\s+/g, ' '); expect(rechtliches, 'die README laesst die kompatible Lizenz aus').toContain(kompatibel); }); }); describe('Der Wortlaut der Lizenz steht im Fenster', () => { it('zeigt die deutsche Fassung Zeile für Zeile', async () => { const kasten = await oeffne(); knopf(kasten, 'Lizenztext (deutsch)').click(); await warte(5); const gezeigt = absaetze(kasten); // Der erste Absatz ist die Ueberschrift der Ansicht, nicht Lizenztext. expect(kasten.querySelector('.dialog-inhalt h3')?.textContent).toBe( 'Wortlaut der Lizenz – deutsche Fassung', ); expect(gezeigt).toEqual(zeilen(LIZENZ_DEUTSCH)); // Anker gegen einen Abgleich, der sich selbst prueft: Anfang, Mitte, Ende. expect(gezeigt[0]).toBe('OPEN-SOURCE-LIZENZ FÜR DIE EUROPÄISCHE UNION v. 1.2'); expect(gezeigt).toContain('Lizenziert unter der EUPL'); expect(gezeigt[gezeigt.length - 1]).toContain( 'Alle sonstigen Änderungen oder Ergänzungen dieses Anhangs', ); expect(gezeigt).toHaveLength(88); }); it('zeigt die englische Fassung Zeile für Zeile', async () => { const kasten = await oeffne(); knopf(kasten, 'Lizenztext (englisch)').click(); await warte(5); const gezeigt = absaetze(kasten); expect(gezeigt).toEqual(zeilen(LIZENZ_ENGLISCH)); expect(gezeigt[0]).toBe('EUROPEAN UNION PUBLIC LICENCE v. 1.2'); expect(gezeigt).toContain('Licensed under the EUPL'); expect(gezeigt).toHaveLength(88); }); it('blättert im selben Fenster und führt zurück', async () => { const kasten = await oeffne(); knopf(kasten, 'Lizenztext (deutsch)').click(); await warte(5); // Kein zweites Fenster - ein Ortswechsel waere fuer eine Sprachausgabe // ein Neuanfang und verwuerfe den Lesestand. expect(titelDerFenster()).toEqual([TITEL]); knopf(kasten, 'Zurück').click(); await warte(5); expect(text(kasten)).toContain('Licensed under the EUPL'); expect(absaetze(kasten).length).toBeLessThan(88); }); it('setzt den Fokus beim Blättern auf die Überschrift der neuen Ansicht', async () => { /* * `setActions` baut die Schaltflaechen neu auf; die gerade gedrueckte ist * danach nicht mehr im Baum. Ohne Nachziehen faellt der Fokus auf den * Seitenkoerper, und der Wechsel wird nirgends angesagt (EK 2.4.3). */ const kasten = await oeffne(); knopf(kasten, 'Lizenztext (deutsch)').click(); await warte(5); const ueberschrift = kasten.querySelector('.dialog-inhalt h3'); expect(document.activeElement).toBe(ueberschrift); expect(ueberschrift?.getAttribute('tabindex')).toBe('-1'); }); }); describe('Sprache von Teilen (EK 3.1.2)', () => { /* * Die Seite ist deutsch ausgezeichnet (``, src/index.html, * oben nachgehalten). Die englische Ansicht bringt 88 Absaetze englischen * Text hinein; ohne eigenes `lang` gaelten sie als deutsch, und eine * Sprachausgabe laese sie mit deutschen Ausspracheregeln. Geprueft wird * deshalb nicht das Attribut am Absatz, sondern die WIRKSAME Sprache - das * naechste `lang` nach oben, also genau das, was ausgewertet wird. */ it('zeichnet die englische Fassung Absatz für Absatz als englisch aus', async () => { const kasten = await oeffne(); knopf(kasten, 'Lizenztext (englisch)').click(); await warte(5); const alle = [...kasten.querySelectorAll('.dialog-inhalt p')]; expect(alle).toHaveLength(88); for (const absatz of alle) { expect(wirksameSprache(absatz), `Absatz "${(absatz.textContent ?? '').slice(0, 48)}…"`).toBe( 'en', ); } // Die Ueberschrift der Ansicht ist deutscher Text und bleibt deutsch. const ueberschrift = kasten.querySelector('.dialog-inhalt h3'); expect(ueberschrift?.textContent).toBe('Wortlaut der Lizenz – englische Fassung'); expect(wirksameSprache(ueberschrift!)).toBe('de'); }); it('lässt die deutsche Fassung deutsch', async () => { const kasten = await oeffne(); knopf(kasten, 'Lizenztext (deutsch)').click(); await warte(5); const alle = [...kasten.querySelectorAll('.dialog-inhalt p')]; expect(alle).toHaveLength(88); for (const absatz of alle) { expect(wirksameSprache(absatz), `Absatz "${(absatz.textContent ?? '').slice(0, 48)}…"`).toBe( 'de', ); } }); it('zeichnet den vorgeschriebenen Vermerk in der Übersicht als englisch aus', async () => { /* * "Licensed under the EUPL" ist ein englischer Satz in einer deutschen * Seite. Ob er als vorgeschriebene Formel unter die Ausnahme des * Kriteriums fuer Eigennamen und Fachbegriffe faellt, laesst sich * vertreten; ausgezeichnet ist die Frage nicht mehr zu stellen, und am * Wortlaut aendert die Auszeichnung nichts - der Fall darueber haelt ihn. */ const kasten = await oeffne(); expect(wirksameSprache(absatzMit(kasten, 'Licensed under the EUPL'))).toBe('en'); // Der deutsche Text daneben bleibt deutsch - nicht die ganze Übersicht. expect(wirksameSprache(absatzMit(kasten, '© 2026 Olaf Willerding'))).toBe('de'); expect( wirksameSprache(absatzMit(kasten, `LSA-Planer Professional, Fassung ${PAKET.version}`)), ).toBe('de'); }); }); describe('Waechter und Riegel', () => { it('stapelt kein zweites Fenster, sondern holt den Fokus zurück', async () => { const kasten = await oeffne(); knopf(kasten, 'Lizenztext (deutsch)').click(); await warte(5); (document.activeElement as HTMLElement | null)?.blur(); sendeMenuebefehl('ueber'); await warte(5); expect(titelDerFenster(), 'ein zweites Fenster wurde gestapelt').toEqual([TITEL]); // Der Lesestand bleibt: Es wurde nicht neu aufgebaut. expect(absaetze(kasten)).toHaveLength(88); expect(kasten.contains(document.activeElement), 'der Fokus steht nicht im Fenster').toBe(true); }); it('greift bei einem anderen offenen Fenster nicht durch', async () => { /* * Derselbe Riegel wie bei "Neu", "Öffnen", "Speichern" und der Ausgabe: * Das Anwendungsmenue wird von der Stilllegung des Hintergrunds nicht * erfasst. */ sendeMenuebefehl('neu'); await warte(10); expect(titelDerFenster()).toEqual(['Neues Projekt']); sendeMenuebefehl('ueber'); await warte(10); expect(titelDerFenster(), 'das "Über"-Fenster kam über die Vorlagenauswahl').toEqual([ 'Neues Projekt', ]); }); });