import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; /* * DIE AUSLIEFERUNGSGROESSE IM FREMDBESTANDTEILEVERZEICHNIS - am Paket gemessen. * * `docs/fremdbestandteile.md` geht ueber `build.extraFiles` woertlich als * FREMDBESTANDTEILE.md an den Anwender. Es sagt ihm unter anderem, wie gross * die Ausgabedateien sind, die er bekommt. Diese Zahl ist zweimal gewandert: * * - Zu 5.13.0 stand dort die Spalte MIT Banner, waehrend das Paket ohne ihn * ausgeliefert wurde. * - Danach stand dort die Spalte OHNE Banner mit dem Zusatz, die andere stehe * "noch in keinem Paket". Seit 5.14.0 geht der Banner mit jedem Paket mit, * und 5.15.0 traegt zusaetzlich den eingebundenen Lizenzwortlaut des * "Über"-Fensters: Das Paket fuehrt seither KEINE der beiden Spalten. * * Beide Male ist die Zahl an einer Tafel im selben Papier haengengeblieben, * statt am Paket gemessen zu werden. Deshalb misst dieser Fall dort, wo die * Antwort steht: im `app.asar` des gebauten Standes. Die innere Rechnung des * Papiers - Tafel, Nachtrag und Satz muessen dieselbe Zahl fuehren - bewacht * eine eigene Pruefung; sie laeuft auch ohne Paket. * * EIN DRITTES MAL, AM 07.09.2026 - und wieder derselbe Schluss von der Tafel * statt vom Paket. Dieselbe Stelle behauptete danach, die Spalte MIT Banner sei * "nie eine Auslieferungsgroesse" gewesen, weil sie an einem Stand ohne das * "Über"-Fenster entstanden ist. Gemessen ist das Gegenteil: Der `app.asar` des * ausgelieferten 5.14.0 fuehrt Datei fuer Datei genau diese Spalte * (1 495 988 Byte), der des ausgelieferten 5.13.0 genau die Spalte OHNE Banner * (1 447 769 Byte). Beide Spalten sind Auslieferungsgroessen, nur eben die * frueherer Pakete. Der Kopf davor - "Das Paket fuehrt seither KEINE der beiden * Spalten" - bleibt richtig: Er spricht vom HEUTIGEN Paket. * * WAS DARAUS FUER DIESE DATEI FOLGT: Eine Aussage ueber ein frueheres Paket * gehoert an dieses Paket gehalten, nicht an den Text daneben. Gemessen wurde * sie mit Fassung 5.16.0 an den Programmdateien 5.13.0 und 5.14.0 - das sind * die beiden Summen oben. Am 18.09.2026 liegen diese Programmdateien nicht * mehr vor, ein Klon dieses Quelltexts hatte sie nie, und ohne sie laesst * sich die Messung nicht wiederholen. Hier steht deshalb, was sich ohne sie pruefen * laesst: dass das Papier jeder Spalte der Tafel eine Fassung zuschreibt. * * OHNE BAUERGEBNIS IST NICHTS ZU MESSEN, und der Fall ueberspringt sich dann * sichtbar. Kein Rueckfall auf eine schwaechere Zusicherung: Ein Fall, der ohne * Paket auf die Tafel im selben Papier ausweicht, prueft den Text an sich * selbst. Uebersprungen wird auch, wenn im `release`-Verzeichnis eine ANDERE * Fassung liegt als die, die `package.json` fuehrt - dann sagt die Messung * nichts ueber den Satz, der die heutige Fassung beschreibt. */ interface AsarKnoten { readonly files?: Readonly>; readonly size?: number; } const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string }; const ASAR = path.join('release', 'win-unpacked', 'resources', 'app.asar'); /** * Der Kopf eines asar-Archivs als Baum. * * Das Format: vier Laengenfelder zu je vier Byte, danach die Kopf-JSON. Das * vierte Feld nennt ihre Laenge. Gebraucht werden hier nur die Groessen, keine * Inhalte - die Ablagestellen dahinter bleiben ungelesen. */ function asarKopf(datei: string): AsarKnoten { const griff = openSync(datei, 'r'); try { const laengen = Buffer.alloc(16); readSync(griff, laengen, 0, 16, 0); const jsonLaenge = laengen.readUInt32LE(12); const roh = Buffer.alloc(jsonLaenge); readSync(griff, roh, 0, jsonLaenge, 16); return JSON.parse(roh.toString('utf8').replace(/\0+$/, '')) as AsarKnoten; } finally { closeSync(griff); } } /** Ein Knoten des Baumes ueber seinen Pfad, oder undefined. */ function knoten(baum: AsarKnoten, pfad: readonly string[]): AsarKnoten | undefined { let hier: AsarKnoten | undefined = baum; for (const teil of pfad) { hier = hier?.files?.[teil]; } return hier; } const KOPF = existsSync(ASAR) ? asarKopf(ASAR) : null; const OHNE_PAKET = KOPF === null; /** Die Ausgabedateien des Renderers in einem asar-Kopf, Name auf Groesse. */ function ausgabedateienAus(kopf: AsarKnoten): Map { const ordner = knoten(kopf, ['build', 'renderer', 'assets']); expect(ordner, 'im app.asar liegt kein build/renderer/assets').toBeDefined(); const gefunden = new Map(); for (const [name, eintrag] of Object.entries(ordner?.files ?? {})) { if (name.endsWith('.js')) gefunden.set(name, Number(eintrag.size)); } return gefunden; } /** Die Ausgabedateien des Renderers im Paket, Name auf Groesse. */ function ausgabedateien(): Map { expect(KOPF, 'kein app.asar gelesen').not.toBeNull(); return ausgabedateienAus(KOPF ?? {}); } /** Tausenderabstand, wie die Unterlagen ihn setzen: 1 529 104, nicht 1529104. */ function mitAbstand(zahl: number): string { return String(zahl).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } /* ------------------------------------------------------------------------- * Die frueheren Pakete - die Zuordnung Spalte -> Fassung * ---------------------------------------------------------------------- */ const VERZEICHNIS = readFileSync('docs/fremdbestandteile.md', 'utf8'); /** Was das Papier behauptet: je Spalte der Groessentafel eine Fassung. */ function behaupteteZuordnung(): { spalte: string; fassung: string }[] { const treffer = VERZEICHNIS.replace(/\s+/g, ' ').matchAll( /Spalte „([^"]+)" ist die Auslieferungsgröße von (\d+\.\d+\.\d+)/g, ); return [...treffer].map((t) => ({ spalte: String(t[1]), fassung: String(t[2]) })); } const ZUORDNUNG = behaupteteZuordnung(); /** Die Groessentafel unter „Was das kostet": Spaltenname -> Dateiname -> Byte. */ function tafel(): Map> { const kopfzeile = '| Ausgabedatei | ohne Banner | mit Banner | Zuwachs |'; const anfang = VERZEICHNIS.indexOf(kopfzeile); expect(anfang, 'die Groessentafel fehlt').toBeGreaterThanOrEqual(0); const spalten = kopfzeile .split('|') .map((s) => s.trim()) .slice(2, 5); const gefunden = new Map>(spalten.map((s) => [s, new Map()])); for (const zeile of VERZEICHNIS.slice(anfang).split('\n').slice(1)) { if (!zeile.startsWith('|')) break; const felder = zeile.split('|').map((s) => s.trim()); const name = String(felder[1]).replace(/`/g, ''); if (name.startsWith('---') || name === 'Summe') continue; spalten.forEach((spalte, i) => { const roh = String(felder[i + 2]).replace(/\s/g, ''); if (/^\d+$/.test(roh)) gefunden.get(spalte)?.set(name, Number(roh)); }); } return gefunden; } describe('Die Auslieferungsgroesse im Verzeichnis stimmt mit dem Paket ueberein', () => { it('nennt das Verzeichnis ueberhaupt eine Auslieferungsgroesse', () => { // Der einzige Fall, der auch ohne Paket greift - er misst nur, dass der // Satz noch dasteht, an dem die drei darunter haengen. const text = readFileSync('docs/fremdbestandteile.md', 'utf8'); expect(text, 'der Satz zur Auslieferungsgroesse fehlt').toContain( 'Keine dieser drei Zahlen ist die Größe des ausgelieferten Pakets', ); }); it.skipIf(OHNE_PAKET)( 'liegen im Paket genau die fuenf Ausgabedateien der Tafel (nur mit Bauergebnis messbar)', () => { // Die Tafel im Papier fuehrt fuenf Zeilen. Kaeme eine sechste Datei // hinzu, waere die Summe unten richtig gerechnet und die Tafel trotzdem // unvollstaendig. expect(ausgabedateien().size, 'Ausgabedateien in build/renderer/assets').toBe(5); }, ); it.skipIf(OHNE_PAKET)( 'nennt der Satz die Summe, die das Paket wirklich fuehrt (nur mit Bauergebnis messbar)', () => { const summe = [...ausgabedateien().values()].reduce((a, b) => a + b, 0); expect(summe, 'Summe der Ausgabedateien im Paket').toBeGreaterThan(0); const text = readFileSync('docs/fremdbestandteile.md', 'utf8'); const anfang = text.indexOf('Keine dieser drei Zahlen ist die Größe'); const satz = text.slice(anfang, text.indexOf('\n\n', anfang)).replace(/\s+/g, ' '); expect( satz, `das Paket fuehrt ${mitAbstand(summe)} Byte; der Satz nennt eine andere Zahl`, ).toContain(mitAbstand(summe)); }, ); it.skipIf(OHNE_PAKET)( 'stammt die gemessene Summe aus dem Stand, den package.json fuehrt (nur mit Bauergebnis messbar)', () => { /* * Die Gegenprobe zur Messung darueber: Sie taugt nur, solange im * `release`-Verzeichnis dieselbe Fassung liegt, die der Satz beschreibt. * Der Kopf des Archivs nennt keine Fassung - er fuehrt nur Namen, * Groessen und Ablagestellen. Nachgesehen wird deshalb an der * Programmdatei daneben: electron-builder legt sie unter der * Fassungsnummer ab. */ const paketdatei = path.join('release', `LSA-Planer Professional ${PAKET.version}.exe`); expect( existsSync(paketdatei), `im release-Verzeichnis liegt kein Paket zu ${PAKET.version}`, ).toBe(true); }, ); it('nennt der Kopfkommentar so viele uebersprungene Faelle, wie die Datei fuehrt', () => { // Dieselbe Vorsorge wie in tests/tools/eigeneLizenzImPaket.test.ts: Ein // uebersprungener Fall sieht im Bericht wie ein ausgelassener aus, wenn er // den Grund nicht im Namen traegt. Kommt eine zweite Bedingung hinzu, // braucht sie hier ihren eigenen Grund, sonst traegt ein Fall den Grund // des anderen. const grundJeBedingung = new Map([['OHNE_PAKET', '(nur mit Bauergebnis messbar)']]); const quelle = readFileSync('tests/tools/auslieferungsgroesse.test.ts', 'utf8'); const titel = [...quelle.matchAll(/it\.skipIf\((\w+)\)\(\s*'([^']*)'/g)]; expect(titel.length, 'kein uebersprungener Fall gefunden').toBeGreaterThan(0); for (const [, bedingung, name] of titel) { const grund = grundJeBedingung.get(String(bedingung)); expect(grund, `zur Bedingung ${String(bedingung)} steht hier kein Grund`).toBeDefined(); expect(String(name), `"${String(name)}" sagt den Grund des Ueberspringens nicht`).toContain( String(grund), ); } for (const bedingung of grundJeBedingung.keys()) { expect( titel.some(([, b]) => String(b) === bedingung), `die Bedingung ${bedingung} wird nirgends mehr benutzt`, ).toBe(true); } }); }); describe('Das Verzeichnis ordnet jede Spalte der Groessentafel einer Fassung zu', () => { /* * HIER LAG DER DRITTE ANLAUF: Das Papier behauptete, die Spalte MIT Banner * sei nie eine Auslieferungsgroesse gewesen. Sie ist es - die von 5.14.0. * Eine Aussage ueber ein frueheres Paket laesst sich an diesem Paket messen, * solange es vorliegt. An 5.13.0 und 5.14.0 ist das mit Fassung 5.16.0 * geschehen; beide Pakete liegen nicht mehr vor, und ohne sie ist die * Messung nicht zu wiederholen. Hier steht, was bleibt: Das Papier nennt zu jeder Spalte * die Fassung, an der sie gemessen wurde. */ it('behauptet das Verzeichnis fuer jede Spalte der Tafel eine Fassung', () => { // Ohne diesen Fall liefe die Messung an den Paketen ueber eine leere Liste // und waere gruen, sobald die Aussage aus dem Papier verschwindet. const spalten = [...tafel().keys()].filter((s) => s !== 'Zuwachs'); expect(spalten, 'die Tafel fuehrt andere Spalten als erwartet').toEqual([ 'ohne Banner', 'mit Banner', ]); expect( ZUORDNUNG.map((z) => z.spalte), 'das Papier schreibt nicht mehr jeder Spalte eine Fassung zu', ).toEqual(spalten); }); });