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 { luftbildDialog } from '@/ui/lageplan/luftbild'; /** * Datenschutz an der Oberflaeche - seit 5.43.0. * * Die Datenschutzhinweise gibt es seit 5.43.0 als docs/datenschutz.md; sie * gehoeren in das Programm selbst und nicht nur auf eine Internetseite (Kopf * von tests/domain/datenschutz.test.ts). Hier wird bewacht, dass und wie der * Text im Programm ankommt, und die drei Stellen, an denen die Oberflaeche * selbst etwas zum Thema sagt: * * 1. das "Über"-Fenster mit einer vierten Ansicht "Datenschutz", gelesen aus * derselben Datei; * 2. der Abschnitt "Fremdbestandteile" im selben Fenster, der je nach * Installationsart nur formal stimmte; * 3. der Luftbild-Dialog, der sagte, DASS geholt wird, aber nicht, was dabei * den Rechner verlaesst; * 4. die Kurzhilfe, die den Anwender allein auf einen Pfad schickte, den der * Explorer je nach Installationsart nicht unbedingt zeigt. * * Geprueft wird am echten Einstiegspunkt wie in * tests/ui/ueberFenster.test.ts: `src/main.ts` startet die ganze * Anwendung, der Menuebefehl kommt ueber die ersetzte Desktop-Bruecke. */ vi.mock('@/services/storage', async (importOriginal) => { const echt = await importOriginal(); return { ...echt, ladeSitzung: vi.fn(), saveSession: vi.fn(), addRecoveryPoint: vi.fn(), }; }); const DATENSCHUTZ = readFileSync('docs/datenschutz.md', 'utf8'); const MENUE = readFileSync('electron/menu.ts', 'utf8'); const PROTOKOLLPFAD = 'C:/Protokoll/lsa-planer.log'; 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 fenster(titel: string): HTMLElement | null { return alleFenster().find((k) => k.querySelector('h2')?.textContent?.trim() === titel) ?? null; } function titelDerFenster(): string[] { return alleFenster().map((k) => k.querySelector('h2')?.textContent?.trim() ?? ''); } 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; } /** Beschriftungen der Schaltflaechenleiste unten - ohne die im Inhalt. */ function leiste(kasten: HTMLElement): string[] { return [...kasten.querySelectorAll('.dialog-aktionen button')].map( (b) => b.textContent?.trim() ?? '', ); } function inhalt(kasten: HTMLElement): HTMLElement { const bereich = kasten.querySelector('.dialog-inhalt'); if (bereich === null) throw new Error('Das Fenster hat keinen Inhaltsbereich.'); return bereich; } function einzeilig(text: string): string { return text.replace(/\s+/g, ' '); } function schliesseAlle(): void { for (const kasten of alleFenster()) { kasten.querySelector('.dialog-schliessen')?.click(); } } /** * Die Datei, unabhaengig vom Leser gegliedert: Bloecke an Leerzeilen. Das geht * nur, weil die Datei zwischen je zwei Bloecken eine Leerzeile fuehrt - und * genau das sieht die Zerlegung je Block mit nach, damit der Abgleich nicht * still an einer Annahme haengt. */ interface Erwartung { readonly titel: string; /** Tag und Text jedes Knotens unter der Ueberschrift, `li` einzeln. */ readonly folge: readonly (readonly [string, string])[]; } function erwartung(datei: string): Erwartung { let titel = ''; const folge: (readonly [string, string])[] = []; for (const block of datei.split(/\n[ \t]*\n/)) { const zeilen = block .split('\n') .map((z) => z.trim()) .filter((z) => z !== ''); if (zeilen.length === 0) continue; const erste = zeilen[0]!; const punkte = zeilen.filter((z) => z.startsWith('- ')); const kopf = zeilen.filter((z) => z.startsWith('#')); // Ein Block ist von einer Art - sonst traegt die Zerlegung an Leerzeilen nicht. if (erste.startsWith('#')) expect(zeilen, 'Überschrift mit Text im selben Block').toHaveLength(1); if (erste.startsWith('- ')) expect(punkte, 'Aufzählung mit Text im selben Block').toEqual(zeilen); else expect(punkte, 'Absatz mit Aufzählungspunkt im selben Block').toEqual([]); expect(kopf.length, 'mehr als eine Überschrift im selben Block').toBeLessThanOrEqual(1); if (erste.startsWith('# ')) titel = erste.slice(2); else if (erste.startsWith('## ')) folge.push(['H3', erste.slice(3)]); else if (erste.startsWith('- ')) for (const z of zeilen) folge.push(['LI', z.slice(2)]); else folge.push(['P', zeilen.join(' ')]); } return { titel, folge }; } /** * Die Faelle, in denen src/ui/datenschutzText.ts anders gliedert als ein Leser * nach CommonMark - die Liste im Kopf dort, hier als Wache ueber die Datei. Was * die Formatwache in tests/domain/datenschutz.test.ts schon verbietet, steht * nicht noch einmal hier. */ const FALL = { zeichenzeile: 'Zeile nur aus „-“, „=“, „*“ oder „_“', zitat: 'Zitat', tilden: 'Codeblock mit Tilden', verweisdefinition: 'Verweisdefinition', tabulator: 'Tabulator', leerzeileZwischenPunkten: 'Leerzeile zwischen zwei Aufzählungspunkten', eingerueckt: 'eingerückte Zeile nach Leerzeile hinter einem Aufzählungspunkt', } as const; const ZEILENMUSTER: readonly (readonly [RegExp, string])[] = [ [/^\s*[-=*_][-=*_\s]*$/, FALL.zeichenzeile], [/^\s*>/, FALL.zitat], [/^\s*~~~/, FALL.tilden], [/^\s*\[[^\]]*\]:/, FALL.verweisdefinition], [/\t/, FALL.tabulator], ]; function gliederungsabweichungen(datei: string): string[] { const funde: string[] = []; /** Die letzte nicht leere Zeile gehoert zu einem Aufzaehlungspunkt. */ let imPunkt = false; let leerzeileDavor = false; datei.split('\n').forEach((roh, index) => { const zeile = roh.replace(/\r$/, ''); const nummer = index + 1; if (zeile.trim() === '') { leerzeileDavor = true; return; } for (const [muster, fall] of ZEILENMUSTER) { if (muster.test(zeile)) funde.push(`Zeile ${nummer}: ${fall}`); } const punkt = zeile.startsWith('- '); if (imPunkt && leerzeileDavor && punkt) { funde.push(`Zeile ${nummer}: ${FALL.leerzeileZwischenPunkten}`); } // Ab zwei Leerzeichen: so weit ruecken die Zeilen eines Punkts "- " ein. if (imPunkt && leerzeileDavor && /^ {2,}\S/.test(zeile)) { funde.push(`Zeile ${nummer}: ${FALL.eingerueckt}`); } if (punkt) imPunkt = true; else if (leerzeileDavor || zeile.startsWith('#')) imPunkt = false; leerzeileDavor = false; }); return funde; } let sendeMenuebefehl: (befehl: MenuCommand) => void = () => undefined; beforeAll(async () => { vi.mocked(ladeSitzung).mockResolvedValue({ art: 'leer' }); vi.mocked(saveSession).mockResolvedValue({ ok: true, message: '' }); (window as unknown as { lsaPlaner: unknown }).lsaPlaner = { platform: 'electron', setDirty: () => undefined, reportError: () => undefined, logPath: () => Promise.resolve(PROTOKOLLPFAD), onMenuCommand: (behandler: (befehl: MenuCommand) => void) => { sendeMenuebefehl = behandler; return () => undefined; }, onBeforeClose: () => undefined, }; // Wie in ueberFenster.test.ts: Die Seite ist deutsch ausgezeichnet. 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 oeffneUeber(): Promise { sendeMenuebefehl('ueber'); await warte(5); const kasten = fenster(TITEL); expect(kasten, 'der Menübefehl "ueber" hat kein Fenster geöffnet').not.toBeNull(); return kasten!; } async function oeffneDatenschutz(): Promise { const kasten = await oeffneUeber(); knopf(kasten, 'Datenschutz').click(); await warte(5); return kasten; } describe('Über-Fenster: Ansicht "Datenschutz"', () => { it('fuehrt die Datei in dem Format, das der Abgleich hier voraussetzt', () => { // `erwartung` prueft je Block, dass er von einer Art ist - zwischen zwei // Bloecken steht also eine Leerzeile. const { titel, folge } = erwartung(DATENSCHUTZ); expect(titel).toBe('Datenschutzhinweise zu LSA-Planer Professional'); expect(folge.length).toBeGreaterThan(20); }); it('steht als Schaltfläche in der Leiste, hinter den Lizenztexten und vor "Schließen"', async () => { const kasten = await oeffneUeber(); expect(leiste(kasten)).toEqual([ 'Lizenztext (deutsch)', 'Lizenztext (englisch)', 'Datenschutz', 'Schließen', ]); }); it('lässt den ersten Fokus beim Öffnen im Inhalt, nicht auf der Leiste', async () => { // Die Begruendung der Reihenfolge in src/ui/ueber.ts stuetzt sich darauf. const kasten = await oeffneUeber(); await warte(5); expect(document.activeElement).toBe(knopf(kasten, 'Quellcode öffnen')); }); it('zeigt die Titelzeile der Datei als Überschrift und setzt den Fokus darauf', async () => { const kasten = await oeffneDatenschutz(); expect(titelDerFenster(), 'die Ansicht öffnet ein zweites Fenster').toEqual([TITEL]); const ueberschrift = inhalt(kasten).firstElementChild; expect(ueberschrift?.tagName).toBe('H3'); expect(ueberschrift?.textContent).toBe('Datenschutzhinweise zu LSA-Planer Professional'); expect(ueberschrift?.getAttribute('tabindex')).toBe('-1'); expect(document.activeElement).toBe(ueberschrift); expect(leiste(kasten)).toEqual(['Zurück', 'Schließen']); }); it('zeigt Abschnitte, Absätze und Aufzählungspunkte in der Reihenfolge der Datei', async () => { const kasten = await oeffneDatenschutz(); const { folge } = erwartung(DATENSCHUTZ); const gezeigt = [...inhalt(kasten).querySelectorAll('h3, p, li')] .slice(1) // die Ueberschrift der Ansicht .map((k) => [k.tagName, k.textContent ?? ''] as const); expect(gezeigt).toEqual(folge); }); it('bringt jeden "## "-Abschnitt als Überschrift, in derselben Reihenfolge', async () => { // Enger als der Fall darueber und unabhaengig von der Blockzerlegung: nur // die Abschnittszeilen, wie sie in der Datei stehen. const kasten = await oeffneDatenschutz(); const abschnitte = DATENSCHUTZ.split('\n') .filter((z) => z.startsWith('## ')) .map((z) => z.slice(3).trim()); expect(abschnitte).toContain('Die einzige Verbindung ins Internet: amtliche Luftbilder'); const ueberschriften = [...inhalt(kasten).querySelectorAll('h3')] .slice(1) .map((h) => h.textContent); expect(ueberschriften).toEqual(abschnitte); }); it('bringt jeden "- "-Punkt als Listeneintrag in einer Aufzählung', async () => { const kasten = await oeffneDatenschutz(); const punkte = DATENSCHUTZ.split('\n') .filter((z) => z.startsWith('- ')) .map((z) => z.slice(2).trim()); const eintraege = [...inhalt(kasten).querySelectorAll('li')]; expect(eintraege.map((li) => li.textContent)).toEqual(punkte); for (const li of eintraege) expect(li.parentElement?.tagName).toBe('UL'); // Anker gegen einen Abgleich, der sich selbst prueft. expect(punkte).toContain( 'sgx.geodatenzentrum.de: Bundesamt für Kartographie und Geodäsie (TopPlusOpen)', ); }); it('fügt die Zeilen eines Absatzes zusammen - kein Zeilenumbruch, keine Doppelabstände', async () => { const kasten = await oeffneDatenschutz(); const absaetze = [...inhalt(kasten).querySelectorAll('p')].map((p) => p.textContent ?? ''); expect(absaetze.length).toBeGreaterThan(10); for (const text of absaetze) { expect(text, 'Zeilenumbruch im Absatz').not.toMatch(/[\r\n]/); expect(text, 'doppelter Abstand').not.toMatch(/ {2}/); expect(text, 'Rand').toBe(text.trim()); } // Der erste Absatz der Datei laeuft ueber fuenf Zeilen. expect(absaetze[1]).toMatch( /^Diese Hinweise beschreiben, welche Daten .* Hinweise veröffentlicht sind\.$/, ); }); it('zeigt keine Auszeichnungszeichen der Datei als Text', async () => { const kasten = await oeffneDatenschutz(); for (const knoten of inhalt(kasten).querySelectorAll('h3, p, li')) { const text = knoten.textContent ?? ''; expect(text, `${knoten.tagName} beginnt mit einer Raute`).not.toMatch(/^#/); expect(text, `${knoten.tagName} beginnt mit einem Aufzählungsstrich`).not.toMatch(/^- /); } const alles = inhalt(kasten).textContent ?? ''; expect(alles).not.toContain('## '); expect(alles).not.toContain('# Datenschutzhinweise'); }); it('zeichnet jeden Block des Textes ausdrücklich als deutsch aus', async () => { // Wie die deutsche Lizenzfassung: ausdruecklich und nicht nur geerbt. const kasten = await oeffneDatenschutz(); const bloecke = [...inhalt(kasten).children].slice(1); expect(bloecke.length).toBeGreaterThan(0); for (const block of bloecke) { expect( block.getAttribute('lang'), `${block.tagName} "${(block.textContent ?? '').slice(0, 40)}…"`, ).toBe('de'); } }); it('führt mit "Zurück" zur Übersicht und setzt den Fokus auf deren Überschrift', async () => { const kasten = await oeffneDatenschutz(); knopf(kasten, 'Zurück').click(); await warte(5); const ueberschrift = inhalt(kasten).firstElementChild; expect(ueberschrift?.textContent).toBe('Programm'); expect(document.activeElement).toBe(ueberschrift); expect(leiste(kasten)).toContain('Datenschutz'); expect(inhalt(kasten).textContent).not.toContain('Das Wichtigste in Kürze'); }); }); describe('docs/datenschutz.md: dieselbe Gliederung wie bei einem Leser nach CommonMark', () => { /* * Die Datei steht auch auf der Internetseite des Herausgebers (Kopf von * src/ui/datenschutzText.ts). Die Faelle unten nehmen die echte Datei und * setzen je eine Zeile ein - so zeigt sich, dass die Wache an genau dieser * Datei greift und nicht nur an einem ausgedachten Beispiel. */ const zeilen = DATENSCHUTZ.split('\n').map((z) => z.replace(/\r$/, '')); /** Die echte Datei, mit `zeile` hinter der Zeile `nach` (gezaehlt ab 1). */ function mitZeile(nach: number, zeile: string): string { return [...zeilen.slice(0, nach), zeile, ...zeilen.slice(nach)].join('\n'); } const absatzzeile = zeilen.findIndex((z, i) => i > 0 && zeilen[i - 1] === '' && /^[^#\-\s]/.test(z)) + 1; const punktpaar = zeilen.findIndex((z, i) => z.startsWith('- ') && zeilen[i + 1]?.startsWith('- ')) + 1; const letzterPunkt = zeilen.findIndex( (z, i) => z.startsWith('- ') && zeilen[i + 1] === '' && zeilen[i + 2] !== undefined, ) + 1; it('enthält keinen der Fälle, die der Leser anders gliedert', () => { expect(gliederungsabweichungen(DATENSCHUTZ)).toEqual([]); }); it.each<[string, () => [string, number, string]]>([ [ 'eine Leerzeile zwischen zwei Punkten', () => [mitZeile(punktpaar, ''), punktpaar + 2, FALL.leerzeileZwischenPunkten], ], [ 'eine eingerückte Zeile nach Leerzeile hinter einem Punkt', () => [mitZeile(letzterPunkt + 1, ' eingerückt'), letzterPunkt + 2, FALL.eingerueckt], ], [ 'Bindestriche unter einer Absatzzeile', () => [mitZeile(absatzzeile, '---'), absatzzeile + 1, FALL.zeichenzeile], ], [ 'Gleichheitszeichen unter einer Absatzzeile', () => [mitZeile(absatzzeile, '==='), absatzzeile + 1, FALL.zeichenzeile], ], [ 'eine Trennlinie aus Sternen', () => [mitZeile(absatzzeile - 1, '***'), absatzzeile, FALL.zeichenzeile], ], ['ein Zitat', () => [mitZeile(absatzzeile - 1, '> Zitat'), absatzzeile, FALL.zitat]], [ 'einen Codeblock mit Tilden', () => [mitZeile(absatzzeile - 1, '~~~'), absatzzeile, FALL.tilden], ], [ 'eine Verweisdefinition', () => [ mitZeile(absatzzeile - 1, '[name]: https://example.org'), absatzzeile, FALL.verweisdefinition, ], ], ['einen Tabulator', () => [mitZeile(absatzzeile - 1, '-\tPunkt'), absatzzeile, FALL.tabulator]], ])('erkennt in der echten Datei %s', (_fall, baue) => { expect(absatzzeile, 'keine Absatzzeile nach einer Leerzeile').toBeGreaterThan(1); expect(punktpaar, 'keine zwei Punkte hintereinander').toBeGreaterThan(0); expect(letzterPunkt, 'kein Punkt vor einer Leerzeile').toBeGreaterThan(0); const [mutiert, nummer, fall] = baue(); expect(gliederungsabweichungen(mutiert)).toEqual([`Zeile ${nummer}: ${fall}`]); }); }); describe('Über-Fenster: Übersicht', () => { it('nennt die Lizenzschaltflächen beim Namen, seit eine dritte in der Leiste steht', async () => { const kasten = await oeffneUeber(); const text = einzeilig(inhalt(kasten).textContent ?? ''); expect(text).not.toContain('die beiden Schaltflächen unten'); expect(text.match(/die beiden Schaltflächen „Lizenztext“ unten zeigen ihn/g)).toHaveLength(2); // Und es sind wirklich genau zwei, die so heissen. expect(leiste(kasten).filter((b) => b.startsWith('Lizenztext'))).toHaveLength(2); }); /* * Der Doppelpunkt fuehrt die Liste ein, und zwar mit dem Satz, zu dem sie * gehoert. Stand der Satz ueber den geschuetzten Ordner vor dem * Doppelpunkt, las sich die Liste als Inhalt dieses Ordners; er steht * deshalb als eigener Absatz hinter der Liste. */ it('nennt vor der Liste die vier Dateien und danach den geschützten Ordner', async () => { const kasten = await oeffneUeber(); const liste = [...inhalt(kasten).querySelectorAll('ul')].find((ul) => (ul.textContent ?? '').includes('DRITTANBIETER-LIZENZEN.txt'), ); expect(liste, 'die Liste der vier Dateien fehlt').toBeDefined(); const davor = liste!.previousElementSibling; expect(davor?.tagName).toBe('P'); expect(davor?.textContent).toBe( 'Zu den Fremdbestandteilen liegen neben der Programmdatei vier Dateien:', ); const danach = liste!.nextElementSibling; expect(danach?.tagName).toBe('P'); expect(danach?.textContent).toBe( 'Je nach Installationsart steht die Programmdatei in einem Ordner, den Windows vor ' + 'Änderungen schützt.', ); }); }); describe('Luftbild-Dialog', () => { it('sagt vor dem Abruf, was den Rechner verlässt, wohin, und wo mehr dazu steht', async () => { const ergebnis = luftbildDialog(); const kasten = fenster('Amtliches Luftbild laden'); expect(kasten, 'der Luftbild-Dialog ist nicht offen').not.toBeNull(); try { const satz = 'Beim Abruf erhält der unter „Kartengrundlage“ gewählte Dienst die Lage des Ausschnitts ' + 'und, wie bei jeder Verbindung ins Internet, die IP-Adresse Ihres Internetanschlusses; ' + 'Projektinhalte gehen nicht mit. Mehr dazu unter ' + '„Hilfe → Über LSA-Planer Professional → Datenschutz“.'; const absaetze = [...inhalt(kasten!).querySelectorAll('p')].map((p) => p.textContent); expect(absaetze).toContain(satz); // Die genannten Beschriftungen gibt es wirklich: das Feld im Dialog ... expect(einzeilig(kasten!.textContent ?? '')).toContain('Kartengrundlage'); // ... und der Menueeintrag, der das "Über"-Fenster oeffnet. expect(MENUE).toContain('label: `Über LSA-Planer Professional ${version}`'); // Dieselbe Aussage in den Datenschutzhinweisen - keine zweite Wahrheit. expect(einzeilig(DATENSCHUTZ)).toContain('erfährt der Dienst die IP-Adresse'); expect(einzeilig(DATENSCHUTZ)).toContain('Projektinhalte enthält sie nicht'); } finally { knopf(kasten!, 'Abbrechen').click(); await ergebnis; } }); }); describe('Kurzhilfe: "Bei Störungen"', () => { async function stoerungen(): Promise { sendeMenuebefehl('hilfe'); await warte(20); const kasten = fenster('Kurzhilfe'); expect(kasten, 'die Kurzhilfe ist nicht offen').not.toBeNull(); const ueberschrift = [...inhalt(kasten!).querySelectorAll('h3')].find( (h) => h.textContent === 'Bei Störungen', ); expect(ueberschrift, 'Abschnitt "Bei Störungen" fehlt').toBeDefined(); const behaelter = ueberschrift!.nextElementSibling; return [...(behaelter?.querySelectorAll('p') ?? [])]; } it('nennt zuerst den Menüweg zur Kopie, mit der Beschriftung aus dem Menü', async () => { const absaetze = await stoerungen(); expect(absaetze[0]?.textContent).toBe( 'Die Anwendung schreibt unerwartete Fehler in ein Protokoll. Über ' + '„Hilfe → Fehlerprotokoll speichern …“ legen Sie eine Kopie an einem Ort Ihrer Wahl an; ' + 'bitte legen Sie diese Kopie einer Fehlermeldung bei. Das Protokoll ist darauf angelegt, ' + 'keine Projektinhalte aufzunehmen, und die Kopie ist eine gewöhnliche Textdatei, die Sie ' + 'vor dem Versenden lesen können.', ); // Die Beschriftung steht so im Hilfemenue (electron/menu.ts). expect(MENUE).toContain("label: 'Fehlerprotokoll speichern …'"); }); it('verspricht nicht mehr, das Protokoll enthalte nur technische Angaben', async () => { const text = (await stoerungen()).map((p) => p.textContent).join(' '); expect(text).not.toContain('nur technische Angaben'); }); it('lässt den Pfad als Auskunft stehen, mit dem Hinweis zur Installationsart', async () => { /* * Fassung 5.43.0, Befund 8: Der Grund stand ohne Einschraenkung da * ("Windows führt die Programmdaten ... gesondert"). Beim Umsteiger ist * gemessen das Gegenteil wahr - hatten Setup oder tragbares Programm den * Ordner schon angelegt, landen auch neue Dateien dort (gemessen am * 17.09.2026 unter Windows 11) -, und die Datenschutzhinweise desselben * Programms sagen das. Der Grund nennt deshalb die Ausnahme, und zwar fuer * Windows 11: Gemessen ist nur dort, und fuer Windows 10 vor Version 1903 * beschreibt Microsoft den Fall anders. */ const absaetze = await stoerungen(); expect(absaetze.map((p) => p.textContent)).toEqual([ expect.stringContaining('Fehlerprotokoll speichern …'), 'Das Protokoll selbst liegt unter dem folgenden Pfad. Bei manchen Installationsarten ' + 'zeigt der Explorer die Datei dort nicht unbedingt an, weil Windows die Programmdaten ' + 'gesondert führt. Unter Windows 11 gilt das nicht, wenn das Programm aus dem Setup ' + 'oder das tragbare Programm ihren Ordner schon vorher angelegt hatte.', PROTOKOLLPFAD, ]); // Dieselbe Ausnahme in den Datenschutzhinweisen - keine zweite Wahrheit. expect(einzeilig(DATENSCHUTZ)).toContain( 'vorher schon vom Programm aus dem Setup oder vom tragbaren Programm angelegt war', ); }); });