lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests domain datenschutz.test.ts

38,9 KB Rohdatei
tests/domain/datenschutz.test.ts — 934 Zeilen
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 });