lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui datenschutzAnsicht.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 { luftbildDialog } from '@/ui/lageplan/luftbild'; |
| 6 | |
| 7 | /** |
| 8 | * Datenschutz an der Oberflaeche - seit 5.43.0. |
| 9 | * |
| 10 | * Die Datenschutzhinweise gibt es seit 5.43.0 als docs/datenschutz.md; sie |
| 11 | * gehoeren in das Programm selbst und nicht nur auf eine Internetseite (Kopf |
| 12 | * von tests/domain/datenschutz.test.ts). Hier wird bewacht, dass und wie der |
| 13 | * Text im Programm ankommt, und die drei Stellen, an denen die Oberflaeche |
| 14 | * selbst etwas zum Thema sagt: |
| 15 | * |
| 16 | * 1. das "Über"-Fenster mit einer vierten Ansicht "Datenschutz", gelesen aus |
| 17 | * derselben Datei; |
| 18 | * 2. der Abschnitt "Fremdbestandteile" im selben Fenster, der je nach |
| 19 | * Installationsart nur formal stimmte; |
| 20 | * 3. der Luftbild-Dialog, der sagte, DASS geholt wird, aber nicht, was dabei |
| 21 | * den Rechner verlaesst; |
| 22 | * 4. die Kurzhilfe, die den Anwender allein auf einen Pfad schickte, den der |
| 23 | * Explorer je nach Installationsart nicht unbedingt zeigt. |
| 24 | * |
| 25 | * Geprueft wird am echten Einstiegspunkt wie in |
| 26 | * tests/ui/ueberFenster.test.ts: `src/main.ts` startet die ganze |
| 27 | * Anwendung, der Menuebefehl kommt ueber die ersetzte Desktop-Bruecke. |
| 28 | */ |
| 29 | |
| 30 | vi.mock('@/services/storage', async (importOriginal) => { |
| 31 | const echt = await importOriginal<typeof import('@/services/storage')>(); |
| 32 | return { |
| 33 | ...echt, |
| 34 | ladeSitzung: vi.fn(), |
| 35 | saveSession: vi.fn(), |
| 36 | addRecoveryPoint: vi.fn(), |
| 37 | }; |
| 38 | }); |
| 39 | |
| 40 | const DATENSCHUTZ = readFileSync('docs/datenschutz.md', 'utf8'); |
| 41 | const MENUE = readFileSync('electron/menu.ts', 'utf8'); |
| 42 | const PROTOKOLLPFAD = 'C:/Protokoll/lsa-planer.log'; |
| 43 | |
| 44 | const TITEL = 'Über LSA-Planer Professional'; |
| 45 | |
| 46 | async function warte(ms = 0): Promise<void> { |
| 47 | await new Promise((fertig) => setTimeout(fertig, ms)); |
| 48 | } |
| 49 | |
| 50 | function alleFenster(): HTMLElement[] { |
| 51 | return [...document.querySelectorAll<HTMLElement>('.dialog-hintergrund')]; |
| 52 | } |
| 53 | |
| 54 | function fenster(titel: string): HTMLElement | null { |
| 55 | return alleFenster().find((k) => k.querySelector('h2')?.textContent?.trim() === titel) ?? null; |
| 56 | } |
| 57 | |
| 58 | function titelDerFenster(): string[] { |
| 59 | return alleFenster().map((k) => k.querySelector('h2')?.textContent?.trim() ?? ''); |
| 60 | } |
| 61 | |
| 62 | function knopf(kasten: HTMLElement, beschriftung: string): HTMLButtonElement { |
| 63 | const gefunden = [...kasten.querySelectorAll('button')].find( |
| 64 | (b) => b.textContent?.trim() === beschriftung, |
| 65 | ); |
| 66 | if (!(gefunden instanceof HTMLButtonElement)) { |
| 67 | throw new Error(`Schaltfläche "${beschriftung}" fehlt im Fenster.`); |
| 68 | } |
| 69 | return gefunden; |
| 70 | } |
| 71 | |
| 72 | /** Beschriftungen der Schaltflaechenleiste unten - ohne die im Inhalt. */ |
| 73 | function leiste(kasten: HTMLElement): string[] { |
| 74 | return [...kasten.querySelectorAll('.dialog-aktionen button')].map( |
| 75 | (b) => b.textContent?.trim() ?? '', |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | function inhalt(kasten: HTMLElement): HTMLElement { |
| 80 | const bereich = kasten.querySelector<HTMLElement>('.dialog-inhalt'); |
| 81 | if (bereich === null) throw new Error('Das Fenster hat keinen Inhaltsbereich.'); |
| 82 | return bereich; |
| 83 | } |
| 84 | |
| 85 | function einzeilig(text: string): string { |
| 86 | return text.replace(/\s+/g, ' '); |
| 87 | } |
| 88 | |
| 89 | function schliesseAlle(): void { |
| 90 | for (const kasten of alleFenster()) { |
| 91 | kasten.querySelector<HTMLButtonElement>('.dialog-schliessen')?.click(); |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * Die Datei, unabhaengig vom Leser gegliedert: Bloecke an Leerzeilen. Das geht |
| 97 | * nur, weil die Datei zwischen je zwei Bloecken eine Leerzeile fuehrt - und |
| 98 | * genau das sieht die Zerlegung je Block mit nach, damit der Abgleich nicht |
| 99 | * still an einer Annahme haengt. |
| 100 | */ |
| 101 | interface Erwartung { |
| 102 | readonly titel: string; |
| 103 | /** Tag und Text jedes Knotens unter der Ueberschrift, `li` einzeln. */ |
| 104 | readonly folge: readonly (readonly [string, string])[]; |
| 105 | } |
| 106 | |
| 107 | function erwartung(datei: string): Erwartung { |
| 108 | let titel = ''; |
| 109 | const folge: (readonly [string, string])[] = []; |
| 110 | for (const block of datei.split(/\n[ \t]*\n/)) { |
| 111 | const zeilen = block |
| 112 | .split('\n') |
| 113 | .map((z) => z.trim()) |
| 114 | .filter((z) => z !== ''); |
| 115 | if (zeilen.length === 0) continue; |
| 116 | const erste = zeilen[0]!; |
| 117 | const punkte = zeilen.filter((z) => z.startsWith('- ')); |
| 118 | const kopf = zeilen.filter((z) => z.startsWith('#')); |
| 119 | // Ein Block ist von einer Art - sonst traegt die Zerlegung an Leerzeilen nicht. |
| 120 | if (erste.startsWith('#')) |
| 121 | expect(zeilen, 'Überschrift mit Text im selben Block').toHaveLength(1); |
| 122 | if (erste.startsWith('- ')) |
| 123 | expect(punkte, 'Aufzählung mit Text im selben Block').toEqual(zeilen); |
| 124 | else expect(punkte, 'Absatz mit Aufzählungspunkt im selben Block').toEqual([]); |
| 125 | expect(kopf.length, 'mehr als eine Überschrift im selben Block').toBeLessThanOrEqual(1); |
| 126 | |
| 127 | if (erste.startsWith('# ')) titel = erste.slice(2); |
| 128 | else if (erste.startsWith('## ')) folge.push(['H3', erste.slice(3)]); |
| 129 | else if (erste.startsWith('- ')) for (const z of zeilen) folge.push(['LI', z.slice(2)]); |
| 130 | else folge.push(['P', zeilen.join(' ')]); |
| 131 | } |
| 132 | return { titel, folge }; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Die Faelle, in denen src/ui/datenschutzText.ts anders gliedert als ein Leser |
| 137 | * nach CommonMark - die Liste im Kopf dort, hier als Wache ueber die Datei. Was |
| 138 | * die Formatwache in tests/domain/datenschutz.test.ts schon verbietet, steht |
| 139 | * nicht noch einmal hier. |
| 140 | */ |
| 141 | const FALL = { |
| 142 | zeichenzeile: 'Zeile nur aus „-“, „=“, „*“ oder „_“', |
| 143 | zitat: 'Zitat', |
| 144 | tilden: 'Codeblock mit Tilden', |
| 145 | verweisdefinition: 'Verweisdefinition', |
| 146 | tabulator: 'Tabulator', |
| 147 | leerzeileZwischenPunkten: 'Leerzeile zwischen zwei Aufzählungspunkten', |
| 148 | eingerueckt: 'eingerückte Zeile nach Leerzeile hinter einem Aufzählungspunkt', |
| 149 | } as const; |
| 150 | |
| 151 | const ZEILENMUSTER: readonly (readonly [RegExp, string])[] = [ |
| 152 | [/^\s*[-=*_][-=*_\s]*$/, FALL.zeichenzeile], |
| 153 | [/^\s*>/, FALL.zitat], |
| 154 | [/^\s*~~~/, FALL.tilden], |
| 155 | [/^\s*\[[^\]]*\]:/, FALL.verweisdefinition], |
| 156 | [/\t/, FALL.tabulator], |
| 157 | ]; |
| 158 | |
| 159 | function gliederungsabweichungen(datei: string): string[] { |
| 160 | const funde: string[] = []; |
| 161 | /** Die letzte nicht leere Zeile gehoert zu einem Aufzaehlungspunkt. */ |
| 162 | let imPunkt = false; |
| 163 | let leerzeileDavor = false; |
| 164 | datei.split('\n').forEach((roh, index) => { |
| 165 | const zeile = roh.replace(/\r$/, ''); |
| 166 | const nummer = index + 1; |
| 167 | if (zeile.trim() === '') { |
| 168 | leerzeileDavor = true; |
| 169 | return; |
| 170 | } |
| 171 | for (const [muster, fall] of ZEILENMUSTER) { |
| 172 | if (muster.test(zeile)) funde.push(`Zeile ${nummer}: ${fall}`); |
| 173 | } |
| 174 | const punkt = zeile.startsWith('- '); |
| 175 | if (imPunkt && leerzeileDavor && punkt) { |
| 176 | funde.push(`Zeile ${nummer}: ${FALL.leerzeileZwischenPunkten}`); |
| 177 | } |
| 178 | // Ab zwei Leerzeichen: so weit ruecken die Zeilen eines Punkts "- " ein. |
| 179 | if (imPunkt && leerzeileDavor && /^ {2,}\S/.test(zeile)) { |
| 180 | funde.push(`Zeile ${nummer}: ${FALL.eingerueckt}`); |
| 181 | } |
| 182 | if (punkt) imPunkt = true; |
| 183 | else if (leerzeileDavor || zeile.startsWith('#')) imPunkt = false; |
| 184 | leerzeileDavor = false; |
| 185 | }); |
| 186 | return funde; |
| 187 | } |
| 188 | |
| 189 | let sendeMenuebefehl: (befehl: MenuCommand) => void = () => undefined; |
| 190 | |
| 191 | beforeAll(async () => { |
| 192 | vi.mocked(ladeSitzung).mockResolvedValue({ art: 'leer' }); |
| 193 | vi.mocked(saveSession).mockResolvedValue({ ok: true, message: '' }); |
| 194 | |
| 195 | (window as unknown as { lsaPlaner: unknown }).lsaPlaner = { |
| 196 | platform: 'electron', |
| 197 | setDirty: () => undefined, |
| 198 | reportError: () => undefined, |
| 199 | logPath: () => Promise.resolve(PROTOKOLLPFAD), |
| 200 | onMenuCommand: (behandler: (befehl: MenuCommand) => void) => { |
| 201 | sendeMenuebefehl = behandler; |
| 202 | return () => undefined; |
| 203 | }, |
| 204 | onBeforeClose: () => undefined, |
| 205 | }; |
| 206 | // Wie in ueberFenster.test.ts: Die Seite ist deutsch ausgezeichnet. |
| 207 | document.documentElement.setAttribute('lang', 'de'); |
| 208 | |
| 209 | document.body.replaceChildren(); |
| 210 | const wurzel = document.createElement('div'); |
| 211 | wurzel.id = 'anwendung'; |
| 212 | document.body.append(wurzel); |
| 213 | |
| 214 | await import('@/main'); |
| 215 | await warte(50); |
| 216 | }); |
| 217 | |
| 218 | beforeEach(async () => { |
| 219 | schliesseAlle(); |
| 220 | await warte(5); |
| 221 | }); |
| 222 | |
| 223 | async function oeffneUeber(): Promise<HTMLElement> { |
| 224 | sendeMenuebefehl('ueber'); |
| 225 | await warte(5); |
| 226 | const kasten = fenster(TITEL); |
| 227 | expect(kasten, 'der Menübefehl "ueber" hat kein Fenster geöffnet').not.toBeNull(); |
| 228 | return kasten!; |
| 229 | } |
| 230 | |
| 231 | async function oeffneDatenschutz(): Promise<HTMLElement> { |
| 232 | const kasten = await oeffneUeber(); |
| 233 | knopf(kasten, 'Datenschutz').click(); |
| 234 | await warte(5); |
| 235 | return kasten; |
| 236 | } |
| 237 | |
| 238 | describe('Über-Fenster: Ansicht "Datenschutz"', () => { |
| 239 | it('fuehrt die Datei in dem Format, das der Abgleich hier voraussetzt', () => { |
| 240 | // `erwartung` prueft je Block, dass er von einer Art ist - zwischen zwei |
| 241 | // Bloecken steht also eine Leerzeile. |
| 242 | const { titel, folge } = erwartung(DATENSCHUTZ); |
| 243 | expect(titel).toBe('Datenschutzhinweise zu LSA-Planer Professional'); |
| 244 | expect(folge.length).toBeGreaterThan(20); |
| 245 | }); |
| 246 | |
| 247 | it('steht als Schaltfläche in der Leiste, hinter den Lizenztexten und vor "Schließen"', async () => { |
| 248 | const kasten = await oeffneUeber(); |
| 249 | expect(leiste(kasten)).toEqual([ |
| 250 | 'Lizenztext (deutsch)', |
| 251 | 'Lizenztext (englisch)', |
| 252 | 'Datenschutz', |
| 253 | 'Schließen', |
| 254 | ]); |
| 255 | }); |
| 256 | |
| 257 | it('lässt den ersten Fokus beim Öffnen im Inhalt, nicht auf der Leiste', async () => { |
| 258 | // Die Begruendung der Reihenfolge in src/ui/ueber.ts stuetzt sich darauf. |
| 259 | const kasten = await oeffneUeber(); |
| 260 | await warte(5); |
| 261 | expect(document.activeElement).toBe(knopf(kasten, 'Quellcode öffnen')); |
| 262 | }); |
| 263 | |
| 264 | it('zeigt die Titelzeile der Datei als Überschrift und setzt den Fokus darauf', async () => { |
| 265 | const kasten = await oeffneDatenschutz(); |
| 266 | expect(titelDerFenster(), 'die Ansicht öffnet ein zweites Fenster').toEqual([TITEL]); |
| 267 | |
| 268 | const ueberschrift = inhalt(kasten).firstElementChild; |
| 269 | expect(ueberschrift?.tagName).toBe('H3'); |
| 270 | expect(ueberschrift?.textContent).toBe('Datenschutzhinweise zu LSA-Planer Professional'); |
| 271 | expect(ueberschrift?.getAttribute('tabindex')).toBe('-1'); |
| 272 | expect(document.activeElement).toBe(ueberschrift); |
| 273 | expect(leiste(kasten)).toEqual(['Zurück', 'Schließen']); |
| 274 | }); |
| 275 | |
| 276 | it('zeigt Abschnitte, Absätze und Aufzählungspunkte in der Reihenfolge der Datei', async () => { |
| 277 | const kasten = await oeffneDatenschutz(); |
| 278 | const { folge } = erwartung(DATENSCHUTZ); |
| 279 | |
| 280 | const gezeigt = [...inhalt(kasten).querySelectorAll('h3, p, li')] |
| 281 | .slice(1) // die Ueberschrift der Ansicht |
| 282 | .map((k) => [k.tagName, k.textContent ?? ''] as const); |
| 283 | expect(gezeigt).toEqual(folge); |
| 284 | }); |
| 285 | |
| 286 | it('bringt jeden "## "-Abschnitt als Überschrift, in derselben Reihenfolge', async () => { |
| 287 | // Enger als der Fall darueber und unabhaengig von der Blockzerlegung: nur |
| 288 | // die Abschnittszeilen, wie sie in der Datei stehen. |
| 289 | const kasten = await oeffneDatenschutz(); |
| 290 | const abschnitte = DATENSCHUTZ.split('\n') |
| 291 | .filter((z) => z.startsWith('## ')) |
| 292 | .map((z) => z.slice(3).trim()); |
| 293 | expect(abschnitte).toContain('Die einzige Verbindung ins Internet: amtliche Luftbilder'); |
| 294 | const ueberschriften = [...inhalt(kasten).querySelectorAll('h3')] |
| 295 | .slice(1) |
| 296 | .map((h) => h.textContent); |
| 297 | expect(ueberschriften).toEqual(abschnitte); |
| 298 | }); |
| 299 | |
| 300 | it('bringt jeden "- "-Punkt als Listeneintrag in einer Aufzählung', async () => { |
| 301 | const kasten = await oeffneDatenschutz(); |
| 302 | const punkte = DATENSCHUTZ.split('\n') |
| 303 | .filter((z) => z.startsWith('- ')) |
| 304 | .map((z) => z.slice(2).trim()); |
| 305 | const eintraege = [...inhalt(kasten).querySelectorAll('li')]; |
| 306 | expect(eintraege.map((li) => li.textContent)).toEqual(punkte); |
| 307 | for (const li of eintraege) expect(li.parentElement?.tagName).toBe('UL'); |
| 308 | // Anker gegen einen Abgleich, der sich selbst prueft. |
| 309 | expect(punkte).toContain( |
| 310 | 'sgx.geodatenzentrum.de: Bundesamt für Kartographie und Geodäsie (TopPlusOpen)', |
| 311 | ); |
| 312 | }); |
| 313 | |
| 314 | it('fügt die Zeilen eines Absatzes zusammen - kein Zeilenumbruch, keine Doppelabstände', async () => { |
| 315 | const kasten = await oeffneDatenschutz(); |
| 316 | const absaetze = [...inhalt(kasten).querySelectorAll('p')].map((p) => p.textContent ?? ''); |
| 317 | expect(absaetze.length).toBeGreaterThan(10); |
| 318 | for (const text of absaetze) { |
| 319 | expect(text, 'Zeilenumbruch im Absatz').not.toMatch(/[\r\n]/); |
| 320 | expect(text, 'doppelter Abstand').not.toMatch(/ {2}/); |
| 321 | expect(text, 'Rand').toBe(text.trim()); |
| 322 | } |
| 323 | // Der erste Absatz der Datei laeuft ueber fuenf Zeilen. |
| 324 | expect(absaetze[1]).toMatch( |
| 325 | /^Diese Hinweise beschreiben, welche Daten .* Hinweise veröffentlicht sind\.$/, |
| 326 | ); |
| 327 | }); |
| 328 | |
| 329 | it('zeigt keine Auszeichnungszeichen der Datei als Text', async () => { |
| 330 | const kasten = await oeffneDatenschutz(); |
| 331 | for (const knoten of inhalt(kasten).querySelectorAll('h3, p, li')) { |
| 332 | const text = knoten.textContent ?? ''; |
| 333 | expect(text, `${knoten.tagName} beginnt mit einer Raute`).not.toMatch(/^#/); |
| 334 | expect(text, `${knoten.tagName} beginnt mit einem Aufzählungsstrich`).not.toMatch(/^- /); |
| 335 | } |
| 336 | const alles = inhalt(kasten).textContent ?? ''; |
| 337 | expect(alles).not.toContain('## '); |
| 338 | expect(alles).not.toContain('# Datenschutzhinweise'); |
| 339 | }); |
| 340 | |
| 341 | it('zeichnet jeden Block des Textes ausdrücklich als deutsch aus', async () => { |
| 342 | // Wie die deutsche Lizenzfassung: ausdruecklich und nicht nur geerbt. |
| 343 | const kasten = await oeffneDatenschutz(); |
| 344 | const bloecke = [...inhalt(kasten).children].slice(1); |
| 345 | expect(bloecke.length).toBeGreaterThan(0); |
| 346 | for (const block of bloecke) { |
| 347 | expect( |
| 348 | block.getAttribute('lang'), |
| 349 | `${block.tagName} "${(block.textContent ?? '').slice(0, 40)}…"`, |
| 350 | ).toBe('de'); |
| 351 | } |
| 352 | }); |
| 353 | |
| 354 | it('führt mit "Zurück" zur Übersicht und setzt den Fokus auf deren Überschrift', async () => { |
| 355 | const kasten = await oeffneDatenschutz(); |
| 356 | knopf(kasten, 'Zurück').click(); |
| 357 | await warte(5); |
| 358 | |
| 359 | const ueberschrift = inhalt(kasten).firstElementChild; |
| 360 | expect(ueberschrift?.textContent).toBe('Programm'); |
| 361 | expect(document.activeElement).toBe(ueberschrift); |
| 362 | expect(leiste(kasten)).toContain('Datenschutz'); |
| 363 | expect(inhalt(kasten).textContent).not.toContain('Das Wichtigste in Kürze'); |
| 364 | }); |
| 365 | }); |
| 366 | |
| 367 | describe('docs/datenschutz.md: dieselbe Gliederung wie bei einem Leser nach CommonMark', () => { |
| 368 | /* |
| 369 | * Die Datei steht auch auf der Internetseite des Herausgebers (Kopf von |
| 370 | * src/ui/datenschutzText.ts). Die Faelle unten nehmen die echte Datei und |
| 371 | * setzen je eine Zeile ein - so zeigt sich, dass die Wache an genau dieser |
| 372 | * Datei greift und nicht nur an einem ausgedachten Beispiel. |
| 373 | */ |
| 374 | const zeilen = DATENSCHUTZ.split('\n').map((z) => z.replace(/\r$/, '')); |
| 375 | |
| 376 | /** Die echte Datei, mit `zeile` hinter der Zeile `nach` (gezaehlt ab 1). */ |
| 377 | function mitZeile(nach: number, zeile: string): string { |
| 378 | return [...zeilen.slice(0, nach), zeile, ...zeilen.slice(nach)].join('\n'); |
| 379 | } |
| 380 | |
| 381 | const absatzzeile = |
| 382 | zeilen.findIndex((z, i) => i > 0 && zeilen[i - 1] === '' && /^[^#\-\s]/.test(z)) + 1; |
| 383 | const punktpaar = |
| 384 | zeilen.findIndex((z, i) => z.startsWith('- ') && zeilen[i + 1]?.startsWith('- ')) + 1; |
| 385 | const letzterPunkt = |
| 386 | zeilen.findIndex( |
| 387 | (z, i) => z.startsWith('- ') && zeilen[i + 1] === '' && zeilen[i + 2] !== undefined, |
| 388 | ) + 1; |
| 389 | |
| 390 | it('enthält keinen der Fälle, die der Leser anders gliedert', () => { |
| 391 | expect(gliederungsabweichungen(DATENSCHUTZ)).toEqual([]); |
| 392 | }); |
| 393 | |
| 394 | it.each<[string, () => [string, number, string]]>([ |
| 395 | [ |
| 396 | 'eine Leerzeile zwischen zwei Punkten', |
| 397 | () => [mitZeile(punktpaar, ''), punktpaar + 2, FALL.leerzeileZwischenPunkten], |
| 398 | ], |
| 399 | [ |
| 400 | 'eine eingerückte Zeile nach Leerzeile hinter einem Punkt', |
| 401 | () => [mitZeile(letzterPunkt + 1, ' eingerückt'), letzterPunkt + 2, FALL.eingerueckt], |
| 402 | ], |
| 403 | [ |
| 404 | 'Bindestriche unter einer Absatzzeile', |
| 405 | () => [mitZeile(absatzzeile, '---'), absatzzeile + 1, FALL.zeichenzeile], |
| 406 | ], |
| 407 | [ |
| 408 | 'Gleichheitszeichen unter einer Absatzzeile', |
| 409 | () => [mitZeile(absatzzeile, '==='), absatzzeile + 1, FALL.zeichenzeile], |
| 410 | ], |
| 411 | [ |
| 412 | 'eine Trennlinie aus Sternen', |
| 413 | () => [mitZeile(absatzzeile - 1, '***'), absatzzeile, FALL.zeichenzeile], |
| 414 | ], |
| 415 | ['ein Zitat', () => [mitZeile(absatzzeile - 1, '> Zitat'), absatzzeile, FALL.zitat]], |
| 416 | [ |
| 417 | 'einen Codeblock mit Tilden', |
| 418 | () => [mitZeile(absatzzeile - 1, '~~~'), absatzzeile, FALL.tilden], |
| 419 | ], |
| 420 | [ |
| 421 | 'eine Verweisdefinition', |
| 422 | () => [ |
| 423 | mitZeile(absatzzeile - 1, '[name]: https://example.org'), |
| 424 | absatzzeile, |
| 425 | FALL.verweisdefinition, |
| 426 | ], |
| 427 | ], |
| 428 | ['einen Tabulator', () => [mitZeile(absatzzeile - 1, '-\tPunkt'), absatzzeile, FALL.tabulator]], |
| 429 | ])('erkennt in der echten Datei %s', (_fall, baue) => { |
| 430 | expect(absatzzeile, 'keine Absatzzeile nach einer Leerzeile').toBeGreaterThan(1); |
| 431 | expect(punktpaar, 'keine zwei Punkte hintereinander').toBeGreaterThan(0); |
| 432 | expect(letzterPunkt, 'kein Punkt vor einer Leerzeile').toBeGreaterThan(0); |
| 433 | const [mutiert, nummer, fall] = baue(); |
| 434 | expect(gliederungsabweichungen(mutiert)).toEqual([`Zeile ${nummer}: ${fall}`]); |
| 435 | }); |
| 436 | }); |
| 437 | |
| 438 | describe('Über-Fenster: Übersicht', () => { |
| 439 | it('nennt die Lizenzschaltflächen beim Namen, seit eine dritte in der Leiste steht', async () => { |
| 440 | const kasten = await oeffneUeber(); |
| 441 | const text = einzeilig(inhalt(kasten).textContent ?? ''); |
| 442 | expect(text).not.toContain('die beiden Schaltflächen unten'); |
| 443 | expect(text.match(/die beiden Schaltflächen „Lizenztext“ unten zeigen ihn/g)).toHaveLength(2); |
| 444 | // Und es sind wirklich genau zwei, die so heissen. |
| 445 | expect(leiste(kasten).filter((b) => b.startsWith('Lizenztext'))).toHaveLength(2); |
| 446 | }); |
| 447 | |
| 448 | /* |
| 449 | * Der Doppelpunkt fuehrt die Liste ein, und zwar mit dem Satz, zu dem sie |
| 450 | * gehoert. Stand der Satz ueber den geschuetzten Ordner vor dem |
| 451 | * Doppelpunkt, las sich die Liste als Inhalt dieses Ordners; er steht |
| 452 | * deshalb als eigener Absatz hinter der Liste. |
| 453 | */ |
| 454 | it('nennt vor der Liste die vier Dateien und danach den geschützten Ordner', async () => { |
| 455 | const kasten = await oeffneUeber(); |
| 456 | const liste = [...inhalt(kasten).querySelectorAll('ul')].find((ul) => |
| 457 | (ul.textContent ?? '').includes('DRITTANBIETER-LIZENZEN.txt'), |
| 458 | ); |
| 459 | expect(liste, 'die Liste der vier Dateien fehlt').toBeDefined(); |
| 460 | const davor = liste!.previousElementSibling; |
| 461 | expect(davor?.tagName).toBe('P'); |
| 462 | expect(davor?.textContent).toBe( |
| 463 | 'Zu den Fremdbestandteilen liegen neben der Programmdatei vier Dateien:', |
| 464 | ); |
| 465 | const danach = liste!.nextElementSibling; |
| 466 | expect(danach?.tagName).toBe('P'); |
| 467 | expect(danach?.textContent).toBe( |
| 468 | 'Je nach Installationsart steht die Programmdatei in einem Ordner, den Windows vor ' + |
| 469 | 'Änderungen schützt.', |
| 470 | ); |
| 471 | }); |
| 472 | }); |
| 473 | |
| 474 | describe('Luftbild-Dialog', () => { |
| 475 | it('sagt vor dem Abruf, was den Rechner verlässt, wohin, und wo mehr dazu steht', async () => { |
| 476 | const ergebnis = luftbildDialog(); |
| 477 | const kasten = fenster('Amtliches Luftbild laden'); |
| 478 | expect(kasten, 'der Luftbild-Dialog ist nicht offen').not.toBeNull(); |
| 479 | try { |
| 480 | const satz = |
| 481 | 'Beim Abruf erhält der unter „Kartengrundlage“ gewählte Dienst die Lage des Ausschnitts ' + |
| 482 | 'und, wie bei jeder Verbindung ins Internet, die IP-Adresse Ihres Internetanschlusses; ' + |
| 483 | 'Projektinhalte gehen nicht mit. Mehr dazu unter ' + |
| 484 | '„Hilfe → Über LSA-Planer Professional → Datenschutz“.'; |
| 485 | const absaetze = [...inhalt(kasten!).querySelectorAll('p')].map((p) => p.textContent); |
| 486 | expect(absaetze).toContain(satz); |
| 487 | |
| 488 | // Die genannten Beschriftungen gibt es wirklich: das Feld im Dialog ... |
| 489 | expect(einzeilig(kasten!.textContent ?? '')).toContain('Kartengrundlage'); |
| 490 | // ... und der Menueeintrag, der das "Über"-Fenster oeffnet. |
| 491 | expect(MENUE).toContain('label: `Über LSA-Planer Professional ${version}`'); |
| 492 | // Dieselbe Aussage in den Datenschutzhinweisen - keine zweite Wahrheit. |
| 493 | expect(einzeilig(DATENSCHUTZ)).toContain('erfährt der Dienst die IP-Adresse'); |
| 494 | expect(einzeilig(DATENSCHUTZ)).toContain('Projektinhalte enthält sie nicht'); |
| 495 | } finally { |
| 496 | knopf(kasten!, 'Abbrechen').click(); |
| 497 | await ergebnis; |
| 498 | } |
| 499 | }); |
| 500 | }); |
| 501 | |
| 502 | describe('Kurzhilfe: "Bei Störungen"', () => { |
| 503 | async function stoerungen(): Promise<HTMLElement[]> { |
| 504 | sendeMenuebefehl('hilfe'); |
| 505 | await warte(20); |
| 506 | const kasten = fenster('Kurzhilfe'); |
| 507 | expect(kasten, 'die Kurzhilfe ist nicht offen').not.toBeNull(); |
| 508 | const ueberschrift = [...inhalt(kasten!).querySelectorAll('h3')].find( |
| 509 | (h) => h.textContent === 'Bei Störungen', |
| 510 | ); |
| 511 | expect(ueberschrift, 'Abschnitt "Bei Störungen" fehlt').toBeDefined(); |
| 512 | const behaelter = ueberschrift!.nextElementSibling; |
| 513 | return [...(behaelter?.querySelectorAll<HTMLElement>('p') ?? [])]; |
| 514 | } |
| 515 | |
| 516 | it('nennt zuerst den Menüweg zur Kopie, mit der Beschriftung aus dem Menü', async () => { |
| 517 | const absaetze = await stoerungen(); |
| 518 | expect(absaetze[0]?.textContent).toBe( |
| 519 | 'Die Anwendung schreibt unerwartete Fehler in ein Protokoll. Über ' + |
| 520 | '„Hilfe → Fehlerprotokoll speichern …“ legen Sie eine Kopie an einem Ort Ihrer Wahl an; ' + |
| 521 | 'bitte legen Sie diese Kopie einer Fehlermeldung bei. Das Protokoll ist darauf angelegt, ' + |
| 522 | 'keine Projektinhalte aufzunehmen, und die Kopie ist eine gewöhnliche Textdatei, die Sie ' + |
| 523 | 'vor dem Versenden lesen können.', |
| 524 | ); |
| 525 | // Die Beschriftung steht so im Hilfemenue (electron/menu.ts). |
| 526 | expect(MENUE).toContain("label: 'Fehlerprotokoll speichern …'"); |
| 527 | }); |
| 528 | |
| 529 | it('verspricht nicht mehr, das Protokoll enthalte nur technische Angaben', async () => { |
| 530 | const text = (await stoerungen()).map((p) => p.textContent).join(' '); |
| 531 | expect(text).not.toContain('nur technische Angaben'); |
| 532 | }); |
| 533 | |
| 534 | it('lässt den Pfad als Auskunft stehen, mit dem Hinweis zur Installationsart', async () => { |
| 535 | /* |
| 536 | * Fassung 5.43.0, Befund 8: Der Grund stand ohne Einschraenkung da |
| 537 | * ("Windows führt die Programmdaten ... gesondert"). Beim Umsteiger ist |
| 538 | * gemessen das Gegenteil wahr - hatten Setup oder tragbares Programm den |
| 539 | * Ordner schon angelegt, landen auch neue Dateien dort (gemessen am |
| 540 | * 17.09.2026 unter Windows 11) -, und die Datenschutzhinweise desselben |
| 541 | * Programms sagen das. Der Grund nennt deshalb die Ausnahme, und zwar fuer |
| 542 | * Windows 11: Gemessen ist nur dort, und fuer Windows 10 vor Version 1903 |
| 543 | * beschreibt Microsoft den Fall anders. |
| 544 | */ |
| 545 | const absaetze = await stoerungen(); |
| 546 | expect(absaetze.map((p) => p.textContent)).toEqual([ |
| 547 | expect.stringContaining('Fehlerprotokoll speichern …'), |
| 548 | 'Das Protokoll selbst liegt unter dem folgenden Pfad. Bei manchen Installationsarten ' + |
| 549 | 'zeigt der Explorer die Datei dort nicht unbedingt an, weil Windows die Programmdaten ' + |
| 550 | 'gesondert führt. Unter Windows 11 gilt das nicht, wenn das Programm aus dem Setup ' + |
| 551 | 'oder das tragbare Programm ihren Ordner schon vorher angelegt hatte.', |
| 552 | PROTOKOLLPFAD, |
| 553 | ]); |
| 554 | // Dieselbe Ausnahme in den Datenschutzhinweisen - keine zweite Wahrheit. |
| 555 | expect(einzeilig(DATENSCHUTZ)).toContain( |
| 556 | 'vorher schon vom Programm aus dem Setup oder vom tragbaren Programm angelegt war', |
| 557 | ); |
| 558 | }); |
| 559 | }); |