waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests symbol.test.ts

11,1 KB Rohdatei
app/tests/symbol.test.ts — 278 Zeilen
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 });