waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Das Programmsymbol muss zur Zeichnung passen. |
| 4 | * |
| 5 | * `build-resources/symbol.svg` ist das Original, `icon.ico` und `icon.png` |
| 6 | * sind daraus erzeugt (`npm run symbol`). Erzeugte Dateien haben in diesem |
| 7 | * Projekt eine eigene Fehlerart: Jemand ändert die Quelle, vergisst den |
| 8 | * Aufruf, und alles bleibt grün – die Anwendung zeigt nur weiter das alte |
| 9 | * Bild. Genau das fängt die Prüfsumme in `symbol.json` ab. |
| 10 | * |
| 11 | * Geprüft wird außerdem die Form der ICO-Datei. Sie wird hier von Hand |
| 12 | * zusammengesetzt, ohne Fremdprogramm; ein Fehler im Behälter fiele sonst |
| 13 | * erst auf, wenn Windows das Symbol nicht anzeigt – und das sieht niemand, |
| 14 | * der die Anwendung nur baut. |
| 15 | */ |
| 16 | |
| 17 | import { createHash } from 'node:crypto'; |
| 18 | import { existsSync, readFileSync } from 'node:fs'; |
| 19 | import { join } from 'node:path'; |
| 20 | import { fileURLToPath } from 'node:url'; |
| 21 | |
| 22 | import { describe, expect, it } from 'vitest'; |
| 23 | |
| 24 | const app = join(fileURLToPath(new URL('..', import.meta.url))); |
| 25 | const ordner = join(app, 'build-resources'); |
| 26 | |
| 27 | const QUELLE = join(ordner, 'symbol.svg'); |
| 28 | const BUCH = join(ordner, 'symbol.json'); |
| 29 | const ICO = join(ordner, 'icon.ico'); |
| 30 | const PNG = join(ordner, 'icon.png'); |
| 31 | /** Die Store-Grafiken; electron-builder sucht sie genau hier. */ |
| 32 | const APPX = join(ordner, 'appx'); |
| 33 | |
| 34 | interface Buchung { |
| 35 | quelle: string; |
| 36 | pruefsumme: string; |
| 37 | groessen: number[]; |
| 38 | pngGroesse: number; |
| 39 | kacheln: { datei: string; breite: number; hoehe: number }[]; |
| 40 | } |
| 41 | |
| 42 | /** Ein Eintrag im Verzeichnis einer ICO-Datei. */ |
| 43 | interface Symboleintrag { |
| 44 | breite: number; |
| 45 | hoehe: number; |
| 46 | bitPunkt: number; |
| 47 | laenge: number; |
| 48 | versatz: number; |
| 49 | /** `true`, wenn die Bilddaten ein PNG sind statt eines BMP. */ |
| 50 | istPng: boolean; |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Liest das Verzeichnis einer ICO-Datei. |
| 55 | * |
| 56 | * Bewusst von Hand statt mit einer Bibliothek: Das Format ist ein Kopf und |
| 57 | * eine Tabelle, und eine Abhängigkeit, die nur ein Testfall braucht, müsste |
| 58 | * im Gate mitgeprüft und in der Lizenzübersicht geführt werden. |
| 59 | */ |
| 60 | function icoLesen(daten: Buffer): Symboleintrag[] { |
| 61 | expect(daten.readUInt16LE(0), 'ICO: erstes Feld muss 0 sein').toBe(0); |
| 62 | expect(daten.readUInt16LE(2), 'ICO: Typ 1 steht für ein Symbol').toBe(1); |
| 63 | |
| 64 | const anzahl = daten.readUInt16LE(4); |
| 65 | const eintraege: Symboleintrag[] = []; |
| 66 | for (let i = 0; i < anzahl; i += 1) { |
| 67 | const p = 6 + i * 16; |
| 68 | const versatz = daten.readUInt32LE(p + 12); |
| 69 | const laenge = daten.readUInt32LE(p + 8); |
| 70 | eintraege.push({ |
| 71 | // 0 steht für 256 – ein Byte fasst die Zahl nicht. |
| 72 | breite: daten.readUInt8(p) === 0 ? 256 : daten.readUInt8(p), |
| 73 | hoehe: daten.readUInt8(p + 1) === 0 ? 256 : daten.readUInt8(p + 1), |
| 74 | bitPunkt: daten.readUInt16LE(p + 6), |
| 75 | laenge, |
| 76 | versatz, |
| 77 | istPng: daten.readUInt32BE(versatz) === 0x89504e47, |
| 78 | }); |
| 79 | } |
| 80 | return eintraege; |
| 81 | } |
| 82 | |
| 83 | describe('Programmsymbol', () => { |
| 84 | it('hat eine Zeichnung als Original', () => { |
| 85 | expect(existsSync(QUELLE), 'build-resources/symbol.svg fehlt.').toBe(true); |
| 86 | const svg = readFileSync(QUELLE, 'utf8'); |
| 87 | expect(svg).toContain('<svg'); |
| 88 | // Zwei Kurven ergeben das Zeichen; bliebe eine übrig, stünde dort ein S. |
| 89 | expect( |
| 90 | (svg.match(/<path/gu) ?? []).length, |
| 91 | 'Das Zeichen besteht aus zwei Kurven – bei einer wäre es ein S.', |
| 92 | ).toBe(2); |
| 93 | }); |
| 94 | |
| 95 | it('trägt die Akzentfarbe der Anwendung, keine eigene', () => { |
| 96 | /* Ein Symbol in einer Farbe, die sonst nirgends vorkommt, fällt beim |
| 97 | nächsten Wechsel des Farbschemas aus dem Rahmen – und niemand sucht |
| 98 | danach in einer SVG-Datei. */ |
| 99 | const tokens = readFileSync( |
| 100 | join(app, 'src', 'renderer', 'src', 'styles', 'tokens.css'), |
| 101 | 'utf8', |
| 102 | ); |
| 103 | const akzent = /--farbe-akzent:\s*(#[0-9a-f]{6})/iu.exec(tokens)?.[1]; |
| 104 | |
| 105 | expect(akzent, 'tokens.css: --farbe-akzent nicht gefunden.').toBeDefined(); |
| 106 | expect( |
| 107 | readFileSync(QUELLE, 'utf8').toLowerCase(), |
| 108 | `symbol.svg führt nicht die Akzentfarbe ${String(akzent)}.`, |
| 109 | ).toContain((akzent ?? '').toLowerCase()); |
| 110 | }); |
| 111 | |
| 112 | it('ist so erzeugt worden, wie die Zeichnung jetzt aussieht', () => { |
| 113 | /* Die eigentliche Zusage. Ohne sie zeigt die Anwendung nach einer |
| 114 | Änderung an der Zeichnung weiter das alte Bild, und alles bleibt grün. */ |
| 115 | expect(existsSync(BUCH), 'build-resources/symbol.json fehlt.').toBe(true); |
| 116 | const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; |
| 117 | const jetzt = createHash('sha256').update(readFileSync(QUELLE, 'utf8'), 'utf8').digest('hex'); |
| 118 | |
| 119 | expect( |
| 120 | buch.pruefsumme, |
| 121 | 'build-resources/symbol.svg wurde geändert, aber icon.ico und icon.png ' + |
| 122 | 'stammen noch von der vorigen Fassung. Neu erzeugen mit: npm run symbol', |
| 123 | ).toBe(jetzt); |
| 124 | }); |
| 125 | |
| 126 | it('liefert alle Größen, die Windows abholt', () => { |
| 127 | /* Fehlt eine Größe, rechnet Windows selbst um – bei 16 und 24 Punkten |
| 128 | sichtbar schlechter als eine eigens gerechnete. */ |
| 129 | const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; |
| 130 | const eintraege = icoLesen(readFileSync(ICO)); |
| 131 | |
| 132 | expect(eintraege.map((e) => e.breite)).toEqual(buch.groessen); |
| 133 | expect(eintraege.map((e) => e.hoehe)).toEqual(buch.groessen); |
| 134 | for (const e of eintraege) { |
| 135 | expect(e.bitPunkt, `Größe ${String(e.breite)}: nicht 32 Bit je Bildpunkt`).toBe(32); |
| 136 | expect(e.laenge, `Größe ${String(e.breite)}: leere Bilddaten`).toBeGreaterThan(0); |
| 137 | } |
| 138 | }); |
| 139 | |
| 140 | it('legt kleine Größen als BMP ab und große als PNG', () => { |
| 141 | /* PNG in kleinen Größen versteht jedes heutige Windows, BMP auch jedes |
| 142 | ältere – und bei 16 x 16 macht die Ersparnis nichts aus. */ |
| 143 | const eintraege = icoLesen(readFileSync(ICO)); |
| 144 | for (const e of eintraege) { |
| 145 | expect(e.istPng, `Größe ${String(e.breite)}: falsche Ablageform`).toBe(e.breite >= 128); |
| 146 | } |
| 147 | }); |
| 148 | |
| 149 | it('zeigt bei 16 Bildpunkten wirklich etwas', () => { |
| 150 | /* Eine ICO-Datei mit richtiger Form und leeren Bildern bestünde alle |
| 151 | Prüfungen darüber. Deshalb einmal in die Bildpunkte gesehen: Bei einem |
| 152 | Symbol auf farbigem Feld muss die Fläche nahezu vollständig deckend |
| 153 | sein – und beide Farben müssen vorkommen. */ |
| 154 | const daten = readFileSync(ICO); |
| 155 | const klein = icoLesen(daten).find((e) => e.breite === 16); |
| 156 | expect(klein, 'Keine Größe 16 in der Datei.').toBeDefined(); |
| 157 | |
| 158 | const anfang = (klein?.versatz ?? 0) + 40; // hinter dem BMP-Kopf |
| 159 | let deckend = 0; |
| 160 | let hell = 0; |
| 161 | let dunkel = 0; |
| 162 | for (let i = 0; i < 16 * 16; i += 1) { |
| 163 | const p = anfang + i * 4; |
| 164 | const [blau, gruen, rot, deckung] = [ |
| 165 | daten.readUInt8(p), |
| 166 | daten.readUInt8(p + 1), |
| 167 | daten.readUInt8(p + 2), |
| 168 | daten.readUInt8(p + 3), |
| 169 | ]; |
| 170 | if (deckung > 200) { |
| 171 | deckend += 1; |
| 172 | if (rot + gruen + blau > 600) { |
| 173 | hell += 1; |
| 174 | } else if (rot + gruen + blau < 300) { |
| 175 | dunkel += 1; |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | expect(deckend, 'Das 16er-Bild ist überwiegend durchsichtig.').toBeGreaterThan(200); |
| 181 | expect(hell, 'Im 16er-Bild fehlt das helle Zeichen.').toBeGreaterThan(10); |
| 182 | expect(dunkel, 'Im 16er-Bild fehlt das farbige Feld.').toBeGreaterThan(60); |
| 183 | }); |
| 184 | |
| 185 | it('legt eine große Fassung als PNG daneben', () => { |
| 186 | /* macOS nimmt kein ICO. */ |
| 187 | const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; |
| 188 | const png = readFileSync(PNG); |
| 189 | |
| 190 | expect(png.readUInt32BE(0), 'icon.png ist keine PNG-Datei.').toBe(0x89504e47); |
| 191 | expect(png.readUInt32BE(16), 'icon.png: falsche Breite').toBe(buch.pngGroesse); |
| 192 | expect(png.readUInt32BE(20), 'icon.png: falsche Höhe').toBe(buch.pngGroesse); |
| 193 | expect(buch.pngGroesse, 'Für macOS mindestens 512.').toBeGreaterThanOrEqual(512); |
| 194 | }); |
| 195 | |
| 196 | /* |
| 197 | Die Wache, die gefehlt hat. |
| 198 | |
| 199 | electron-builder sucht die Grafiken des Store-Pakets ausschließlich unter |
| 200 | `build-resources/appx/` und **schweigt**, wenn sie fehlen: Es greift dann |
| 201 | zu `SampleAppx.*` aus seinem eigenen Werkzeugpaket. Am Paket 0.22.0 |
| 202 | nachgemessen lagen genau diese Beispielbilder darin – Store-Eintrag, |
| 203 | Startmenü-Kachel und Taskleiste zeigten ein fremdes Platzhalterbild. |
| 204 | Weder ein Test noch eine Zeile Dokumentation erwähnte das; `icon.ico` und |
| 205 | `icon.png` fasst das AppX-Ziel gar nicht an. |
| 206 | */ |
| 207 | it('bringt die sechs Store-Grafiken mit', () => { |
| 208 | const buch = JSON.parse(readFileSync(BUCH, 'utf8')) as Buchung; |
| 209 | |
| 210 | expect(buch.kacheln.map((k) => k.datei).sort()).toEqual([ |
| 211 | 'LargeTile.png', |
| 212 | 'SmallTile.png', |
| 213 | 'Square150x150Logo.png', |
| 214 | 'Square44x44Logo.png', |
| 215 | 'StoreLogo.png', |
| 216 | 'Wide310x150Logo.png', |
| 217 | ]); |
| 218 | |
| 219 | for (const kachel of buch.kacheln) { |
| 220 | const bild = readFileSync(join(APPX, kachel.datei)); |
| 221 | expect(bild.readUInt32BE(0), `${kachel.datei} ist keine PNG-Datei.`).toBe(0x89504e47); |
| 222 | expect(bild.readUInt32BE(16), `${kachel.datei}: falsche Breite`).toBe(kachel.breite); |
| 223 | expect(bild.readUInt32BE(20), `${kachel.datei}: falsche Höhe`).toBe(kachel.hoehe); |
| 224 | /* Eine leere oder abgeschnittene Datei fällt hier durch. */ |
| 225 | expect(bild.length, `${kachel.datei} ist verdächtig klein.`).toBeGreaterThan(500); |
| 226 | } |
| 227 | }); |
| 228 | |
| 229 | /* |
| 230 | Die Wache hinter der Wache – und der Grund, warum es die erste allein |
| 231 | nicht tut. |
| 232 | |
| 233 | Die Liste oben ist von Hand gepflegt. Sie sagt, was dieses Projekt |
| 234 | mitliefern will, nicht, was electron-builder verlangt. Genau dort lag der |
| 235 | Fehler: `AppxTarget.js` führt eine Liste `vendorAssetsForDefaultAssets`, |
| 236 | und für jeden Eintrag, den `build-resources/appx/` **nicht** liefert, |
| 237 | kopiert es sein eigenes `SampleAppx.*` ins Paket – ohne Warnung, ohne |
| 238 | Eintrag im Bauprotokoll. Am Paket 0.22.0 traf das alle vier: Die Kacheln |
| 239 | darin waren byteweise identisch mit den Beispielbildern. Der Store hat es |
| 240 | am 31.08.2026 unter Richtlinie 10.1.1.11 beanstandet. |
| 241 | |
| 242 | Deshalb wird die Liste hier aus electron-builder selbst gelesen statt |
| 243 | danebengeschrieben. Kommt dort ein fünfter Eintrag hinzu, wird dieser |
| 244 | Test rot – statt dass ein neues Platzhalterbild still mitreist. |
| 245 | */ |
| 246 | it('lässt electron-builder kein einziges Beispielbild einsetzen', () => { |
| 247 | const quelle = readFileSync( |
| 248 | join(app, 'node_modules', 'app-builder-lib', 'out', 'targets', 'AppxTarget.js'), |
| 249 | 'utf8', |
| 250 | ); |
| 251 | const block = /vendorAssetsForDefaultAssets\s*=\s*\{([^}]*)\}/u.exec(quelle); |
| 252 | |
| 253 | expect( |
| 254 | block, |
| 255 | 'Die Liste der Beispielbilder steht nicht mehr in AppxTarget.js. Von Hand nachsehen, ' + |
| 256 | 'wie electron-builder fehlende Store-Grafiken heute ersetzt.', |
| 257 | ).not.toBeNull(); |
| 258 | |
| 259 | /* Die Liste steht dort in doppelten Anführungszeichen; einfache sind |
| 260 | mitgefasst, damit eine Umformatierung von electron-builder den |
| 261 | Ausdruck nicht bricht. */ |
| 262 | const verlangt = [...(block?.[1] ?? '').matchAll(/["']([^"']+\.png)["']\s*:/gu)] |
| 263 | .map((treffer) => treffer[1]) |
| 264 | .filter((name): name is string => name !== undefined); |
| 265 | |
| 266 | expect(verlangt.length, 'Kein einziger Eintrag gelesen – der Ausdruck passt nicht mehr.').toBe( |
| 267 | 4, |
| 268 | ); |
| 269 | |
| 270 | for (const name of verlangt) { |
| 271 | expect( |
| 272 | existsSync(join(APPX, name)), |
| 273 | `${name} fehlt in build-resources/appx/. electron-builder legte sonst sein ` + |
| 274 | 'Beispielbild ins Store-Paket.', |
| 275 | ).toBe(true); |
| 276 | } |
| 277 | }); |
| 278 | }); |