lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests tools abdeckungsmessung.test.ts
| 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 | }); |