// @vitest-environment node /** * Das Programmsymbol muss zur Zeichnung passen. * * `build-resources/symbol.svg` ist das Original, `icon.ico` und `icon.png` * sind daraus erzeugt (`npm run symbol`). Erzeugte Dateien haben in diesem * Projekt eine eigene Fehlerart: Jemand ändert die Quelle, vergisst den * Aufruf, und alles bleibt grün – die Anwendung zeigt nur weiter das alte * Bild. Genau das fängt die Prüfsumme in `symbol.json` ab. * * Geprüft wird außerdem die Form der ICO-Datei. Sie wird hier von Hand * zusammengesetzt, ohne Fremdprogramm; ein Fehler im Behälter fiele sonst * erst auf, wenn Windows das Symbol nicht anzeigt – und das sieht niemand, * der die Anwendung nur baut. */ import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; const app = join(fileURLToPath(new URL('..', import.meta.url))); const ordner = join(app, 'build-resources'); const QUELLE = join(ordner, 'symbol.svg'); const BUCH = join(ordner, 'symbol.json'); const ICO = join(ordner, 'icon.ico'); const PNG = join(ordner, 'icon.png'); /** Die Store-Grafiken; electron-builder sucht sie genau hier. */ const APPX = join(ordner, 'appx'); interface Buchung { quelle: string; pruefsumme: string; groessen: number[]; pngGroesse: number; kacheln: { datei: string; breite: number; hoehe: number }[]; } /** Ein Eintrag im Verzeichnis einer ICO-Datei. */ interface Symboleintrag { breite: number; hoehe: number; bitPunkt: number; laenge: number; versatz: number; /** `true`, wenn die Bilddaten ein PNG sind statt eines BMP. */ istPng: boolean; } /** * Liest das Verzeichnis einer ICO-Datei. * * Bewusst von Hand statt mit einer Bibliothek: Das Format ist ein Kopf und * eine Tabelle, und eine Abhängigkeit, die nur ein Testfall braucht, müsste * im Gate mitgeprüft und in der Lizenzübersicht geführt werden. */ function icoLesen(daten: Buffer): Symboleintrag[] { expect(daten.readUInt16LE(0), 'ICO: erstes Feld muss 0 sein').toBe(0); expect(daten.readUInt16LE(2), 'ICO: Typ 1 steht für ein Symbol').toBe(1); const anzahl = daten.readUInt16LE(4); const eintraege: Symboleintrag[] = []; for (let i = 0; i < anzahl; i += 1) { const p = 6 + i * 16; const versatz = daten.readUInt32LE(p + 12); const laenge = daten.readUInt32LE(p + 8); eintraege.push({ // 0 steht für 256 – ein Byte fasst die Zahl nicht. breite: daten.readUInt8(p) === 0 ? 256 : daten.readUInt8(p), hoehe: daten.readUInt8(p + 1) === 0 ? 256 : daten.readUInt8(p + 1), bitPunkt: daten.readUInt16LE(p + 6), laenge, versatz, istPng: daten.readUInt32BE(versatz) === 0x89504e47, }); } return eintraege; } describe('Programmsymbol', () => { it('hat eine Zeichnung als Original', () => { expect(existsSync(QUELLE), 'build-resources/symbol.svg fehlt.').toBe(true); const svg = readFileSync(QUELLE, 'utf8'); expect(svg).toContain(' { /* Ein Symbol in einer Farbe, die sonst nirgends vorkommt, fällt beim nächsten Wechsel des Farbschemas aus dem Rahmen – und niemand sucht danach in einer SVG-Datei. */ const tokens = readFileSync( join(app, 'src', 'renderer', 'src', 'styles', 'tokens.css'), 'utf8', ); const akzent = /--farbe-akzent:\s*(#[0-9a-f]{6})/iu.exec(tokens)?.[1]; expect(akzent, 'tokens.css: --farbe-akzent nicht gefunden.').toBeDefined(); expect( readFileSync(QUELLE, 'utf8').toLowerCase(), `symbol.svg führt nicht die Akzentfarbe ${String(akzent)}.`, ).toContain((akzent ?? '').toLowerCase()); }); it('ist so erzeugt worden, wie die Zeichnung jetzt aussieht', () => { /* Die eigentliche Zusage. Ohne sie zeigt die Anwendung nach einer Änderung an der Zeichnung weiter das alte Bild, und alles bleibt grün. */ expect(existsSync(BUCH), 'build-resources/symbol.json fehlt.').toBe(true); const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; const jetzt = createHash('sha256').update(readFileSync(QUELLE, 'utf8'), 'utf8').digest('hex'); expect( buch.pruefsumme, 'build-resources/symbol.svg wurde geändert, aber icon.ico und icon.png ' + 'stammen noch von der vorigen Fassung. Neu erzeugen mit: npm run symbol', ).toBe(jetzt); }); it('liefert alle Größen, die Windows abholt', () => { /* Fehlt eine Größe, rechnet Windows selbst um – bei 16 und 24 Punkten sichtbar schlechter als eine eigens gerechnete. */ const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; const eintraege = icoLesen(readFileSync(ICO)); expect(eintraege.map((e) => e.breite)).toEqual(buch.groessen); expect(eintraege.map((e) => e.hoehe)).toEqual(buch.groessen); for (const e of eintraege) { expect(e.bitPunkt, `Größe ${String(e.breite)}: nicht 32 Bit je Bildpunkt`).toBe(32); expect(e.laenge, `Größe ${String(e.breite)}: leere Bilddaten`).toBeGreaterThan(0); } }); it('legt kleine Größen als BMP ab und große als PNG', () => { /* PNG in kleinen Größen versteht jedes heutige Windows, BMP auch jedes ältere – und bei 16 x 16 macht die Ersparnis nichts aus. */ const eintraege = icoLesen(readFileSync(ICO)); for (const e of eintraege) { expect(e.istPng, `Größe ${String(e.breite)}: falsche Ablageform`).toBe(e.breite >= 128); } }); it('zeigt bei 16 Bildpunkten wirklich etwas', () => { /* Eine ICO-Datei mit richtiger Form und leeren Bildern bestünde alle Prüfungen darüber. Deshalb einmal in die Bildpunkte gesehen: Bei einem Symbol auf farbigem Feld muss die Fläche nahezu vollständig deckend sein – und beide Farben müssen vorkommen. */ const daten = readFileSync(ICO); const klein = icoLesen(daten).find((e) => e.breite === 16); expect(klein, 'Keine Größe 16 in der Datei.').toBeDefined(); const anfang = (klein?.versatz ?? 0) + 40; // hinter dem BMP-Kopf let deckend = 0; let hell = 0; let dunkel = 0; for (let i = 0; i < 16 * 16; i += 1) { const p = anfang + i * 4; const [blau, gruen, rot, deckung] = [ daten.readUInt8(p), daten.readUInt8(p + 1), daten.readUInt8(p + 2), daten.readUInt8(p + 3), ]; if (deckung > 200) { deckend += 1; if (rot + gruen + blau > 600) { hell += 1; } else if (rot + gruen + blau < 300) { dunkel += 1; } } } expect(deckend, 'Das 16er-Bild ist überwiegend durchsichtig.').toBeGreaterThan(200); expect(hell, 'Im 16er-Bild fehlt das helle Zeichen.').toBeGreaterThan(10); expect(dunkel, 'Im 16er-Bild fehlt das farbige Feld.').toBeGreaterThan(60); }); it('legt eine große Fassung als PNG daneben', () => { /* macOS nimmt kein ICO. */ const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; const png = readFileSync(PNG); expect(png.readUInt32BE(0), 'icon.png ist keine PNG-Datei.').toBe(0x89504e47); expect(png.readUInt32BE(16), 'icon.png: falsche Breite').toBe(buch.pngGroesse); expect(png.readUInt32BE(20), 'icon.png: falsche Höhe').toBe(buch.pngGroesse); expect(buch.pngGroesse, 'Für macOS mindestens 512.').toBeGreaterThanOrEqual(512); }); /* Die Wache, die gefehlt hat. electron-builder sucht die Grafiken des Store-Pakets ausschließlich unter `build-resources/appx/` und **schweigt**, wenn sie fehlen: Es greift dann zu `SampleAppx.*` aus seinem eigenen Werkzeugpaket. Am Paket 0.22.0 nachgemessen lagen genau diese Beispielbilder darin – Store-Eintrag, Startmenü-Kachel und Taskleiste zeigten ein fremdes Platzhalterbild. Weder ein Test noch eine Zeile Dokumentation erwähnte das; `icon.ico` und `icon.png` fasst das AppX-Ziel gar nicht an. */ it('bringt die sechs Store-Grafiken mit', () => { const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; expect(buch.kacheln.map((k) => k.datei).sort()).toEqual([ 'LargeTile.png', 'SmallTile.png', 'Square150x150Logo.png', 'Square44x44Logo.png', 'StoreLogo.png', 'Wide310x150Logo.png', ]); for (const kachel of buch.kacheln) { const bild = readFileSync(join(APPX, kachel.datei)); expect(bild.readUInt32BE(0), `${kachel.datei} ist keine PNG-Datei.`).toBe(0x89504e47); expect(bild.readUInt32BE(16), `${kachel.datei}: falsche Breite`).toBe(kachel.breite); expect(bild.readUInt32BE(20), `${kachel.datei}: falsche Höhe`).toBe(kachel.hoehe); /* Eine leere oder abgeschnittene Datei fällt hier durch. */ expect(bild.length, `${kachel.datei} ist verdächtig klein.`).toBeGreaterThan(500); } }); /* Die Wache hinter der Wache – und der Grund, warum es die erste allein nicht tut. Die Liste oben ist von Hand gepflegt. Sie sagt, was dieses Projekt mitliefern will, nicht, was electron-builder verlangt. Genau dort lag der Fehler: `AppxTarget.js` führt eine Liste `vendorAssetsForDefaultAssets`, und für jeden Eintrag, den `build-resources/appx/` **nicht** liefert, kopiert es sein eigenes `SampleAppx.*` ins Paket – ohne Warnung, ohne Eintrag im Bauprotokoll. Am Paket 0.22.0 traf das alle vier: Die Kacheln darin waren byteweise identisch mit den Beispielbildern. Der Store hat es am 31.08.2026 unter Richtlinie 10.1.1.11 beanstandet. Deshalb wird die Liste hier aus electron-builder selbst gelesen statt danebengeschrieben. Kommt dort ein fünfter Eintrag hinzu, wird dieser Test rot – statt dass ein neues Platzhalterbild still mitreist. */ it('lässt electron-builder kein einziges Beispielbild einsetzen', () => { const quelle = readFileSync( join(app, 'node_modules', 'app-builder-lib', 'out', 'targets', 'AppxTarget.js'), 'utf8', ); const block = /vendorAssetsForDefaultAssets\s*=\s*\{([^}]*)\}/u.exec(quelle); expect( block, 'Die Liste der Beispielbilder steht nicht mehr in AppxTarget.js. Von Hand nachsehen, ' + 'wie electron-builder fehlende Store-Grafiken heute ersetzt.', ).not.toBeNull(); /* Die Liste steht dort in doppelten Anführungszeichen; einfache sind mitgefasst, damit eine Umformatierung von electron-builder den Ausdruck nicht bricht. */ const verlangt = [...(block?.[1] ?? '').matchAll(/["']([^"']+\.png)["']\s*:/gu)] .map((treffer) => treffer[1]) .filter((name): name is string => name !== undefined); expect(verlangt.length, 'Kein einziger Eintrag gelesen – der Ausdruck passt nicht mehr.').toBe( 4, ); for (const name of verlangt) { expect( existsSync(join(APPX, name)), `${name} fehlt in build-resources/appx/. electron-builder legte sonst sein ` + 'Beispielbild ins Store-Paket.', ).toBe(true); } }); });