lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests tools auslieferungsgroesse.test.ts
| 1 | import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs'; |
| 2 | import path from 'node:path'; |
| 3 | import { describe, expect, it } from 'vitest'; |
| 4 | |
| 5 | /* |
| 6 | * DIE AUSLIEFERUNGSGROESSE IM FREMDBESTANDTEILEVERZEICHNIS - am Paket gemessen. |
| 7 | * |
| 8 | * `docs/fremdbestandteile.md` geht ueber `build.extraFiles` woertlich als |
| 9 | * FREMDBESTANDTEILE.md an den Anwender. Es sagt ihm unter anderem, wie gross |
| 10 | * die Ausgabedateien sind, die er bekommt. Diese Zahl ist zweimal gewandert: |
| 11 | * |
| 12 | * - Zu 5.13.0 stand dort die Spalte MIT Banner, waehrend das Paket ohne ihn |
| 13 | * ausgeliefert wurde. |
| 14 | * - Danach stand dort die Spalte OHNE Banner mit dem Zusatz, die andere stehe |
| 15 | * "noch in keinem Paket". Seit 5.14.0 geht der Banner mit jedem Paket mit, |
| 16 | * und 5.15.0 traegt zusaetzlich den eingebundenen Lizenzwortlaut des |
| 17 | * "Über"-Fensters: Das Paket fuehrt seither KEINE der beiden Spalten. |
| 18 | * |
| 19 | * Beide Male ist die Zahl an einer Tafel im selben Papier haengengeblieben, |
| 20 | * statt am Paket gemessen zu werden. Deshalb misst dieser Fall dort, wo die |
| 21 | * Antwort steht: im `app.asar` des gebauten Standes. Die innere Rechnung des |
| 22 | * Papiers - Tafel, Nachtrag und Satz muessen dieselbe Zahl fuehren - bewacht |
| 23 | * eine eigene Pruefung; sie laeuft auch ohne Paket. |
| 24 | * |
| 25 | * EIN DRITTES MAL, AM 07.09.2026 - und wieder derselbe Schluss von der Tafel |
| 26 | * statt vom Paket. Dieselbe Stelle behauptete danach, die Spalte MIT Banner sei |
| 27 | * "nie eine Auslieferungsgroesse" gewesen, weil sie an einem Stand ohne das |
| 28 | * "Über"-Fenster entstanden ist. Gemessen ist das Gegenteil: Der `app.asar` des |
| 29 | * ausgelieferten 5.14.0 fuehrt Datei fuer Datei genau diese Spalte |
| 30 | * (1 495 988 Byte), der des ausgelieferten 5.13.0 genau die Spalte OHNE Banner |
| 31 | * (1 447 769 Byte). Beide Spalten sind Auslieferungsgroessen, nur eben die |
| 32 | * frueherer Pakete. Der Kopf davor - "Das Paket fuehrt seither KEINE der beiden |
| 33 | * Spalten" - bleibt richtig: Er spricht vom HEUTIGEN Paket. |
| 34 | * |
| 35 | * WAS DARAUS FUER DIESE DATEI FOLGT: Eine Aussage ueber ein frueheres Paket |
| 36 | * gehoert an dieses Paket gehalten, nicht an den Text daneben. Gemessen wurde |
| 37 | * sie mit Fassung 5.16.0 an den Programmdateien 5.13.0 und 5.14.0 - das sind |
| 38 | * die beiden Summen oben. Am 18.09.2026 liegen diese Programmdateien nicht |
| 39 | * mehr vor, ein Klon dieses Quelltexts hatte sie nie, und ohne sie laesst |
| 40 | * sich die Messung nicht wiederholen. Hier steht deshalb, was sich ohne sie pruefen |
| 41 | * laesst: dass das Papier jeder Spalte der Tafel eine Fassung zuschreibt. |
| 42 | * |
| 43 | * OHNE BAUERGEBNIS IST NICHTS ZU MESSEN, und der Fall ueberspringt sich dann |
| 44 | * sichtbar. Kein Rueckfall auf eine schwaechere Zusicherung: Ein Fall, der ohne |
| 45 | * Paket auf die Tafel im selben Papier ausweicht, prueft den Text an sich |
| 46 | * selbst. Uebersprungen wird auch, wenn im `release`-Verzeichnis eine ANDERE |
| 47 | * Fassung liegt als die, die `package.json` fuehrt - dann sagt die Messung |
| 48 | * nichts ueber den Satz, der die heutige Fassung beschreibt. |
| 49 | */ |
| 50 | |
| 51 | interface AsarKnoten { |
| 52 | readonly files?: Readonly<Record<string, AsarKnoten>>; |
| 53 | readonly size?: number; |
| 54 | } |
| 55 | |
| 56 | const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string }; |
| 57 | |
| 58 | const ASAR = path.join('release', 'win-unpacked', 'resources', 'app.asar'); |
| 59 | |
| 60 | /** |
| 61 | * Der Kopf eines asar-Archivs als Baum. |
| 62 | * |
| 63 | * Das Format: vier Laengenfelder zu je vier Byte, danach die Kopf-JSON. Das |
| 64 | * vierte Feld nennt ihre Laenge. Gebraucht werden hier nur die Groessen, keine |
| 65 | * Inhalte - die Ablagestellen dahinter bleiben ungelesen. |
| 66 | */ |
| 67 | function asarKopf(datei: string): AsarKnoten { |
| 68 | const griff = openSync(datei, 'r'); |
| 69 | try { |
| 70 | const laengen = Buffer.alloc(16); |
| 71 | readSync(griff, laengen, 0, 16, 0); |
| 72 | const jsonLaenge = laengen.readUInt32LE(12); |
| 73 | const roh = Buffer.alloc(jsonLaenge); |
| 74 | readSync(griff, roh, 0, jsonLaenge, 16); |
| 75 | return JSON.parse(roh.toString('utf8').replace(/\0+$/, '')) as AsarKnoten; |
| 76 | } finally { |
| 77 | closeSync(griff); |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /** Ein Knoten des Baumes ueber seinen Pfad, oder undefined. */ |
| 82 | function knoten(baum: AsarKnoten, pfad: readonly string[]): AsarKnoten | undefined { |
| 83 | let hier: AsarKnoten | undefined = baum; |
| 84 | for (const teil of pfad) { |
| 85 | hier = hier?.files?.[teil]; |
| 86 | } |
| 87 | return hier; |
| 88 | } |
| 89 | |
| 90 | const KOPF = existsSync(ASAR) ? asarKopf(ASAR) : null; |
| 91 | |
| 92 | const OHNE_PAKET = KOPF === null; |
| 93 | |
| 94 | /** Die Ausgabedateien des Renderers in einem asar-Kopf, Name auf Groesse. */ |
| 95 | function ausgabedateienAus(kopf: AsarKnoten): Map<string, number> { |
| 96 | const ordner = knoten(kopf, ['build', 'renderer', 'assets']); |
| 97 | expect(ordner, 'im app.asar liegt kein build/renderer/assets').toBeDefined(); |
| 98 | const gefunden = new Map<string, number>(); |
| 99 | for (const [name, eintrag] of Object.entries(ordner?.files ?? {})) { |
| 100 | if (name.endsWith('.js')) gefunden.set(name, Number(eintrag.size)); |
| 101 | } |
| 102 | return gefunden; |
| 103 | } |
| 104 | |
| 105 | /** Die Ausgabedateien des Renderers im Paket, Name auf Groesse. */ |
| 106 | function ausgabedateien(): Map<string, number> { |
| 107 | expect(KOPF, 'kein app.asar gelesen').not.toBeNull(); |
| 108 | return ausgabedateienAus(KOPF ?? {}); |
| 109 | } |
| 110 | |
| 111 | /** Tausenderabstand, wie die Unterlagen ihn setzen: 1 529 104, nicht 1529104. */ |
| 112 | function mitAbstand(zahl: number): string { |
| 113 | return String(zahl).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); |
| 114 | } |
| 115 | |
| 116 | /* ------------------------------------------------------------------------- |
| 117 | * Die frueheren Pakete - die Zuordnung Spalte -> Fassung |
| 118 | * ---------------------------------------------------------------------- */ |
| 119 | |
| 120 | const VERZEICHNIS = readFileSync('docs/fremdbestandteile.md', 'utf8'); |
| 121 | |
| 122 | /** Was das Papier behauptet: je Spalte der Groessentafel eine Fassung. */ |
| 123 | function behaupteteZuordnung(): { spalte: string; fassung: string }[] { |
| 124 | const treffer = VERZEICHNIS.replace(/\s+/g, ' ').matchAll( |
| 125 | /Spalte „([^"]+)" ist die Auslieferungsgröße von (\d+\.\d+\.\d+)/g, |
| 126 | ); |
| 127 | return [...treffer].map((t) => ({ spalte: String(t[1]), fassung: String(t[2]) })); |
| 128 | } |
| 129 | |
| 130 | const ZUORDNUNG = behaupteteZuordnung(); |
| 131 | |
| 132 | /** Die Groessentafel unter „Was das kostet": Spaltenname -> Dateiname -> Byte. */ |
| 133 | function tafel(): Map<string, Map<string, number>> { |
| 134 | const kopfzeile = '| Ausgabedatei | ohne Banner | mit Banner | Zuwachs |'; |
| 135 | const anfang = VERZEICHNIS.indexOf(kopfzeile); |
| 136 | expect(anfang, 'die Groessentafel fehlt').toBeGreaterThanOrEqual(0); |
| 137 | const spalten = kopfzeile |
| 138 | .split('|') |
| 139 | .map((s) => s.trim()) |
| 140 | .slice(2, 5); |
| 141 | const gefunden = new Map<string, Map<string, number>>(spalten.map((s) => [s, new Map()])); |
| 142 | for (const zeile of VERZEICHNIS.slice(anfang).split('\n').slice(1)) { |
| 143 | if (!zeile.startsWith('|')) break; |
| 144 | const felder = zeile.split('|').map((s) => s.trim()); |
| 145 | const name = String(felder[1]).replace(/`/g, ''); |
| 146 | if (name.startsWith('---') || name === 'Summe') continue; |
| 147 | spalten.forEach((spalte, i) => { |
| 148 | const roh = String(felder[i + 2]).replace(/\s/g, ''); |
| 149 | if (/^\d+$/.test(roh)) gefunden.get(spalte)?.set(name, Number(roh)); |
| 150 | }); |
| 151 | } |
| 152 | return gefunden; |
| 153 | } |
| 154 | |
| 155 | describe('Die Auslieferungsgroesse im Verzeichnis stimmt mit dem Paket ueberein', () => { |
| 156 | it('nennt das Verzeichnis ueberhaupt eine Auslieferungsgroesse', () => { |
| 157 | // Der einzige Fall, der auch ohne Paket greift - er misst nur, dass der |
| 158 | // Satz noch dasteht, an dem die drei darunter haengen. |
| 159 | const text = readFileSync('docs/fremdbestandteile.md', 'utf8'); |
| 160 | expect(text, 'der Satz zur Auslieferungsgroesse fehlt').toContain( |
| 161 | 'Keine dieser drei Zahlen ist die Größe des ausgelieferten Pakets', |
| 162 | ); |
| 163 | }); |
| 164 | |
| 165 | it.skipIf(OHNE_PAKET)( |
| 166 | 'liegen im Paket genau die fuenf Ausgabedateien der Tafel (nur mit Bauergebnis messbar)', |
| 167 | () => { |
| 168 | // Die Tafel im Papier fuehrt fuenf Zeilen. Kaeme eine sechste Datei |
| 169 | // hinzu, waere die Summe unten richtig gerechnet und die Tafel trotzdem |
| 170 | // unvollstaendig. |
| 171 | expect(ausgabedateien().size, 'Ausgabedateien in build/renderer/assets').toBe(5); |
| 172 | }, |
| 173 | ); |
| 174 | |
| 175 | it.skipIf(OHNE_PAKET)( |
| 176 | 'nennt der Satz die Summe, die das Paket wirklich fuehrt (nur mit Bauergebnis messbar)', |
| 177 | () => { |
| 178 | const summe = [...ausgabedateien().values()].reduce((a, b) => a + b, 0); |
| 179 | expect(summe, 'Summe der Ausgabedateien im Paket').toBeGreaterThan(0); |
| 180 | |
| 181 | const text = readFileSync('docs/fremdbestandteile.md', 'utf8'); |
| 182 | const anfang = text.indexOf('Keine dieser drei Zahlen ist die Größe'); |
| 183 | const satz = text.slice(anfang, text.indexOf('\n\n', anfang)).replace(/\s+/g, ' '); |
| 184 | expect( |
| 185 | satz, |
| 186 | `das Paket fuehrt ${mitAbstand(summe)} Byte; der Satz nennt eine andere Zahl`, |
| 187 | ).toContain(mitAbstand(summe)); |
| 188 | }, |
| 189 | ); |
| 190 | |
| 191 | it.skipIf(OHNE_PAKET)( |
| 192 | 'stammt die gemessene Summe aus dem Stand, den package.json fuehrt (nur mit Bauergebnis messbar)', |
| 193 | () => { |
| 194 | /* |
| 195 | * Die Gegenprobe zur Messung darueber: Sie taugt nur, solange im |
| 196 | * `release`-Verzeichnis dieselbe Fassung liegt, die der Satz beschreibt. |
| 197 | * Der Kopf des Archivs nennt keine Fassung - er fuehrt nur Namen, |
| 198 | * Groessen und Ablagestellen. Nachgesehen wird deshalb an der |
| 199 | * Programmdatei daneben: electron-builder legt sie unter der |
| 200 | * Fassungsnummer ab. |
| 201 | */ |
| 202 | const paketdatei = path.join('release', `LSA-Planer Professional ${PAKET.version}.exe`); |
| 203 | expect( |
| 204 | existsSync(paketdatei), |
| 205 | `im release-Verzeichnis liegt kein Paket zu ${PAKET.version}`, |
| 206 | ).toBe(true); |
| 207 | }, |
| 208 | ); |
| 209 | |
| 210 | it('nennt der Kopfkommentar so viele uebersprungene Faelle, wie die Datei fuehrt', () => { |
| 211 | // Dieselbe Vorsorge wie in tests/tools/eigeneLizenzImPaket.test.ts: Ein |
| 212 | // uebersprungener Fall sieht im Bericht wie ein ausgelassener aus, wenn er |
| 213 | // den Grund nicht im Namen traegt. Kommt eine zweite Bedingung hinzu, |
| 214 | // braucht sie hier ihren eigenen Grund, sonst traegt ein Fall den Grund |
| 215 | // des anderen. |
| 216 | const grundJeBedingung = new Map([['OHNE_PAKET', '(nur mit Bauergebnis messbar)']]); |
| 217 | const quelle = readFileSync('tests/tools/auslieferungsgroesse.test.ts', 'utf8'); |
| 218 | const titel = [...quelle.matchAll(/it\.skipIf\((\w+)\)\(\s*'([^']*)'/g)]; |
| 219 | expect(titel.length, 'kein uebersprungener Fall gefunden').toBeGreaterThan(0); |
| 220 | for (const [, bedingung, name] of titel) { |
| 221 | const grund = grundJeBedingung.get(String(bedingung)); |
| 222 | expect(grund, `zur Bedingung ${String(bedingung)} steht hier kein Grund`).toBeDefined(); |
| 223 | expect(String(name), `"${String(name)}" sagt den Grund des Ueberspringens nicht`).toContain( |
| 224 | String(grund), |
| 225 | ); |
| 226 | } |
| 227 | for (const bedingung of grundJeBedingung.keys()) { |
| 228 | expect( |
| 229 | titel.some(([, b]) => String(b) === bedingung), |
| 230 | `die Bedingung ${bedingung} wird nirgends mehr benutzt`, |
| 231 | ).toBe(true); |
| 232 | } |
| 233 | }); |
| 234 | }); |
| 235 | |
| 236 | describe('Das Verzeichnis ordnet jede Spalte der Groessentafel einer Fassung zu', () => { |
| 237 | /* |
| 238 | * HIER LAG DER DRITTE ANLAUF: Das Papier behauptete, die Spalte MIT Banner |
| 239 | * sei nie eine Auslieferungsgroesse gewesen. Sie ist es - die von 5.14.0. |
| 240 | * Eine Aussage ueber ein frueheres Paket laesst sich an diesem Paket messen, |
| 241 | * solange es vorliegt. An 5.13.0 und 5.14.0 ist das mit Fassung 5.16.0 |
| 242 | * geschehen; beide Pakete liegen nicht mehr vor, und ohne sie ist die |
| 243 | * Messung nicht zu wiederholen. Hier steht, was bleibt: Das Papier nennt zu jeder Spalte |
| 244 | * die Fassung, an der sie gemessen wurde. |
| 245 | */ |
| 246 | |
| 247 | it('behauptet das Verzeichnis fuer jede Spalte der Tafel eine Fassung', () => { |
| 248 | // Ohne diesen Fall liefe die Messung an den Paketen ueber eine leere Liste |
| 249 | // und waere gruen, sobald die Aussage aus dem Papier verschwindet. |
| 250 | const spalten = [...tafel().keys()].filter((s) => s !== 'Zuwachs'); |
| 251 | expect(spalten, 'die Tafel fuehrt andere Spalten als erwartet').toEqual([ |
| 252 | 'ohne Banner', |
| 253 | 'mit Banner', |
| 254 | ]); |
| 255 | expect( |
| 256 | ZUORDNUNG.map((z) => z.spalte), |
| 257 | 'das Papier schreibt nicht mehr jeder Spalte eine Fassung zu', |
| 258 | ).toEqual(spalten); |
| 259 | }); |
| 260 | }); |