// @vitest-environment node // Der Katalog wird im Main-Prozess von der Platte gelesen – jsdom hätte hier // nichts zu tun und würde nur Dateizugriffe verkomplizieren. import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Katalog } from '../src/shared/katalog'; /** * `app.getAppPath()` liefert im Entwicklungsmodus das Verzeichnis mit der * package.json – unter Vitest ist das genau das Arbeitsverzeichnis. */ vi.mock('electron', () => ({ app: { isPackaged: false, getAppPath: (): string => process.cwd(), }, })); const { bildAufloesen, katalogBild, katalogLaden, katalogValidieren, katalogVerzeichnis, katalogZuruecksetzen, } = await import('../src/main/katalog'); // ─── Prüfmuster ───────────────────────────────────────────────────────────── /** Kleiner, gültiger Katalog. Die Tests verbiegen jeweils genau ein Feld. */ function musterkatalog(): Record { return { meta: { titel: 'Prüfkatalog', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: 'a'.repeat(64), fragen_gesamt: 2, }, kapitel: [ { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }], }, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, ], bilder: [ { id: 'zeichen-1', datei: 'zeichen-1.png', breite: 73, hoehe: 73, alt: 'Rundes Zeichen', beschreibung: 'Zulassungszeichen für bauartgeprüfte Waffen.', }, ], fragen: [ { id: 'I.1-01', amtliche_nummer: '1.01', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 6, frage: { text: 'Umgang mit einer Schusswaffe hat…', segmente: [{ t: 'Umgang…' }] }, bilder: [], optionen: [ { label: 'a', inhalt: { text: 'wer schießt.', segmente: [] }, korrekt: true, bilder: [] }, { label: 'b', inhalt: { text: 'niemand.', segmente: [] }, korrekt: false, bilder: [] }, ], }, { id: 'II-01', amtliche_nummer: '2.01', kapitel: 'II', abschnitt: null, typ: 'freitext', seite: 40, frage: { text: 'Welches Zeichen ist abgebildet?', segmente: [{ t: 'Welches Zeichen?' }] }, bilder: ['zeichen-1'], musterantwort: { text: 'Das PTB-Zeichen.', segmente: [{ t: 'Das ' }, { t: 'PTB-Zeichen', h: true }, { t: '.' }], }, }, ], }; } /** Baut einen Musterkatalog und lässt eine Änderung darauf laufen. */ function verbogen(aendern: (katalog: Record) => void): Record { const katalog = musterkatalog(); aendern(katalog); return katalog; } function fragen(katalog: Record): Record[] { return katalog['fragen'] as Record[]; } function meta(katalog: Record): Record { return katalog['meta'] as Record; } beforeEach(() => { katalogZuruecksetzen(); }); // ─── Validierung ──────────────────────────────────────────────────────────── describe('katalogValidieren', () => { it('nimmt einen vollständigen Katalog an', () => { const katalog = katalogValidieren(musterkatalog()); expect(katalog.fragen).toHaveLength(2); expect(katalog.meta.fragen_gesamt).toBe(2); expect(katalog.fragen[0]?.optionen).toHaveLength(2); expect(katalog.fragen[1]?.musterantwort?.segmente[1]).toEqual({ t: 'PTB-Zeichen', h: true }); }); it('weist eine abweichende Kopfzahl zurück und nennt beide Zahlen', () => { const roh = verbogen((k) => { meta(k)['fragen_gesamt'] = 575; }); expect(() => katalogValidieren(roh)).toThrow(/meta\.fragen_gesamt meldet 575/u); expect(() => katalogValidieren(roh)).toThrow(/enthalten sind aber 2/u); }); it.each([ ['titel', 'meta.titel'], ['quellenangabe', 'meta.quellenangabe'], ['quelldatei_sha256', 'meta.quelldatei_sha256'], ])('vermisst das Pflichtfeld meta.%s', (feld, pfad) => { const roh = verbogen((k) => { // `undefined` und „nicht vorhanden“ müssen gleich behandelt werden. meta(k)[feld] = undefined; }); expect(() => katalogValidieren(roh)).toThrow( new RegExp(`${pfad.replace('.', '\\.')} fehlt`, 'u'), ); }); /* Zweistufiges Bildkonzept (Prüfplan S4): `alt` bleibt neutral, die erklärende `beschreibung` wird nach dem Antworten nachgereicht. Beide Felder dürfen fehlen – ein Katalog ohne Beschreibungen bleibt gültig, die Oberfläche lässt den Aufklapper dann einfach weg. */ it('reicht die erklärende Bildbeschreibung durch', () => { const katalog = katalogValidieren(musterkatalog()); expect(katalog.bilder[0]?.beschreibung).toBe('Zulassungszeichen für bauartgeprüfte Waffen.'); }); it('nimmt ein Bild ohne Beschreibung an und liefert null', () => { const roh = verbogen((k) => { delete (k['bilder'] as Record[])[0]!['beschreibung']; }); expect(katalogValidieren(roh).bilder[0]?.beschreibung).toBeNull(); }); it('weist eine Beschreibung zurück, die keine Zeichenkette ist', () => { const roh = verbogen((k) => { (k['bilder'] as Record[])[0]!['beschreibung'] = 42; }); expect(() => katalogValidieren(roh)).toThrow( /bilder\[0\]\.beschreibung ist weder Zeichenkette noch null/u, ); }); it('weist eine leere Zeichenkette wie ein fehlendes Feld zurück', () => { const roh = verbogen((k) => { meta(k)['herausgeber'] = ' '; }); expect(() => katalogValidieren(roh)).toThrow(/meta\.herausgeber fehlt/u); }); it('weist einen unbekannten Fragetyp zurück', () => { const roh = verbogen((k) => { fragen(k)[0]!['typ'] = 'ankreuzen'; }); expect(() => katalogValidieren(roh)).toThrow(/typ ist unbekannt: „ankreuzen“/u); }); it('weist eine Multiple-Choice-Frage ohne richtige Option zurück', () => { const roh = verbogen((k) => { const optionen = fragen(k)[0]!['optionen'] as Record[]; for (const option of optionen) { option['korrekt'] = false; } }); expect(() => katalogValidieren(roh)).toThrow(/keine richtige Antwortoption/u); }); it('weist doppelte Frage-IDs zurück', () => { const roh = verbogen((k) => { fragen(k)[1]!['id'] = 'I.1-01'; }); expect(() => katalogValidieren(roh)).toThrow(/ID „I\.1-01“ mehrfach/u); }); it('weist einen Verweis auf ein unbekanntes Kapitel zurück', () => { const roh = verbogen((k) => { fragen(k)[0]!['kapitel'] = 'IX'; }); expect(() => katalogValidieren(roh)).toThrow(/unbekannte Kapitel „IX“/u); }); it('weist einen Verweis auf ein unbekanntes Bild zurück', () => { const roh = verbogen((k) => { fragen(k)[1]!['bilder'] = ['zeichen-erfunden']; }); expect(() => katalogValidieren(roh)).toThrow(/unbekannte Bild „zeichen-erfunden“/u); }); it.each([ ['../../etc/passwd.png'], ['assets/zeichen-1.png'], ['..\\zeichen-1.png'], ['zeichen-1.exe'], ])('weist den Dateinamen %s zurück', (datei) => { const roh = verbogen((k) => { (k['bilder'] as Record[])[0]!['datei'] = datei; }); expect(() => katalogValidieren(roh)).toThrow(/kein einfacher PNG-Dateiname/u); }); it('weist eine Nutzlast zurück, die gar kein Objekt ist', () => { expect(() => katalogValidieren('katalog')).toThrow(/Katalog ist kein Objekt/u); expect(() => katalogValidieren(null)).toThrow(/Katalog ist kein Objekt/u); expect(() => katalogValidieren([])).toThrow(/Katalog ist kein Objekt/u); }); }); // ─── Bild-IDs ─────────────────────────────────────────────────────────────── describe('bildAufloesen', () => { let katalog: Katalog; beforeEach(() => { katalog = katalogValidieren(musterkatalog()); }); it('findet ein bekanntes Prüfzeichen', () => { expect(bildAufloesen(katalog, 'zeichen-1').datei).toBe('zeichen-1.png'); }); it.each([['zeichen-2'], ['ZEICHEN-1'], ['zeichen-1.png'], ['zeichen-1 '], ['']])( 'weist die unbekannte Bild-ID „%s“ zurück', (bildId) => { expect(() => bildAufloesen(katalog, bildId)).toThrow(/Bild-ID/u); }, ); it.each([ ['../assets/zeichen-1.png'], ['../../../../windows/win.ini'], ['..\\..\\zeichen-1.png'], ['/etc/passwd'], ['C:\\Windows\\System32\\config\\SAM'], ['assets/../assets/zeichen-1.png'], ['%2e%2e%2fzeichen-1.png'], ])('weist die pfadartige Bild-ID „%s“ zurück', (bildId) => { expect(() => bildAufloesen(katalog, bildId)).toThrow(/Unbekannte Bild-ID/u); }); it.each([[null], [undefined], [42], [{ id: 'zeichen-1' }], [['zeichen-1']]])( 'weist die Bild-ID %o zurück, weil sie keine Zeichenkette ist', (bildId) => { expect(() => bildAufloesen(katalog, bildId)).toThrow(/Ungültige Bild-ID/u); }, ); it('nimmt Steuerzeichen nicht in die Fehlermeldung auf', () => { // Nullbyte und ANSI-Escape: beides darf nicht unverändert in einer // Meldung oder Protokollzeile landen. const nullbyte = String.fromCharCode(0); const escape = String.fromCharCode(27); const bildId = `zeichen${nullbyte}-${escape}[31mrot`; let meldung = ''; try { bildAufloesen(katalog, bildId); } catch (fehler) { meldung = fehler instanceof Error ? fehler.message : String(fehler); } expect(meldung).toContain('Unbekannte Bild-ID'); expect(meldung).toContain('zeichen?-?[31mrot'); expect(meldung).not.toContain(nullbyte); expect(meldung).not.toContain(escape); }); }); // ─── Auflösung des Ablageortes ────────────────────────────────────────────── describe('katalogVerzeichnis', () => { it('zeigt in der Entwicklung auf content/katalog neben dem app-Verzeichnis', () => { expect(katalogVerzeichnis()).toBe(join(process.cwd(), '..', 'content', 'katalog')); }); it('zeigt im gepackten Build auf resources/katalog', async () => { const vorher = process.resourcesPath; Object.defineProperty(process, 'resourcesPath', { value: join('C:', 'Programme', 'Waffensachkunde', 'resources'), configurable: true, }); vi.resetModules(); vi.doMock('electron', () => ({ app: { isPackaged: true, getAppPath: (): string => process.cwd() }, })); try { const modul = await import('../src/main/katalog'); expect(modul.katalogVerzeichnis()).toBe(join(process.resourcesPath, 'katalog')); } finally { Object.defineProperty(process, 'resourcesPath', { value: vorher, configurable: true }); vi.doUnmock('electron'); vi.resetModules(); } }); }); // ─── Der tatsächlich ausgelieferte Katalog ────────────────────────────────── describe('ausgelieferter Fragenkatalog', () => { it('besteht die Validierung und enthält die angekündigte Fragenzahl', () => { const katalog = katalogLaden(); expect(katalog.fragen).toHaveLength(katalog.meta.fragen_gesamt); expect(katalog.meta.fragen_gesamt).toBe(575); expect(katalog.kapitel.map((k) => k.id)).toEqual(['I', 'II', 'III', 'IV']); expect(katalog.bilder).toHaveLength(17); }); /* Der Prüfplan (Szenario S4) verlangt zwei Stufen je Zeichen: einen neutralen Alt-Text und eine erklärende Beschreibung, die nach dem Antworten nachgereicht wird. Bis Fassung 0.20.0 existierte nur die erste Stufe – Sehende bekamen über die Erklärung mehr Kontext zum Zeichen als Screenreader-Nutzer über das Bild. Alle 17 Zeichen sind belegt (content/alttexte.json, hergeleitet aus den Erklärungstexten). */ it('führt zu jedem Prüfzeichen beide Stufen: Alt-Text und Beschreibung', () => { const { bilder } = katalogLaden(); expect(bilder.filter((b) => (b.alt ?? '').trim().length > 0)).toHaveLength(17); expect(bilder.filter((b) => (b.beschreibung ?? '').trim().length > 0)).toHaveLength(17); }); /* Frage 3.05 fragt, welche Kennzeichen *nicht* auf einer waffenbesitzkartenpflichtigen Schusswaffe zu finden sind. BKA-Raute und PTB-Trapez gehören zusammen zur richtigen Antwort c). Bis Fassung 0.19.2 zerschnitt der Katalogeinleser das Doppelzeichen und hängte die BKA-Raute an b) – die falsche Antwort. Ein Zeichen an der falschen Antwort ist in einer Prüfungssoftware kein Schönheitsfehler, deshalb steht die Zuordnung hier fest. */ it('führt das Doppelzeichen der Frage 3.05 ungeteilt an der richtigen Antwort', () => { const frage = katalogLaden().fragen.find((f) => f.id === 'I.3-05'); const optionen = frage?.optionen ?? []; expect(optionen.map((o) => [o.label, o.korrekt, o.bilder.length])).toEqual([ ['a', true, 1], ['b', false, 2], ['c', true, 1], ]); expect(frage?.warnungen ?? []).toEqual([]); }); it('vermerkt keine Antwortmöglichkeit ohne Text und ohne Bild', () => { /* Der Einleser rät nicht mehr, sondern meldet. Bleibt hier etwas übrig, ist eine Antwort im Katalog leer – und niemand sähe es sonst. */ const leer = katalogLaden().fragen.flatMap((f) => f.warnungen ?? []); expect(leer).toEqual([]); }); it('wird nur einmal von der Platte gelesen', () => { expect(katalogLaden()).toBe(katalogLaden()); }); it('liefert Prüfzeichen als Data-URL mit gültiger PNG-Signatur', () => { const katalog = katalogLaden(); const bildId = katalog.bilder[0]!.id; const datenUrl = katalogBild(bildId); expect(datenUrl.startsWith('data:image/png;base64,')).toBe(true); const roh = Buffer.from(datenUrl.slice('data:image/png;base64,'.length), 'base64'); expect([...roh.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); }); it('gibt dieselbe Data-URL aus dem Zwischenspeicher zurück', () => { const bildId = katalogLaden().bilder[0]!.id; expect(katalogBild(bildId)).toBe(katalogBild(bildId)); }); it('liest keine Datei, wenn die Bild-ID unbekannt oder pfadartig ist', () => { katalogLaden(); expect(() => katalogBild('../katalog.json')).toThrow(/Unbekannte Bild-ID/u); expect(() => katalogBild('zeichen-gibt-es-nicht')).toThrow(/Unbekannte Bild-ID/u); }); });