lsa-planer

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

/ tests tools abdeckungsmessung.test.ts

26,9 KB Rohdatei
tests/tools/abdeckungsmessung.test.ts — 598 Zeilen
1 import { spawnSync } from 'node:child_process';
2 import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3 import os from 'node:os';
4 import path from 'node:path';
5 import { describe, expect, it } from 'vitest';
6 import { coverageConfigDefaults } from 'vitest/config';
7 import konfiguration from '../../vitest.config';
8
9 /*
10 * Die Wache ueber die Abdeckungsmessung.
11 *
12 * Bis 5.13.0 stand in den Unterlagen eine Abdeckungszahl, die kein Test hielt: Sie
13 * entstand nur in einem eigenen Lauf, und gemessen wurden allein
14 * `src/domain/**` und `src/services/**`. Oberflaeche, Ausdruck und
15 * Hauptprozess waren getestet, aber ungemessen; Schwellen gab es keine. Wer
16 * Pruefung abbaute, merkte es nicht, und wer die Zahl im Text aenderte, musste
17 * nichts nachrechnen.
18 *
19 * `vitest.config.ts` misst jetzt `src/**`, `shared/**`, `electron/**` und
20 * `tools/**` und traegt je Bereich eine Schwelle knapp unter dem gemessenen
21 * Stand. Damit faellt der Lauf, wenn die Abdeckung sinkt.
22 *
23 * WAS DIESE DATEI DAZU BEITRAEGT - und was sie ausdruecklich NICHT tut. Sie
24 * faehrt KEINE Messung. Eine Abdeckung entsteht nur unter `--coverage`; ein
25 * Testfall, der das noch einmal anwuerfe, verdoppelte die teuerste Stufe des
26 * Laufs (gemessen: 34,6 s ohne, 53,2 s mit Messung). Bewacht wird stattdessen
27 * alles, was ohne Messung nachpruefbar ist und ohne das die Schwellen nichts
28 * wert waeren:
29 *
30 * 1. dass der gemessene UMFANG jede Quelldatei des Baumes erfasst - eine
31 * Datei oder ein Verzeichnis, das niemand in `include` nachtraegt, waere
32 * sonst stumm ungemessen und sein Fehlen an keiner Zahl zu sehen;
33 * 2. dass die einzige AUSNAHME (`electron/preload.ts`) namentlich dasteht,
34 * die Vorgaben von Vitest daneben stehen bleiben und die beiden Wachen
35 * noch existieren, auf die sich die Ausnahme beruft;
36 * 3. dass jeder gemessene Bereich eine EIGENE Schwelle hat - eine Schwelle
37 * fuer alles liesse `src/domain/**` die Oberflaeche zudecken;
38 * 4. dass keine Schwelle unter den Stand GESENKT wurde, auf den sie gesetzt
39 * worden ist. Anheben ist frei, Senken verlangt, hier dieselbe Zahl
40 * nachzuziehen - also eine bewusste Entscheidung statt eines Handgriffs;
41 * 5. dass die Schwellen ueberhaupt GEFAHREN werden. Eine Schwelle, die
42 * niemand faehrt, bewacht nichts; sie haengt an der Kette
43 * `.githooks/pre-commit` -> `gate:schnell` -> `test:coverage`.
44 *
45 * NACHGEBESSERT: Die Punkte 1 und 3 sahen zuerst nur auf die
46 * WURZELVERZEICHNISSE: Aus dem Muster `src/**` wurde `src`, und damit war ein
47 * `include` von `src/domain/**` von einem `include` von `src/**` nicht zu
48 * unterscheiden. Wer den Umfang INNERHALB von `src/` zusammenstrich, nahm
49 * Oberflaeche, Ausdruck, Speicherort und Bruecke aus der Messung, ohne dass
50 * etwas rot wurde - auch der Abdeckungslauf nicht: Ein Schwellenmuster ohne
51 * gemessene Datei ergibt eine leere Abdeckungskarte, und die zaehlt istanbul
52 * als 100 %. Beide Faelle halten jetzt jede einzelne Quelldatei des Baumes
53 * gegen die vollstaendigen Muster aus `coverage.include`, Pfad fuer Pfad statt
54 * Wurzel fuer Wurzel.
55 *
56 * Nicht bewacht - und das bleibt so - ist die Abdeckungszahl selbst. Sie
57 * entsteht im Lauf mit `--coverage`, und dort halten sie die Schwellen.
58 */
59
60 /* -------------------------------------------------------------------------
61 * Die Einstellungen, wie sie in vitest.config.ts stehen
62 * ---------------------------------------------------------------------- */
63
64 /** Die vier Schwellenarten, die Vitest kennt. */
65 interface Schwellensatz {
66 readonly lines?: number;
67 readonly branches?: number;
68 readonly functions?: number;
69 readonly statements?: number;
70 }
71
72 /**
73 * Der Abdeckungsteil der Einstellungen.
74 *
75 * Eingebunden wird die Einstellungsdatei selbst und nicht ihr Wortlaut: So
76 * bricht eine Umbenennung beim Uebersetzen und nicht erst hier, und die Werte
77 * kommen aus derselben Quelle, die Vitest liest.
78 */
79 interface Abdeckungsteil extends Schwellensatz {
80 readonly include?: readonly string[];
81 readonly exclude?: readonly string[];
82 readonly thresholds?: Readonly<Record<string, Schwellensatz | number | boolean | undefined>>;
83 }
84
85 const ABDECKUNG = konfiguration.test?.coverage as Abdeckungsteil | undefined;
86 const SCHWELLEN = ABDECKUNG?.thresholds ?? {};
87
88 /** Die Schluessel, die keine Glob-Muster sind, sondern Einstellungen. */
89 const KEINE_GLOBS = new Set([
90 'lines',
91 'branches',
92 'functions',
93 'statements',
94 'perFile',
95 'autoUpdate',
96 '100',
97 ]);
98
99 /** Alle Glob-Muster der Schwellentabelle, in der Reihenfolge der Datei. */
100 const GLOBS = Object.keys(SCHWELLEN).filter((s) => !KEINE_GLOBS.has(s));
101
102 /** Der Schwellensatz zu einem Glob-Muster. */
103 function satz(glob: string): Schwellensatz {
104 const wert = SCHWELLEN[glob];
105 expect(typeof wert, `Schwellensatz zu "${glob}"`).toBe('object');
106 return wert as Schwellensatz;
107 }
108
109 /* -------------------------------------------------------------------------
110 * 1. Der Umfang
111 * ---------------------------------------------------------------------- */
112
113 /**
114 * Verzeichnisse der Wurzel, die absichtlich NICHT gemessen werden - jedes mit
115 * dem Grund, warum eine Zahl darueber nichts aussagte. Kommt ein Verzeichnis
116 * mit Quelltext hinzu, das weder hier noch in `include` steht, faellt der Fall
117 * darunter.
118 */
119 const AUSSERHALB: Readonly<Record<string, string>> = {
120 tests: 'die Pruefung selbst',
121 e2e: 'der Rauchtest, laeuft unter Playwright und nicht unter Vitest',
122 scripts:
123 'laeuft als eigener Prozess (spawnSync in tests/tools), die v8-Messung dieses Prozesses sieht davon nichts',
124 node_modules: 'fremder Kode',
125 build: 'Bauergebnis',
126 release: 'Bauergebnis',
127 coverage: 'Ergebnis der Messung selbst',
128 'test-results': 'Ausgabe des Rauchtests',
129 assets: 'Bilddateien',
130 beispiele: 'Beispieldaten',
131 docs: 'Unterlagen',
132 public: 'unveraendert mitkopierte Dateien, nichts wird daraus uebersetzt',
133 '.git': 'Versionsverwaltung',
134 '.githooks': 'Haken der Versionsverwaltung, Shell',
135 '.vscode': 'oertliche Einstellungen des Arbeitsplatzes',
136 };
137
138 /**
139 * Einzelne Dateien der Wurzel, die absichtlich nicht gemessen werden - wie
140 * AUSSERHALB, nur je Datei. Es sind die Einstellungen der Werkzeuge; zwei davon
141 * nimmt Vitest schon ueber seine Vorgabeliste heraus, `playwright.config.ts`
142 * steht dort nicht. Eine weitere Datei in der Wurzel gehoert hierher, mit Grund,
143 * und wird nicht stillschweigend uebergangen.
144 */
145 const AUSSERHALB_DATEIEN: Readonly<Record<string, string>> = {
146 'vite.config.ts': 'Einstellungen des Bauwerkzeugs, kein Bestandteil der Fassung',
147 'vitest.config.ts': 'Einstellungen des Testlaufs, kein Bestandteil der Fassung',
148 'playwright.config.ts': 'Einstellungen des Rauchtests, kein Bestandteil der Fassung',
149 };
150
151 /**
152 * Passt der Pfad auf das Glob-Muster?
153 *
154 * Dieselbe Frage, die Vitest stellt: Die Schwellenmuster vergleicht es mit
155 * `micromatch.isMatch(relative(root, datei), glob)`, den Umfang mit
156 * `test-exclude` (minimatch). Beide Pakete liegen nur mittelbar im Baum, und
157 * aufgenommen wird keines. Nachgebildet ist deshalb genau der Ausschnitt, den
158 * `coverage.include` und die Schwellentabelle brauchen: `**` ueber beliebig
159 * viele Abschnitte, `*` und `?` innerhalb eines Abschnitts, sonst feste
160 * Zeichen. Verglichen wird mit Schraegstrich, so wie beide Werkzeuge es tun.
161 *
162 * Jedes andere Sonderzeichen der Glob-Sprache bricht ab, statt einfach nicht zu
163 * passen: Ein Muster, das diese Wache nicht lesen kann, darf sie nicht fuer
164 * erfuellt erklaeren.
165 */
166 function passt(pfad: string, muster: string): boolean {
167 if (/[{}()[\]!+@|]/.test(muster)) {
168 throw new Error(
169 `Das Muster "${muster}" nutzt Glob-Zeichen, die diese Wache nicht nachbildet - ` +
170 'entweder das Muster einfach halten oder den Vergleich hier erweitern.',
171 );
172 }
173 if (muster.split('/').some((teil) => teil.includes('**') && teil !== '**')) {
174 // Bei micromatch steht ein "**", das nicht allein im Abschnitt steht, nur
175 // fuer "*". Diese Wache bildet das nicht nach und sagt es lieber.
176 throw new Error(
177 `Das Muster "${muster}" mischt "**" mit anderen Zeichen im selben Abschnitt - ` +
178 'so gelesen waere es weiter als bei Vitest.',
179 );
180 }
181 let quelle = '^';
182 for (let i = 0; i < muster.length; i += 1) {
183 const zeichen = muster[i];
184 if (zeichen === '*' && muster[i + 1] === '*') {
185 i += 1;
186 // "**/" darf auch fuer keinen Abschnitt stehen, "**" am Ende fuer alles.
187 if (muster[i + 1] === '/') {
188 i += 1;
189 quelle += '(?:[^/]+/)*';
190 } else {
191 quelle += '.*';
192 }
193 } else if (zeichen === '*') {
194 quelle += '[^/]*';
195 } else if (zeichen === '?') {
196 quelle += '[^/]';
197 } else {
198 quelle += zeichen === undefined ? '' : zeichen.replace(/[.^$\\]/g, '\\$&');
199 }
200 }
201 return new RegExp(`${quelle}$`).test(pfad);
202 }
203
204 /** Die Ausschlussmuster, die Vitest von sich aus mitbringt. */
205 const VORGABE_AUS = coverageConfigDefaults.exclude;
206
207 /** Die Ausschlussmuster, die diese Fassung selbst gesetzt hat. */
208 const EIGENE_AUS = (ABDECKUNG?.exclude ?? []).filter((m) => !VORGABE_AUS.includes(m));
209
210 /**
211 * Wird diese Datei gemessen?
212 *
213 * Gefragt wird nach `coverage.include` und den eigenen Ausnahmen. Die
214 * Vorgabeliste von Vitest bleibt aussen vor - was sie trifft (Typmodule,
215 * Testdateien, die bekannten Einstellungsdateien), liegt im Baum entweder unter
216 * AUSSERHALB oder in AUSSERHALB_DATEIEN, und dass sie vollstaendig stehen
217 * bleibt, prueft ein eigener Fall.
218 */
219 function wirdGemessen(datei: string): boolean {
220 return (
221 (ABDECKUNG?.include ?? []).some((m) => passt(datei, m)) &&
222 !EIGENE_AUS.some((m) => passt(datei, m))
223 );
224 }
225
226 /**
227 * Nennt die .gitignore das Verzeichnis der Wurzel? Dann gehoert es nicht zum
228 * Baum, und die Suche geht nicht hinein - auch nicht in ein Verzeichnis, das an
229 * einem Arbeitsplatz daneben liegt und in diesem Quelltext nirgends vorkommt.
230 * Gefragt wird Git selbst (`--no-index`: allein nach den Mustern); fehlt Git,
231 * gilt das Verzeichnis als Teil des Baumes und wird durchsucht. `wurzel` ist
232 * das Verzeichnis, in dem Git fragt - die Wurzel des durchsuchten Baumes.
233 */
234 function gitIgnoriert(verzeichnis: string, wurzel = '.'): boolean {
235 return (
236 spawnSync('git', ['check-ignore', '-q', '--no-index', `${verzeichnis}/`], { cwd: wurzel })
237 .status === 0
238 );
239 }
240
241 /**
242 * Alle Quelldateien des Baumes, ohne die Verzeichnisse aus AUSSERHALB und ohne
243 * die, die die .gitignore nennt - als Pfade von der Wurzel aus, mit
244 * Schraegstrich. Typmodule (`*.d.ts`) tragen keinen ausfuehrbaren Kode und
245 * bleiben aussen vor; Vitest schliesst sie ohnehin aus, wie auch alles, was mit
246 * einem Punkt beginnt (`**\/[.]**`).
247 */
248 function quelldateien(verzeichnis: string, praefix = ''): string[] {
249 const gefunden: string[] = [];
250 for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) {
251 if (eintrag.name.startsWith('.')) continue;
252 const relativ = praefix === '' ? eintrag.name : `${praefix}/${eintrag.name}`;
253 if (eintrag.isDirectory()) {
254 if (
255 praefix === '' &&
256 (eintrag.name in AUSSERHALB || gitIgnoriert(eintrag.name, verzeichnis))
257 ) {
258 continue;
259 }
260 gefunden.push(...quelldateien(path.join(verzeichnis, eintrag.name), relativ));
261 } else if (eintrag.name.endsWith('.ts') && !eintrag.name.endsWith('.d.ts')) {
262 gefunden.push(relativ);
263 }
264 }
265 return gefunden;
266 }
267
268 /** Enthaelt das Verzeichnis - auch tiefer - eine .ts-Datei, die kein Typmodul ist? */
269 function traegtQuelltext(verzeichnis: string): boolean {
270 for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) {
271 const pfad = path.join(verzeichnis, eintrag.name);
272 if (eintrag.isDirectory()) {
273 if (traegtQuelltext(pfad)) return true;
274 } else if (eintrag.name.endsWith('.ts') && !eintrag.name.endsWith('.d.ts')) {
275 return true;
276 }
277 }
278 return false;
279 }
280
281 describe('Abdeckungsmessung: der Umfang', () => {
282 it('vergleicht Pfad und Muster so, wie es die Muster verlangen', () => {
283 // Der Vergleich oben ist selbstgebaut; hier steht, was er zusagt. Ohne
284 // diese Faelle stuende die ganze Umfangswache auf einer ungeprueften
285 // Zeichenkettenrechnung.
286 expect(passt('src/main.ts', 'src/**'), 'src/** trifft die Datei daneben').toBe(true);
287 expect(passt('src/ui/views/projectView.ts', 'src/**'), 'src/** trifft auch tiefer').toBe(true);
288 expect(passt('src', 'src/**'), 'src/** trifft das Verzeichnis selbst nicht').toBe(false);
289 expect(passt('src/ui/dom.ts', 'src/domain/**'), 'src/domain/** trifft src/ui nicht').toBe(
290 false,
291 );
292 expect(passt('src/main.ts', 'src/main.ts'), 'ein Muster ohne Sternchen').toBe(true);
293 expect(passt('src/mainXts', 'src/main.ts'), 'der Punkt ist kein Platzhalter').toBe(false);
294 expect(passt('a/b.ts', '**/*.ts'), '**/ darf ueber einen Abschnitt gehen').toBe(true);
295 expect(passt('b.ts', '**/*.ts'), '**/ darf auch ueber keinen gehen').toBe(true);
296 expect(() => passt('a.ts', 'a.{ts,js}'), 'unbekanntes Glob-Zeichen').toThrow();
297 expect(() => passt('a.ts', 'src/**.ts'), '"**" mitten im Abschnitt').toThrow();
298 });
299
300 it('misst jede Quelldatei des Baumes, die nicht namentlich ausgenommen ist', () => {
301 /*
302 * Datei fuer Datei und nicht Wurzelverzeichnis fuer Wurzelverzeichnis: Ein
303 * `include` von `src/domain/**` liegt unter demselben Wurzelverzeichnis wie
304 * `src/**` und waere so nicht davon zu unterscheiden - dabei naehme es
305 * `src/ui`, `src/app`, `src/render`, `src/services` und `src/platform` aus
306 * der Messung, ohne dass eine Zahl faellt.
307 *
308 * Unerfasst bleiben darf nur, was in AUSSERHALB, in AUSSERHALB_DATEIEN oder
309 * in den eigenen Ausnahmen der Einstellungen steht - also die eine Datei
310 * `electron/preload.ts`, an den beiden Faellen weiter unten festgemacht.
311 */
312 const muster = ABDECKUNG?.include ?? [];
313 expect(muster.length, 'coverage.include ist leer').toBeGreaterThan(0);
314
315 const ungemessen = quelldateien('.').filter(
316 (datei) =>
317 !(datei in AUSSERHALB_DATEIEN) &&
318 !EIGENE_AUS.some((m) => passt(datei, m)) &&
319 !muster.some((m) => passt(datei, m)),
320 );
321 expect(
322 ungemessen,
323 'diese Dateien tragen Quelltext, werden aber weder gemessen noch als Ausnahme gefuehrt',
324 ).toEqual([]);
325 });
326
327 it('geht in kein Verzeichnis, das die .gitignore nennt', () => {
328 /*
329 * An einem Wegwerfbaum und nicht an diesem: Hier traegt kein Verzeichnis,
330 * das die .gitignore nennt, eine .ts-Datei, und ohne die Frage an die
331 * .gitignore blieben die Faelle darueber gruen. Im Wegwerfbaum liegt je
332 * eine Quelldatei in einem genannten Verzeichnis und in einem, das niemand
333 * nennt; gefunden werden darf nur die zweite.
334 */
335 const wurzel = mkdtempSync(path.join(os.tmpdir(), 'lsa-abdeckung-'));
336 try {
337 const anlegen = spawnSync('git', ['init', '-q', wurzel], { encoding: 'utf8' });
338 expect(anlegen.status, `git init: ${anlegen.stderr}`).toBe(0);
339 writeFileSync(path.join(wurzel, '.gitignore'), 'ablage/\n', 'utf8');
340 for (const verzeichnis of ['ablage', 'quelle']) {
341 mkdirSync(path.join(wurzel, verzeichnis));
342 writeFileSync(path.join(wurzel, verzeichnis, 'modul.ts'), 'export const x = 1;\n', 'utf8');
343 }
344 expect(quelldateien(wurzel), 'die Suche ging in ein genanntes Verzeichnis').toEqual([
345 'quelle/modul.ts',
346 ]);
347 } finally {
348 rmSync(wurzel, { recursive: true, force: true });
349 }
350 });
351
352 it('bleibt jede Ausnahme, die hier steht, auch wirklich ausserhalb', () => {
353 // Die Gegenrichtung: Ein Eintrag in AUSSERHALB, den `include` doch
354 // mitnimmt, taeuschte den naechsten Leser ueber den Umfang. Gefragt wird
355 // mit einem gedachten Pfad je Verzeichnis - griffe `include` darauf zu,
356 // gehoerte das Verzeichnis nicht in diese Tabelle.
357 const doppelt = Object.keys(AUSSERHALB).filter((name) =>
358 (ABDECKUNG?.include ?? []).some((m) => passt(`${name}/beispiel.ts`, m)),
359 );
360 expect(doppelt, 'als Ausnahme gefuehrt und trotzdem von coverage.include erfasst').toEqual([]);
361 });
362
363 it('nennt genau eine eigene Ausnahme und laesst die Vorgaben von Vitest stehen', () => {
364 /*
365 * `coverage.exclude` ERSETZT die Vorgabeliste, sie ergaenzt sie nicht. Wer
366 * hier den Spreizoperator vergisst, nimmt unbemerkt `**\/*.d.ts` und die
367 * Testmuster wieder in die Messung auf.
368 */
369 const ausschluss = ABDECKUNG?.exclude ?? [];
370 for (const vorgabe of coverageConfigDefaults.exclude) {
371 expect(ausschluss, `Vorgabe "${vorgabe}" fehlt in coverage.exclude`).toContain(vorgabe);
372 }
373 const eigene = ausschluss.filter((m) => !coverageConfigDefaults.exclude.includes(m));
374 expect(eigene, 'eigene Ausnahmen der Messung').toEqual(['electron/preload.ts']);
375 });
376
377 it('laesst die Ausnahme nur gelten, solange preload.ts anderswo geprueft wird', () => {
378 /*
379 * Ohne diesen Fall waere die Ausnahme oben eine Beschoenigung: Man nimmt
380 * die schlecht gemessene Datei heraus, und die Zahl steigt. Sie ist nur
381 * zulaessig, weil zwei andere Wachen die Datei halten - eine am Quelltext,
382 * eine am gebauten Stand. Faellt eine davon weg, gehoert preload.ts
383 * zurueck in die Messung oder braucht einen neuen Ersatz.
384 */
385 const bruecke = readFileSync('tests/electron/brueckeVersion.test.ts', 'utf8');
386 expect(bruecke, 'brueckeVersion.test.ts liest preload.ts nicht mehr').toContain('preload.ts');
387
388 const rauchtest = readFileSync('e2e/rauchtest.spec.ts', 'utf8');
389 expect(rauchtest, 'der Rauchtest nennt preload.ts nicht mehr').toContain('preload.ts');
390 expect(rauchtest, 'der Rauchtest prueft die Bruecke nicht mehr').toContain(
391 'die Bruecke ist vollstaendig freigegeben',
392 );
393 expect(rauchtest, 'der Rauchtest liest window.lsaPlaner nicht mehr').toContain('lsaPlaner');
394 });
395 });
396
397 /* -------------------------------------------------------------------------
398 * 2. Die Schwellen
399 * ---------------------------------------------------------------------- */
400
401 /**
402 * Der Stand, auf den die Schwellen gesetzt worden sind (Fassung 5.13.0,
403 * gemessen mit `npm run test:coverage`).
404 *
405 * DIESE TABELLE IST DIE EIGENTLICHE WACHE. Eine Schwelle in `vitest.config.ts`
406 * faellt nur, wenn jemand mit `--coverage` faehrt; eine gesenkte Schwelle
407 * faellt dort NIE auf - sie macht den Lauf ja gerade wieder gruen. Hier steht
408 * dieselbe Zahl ein zweites Mal, und dieser Fall haelt beide gegeneinander:
409 * Anheben ist frei, Senken verlangt denselben Handgriff auch hier.
410 *
411 * Wer eine Zahl hier senkt, senkt die Pruefdichte des Erzeugnisses. Das ist
412 * kein Formfehler, sondern eine fachliche Entscheidung, und sie gehoert in die
413 * Commit-Botschaft.
414 */
415 const UNTERGRENZEN: Readonly<Record<string, Schwellensatz>> = {
416 global: { lines: 93.4, branches: 89.0, functions: 90.4 },
417 'src/domain/**': { lines: 96.3, branches: 91.4, functions: 97.1 },
418 'src/services/**': { lines: 95.7, branches: 89.8, functions: 97.6 },
419 'src/app/**': { lines: 94.4, branches: 91.8, functions: 93.7 },
420 'src/render/**': { lines: 99.1, branches: 92.5, functions: 99.9 },
421 'src/ui/**': { lines: 91.3, branches: 86.0, functions: 84.1 },
422 'src/platform/**': { lines: 66.5, branches: 49.9, functions: 49.9 },
423 'src/main.ts': { lines: 81.2, branches: 75.6, functions: 74.9 },
424 'shared/**': { lines: 99.9, branches: 99.9, functions: 99.9 },
425 'electron/**': { lines: 83.4, branches: 84.6, functions: 79.3 },
426 // Mit 5.43.0 gesenkt: Die Behebung der Zeichenrichtung nimmt dem Werkzeug
427 // den Hinweis "einfahrweg-null" und damit zwei Ausgabestellen (Begruendung
428 // in vitest.config.ts).
429 'tools/**': { lines: 98.9, branches: 88.5, functions: 99.9 },
430 };
431
432 describe('Abdeckungsmessung: die Schwellen', () => {
433 it('traegt jeder gemessene Bereich eine eigene Schwelle', () => {
434 /*
435 * Abgeleitet vom Baum, nicht abgeschrieben: Jedes unmittelbare
436 * Unterverzeichnis von `src/`, das Quelltext traegt, braucht einen eigenen
437 * Eintrag - sonst deckt der Durchschnitt der grossen Bereiche einen neuen,
438 * unpruefbaren zu. `src/styles/` traegt nur Stilblaetter und faellt hier
439 * heraus, ohne dass es aufgezaehlt werden muss.
440 */
441 const fehlend: string[] = [];
442 for (const eintrag of readdirSync('src', { withFileTypes: true })) {
443 if (!eintrag.isDirectory()) continue;
444 if (!traegtQuelltext(path.join('src', eintrag.name))) continue;
445 const glob = `src/${eintrag.name}/**`;
446 if (!GLOBS.includes(glob)) fehlend.push(glob);
447 }
448 for (const wurzel of ['shared', 'electron', 'tools']) {
449 const glob = `${wurzel}/**`;
450 if (!GLOBS.includes(glob)) fehlend.push(glob);
451 }
452 expect(fehlend, 'diese Bereiche werden gemessen, haben aber keine eigene Schwelle').toEqual([]);
453 });
454
455 it('zeigt jedes Muster der Schwellentabelle auf etwas, das gemessen wird', () => {
456 /*
457 * Ein Muster, das keine gemessene Datei trifft, ergibt eine leere
458 * Abdeckungskarte, und die zaehlt istanbul als 100 % - die Schwelle waere
459 * stumm immer gruen. Das entsteht auf zwei Wegen: durch einen Tippfehler im
460 * Muster, und durch ein zusammengestrichenes `coverage.include`. Im zweiten
461 * Fall gibt es das Verzeichnis weiterhin, gemessen wird es nicht mehr;
462 * geprueft wird deshalb nicht, ob das Muster im Baum etwas VORFINDET,
463 * sondern ob es etwas GEMESSENES trifft.
464 */
465 const gemessen = quelldateien('.').filter((datei) => wirdGemessen(datei));
466 const daneben = GLOBS.filter((glob) => !gemessen.some((datei) => passt(datei, glob)));
467 expect(daneben, 'diese Muster treffen keine gemessene Datei').toEqual([]);
468 });
469
470 it('spannt ein Auffangnetz ueber alles, was kein eigenes Muster hat', () => {
471 // Die globalen Werte gelten bei Vitest fuer ALLE gemessenen Dateien, auch
472 // fuer die von einem Muster getroffenen. Sie fangen damit eine neue Datei
473 // unmittelbar unter src/ ab, die noch keinen eigenen Eintrag hat.
474 expect(typeof SCHWELLEN['lines'], 'globale Schwelle fuer Zeilen').toBe('number');
475 expect(typeof SCHWELLEN['branches'], 'globale Schwelle fuer Zweige').toBe('number');
476 expect(typeof SCHWELLEN['functions'], 'globale Schwelle fuer Funktionen').toBe('number');
477 });
478
479 it('setzt keine Schwelle fuer Anweisungen neben der fuer Zeilen', () => {
480 /*
481 * Unter dem v8-Anbieter liefern `lines` und `statements` in jedem Bereich
482 * denselben Wert - v8-to-istanbul bildet je Anweisung genau einen
483 * Zeilenbereich. Eine zweite Zahl daneben bewachte dasselbe zweimal und
484 * gaebe vor, zwei Dinge zu messen.
485 */
486 expect(SCHWELLEN['statements'], 'globale Schwelle fuer Anweisungen').toBeUndefined();
487 for (const glob of GLOBS) {
488 expect(satz(glob).statements, `Anweisungsschwelle bei "${glob}"`).toBeUndefined();
489 }
490 });
491
492 it('setzt kein autoUpdate', () => {
493 // `autoUpdate` schreibt nach jedem Lauf in vitest.config.ts zurueck. Weil
494 // die Messung im pre-commit-Haken laeuft, veraenderte sie die Wache
495 // waehrend des Commits - und der Baum waere nach dem Lauf ein anderer als
496 // davor.
497 expect(SCHWELLEN['autoUpdate'], 'thresholds.autoUpdate').toBeFalsy();
498 });
499
500 it('fuehrt die Untergrenzen zu genau den Bereichen, die es gibt', () => {
501 // Ohne diesen Fall bewacht der naechste nur die Bereiche, die zufaellig in
502 // beiden Tabellen stehen: Ein neu aufgenommenes Muster ohne Untergrenze
503 // liefe ungeprueft mit.
504 expect(Object.keys(UNTERGRENZEN).sort()).toEqual(['global', ...GLOBS].sort());
505 });
506
507 it('steht keine Schwelle unter dem Stand, auf den sie gesetzt wurde', () => {
508 const gesenkt: string[] = [];
509 for (const [bereich, grenze] of Object.entries(UNTERGRENZEN)) {
510 const ist = bereich === 'global' ? (SCHWELLEN as Schwellensatz) : satz(bereich);
511 for (const art of ['lines', 'branches', 'functions'] as const) {
512 const soll = grenze[art];
513 if (soll === undefined) continue;
514 const wert = ist[art];
515 if (wert === undefined) {
516 gesenkt.push(`${bereich}/${art}: fehlt (war ${soll})`);
517 } else if (wert < soll) {
518 gesenkt.push(`${bereich}/${art}: ${wert} statt mindestens ${soll}`);
519 }
520 }
521 }
522 expect(
523 gesenkt,
524 'gesenkte Schwellen - wenn das beabsichtigt ist, gehoert dieselbe Zahl in UNTERGRENZEN und der Grund in die Commit-Botschaft',
525 ).toEqual([]);
526 });
527 });
528
529 /* -------------------------------------------------------------------------
530 * 3. Wird die Messung ueberhaupt gefahren?
531 * ---------------------------------------------------------------------- */
532
533 interface Paketdatei {
534 readonly scripts: Record<string, string>;
535 }
536
537 const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as Paketdatei;
538 const HAKEN = readFileSync('.githooks/pre-commit', 'utf8');
539
540 describe('Abdeckungsmessung: wird sie gefahren?', () => {
541 it('faehrt die Schnellstufe die Messung und nicht den blossen Testlauf', () => {
542 /*
543 * Der teure Teil ist der Testlauf, nicht die Messung: `npm run
544 * test:coverage` FAEHRT die Tests, es kommt keiner dazu. Der Zuwachs
545 * betraegt am Arbeitsplatz 18,6 s (34,6 s gegen 53,2 s), die ganze
546 * Schnellstufe damit 81,3 s statt 63,8 s. Dafuer faellt der
547 * Commit, der die Abdeckung senkt, dort, wo er entsteht - und nicht erst
548 * in der Vollstufe vor einer Veroeffentlichung, wenn niemand mehr weiss,
549 * welche Aenderung es war.
550 *
551 * Abgeleitet und nicht abgeschrieben: Gesucht wird der Teilbefehl, den
552 * gate:schnell fuer die Tests anwirft, und dann in dessen Befehlszeile
553 * nach `--coverage`.
554 */
555 const schnell = PAKET.scripts['gate:schnell'];
556 expect(schnell, 'kein Skript gate:schnell').toBeDefined();
557
558 const teilbefehle = [...(schnell ?? '').matchAll(/npm run ([\w:-]+)/g)].map((t) =>
559 String(t[1]),
560 );
561 expect(teilbefehle.length, 'gate:schnell ruft keine Teilbefehle').toBeGreaterThan(0);
562
563 const mitMessung = teilbefehle.filter((name) =>
564 (PAKET.scripts[name] ?? '').includes('--coverage'),
565 );
566 expect(
567 mitMessung,
568 'keine Stufe von gate:schnell faehrt vitest mit --coverage - die Schwellen bewachen dann nichts',
569 ).not.toEqual([]);
570 });
571
572 it('faehrt der pre-commit-Haken die Schnellstufe', () => {
573 // Das letzte Glied der Kette. Es steht auch in einer Pruefung der
574 // Unterlagen; hier gehoert es dazu, weil ohne dieses Glied die Zusage des
575 // Falls darueber nicht traegt.
576 expect(HAKEN, 'der Haken faehrt gate:schnell nicht').toContain('npm run gate:schnell');
577 });
578
579 it('faengt .gitignore die Ablage der Messung ab', () => {
580 // Die Messung laeuft jetzt bei jedem Commit und legt `coverage/` an. Bliebe
581 // das im git status stehen, wanderte es frueher oder spaeter versehentlich
582 // in einen Commit.
583 const lauf = spawnSync('git', ['check-ignore', '--no-index', '--stdin'], {
584 cwd: process.cwd(),
585 input: 'coverage/index.html\ncoverage/coverage-final.json\n',
586 encoding: 'utf8',
587 });
588 expect([0, 1], `git check-ignore: ${lauf.stderr}`).toContain(lauf.status);
589 const abgefangen = lauf.stdout
590 .split('\n')
591 .map((z) => z.trim().replace(/\\/g, '/'))
592 .filter((z) => z !== '');
593 expect(abgefangen.sort(), 'die Ablage der Messung steht im git status').toEqual([
594 'coverage/coverage-final.json',
595 'coverage/index.html',
596 ]);
597 });
598 });