lsa-planer

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

/ tests tools auslieferungsgroesse.test.ts

11,6 KB Rohdatei
tests/tools/auslieferungsgroesse.test.ts — 260 Zeilen
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 });