lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain datenschutz.test.ts
| 1 | import { readFileSync, readdirSync, statSync } from 'node:fs'; |
| 2 | import path from 'node:path'; |
| 3 | import ts from 'typescript'; |
| 4 | import { describe, expect, it } from 'vitest'; |
| 5 | import { erlaubteRechner } from '../../electron/karte'; |
| 6 | import { ANSICHT_DATEINAME } from '../../electron/schriftgroesse'; |
| 7 | import { VERWEISADRESSEN } from '../../shared/verweise'; |
| 8 | import { KARTENDIENSTE, rechnernamen } from '@/services/kartendienste'; |
| 9 | |
| 10 | /** |
| 11 | * Die Datenschutzhinweise - Satz fuer Satz gegen den Quelltext gehalten. |
| 12 | * |
| 13 | * ANLASS. Ein Programm, das weitergegeben wird, braucht Datenschutzhinweise, |
| 14 | * und zwar gut sichtbar im Programm selbst und nicht nur auf einer |
| 15 | * Internetseite. Im Baum gab es keinen Datenschutztext. Die Seite des |
| 16 | * Herausgebers sagte sinngemaess, die Software uebertrage keine Daten - und |
| 17 | * das stimmt nicht: Der Luftbildabruf schickt die Lage des geplanten |
| 18 | * Knotenpunkts an einen Kartendienst. |
| 19 | * |
| 20 | * EINE QUELLE. `docs/datenschutz.md` wird im "Ueber"-Fenster angezeigt und vom |
| 21 | * Herausgeber auf seiner Internetseite veroeffentlicht. Zwei Texte liefen |
| 22 | * auseinander; deshalb gibt es einen, und die Zahlen und Namen darin sind |
| 23 | * hier an den Code gebunden: Rechnernamen und ihre Anzahl, Herausgeber, |
| 24 | * Wiederherstellungspunkte, Protokollgrenze, Dateinamen, Menueeintraege und |
| 25 | * Verweisadressen. |
| 26 | * |
| 27 | * DAS FORMAT IST EINE SCHNITTSTELLE. Das Programm stellt die Datei mit einem |
| 28 | * eigenen kleinen Leser dar, ohne innerHTML (Grundsatz: kein innerHTML mit |
| 29 | * Daten). Er kennt Titel, Abschnitte, Absaetze und einfache Aufzaehlungen |
| 30 | * und sonst nichts. Eine Tabelle, ein Verweis in Markdown-Schreibweise oder |
| 31 | * Fettdruck erschienen dort als Zeichensalat - und eine spitze Klammer ist |
| 32 | * genau das Zeichen, das in einem HTML-Leser zur Gefahr wird. |
| 33 | * |
| 34 | * WAS HIER NICHT GEPRUEFT WIRD, weil es kein Test pruefen kann: |
| 35 | * - die Ablage der Benutzerdaten dort, wo Windows sie je nach |
| 36 | * Installationsart gesondert fuehrt, und ihr Verbleib nach der |
| 37 | * Deinstallation. Beides ist am 17.09.2026 unter Windows 11 gemessen |
| 38 | * (Neukunde und Umsteiger) und steht datiert im Text; hier wird nur |
| 39 | * gehalten, dass Datum und die Ausnahme fuer den Umsteiger dort stehen und |
| 40 | * dass die Ausnahme nicht ueber Windows 11 hinaus zugesagt wird. |
| 41 | * - die Berichte, die ein Vertriebsdienst dem Herausgeber bereitstellen |
| 42 | * kann. Der Text nennt sie als Moeglichkeit, ohne Einzelheiten. |
| 43 | * - die Anfragekoepfe des Abrufs (Programmkennung "node", kein Cookie). Sie |
| 44 | * sind datiert gemessen und stehen so im Text. Ein Test geht nicht ins |
| 45 | * Netz, und `fetch` ist in den Abruftests ersetzt. |
| 46 | * - dass die Deinstallation aus dem Setup das Benutzerdatenverzeichnis stehen |
| 47 | * laesst. Das folgt aus der Vorgabe des Installationsbauers |
| 48 | * (`deleteAppDataOnUninstall`, nur fuer den Ein-Klick-Einrichter) und ist |
| 49 | * nicht gemessen. |
| 50 | */ |
| 51 | |
| 52 | const TEXT = readFileSync('docs/datenschutz.md', 'utf8'); |
| 53 | const ZEILEN = TEXT.split(/\r?\n/); |
| 54 | const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as { |
| 55 | name: string; |
| 56 | version: string; |
| 57 | productName?: string; |
| 58 | scripts: Record<string, string>; |
| 59 | devDependencies: Record<string, string>; |
| 60 | build: { |
| 61 | extraMetadata?: { productName?: string }; |
| 62 | win: { target: (string | { target: string })[] }; |
| 63 | }; |
| 64 | }; |
| 65 | |
| 66 | /** Text ohne Zeilenumbrueche - ein Absatz darf ueber mehrere Zeilen laufen. */ |
| 67 | const EINZEILIG = TEXT.replace(/\s+/g, ' '); |
| 68 | |
| 69 | /** Die Zeilen des Abschnitts "## <ueberschrift>" bis zum naechsten Abschnitt. */ |
| 70 | function abschnitt(ueberschrift: string): string[] { |
| 71 | const beginn = ZEILEN.indexOf(`## ${ueberschrift}`); |
| 72 | expect(beginn, `Abschnitt "${ueberschrift}"`).toBeGreaterThanOrEqual(0); |
| 73 | const rest = ZEILEN.slice(beginn + 1); |
| 74 | const ende = rest.findIndex((zeile) => zeile.startsWith('## ')); |
| 75 | return ende === -1 ? rest : rest.slice(0, ende); |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Die Aussagen einer Zeilenfolge: jeder Aufzaehlungspunkt fuer sich und jeder |
| 80 | * Absatz als Ganzes, ohne Zeilenumbrueche. Eine Einschraenkung gehoert in |
| 81 | * dieselbe Aussage wie die Zusage, die sie einschraenkt - zwei Absaetze |
| 82 | * weiter liest sie niemand mit. |
| 83 | */ |
| 84 | function aussagen(zeilen: readonly string[]): string[] { |
| 85 | const ergebnis: string[] = []; |
| 86 | let absatz: string[] = []; |
| 87 | const schliesse = (): void => { |
| 88 | if (absatz.length > 0) ergebnis.push(absatz.join(' ')); |
| 89 | absatz = []; |
| 90 | }; |
| 91 | for (const zeile of zeilen) { |
| 92 | if (zeile.startsWith('- ')) { |
| 93 | schliesse(); |
| 94 | ergebnis.push(zeile.slice(2).trim()); |
| 95 | } else if (zeile.trim() === '') { |
| 96 | schliesse(); |
| 97 | } else { |
| 98 | absatz.push(zeile.trim()); |
| 99 | } |
| 100 | } |
| 101 | schliesse(); |
| 102 | return ergebnis; |
| 103 | } |
| 104 | |
| 105 | const ZAHLWOERTER: ReadonlyMap<string, number> = new Map([ |
| 106 | ['ein', 1], |
| 107 | ['eine', 1], |
| 108 | ['einen', 1], |
| 109 | ['eins', 1], |
| 110 | ['zwei', 2], |
| 111 | ['drei', 3], |
| 112 | ['vier', 4], |
| 113 | ['fünf', 5], |
| 114 | ['sechs', 6], |
| 115 | ['sieben', 7], |
| 116 | ['acht', 8], |
| 117 | ['neun', 9], |
| 118 | ['zehn', 10], |
| 119 | ['elf', 11], |
| 120 | ['zwölf', 12], |
| 121 | ['dreizehn', 13], |
| 122 | ['vierzehn', 14], |
| 123 | ['fünfzehn', 15], |
| 124 | ]); |
| 125 | |
| 126 | /** Ziffern (auch mit Tausenderpunkten) oder ein Zahlwort als Zahl; sonst NaN. */ |
| 127 | function zahl(wort: string): number { |
| 128 | if (/^\d{1,3}(\.\d{3})*$|^\d+$/.test(wort)) return Number(wort.replace(/\./g, '')); |
| 129 | return ZAHLWOERTER.get(wort.toLowerCase()) ?? Number.NaN; |
| 130 | } |
| 131 | |
| 132 | /** Erste Fundgruppe eines Musters im einzeiligen Text - der Fall bricht, wenn es fehlt. */ |
| 133 | function fundstelle(muster: RegExp, wofuer: string): string { |
| 134 | const treffer = muster.exec(EINZEILIG); |
| 135 | expect(treffer, `${wofuer}: Satz nicht gefunden (${String(muster)})`).not.toBeNull(); |
| 136 | return treffer?.[1] ?? ''; |
| 137 | } |
| 138 | |
| 139 | function quelle(datei: string): ts.SourceFile { |
| 140 | return ts.createSourceFile( |
| 141 | datei, |
| 142 | readFileSync(datei, 'utf8'), |
| 143 | ts.ScriptTarget.Latest, |
| 144 | false, |
| 145 | ts.ScriptKind.TS, |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Alle Zeichenkettenliterale einer Quelldatei. |
| 151 | * |
| 152 | * Ueber den Syntaxbaum und nicht ueber `toContain`: Eine Beschriftung, die nur |
| 153 | * noch in einem Kommentar steht ("hier stand frueher ..."), zaehlt nicht. Von |
| 154 | * einer Vorlage mit Platzhaltern zaehlt das Stueck vor dem ersten. |
| 155 | */ |
| 156 | function zeichenketten(datei: string): ReadonlySet<string> { |
| 157 | const gefunden = new Set<string>(); |
| 158 | const besuche = (knoten: ts.Node): void => { |
| 159 | if (ts.isStringLiteral(knoten) || ts.isNoSubstitutionTemplateLiteral(knoten)) { |
| 160 | gefunden.add(knoten.text); |
| 161 | } else if (ts.isTemplateExpression(knoten)) { |
| 162 | gefunden.add(knoten.head.text.trim()); |
| 163 | } |
| 164 | ts.forEachChild(knoten, besuche); |
| 165 | }; |
| 166 | besuche(quelle(datei)); |
| 167 | return gefunden; |
| 168 | } |
| 169 | |
| 170 | /** Wert einer Zahlenkonstante `const NAME = 123` einer Quelldatei. */ |
| 171 | function zahlenkonstante(datei: string, name: string): number { |
| 172 | let wert = Number.NaN; |
| 173 | const besuche = (knoten: ts.Node): void => { |
| 174 | if ( |
| 175 | ts.isVariableDeclaration(knoten) && |
| 176 | ts.isIdentifier(knoten.name) && |
| 177 | knoten.name.text === name && |
| 178 | knoten.initializer !== undefined && |
| 179 | ts.isNumericLiteral(knoten.initializer) |
| 180 | ) { |
| 181 | wert = Number(knoten.initializer.text.replace(/_/g, '')); |
| 182 | } |
| 183 | ts.forEachChild(knoten, besuche); |
| 184 | }; |
| 185 | besuche(quelle(datei)); |
| 186 | expect(Number.isFinite(wert), `Konstante ${name} in ${datei} nicht gefunden`).toBe(true); |
| 187 | return wert; |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Die Dateiendungen aller Dateidialoge einer Quelldatei: jedes |
| 192 | * `extensions: [...]` als Feldliteral. Ein Bezeichner darin wird ueber eine |
| 193 | * `const NAME = '...'` derselben Datei aufgeloest; laesst er sich nicht |
| 194 | * aufloesen, steht er mit "?" davor im Ergebnis und bricht den Fall, statt |
| 195 | * still zu fehlen. |
| 196 | */ |
| 197 | function dialogendungen(datei: string): string[] { |
| 198 | const baum = quelle(datei); |
| 199 | const konstanten = new Map<string, string>(); |
| 200 | const felder: ts.ArrayLiteralExpression[] = []; |
| 201 | const besuche = (knoten: ts.Node): void => { |
| 202 | if ( |
| 203 | ts.isVariableDeclaration(knoten) && |
| 204 | ts.isIdentifier(knoten.name) && |
| 205 | knoten.initializer !== undefined && |
| 206 | ts.isStringLiteral(knoten.initializer) |
| 207 | ) { |
| 208 | konstanten.set(knoten.name.text, knoten.initializer.text); |
| 209 | } |
| 210 | if ( |
| 211 | ts.isPropertyAssignment(knoten) && |
| 212 | ts.isIdentifier(knoten.name) && |
| 213 | knoten.name.text === 'extensions' && |
| 214 | ts.isArrayLiteralExpression(knoten.initializer) |
| 215 | ) { |
| 216 | felder.push(knoten.initializer); |
| 217 | } |
| 218 | ts.forEachChild(knoten, besuche); |
| 219 | }; |
| 220 | besuche(baum); |
| 221 | return felder.flatMap((feld) => |
| 222 | feld.elements.map((element) => { |
| 223 | if (ts.isStringLiteral(element)) return element.text; |
| 224 | if (ts.isIdentifier(element)) return konstanten.get(element.text) ?? `?${element.text}`; |
| 225 | return `?${element.getText(baum)}`; |
| 226 | }), |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | /** Ein einzelnes Wort, das eine Behauptung dahinter verneint oder zur Frage macht. */ |
| 231 | const VERNEINUNGSWORT = /^(?:nicht|kein\w*|weder|ob|ohne|not|no)$/i; |
| 232 | |
| 233 | /** |
| 234 | * Behauptet der Satz, was `pruefwort` sagt? Ja, sobald eine Fundstelle des |
| 235 | * Pruefworts ohne Verneinung in den drei Woertern davor steht. Die Funktion |
| 236 | * und VERNEINUNGSWORT sind aus einer anderen Pruefdatei wortgleich |
| 237 | * abgeschrieben und nicht eingebunden, weil ein Import deren Faelle mitliefe; |
| 238 | * eine eigene Pruefung haelt beide Abschriften gleich. Mitgekommen sind damit auch |
| 239 | * die englischen Verneinungen und das Wegraeumen von Markdown-Verweisen und |
| 240 | * Dateinamen, die in diesem Text nicht vorkommen - das Format verbietet |
| 241 | * Verweise (Faelle ganz oben). |
| 242 | * |
| 243 | * NUR DAVOR UND NUR DREI WOERTER: Vor der Fassung 5.43.0 genuegte |
| 244 | * hier eine Verneinung irgendwo im Satz, und "Das Programm erfuellt die |
| 245 | * Pflichten der DSGVO und braucht kein Konto." blieb gruen. |
| 246 | */ |
| 247 | function behauptet(satz: string, pruefwort: RegExp): boolean { |
| 248 | const woerter = satz |
| 249 | .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') |
| 250 | .replace(/[\w-]+\.md\b/g, '') |
| 251 | .split(/\s+/) |
| 252 | .filter((wort) => wort !== ''); |
| 253 | return woerter.some( |
| 254 | (wort, i) => |
| 255 | pruefwort.test(wort) && |
| 256 | !woerter |
| 257 | .slice(Math.max(0, i - 3), i) |
| 258 | .some((davor) => VERNEINUNGSWORT.test(davor.replace(/[^\p{L}]/gu, ''))), |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | // --- Netzabrufe im Quelltext --------------------------------------------------- |
| 263 | |
| 264 | interface Netzstelle { |
| 265 | readonly datei: string; |
| 266 | readonly zeile: number; |
| 267 | readonly art: string; |
| 268 | } |
| 269 | |
| 270 | /** Module, deren Einbinden allein schon einen Weg ins Netz oeffnet. */ |
| 271 | const NETZMODULE: ReadonlySet<string> = new Set( |
| 272 | ['http', 'https', 'http2', 'net', 'tls', 'dgram', 'undici', 'ws', 'electron-updater'].flatMap( |
| 273 | (name) => [name, `node:${name}`], |
| 274 | ), |
| 275 | ); |
| 276 | |
| 277 | /** Klassen des Browserkerns, die eine Verbindung aufbauen. */ |
| 278 | const NETZKLASSEN: ReadonlySet<string> = new Set([ |
| 279 | 'XMLHttpRequest', |
| 280 | 'WebSocket', |
| 281 | 'EventSource', |
| 282 | 'WebTransport', |
| 283 | 'RTCPeerConnection', |
| 284 | ]); |
| 285 | |
| 286 | /** Was Electron an Netzwegen mitbringt: Abruf, Absturzberichte, Selbstaktualisierung. */ |
| 287 | const ELECTRON_NETZ: ReadonlySet<string> = new Set(['net', 'crashReporter', 'autoUpdater']); |
| 288 | |
| 289 | /** |
| 290 | * Stellen einer Quelldatei, an denen das Programm ins Netz greift. |
| 291 | * |
| 292 | * Ueber den Syntaxbaum, damit ein Kommentar ("das einzige `fetch` ...") und |
| 293 | * eine Zeichenkette nicht mitzaehlen, ein Aufruf in derselben Zeile hinter |
| 294 | * einer Adresse mit "//" aber sehr wohl. Gesucht wird, was einen Weg nach |
| 295 | * draussen oeffnet: `fetch` und `sendBeacon` als Aufruf, gleich an welchem |
| 296 | * Gegenstand; `request`/`get`/`connect` an `net`, `http`, `https`; die |
| 297 | * Verbindungsklassen des Browserkerns; das Einbinden von Netzmodulen und der |
| 298 | * Netzteile von Electron. |
| 299 | */ |
| 300 | function netzstellen(datei: string, quelltext: string): Netzstelle[] { |
| 301 | const baum = ts.createSourceFile( |
| 302 | datei, |
| 303 | quelltext, |
| 304 | ts.ScriptTarget.Latest, |
| 305 | false, |
| 306 | ts.ScriptKind.TS, |
| 307 | ); |
| 308 | const funde: Netzstelle[] = []; |
| 309 | const merke = (knoten: ts.Node, art: string): void => { |
| 310 | const zeile = baum.getLineAndCharacterOfPosition(knoten.getStart(baum)).line + 1; |
| 311 | funde.push({ datei, zeile, art }); |
| 312 | }; |
| 313 | const besuche = (knoten: ts.Node): void => { |
| 314 | if (ts.isCallExpression(knoten)) { |
| 315 | const ziel = knoten.expression; |
| 316 | const name = ts.isIdentifier(ziel) |
| 317 | ? ziel.text |
| 318 | : ts.isPropertyAccessExpression(ziel) |
| 319 | ? ziel.name.text |
| 320 | : ''; |
| 321 | if (name === 'fetch' || name === 'sendBeacon') merke(knoten, name); |
| 322 | else if ( |
| 323 | ts.isPropertyAccessExpression(ziel) && |
| 324 | ts.isIdentifier(ziel.expression) && |
| 325 | ['net', 'http', 'https', 'http2'].includes(ziel.expression.text) && |
| 326 | ['request', 'get', 'connect'].includes(name) |
| 327 | ) { |
| 328 | merke(knoten, `${ziel.expression.text}.${name}`); |
| 329 | } |
| 330 | const erstes = knoten.arguments[0]; |
| 331 | if ( |
| 332 | (name === 'require' || knoten.expression.kind === ts.SyntaxKind.ImportKeyword) && |
| 333 | erstes !== undefined && |
| 334 | ts.isStringLiteral(erstes) && |
| 335 | NETZMODULE.has(erstes.text) |
| 336 | ) { |
| 337 | merke(knoten, `laedt ${erstes.text}`); |
| 338 | } |
| 339 | } |
| 340 | if ( |
| 341 | ts.isNewExpression(knoten) && |
| 342 | ts.isIdentifier(knoten.expression) && |
| 343 | NETZKLASSEN.has(knoten.expression.text) |
| 344 | ) { |
| 345 | merke(knoten, knoten.expression.text); |
| 346 | } |
| 347 | if (ts.isImportDeclaration(knoten) && ts.isStringLiteral(knoten.moduleSpecifier)) { |
| 348 | const modul = knoten.moduleSpecifier.text; |
| 349 | if (NETZMODULE.has(modul)) merke(knoten, `bindet ${modul} ein`); |
| 350 | const benannt = knoten.importClause?.namedBindings; |
| 351 | if (modul === 'electron' && benannt !== undefined && ts.isNamedImports(benannt)) { |
| 352 | for (const element of benannt.elements) { |
| 353 | const original = (element.propertyName ?? element.name).text; |
| 354 | if (ELECTRON_NETZ.has(original)) merke(element, `electron.${original}`); |
| 355 | } |
| 356 | } |
| 357 | } |
| 358 | if ( |
| 359 | ts.isPropertyAccessExpression(knoten) && |
| 360 | (knoten.name.text === 'crashReporter' || knoten.name.text === 'autoUpdater') |
| 361 | ) { |
| 362 | merke(knoten, `.${knoten.name.text}`); |
| 363 | } |
| 364 | ts.forEachChild(knoten, besuche); |
| 365 | }; |
| 366 | besuche(baum); |
| 367 | return funde; |
| 368 | } |
| 369 | |
| 370 | /** Alle TypeScript-Dateien unter einem Verzeichnis, mit Schraegstrichen. */ |
| 371 | function tsDateien(verzeichnis: string): string[] { |
| 372 | return readdirSync(verzeichnis).flatMap((name) => { |
| 373 | const pfad = path.posix.join(verzeichnis, name); |
| 374 | if (statSync(pfad).isDirectory()) return tsDateien(pfad); |
| 375 | return name.endsWith('.ts') ? [pfad] : []; |
| 376 | }); |
| 377 | } |
| 378 | |
| 379 | /** Quellenangaben einer Richtlinie, die keinen fremden Rechner erreichen. */ |
| 380 | const QUELLEN_OHNE_NETZ: ReadonlySet<string> = new Set([ |
| 381 | "'self'", |
| 382 | "'none'", |
| 383 | "'unsafe-inline'", |
| 384 | 'data:', |
| 385 | 'blob:', |
| 386 | ]); |
| 387 | |
| 388 | /** |
| 389 | * Jede Quelle einer Abrufdirektive (`*-src`), die nicht in |
| 390 | * `QUELLEN_OHNE_NETZ` steht, als "<direktive> <quelle>". Bewusst eine |
| 391 | * Positivliste: Ein Rechnername, `https:`, `*` oder eine kuenftige neue |
| 392 | * Schreibweise faellt auf, statt durchzurutschen. |
| 393 | */ |
| 394 | function fremdeQuellen(richtlinie: string): string[] { |
| 395 | return richtlinie |
| 396 | .split(';') |
| 397 | .map((direktive) => direktive.trim().split(/\s+/)) |
| 398 | .flatMap(([name = '', ...quellen]) => |
| 399 | name.endsWith('-src') |
| 400 | ? quellen.filter((q) => !QUELLEN_OHNE_NETZ.has(q)).map((q) => `${name} ${q}`) |
| 401 | : [], |
| 402 | ); |
| 403 | } |
| 404 | |
| 405 | /** Die Richtlinie einer Quelldatei: alle Zeichenkettenliterale mit einer `*-src`-Direktive. */ |
| 406 | function richtlinieAus(datei: string): string { |
| 407 | return [...zeichenketten(datei)].filter((text) => /\b[a-z]+-src\b/.test(text)).join('; '); |
| 408 | } |
| 409 | |
| 410 | /** |
| 411 | * Die Windows-Pakete, die gebaut werden: `build.win.target` und die Ziele |
| 412 | * hinter `--win` in den Skripten, auch solche ausserhalb von |
| 413 | * `build.win.target`. |
| 414 | */ |
| 415 | function windowsPaketziele(): string[] { |
| 416 | const ziele = new Set<string>(); |
| 417 | for (const ziel of PAKET.build.win.target) { |
| 418 | ziele.add(typeof ziel === 'string' ? ziel : ziel.target); |
| 419 | } |
| 420 | for (const skript of Object.values(PAKET.scripts)) { |
| 421 | for (const treffer of skript.matchAll(/--win((?:\s+[a-z][a-z0-9-]*)+)/g)) { |
| 422 | for (const ziel of (treffer[1] ?? '').trim().split(/\s+/)) ziele.add(ziel); |
| 423 | } |
| 424 | } |
| 425 | return [...ziele].sort(); |
| 426 | } |
| 427 | |
| 428 | /** Wie der Text jedes Windows-Paket nennt. Ein neues Ziel ohne Eintrag bricht den Fall. */ |
| 429 | const PAKETBEZEICHNUNG: ReadonlyMap<string, string> = new Map([ |
| 430 | ['nsis', 'Programm aus dem Setup'], |
| 431 | ['portable', 'tragbaren Programm'], |
| 432 | ]); |
| 433 | |
| 434 | /** |
| 435 | * Wie der Text die Dateien nennt, deren Ort der Anwender im Dateidialog |
| 436 | * bestimmt - je Endung, die ein Dateidialog im Quelltext anbietet. Eine neue |
| 437 | * Endung ohne Eintrag bricht den Fall, ein Eintrag ohne Endung im Quelltext |
| 438 | * ebenso. `null` heisst: keine eigene Art Datei. |
| 439 | */ |
| 440 | const DATEIART: ReadonlyMap<string, string | null> = new Map([ |
| 441 | // src/services/storage.ts (PROJECT_FILE_FILTERS) und exportView.ts |
| 442 | ['lsap', 'Projektdateien'], |
| 443 | // dieselbe Projektdatei, im Dialog als "JSON-Datei" waehlbar |
| 444 | ['json', 'Projektdateien'], |
| 445 | ['pdf', 'Planunterlagen'], |
| 446 | // src/ui/views/exportView.ts: vier Plan-Tabellen und der Pruefbericht |
| 447 | ['csv', 'Tabellen'], |
| 448 | // electron/main.ts, "Fehlerprotokoll speichern" |
| 449 | ['txt', 'Kopien des Fehlerprotokolls'], |
| 450 | // electron/main.ts, Rueckfall "Alle Dateien", wenn der Renderer keine Filter schickt |
| 451 | ['*', null], |
| 452 | ]); |
| 453 | |
| 454 | // --- Faelle -------------------------------------------------------------------- |
| 455 | |
| 456 | describe('Format: Die Datei laesst sich ohne innerHTML darstellen', () => { |
| 457 | it('beginnt mit der Titelzeile und fuehrt keinen zweiten Titel', () => { |
| 458 | expect(ZEILEN[0], 'erste Zeile').toMatch(/^# \S/); |
| 459 | const titel = ZEILEN.filter((zeile) => zeile.startsWith('# ')); |
| 460 | expect(titel, 'Titelzeilen').toHaveLength(1); |
| 461 | }); |
| 462 | |
| 463 | it.each([ |
| 464 | [/^\s*\|/, 'eine Tabellenzeile'], |
| 465 | [/^#{3,}/, 'eine Ueberschrift ab der dritten Ebene'], |
| 466 | [/^#(?!#? )/, 'eine Ueberschrift ohne Leerzeichen'], |
| 467 | [/\]\(/, 'einen Verweis in Markdown-Schreibweise'], |
| 468 | [/</, 'eine spitze Klammer'], |
| 469 | [/\*\*|__/, 'Fettdruck'], |
| 470 | [/`/, 'ein Gravis-Zeichen'], |
| 471 | [/^\s*\d+[.)]\s/, 'eine nummerierte Liste'], |
| 472 | [/^\s*[*+]\s/, 'eine Aufzaehlung ohne Bindestrich'], |
| 473 | [/^\s+-\s/, 'eine verschachtelte Aufzaehlung'], |
| 474 | [/^\s{4,}\S|^\s*```/, 'einen Codeblock'], |
| 475 | ])('enthaelt keine Zeile mit %s (%s)', (muster, _was) => { |
| 476 | const verstoesse = ZEILEN.map((zeile, i) => ({ zeile, nummer: i + 1 })).filter(({ zeile }) => |
| 477 | muster.test(zeile), |
| 478 | ); |
| 479 | expect(verstoesse, `Zeilen mit ${String(muster)}`).toEqual([]); |
| 480 | }); |
| 481 | }); |
| 482 | |
| 483 | describe('Die einzige Verbindung ins Internet', () => { |
| 484 | it('nennt jeden freigegebenen Rechnernamen als eigenen Eintrag', () => { |
| 485 | /* |
| 486 | * Als Zeilenanfang und nicht mit `toContain`: "wms.nrw.de" steht auch in |
| 487 | * "www.wms.nrw.de", und ein fehlender Eintrag fiele sonst nicht auf. |
| 488 | */ |
| 489 | for (const name of erlaubteRechner()) { |
| 490 | const eintraege = ZEILEN.filter((zeile) => zeile.startsWith(`- ${name}: `)); |
| 491 | expect(eintraege, `Eintrag zu ${name}`).toHaveLength(1); |
| 492 | } |
| 493 | }); |
| 494 | |
| 495 | it('fuehrt keinen Rechnernamen, der nicht freigegeben ist', () => { |
| 496 | const genannt = ZEILEN.map((zeile) => /^- ([a-z0-9.-]+\.[a-z]{2,}): /.exec(zeile)?.[1]) |
| 497 | .filter((name): name is string => name !== undefined) |
| 498 | .sort(); |
| 499 | expect(genannt).toEqual([...erlaubteRechner()].sort()); |
| 500 | }); |
| 501 | |
| 502 | it('nennt die Zahl der Rechnernamen so, wie die Freigabeliste lang ist', () => { |
| 503 | const wort = fundstelle(/Liste von (\S+) Rechnernamen/, 'Anzahl der Rechnernamen'); |
| 504 | expect(zahl(wort), `"${wort}" Rechnernamen`).toBe(erlaubteRechner().length); |
| 505 | }); |
| 506 | |
| 507 | it('nennt zu jedem eingetragenen Dienst den Herausgeber, den das Programm fuehrt', () => { |
| 508 | for (const dienst of KARTENDIENSTE) { |
| 509 | const rechner = new URL(dienst.basisUrl).hostname; |
| 510 | const eintrag = ZEILEN.find((zeile) => zeile.startsWith(`- ${rechner}: `)) ?? ''; |
| 511 | expect(eintrag, `Herausgeber zu ${rechner}`).toContain(dienst.herausgeber); |
| 512 | } |
| 513 | }); |
| 514 | |
| 515 | it('sagt "derzeit keine Anfrage" genau bei den Namen, zu denen es keinen Dienst gibt', () => { |
| 516 | const angefragt = new Set(rechnernamen()); |
| 517 | for (const name of erlaubteRechner()) { |
| 518 | const eintrag = ZEILEN.find((zeile) => zeile.startsWith(`- ${name}: `)) ?? ''; |
| 519 | expect(eintrag.includes('derzeit keine Anfrage'), `Eintrag zu ${name}`).toBe( |
| 520 | !angefragt.has(name), |
| 521 | ); |
| 522 | } |
| 523 | }); |
| 524 | |
| 525 | it('findet der Zaehler Netzabrufe und uebergeht Kommentare und Zeichenketten', () => { |
| 526 | // Ohne diesen Fall koennte der Fall darunter gruen sein, weil der Zaehler |
| 527 | // gar nichts findet - auch den einen Abruf in electron/karte.ts nicht. |
| 528 | const probe = [ |
| 529 | '// fetch(im Kommentar)', |
| 530 | '/* new WebSocket("im Kommentar") */', |
| 531 | "const nurText = 'fetch(adresse)';", |
| 532 | "const adresse = 'https://beispiel.invalid/'; await fetch(adresse);", |
| 533 | 'globalThis.fetch(adresse);', |
| 534 | 'navigator.sendBeacon("/x");', |
| 535 | 'new XMLHttpRequest();', |
| 536 | "import { request } from 'node:https';", |
| 537 | "import { net as netz, shell } from 'electron';", |
| 538 | 'net.request("x");', |
| 539 | 'bruecke.fetchMapImage({ url: adresse });', |
| 540 | ].join('\n'); |
| 541 | expect(netzstellen('probe.ts', probe).map((stelle) => `${stelle.zeile} ${stelle.art}`)).toEqual( |
| 542 | [ |
| 543 | '4 fetch', |
| 544 | '5 fetch', |
| 545 | '6 sendBeacon', |
| 546 | '7 XMLHttpRequest', |
| 547 | '8 bindet node:https ein', |
| 548 | '9 electron.net', |
| 549 | '10 net.request', |
| 550 | ], |
| 551 | ); |
| 552 | }); |
| 553 | |
| 554 | it('steht im eigenen Quelltext genau ein Netzabruf, und zwar in electron/karte.ts', () => { |
| 555 | /* |
| 556 | * Der eigene Code steht in `electron/`, `src/` und `shared/`. `tools/` und |
| 557 | * `scripts/` erzeugen Dateien am Arbeitsplatz und gehen nicht ins Paket. |
| 558 | * Ausgeliefert wird daneben fremder Code, den Vite in die Oberflaeche |
| 559 | * buendelt - der zaehlt hier nicht mit, und deshalb sagt der Text "der |
| 560 | * eigene Programmcode" (Fall darunter). |
| 561 | */ |
| 562 | expect(EINZEILIG, 'die Zusage im Text').toContain( |
| 563 | 'Der eigene Programmcode enthält genau einen Netzabruf', |
| 564 | ); |
| 565 | const funde = ['electron', 'src', 'shared'] |
| 566 | .flatMap(tsDateien) |
| 567 | .flatMap((datei) => netzstellen(datei, readFileSync(datei, 'utf8'))); |
| 568 | expect(funde.map((stelle) => `${stelle.datei} ${stelle.art}`)).toEqual([ |
| 569 | 'electron/karte.ts fetch', |
| 570 | ]); |
| 571 | }); |
| 572 | |
| 573 | it('nennt die Abrufbefehle der mitgebuendelten Bibliotheken und haelt die Richtlinie dagegen', () => { |
| 574 | /* |
| 575 | * Pruefbefund zu 5.43.0: "Der Programmcode enthaelt genau einen Netzabruf" |
| 576 | * stimmte nur fuer den eigenen Quelltext. Im app.asar stecken jsPDF und |
| 577 | * seine Hilfsbibliotheken mit `XMLHttpRequest` und `fetch`, dazu die |
| 578 | * Ladehilfe von Vite. Hinaus kommen sie nicht, weil die |
| 579 | * Inhaltssicherheitsrichtlinie der Oberflaeche keine fremde Quelle |
| 580 | * zulaesst - und genau das muss der Text sagen und der Code halten. |
| 581 | * |
| 582 | * Die Richtlinie wird an beiden wirksamen Stellen gelesen (Meta-Element |
| 583 | * aus vite.config.ts, Antwortkopf aus electron/main.ts); dass beide |
| 584 | * dasselbe `connect-src` fuehren, bewacht |
| 585 | * tests/domain/sicherheitsrichtlinie.test.ts. Der Hauptprozess |
| 586 | * unterliegt ihr nicht, buendelt aber auch nichts Fremdes: Das Paket hat |
| 587 | * keine `dependencies`, und `electron/` wird nur uebersetzt. |
| 588 | */ |
| 589 | const absatz = aussagen(ZEILEN).find((aussage) => aussage.includes('genau einen Netzabruf')); |
| 590 | expect(absatz, 'Absatz mit der Zusage').toBeDefined(); |
| 591 | for (const teil of ['Bibliotheken', 'jsPDF', 'Abrufbefehle', 'Sicherheitsrichtlinie']) { |
| 592 | expect(absatz, `im selben Absatz: ${teil}`).toContain(teil); |
| 593 | } |
| 594 | |
| 595 | // Die genannte Bibliothek wird gebuendelt und enthaelt wirklich Abrufbefehle. |
| 596 | expect(PAKET.devDependencies, 'jspdf in package.json').toHaveProperty('jspdf'); |
| 597 | const jspdf = JSON.parse(readFileSync('node_modules/jspdf/package.json', 'utf8')) as { |
| 598 | browser: string; |
| 599 | }; |
| 600 | const buendel = path.posix.join('node_modules/jspdf', jspdf.browser); |
| 601 | expect(netzstellen(buendel, readFileSync(buendel, 'utf8')).length, buendel).toBeGreaterThan(0); |
| 602 | |
| 603 | // Der Leser der Richtlinie erkennt eine fremde Quelle ... |
| 604 | expect( |
| 605 | fremdeQuellen("default-src 'none'; connect-src 'self' https://a.example; img-src *"), |
| 606 | ).toEqual(['connect-src https://a.example', 'img-src *']); |
| 607 | // ... und an beiden wirksamen Stellen steht keine. |
| 608 | for (const datei of ['vite.config.ts', 'electron/main.ts']) { |
| 609 | const richtlinie = richtlinieAus(datei); |
| 610 | expect(richtlinie, `${datei}: default-src`).toContain("default-src 'none'"); |
| 611 | expect(richtlinie, `${datei}: connect-src`).toContain('connect-src '); |
| 612 | expect(fremdeQuellen(richtlinie), `${datei}: fremde Quellen`).toEqual([]); |
| 613 | } |
| 614 | }); |
| 615 | }); |
| 616 | |
| 617 | describe('Was auf dem Rechner liegt - Zahlen und Namen wie im Code', () => { |
| 618 | it('nennt die Zahl der Wiederherstellungspunkte, die der Sitzungsspeicher fuehrt', () => { |
| 619 | const wort = fundstelle(/bis zu (\S+) Wiederherstellungspunkte/, 'Wiederherstellungspunkte'); |
| 620 | expect(zahl(wort), `bis zu "${wort}"`).toBe( |
| 621 | zahlenkonstante('src/services/storage.ts', 'RECOVERY_SLOTS'), |
| 622 | ); |
| 623 | }); |
| 624 | |
| 625 | it('nennt die Groessengrenze, ab der das Fehlerprotokoll abgeloest wird', () => { |
| 626 | const wort = fundstelle(/Hat die Datei (\S+) Byte erreicht/, 'Protokollgrenze'); |
| 627 | expect(zahl(wort), `"${wort}" Byte`).toBe( |
| 628 | zahlenkonstante('electron/protokoll.ts', 'MAX_BYTES'), |
| 629 | ); |
| 630 | expect(readFileSync('electron/protokoll.ts', 'utf8'), 'Endung der abgeloesten Datei').toContain( |
| 631 | '.alt', |
| 632 | ); |
| 633 | expect(EINZEILIG).toContain('fehlerprotokoll.log.alt'); |
| 634 | }); |
| 635 | |
| 636 | it('nennt die Zahl der Stapelzeilen, die das Protokoll uebernimmt', () => { |
| 637 | const wort = fundstelle(/bis zu (\S+) Zeilen des Aufrufstapels/, 'Stapelzeilen'); |
| 638 | const code = /\.split\([^)]*\)\s*\.slice\(0,\s*(\d+)\)/.exec( |
| 639 | readFileSync('electron/protokoll.ts', 'utf8'), |
| 640 | ); |
| 641 | expect(code, 'Kuerzung des Stapels in electron/protokoll.ts').not.toBeNull(); |
| 642 | expect(zahl(wort), `bis zu "${wort}" Zeilen`).toBe(Number(code?.[1])); |
| 643 | }); |
| 644 | |
| 645 | it('nennt die Dateinamen, die das Programm anlegt', () => { |
| 646 | expect(zeichenketten('electron/protokoll.ts'), 'Name der Protokolldatei').toContain( |
| 647 | 'fehlerprotokoll.log', |
| 648 | ); |
| 649 | expect(EINZEILIG).toContain('fehlerprotokoll.log'); |
| 650 | expect(EINZEILIG).toContain(ANSICHT_DATEINAME); |
| 651 | expect(zeichenketten('src/services/storage.ts'), 'Name der Datenbank').toContain( |
| 652 | 'lsa-planer.v5', |
| 653 | ); |
| 654 | expect(EINZEILIG).toContain('Datenbank lsa-planer.v5'); |
| 655 | }); |
| 656 | |
| 657 | it('nennt das Benutzerdatenverzeichnis so, wie Electron es aus package.json bildet', () => { |
| 658 | /* |
| 659 | * `app.getName()` nimmt `productName` und sonst `name` aus der |
| 660 | * package.json im Paket. Am gebauten Stand nachgesehen (17.09.2026): |
| 661 | * Die package.json in `app.asar` traegt kein `productName`. Wer eines |
| 662 | * nachtraegt, verlegt die Daten aller Anwender - und diese Zeile im Text. |
| 663 | */ |
| 664 | expect(PAKET.productName, 'productName in package.json').toBeUndefined(); |
| 665 | expect(PAKET.build.extraMetadata?.productName, 'productName in extraMetadata').toBeUndefined(); |
| 666 | expect(EINZEILIG).toContain(`%APPDATA%\\${PAKET.name}`); |
| 667 | }); |
| 668 | |
| 669 | it('nennt jede Art Datei, deren Ort ein Dateidialog bestimmt, beim Ablegen und beim Loeschen', () => { |
| 670 | /* |
| 671 | * BEFUND (Fassung 5.43.0): Die Liste "Was das Programm auf Ihrem Rechner |
| 672 | * ablegt" nannte am selbst bestimmten Ort nur Projektdateien und |
| 673 | * Planunterlagen, und der Satz darunter legte alles andere ins |
| 674 | * Benutzerdatenverzeichnis. Die Ansicht Ausgabe schreibt aber auch |
| 675 | * CSV-Tabellen dorthin, wo der Anwender es bestimmt, darunter den |
| 676 | * Pruefbericht, der keine Planunterlage ist (exportView.ts), und das Menue |
| 677 | * Hilfe eine Kopie des Fehlerprotokolls. |
| 678 | * |
| 679 | * Gelesen werden die Endungen aller Dateidialoge und nicht nur die der |
| 680 | * Speicherdialoge: Beide Arten teilen sich Filterlisten |
| 681 | * (PROJECT_FILE_FILTERS), und eine Endung, die nur geoeffnet wird, bekommt |
| 682 | * ihren Eintrag in DATEIART mit `null` und einem Grund. |
| 683 | */ |
| 684 | const endungen = [ |
| 685 | ...new Set(['electron', 'src', 'shared'].flatMap(tsDateien).flatMap(dialogendungen)), |
| 686 | ].sort(); |
| 687 | expect(endungen, 'Endungen der Dateidialoge').toEqual([...DATEIART.keys()].sort()); |
| 688 | const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).filter( |
| 689 | (aussage) => aussage.includes('Speicherdialog'), |
| 690 | ); |
| 691 | expect(ablage, 'Aussage zu den Dateien am selbst bestimmten Ort').toHaveLength(1); |
| 692 | const loeschen = aussagen(abschnitt('Was Sie selbst in der Hand haben')).filter((aussage) => |
| 693 | aussage.includes('wie jede andere Datei'), |
| 694 | ); |
| 695 | expect(loeschen, 'Aussage zum Loeschen dieser Dateien').toHaveLength(1); |
| 696 | for (const [endung, art] of DATEIART) { |
| 697 | if (art === null) continue; |
| 698 | expect(ablage[0], `.${endung}: ${art} am selbst bestimmten Ort`).toContain(art); |
| 699 | expect(loeschen[0], `.${endung}: ${art} beim Loeschen`).toContain(art); |
| 700 | } |
| 701 | expect(EINZEILIG, 'was ausserhalb des Benutzerdatenverzeichnisses liegt').not.toContain( |
| 702 | 'Außer Projektdateien und Planunterlagen', |
| 703 | ); |
| 704 | }); |
| 705 | |
| 706 | it('nennt zu einer Art Datei jede Endung, die ein Dialog anbietet, sobald es Endungen nennt', () => { |
| 707 | /* |
| 708 | * BEFUND (Fassung 5.43.0): "Projektdateien (Endung .lsap)" stand da, |
| 709 | * waehrend der Speicherdialog fuer Projekte auch "JSON-Datei" anbietet |
| 710 | * (PROJECT_FILE_FILTERS in src/services/storage.ts) und eine Projektdatei |
| 711 | * mit Bearbeiter und Auftraggeber damit als .json entstehen kann. Der Fall |
| 712 | * darueber prueft je Endung nur die Art und blieb gruen. |
| 713 | * |
| 714 | * Geprueft wird die Klammer hinter der Art, und nur, wenn sie eine Endung |
| 715 | * mit Punkt nennt: "Planunterlagen (PDF)" nennt keine, und eine Art ganz |
| 716 | * ohne Endungsangabe ist nicht falsch, nur knapper. |
| 717 | */ |
| 718 | const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).filter( |
| 719 | (aussage) => aussage.includes('Speicherdialog'), |
| 720 | ); |
| 721 | expect(ablage, 'Aussage zu den Dateien am selbst bestimmten Ort').toHaveLength(1); |
| 722 | const endungenJeArt = new Map<string, string[]>(); |
| 723 | for (const [endung, art] of DATEIART) { |
| 724 | if (art !== null) endungenJeArt.set(art, [...(endungenJeArt.get(art) ?? []), endung]); |
| 725 | } |
| 726 | const geprueft: string[] = []; |
| 727 | for (const [art, endungenDerArt] of endungenJeArt) { |
| 728 | const maskiert = art.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 729 | const klammer = new RegExp(`${maskiert} \\(([^)]*)\\)`).exec(ablage[0] ?? '')?.[1] ?? ''; |
| 730 | if (!/\.[a-z]+\b/.test(klammer)) continue; |
| 731 | geprueft.push(art); |
| 732 | for (const endung of endungenDerArt) { |
| 733 | expect(klammer, `${art}: Endung .${endung}`).toMatch(new RegExp(`\\.${endung}\\b`)); |
| 734 | } |
| 735 | } |
| 736 | /* |
| 737 | * Anker gegen einen leeren Durchlauf, am heutigen Text: Die Projektdateien |
| 738 | * nennen ihre Endungen. Faellt die Angabe dort bewusst weg, geht der Anker |
| 739 | * mit fort. |
| 740 | */ |
| 741 | expect(geprueft, 'Arten mit Endungsangabe').toContain('Projektdateien'); |
| 742 | }); |
| 743 | |
| 744 | it.each(windowsPaketziele())( |
| 745 | 'sagt fuer das Paketziel "%s", wo die Daten liegen und wie sie wieder fortkommen', |
| 746 | (ziel) => { |
| 747 | /* |
| 748 | * Pruefbefund zu 5.43.0: Der Text kannte das Setup, aber das tragbare |
| 749 | * Programm fehlte, obwohl es ausgeliefert wird und seine |
| 750 | * Daten ebenso unter %APPDATA% ablegt (kein `setPath('userData')` in |
| 751 | * electron/) - wer die Programmdatei loescht, behaelt sie. |
| 752 | * |
| 753 | * Mit "beim" davor: Eine blosse Nennung in einer Ausnahme ("Dateien, |
| 754 | * die schon vorher vom tragbaren Programm dort lagen") sagt nichts |
| 755 | * darueber, wo dessen eigene Daten liegen. Ohne das "beim" blieb der |
| 756 | * Fall gruen, als die eigentliche Aussage gestrichen war. |
| 757 | */ |
| 758 | const bezeichnung = PAKETBEZEICHNUNG.get(ziel); |
| 759 | expect(bezeichnung, `Paketziel ${ziel}: keine Aussage im Text vorgesehen`).toBeDefined(); |
| 760 | const aussageDazu = new RegExp(`\\b[Bb]eim ${bezeichnung ?? ''}\\b`); |
| 761 | const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).join(' '); |
| 762 | expect(ablage, 'Abschnitt zum Ablageort').toMatch(aussageDazu); |
| 763 | const entfernen = aussagen(abschnitt('Was Sie selbst in der Hand haben')).filter((aussage) => |
| 764 | aussage.includes('Benutzerdatenverzeichnis'), |
| 765 | ); |
| 766 | expect(entfernen, 'Aussage zum Entfernen der Daten').toHaveLength(1); |
| 767 | expect(entfernen[0], 'Aussage zum Entfernen der Daten').toMatch(aussageDazu); |
| 768 | }, |
| 769 | ); |
| 770 | |
| 771 | it('sagt das Entfernen bei der Deinstallation nur mit der Ausnahme vorher angelegter Ordner zu', () => { |
| 772 | /* |
| 773 | * Pruefbefund zu 5.43.0: Der Abschnitt zum Ablageort schraenkte ein - |
| 774 | * Dateien, die schon vorher unter AppData lagen, leitet Windows nicht um |
| 775 | * und entfernt sie nicht -, die Liste "Was Sie selbst in der Hand haben" |
| 776 | * sagte es ohne Einschraenkung zu. Jede Installationsart nutzt denselben |
| 777 | * Ordnernamen; der Wechsel ist genau der Fall. Jede Aussage, die beides |
| 778 | * nennt, traegt deshalb die Ausnahme. |
| 779 | * |
| 780 | * Gemessen am 17.09.2026 unter Windows 11: Ohne vorhandenen Ordner fuehrt |
| 781 | * Windows die Daten gesondert, und die Deinstallation entfernt sie; mit |
| 782 | * vorhandenem Ordner landen auch NEUE Dateien im echten Ordner, und der |
| 783 | * bleibt. Die Aussage zum Ablageort traegt deshalb das Datum. Erkannt wird |
| 784 | * sie an "Installationsart". |
| 785 | */ |
| 786 | const zusagen = aussagen(ZEILEN).filter( |
| 787 | (aussage) => aussage.includes('Installationsart') && aussage.includes('Deinstallation'), |
| 788 | ); |
| 789 | expect(zusagen.length, 'Aussagen zur Deinstallation je nach Installationsart').toBeGreaterThan( |
| 790 | 0, |
| 791 | ); |
| 792 | for (const aussage of zusagen) { |
| 793 | expect(aussage, 'Ausnahme der vorher angelegten Ordner').toMatch(/\bvorher\b/); |
| 794 | } |
| 795 | const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).filter( |
| 796 | (aussage) => aussage.includes('Installationsart') && aussage.includes('Deinstallation'), |
| 797 | ); |
| 798 | expect(ablage, 'Aussage zum gesonderten Ablageort').toHaveLength(1); |
| 799 | expect(ablage[0], 'Messdatum').toContain('17.09.2026'); |
| 800 | }); |
| 801 | |
| 802 | it('sagt die Ausnahme fuer Umsteiger nur fuer das Gemessene zu, Windows 11', () => { |
| 803 | /* |
| 804 | * BEFUND (Fassung 5.43.0): "Dann benutzt das Paket diesen Ordner weiter |
| 805 | * ... teilt sich mit dem anderen Programm Sitzungsspeicher und |
| 806 | * Einstellungen" stand ohne Einschraenkung da; Windows 11 hiess nur der |
| 807 | * Ort, an dem nachgesehen wurde. Das Programm laeuft aber ab Windows 10 |
| 808 | * Version 1809 (Systemvoraussetzung in LIESMICH.txt), und fuer Windows 10 |
| 809 | * vor Version 1903 beschreibt Microsoft, dass ein Paket in diesem Fall nur |
| 810 | * in eine eigene Kopie schreibt. LIESMICH.txt war schon so eingegrenzt |
| 811 | * (tests/tools/liesmich.test.ts, Fall zu den Umsteigern), dieser Text |
| 812 | * nicht - und beide gehen mit demselben Paket hinaus. |
| 813 | */ |
| 814 | const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).join(' '); |
| 815 | const ausnahme = ablage.split(/(?<=[.!?])\s+/u).filter((satz) => satz.includes('vorher schon')); |
| 816 | expect(ausnahme, 'Satz zur Ausnahme mit "vorher schon"').toHaveLength(1); |
| 817 | expect(ausnahme[0], 'wofuer die Ausnahme gilt').toContain('Windows 11'); |
| 818 | expect(ablage, 'was unter Windows 10 nicht nachgesehen ist').toContain( |
| 819 | 'unter Windows 10 nicht', |
| 820 | ); |
| 821 | expect(ablage, 'was Microsoft fuer aeltere Fassungen beschreibt').toContain( |
| 822 | 'Windows 10 vor Version 1903', |
| 823 | ); |
| 824 | }); |
| 825 | }); |
| 826 | |
| 827 | describe('Bedienwege und Verweise, die der Text nennt', () => { |
| 828 | /* |
| 829 | * Der Text nennt nur Bedienwege, die es als Beschriftung gibt; "Fehlerprotokoll |
| 830 | * speichern …" kam mit 5.43.0 in electron/menu.ts hinzu. |
| 831 | */ |
| 832 | it.each([ |
| 833 | ['Fehlerprotokoll speichern …', 'electron/menu.ts'], |
| 834 | ['Internetseite des Herstellers', 'electron/menu.ts'], |
| 835 | ['Unterstützen', 'electron/menu.ts'], |
| 836 | ['Quellcode öffnen', 'src/ui/ueber.ts'], |
| 837 | ['Unterstützen', 'src/ui/ueber.ts'], |
| 838 | ['Über LSA-Planer Professional', 'src/ui/ueber.ts'], |
| 839 | ['Amtliches Luftbild holen', 'src/ui/views/lageplanView.ts'], |
| 840 | ['Amtliches Luftbild', 'src/ui/views/lageplanView.ts'], |
| 841 | ['Amtliches Luftbild laden', 'src/ui/lageplan/luftbild.ts'], |
| 842 | ['Ausschnitt holen', 'src/ui/lageplan/luftbild.ts'], |
| 843 | ['Kartengrundlage', 'src/ui/lageplan/luftbild.ts'], |
| 844 | ['Bei Störungen', 'src/main.ts'], |
| 845 | ])('fuehrt "%s" in %s', (beschriftung, datei) => { |
| 846 | expect(EINZEILIG, 'Nennung im Text').toContain(`„${beschriftung}“`); |
| 847 | expect(zeichenketten(datei), `Beschriftung in ${datei}`).toContain(beschriftung); |
| 848 | }); |
| 849 | |
| 850 | it('nennt jede Adresse, die ein Verweis oeffnen kann', () => { |
| 851 | const imMenue = [...zeichenketten('electron/menu.ts')].filter((text) => |
| 852 | text.startsWith('https://'), |
| 853 | ); |
| 854 | expect(imMenue.length, 'Adressen im Menue').toBeGreaterThan(0); |
| 855 | for (const adresse of [...Object.values(VERWEISADRESSEN), ...imMenue]) { |
| 856 | expect(EINZEILIG, `Adresse ${adresse}`).toContain(adresse); |
| 857 | } |
| 858 | }); |
| 859 | }); |
| 860 | |
| 861 | describe('Keine Zusage, die der Text nicht belegen kann', () => { |
| 862 | it('behauptet keine Erfuellung und keine Rechtskonformitaet', () => { |
| 863 | /* |
| 864 | * Die Datei beschreibt Tatsachen des Programms. Ob damit Pflichten |
| 865 | * erfuellt sind, ist eine Rechtsfrage - dieselbe Haltung wie in den |
| 866 | * uebrigen Unterlagen des Projekts. |
| 867 | */ |
| 868 | const saetze = EINZEILIG.split(/(?<=[.;:])\s/); |
| 869 | expect( |
| 870 | saetze.filter((satz) => behauptet(satz, /erfüll|erfuell/i)), |
| 871 | 'Saetze behaupten Erfuellung', |
| 872 | ).toEqual([]); |
| 873 | expect(EINZEILIG).not.toMatch(/konform|gewährleist|garantier|rechtssicher/i); |
| 874 | }); |
| 875 | |
| 876 | it('erkennt eine Erfuellungsbehauptung auch neben einer Verneinung an anderer Stelle', () => { |
| 877 | /* |
| 878 | * Ohne diesen Fall waere der Fall darueber auch mit einer Pruefung gruen, |
| 879 | * die gar nichts erkennt - docs/datenschutz.md enthaelt heute keinen Satz |
| 880 | * mit "erfuell", die Schleife laeuft ueber null Saetze. Ein Anker "mindestens |
| 881 | * ein solcher Satz" waere dagegen falsch: Er verlangte, dass der Text einen |
| 882 | * aufnimmt. |
| 883 | */ |
| 884 | const pruefwort = /erfüll|erfuell/i; |
| 885 | expect( |
| 886 | behauptet('Das Programm erfüllt die Pflichten der DSGVO und braucht kein Konto.', pruefwort), |
| 887 | 'Verneinung hinter der Behauptung', |
| 888 | ).toBe(true); |
| 889 | expect( |
| 890 | behauptet( |
| 891 | 'Das Programm erfüllt die Pflichten der DSGVO, nicht nur die des TTDSG.', |
| 892 | pruefwort, |
| 893 | ), |
| 894 | 'Verneinung eines anderen Satzteils', |
| 895 | ).toBe(true); |
| 896 | expect( |
| 897 | behauptet('Ob damit Pflichten erfüllt sind, ist eine Rechtsfrage.', pruefwort), |
| 898 | 'Frage', |
| 899 | ).toBe(false); |
| 900 | expect( |
| 901 | behauptet('Diese Seite behauptet nicht, dass Pflichten erfüllt sind.', pruefwort), |
| 902 | 'Verneinung davor', |
| 903 | ).toBe(false); |
| 904 | }); |
| 905 | |
| 906 | it('verschweigt in der Kurzfassung nicht, dass ein Vertriebsdienst Absturzberichte bereitstellen kann', () => { |
| 907 | /* |
| 908 | * Pruefbefund zu 5.43.0: "schickt keine Absturzberichte an den |
| 909 | * Herausgeber" stimmte fuer das Programm, aber wer nur die Kurzfassung |
| 910 | * las, schloss, dass keine Absturzdaten beim Herausgeber ankommen. Beim |
| 911 | * Bezug ueber einen Vertriebsdienst koennen sie es - ueber den Dienst, |
| 912 | * weiter unten im Text. Wo die Kurzfassung von Absturzberichten spricht, |
| 913 | * nennt sie den Vertriebsdienst mit. |
| 914 | */ |
| 915 | const dienst = aussagen(abschnitt('Bezug über einen Vertriebsdienst')).join(' '); |
| 916 | expect(dienst, 'Voraussetzung: der Abschnitt nennt Abstuerze').toContain('Abstürze'); |
| 917 | const kurz = aussagen(abschnitt('Das Wichtigste in Kürze')).filter((aussage) => |
| 918 | aussage.includes('Absturz'), |
| 919 | ); |
| 920 | expect(kurz.length, 'Absturzberichte in der Kurzfassung').toBeGreaterThan(0); |
| 921 | for (const aussage of kurz) { |
| 922 | expect(aussage, 'Absturzberichte in der Kurzfassung').toContain('Vertriebsdienst'); |
| 923 | } |
| 924 | }); |
| 925 | |
| 926 | it('datiert den Stand auf die Fassung aus package.json', () => { |
| 927 | /* |
| 928 | * Die Fassungsnummer steht einmal - in package.json. Der Text sagt, fuer |
| 929 | * welche Fassung er gilt, und muss mit ihr gehen; sonst beschreibt er ein |
| 930 | * Programm, das der Anwender nicht vor sich hat. |
| 931 | */ |
| 932 | expect(ZEILEN, 'Zeile "Stand: Fassung ..."').toContain(`Stand: Fassung ${PAKET.version}`); |
| 933 | }); |
| 934 | }); |