waffensachkunde

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

/ app e2e druck.spec.ts

6,4 KB Rohdatei
app/e2e/druck.spec.ts — 173 Zeilen
1 /**
2 * Der PDF-Export im echten Electron.
3 *
4 * Die Einheitentests prüfen das HTML. Hier wird gemessen, was Chromium daraus
5 * macht – und zwar am erzeugten Artefakt, nicht an der Abwesenheit einer
6 * Ausnahme.
7 *
8 * **Die stille Falle.** Wird der eingebettete Stil blockiert, entsteht das PDF
9 * trotzdem: gleiche Struktur, gleiche Größenordnung, nur eben ohne
10 * Formatierung. `printToPDF` wirft dabei nicht. Deshalb prüft dieser Lauf
11 * nicht nur die Struktur, sondern auch, dass der Stil überhaupt eine Wirkung
12 * hat – gemessen daran, dass Normal- und Großdruck verschieden viele Seiten
13 * ergeben.
14 *
15 * Der Speicherdialog wird im Hauptprozess durch eine Attrappe ersetzt: Ein
16 * Testlauf darf kein Fenster öffnen, auf das niemand klickt.
17 */
18
19 import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
20 import { tmpdir } from 'node:os';
21 import { join } from 'node:path';
22
23 import { type ElectronApplication, type Page } from '@playwright/test';
24
25 import { appStarten, bauPruefen } from './electron-hilfe';
26 import { expect, test } from './konsolenwache';
27
28 let app: ElectronApplication | undefined;
29 let fenster: Page;
30 let ordner = '';
31
32 test.beforeAll(async () => {
33 // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen.
34 bauPruefen();
35 ({ app, fenster } = await appStarten());
36 ordner = mkdtempSync(join(tmpdir(), 'wsk-druck-e2e-'));
37 });
38
39 test.afterAll(async () => {
40 await app?.close();
41 try {
42 rmSync(ordner, { recursive: true, force: true });
43 } catch {
44 /* Aufräumen ist Kür. */
45 }
46 });
47
48 /** Ersetzt den Speicherdialog; `null` bedeutet „Nutzer bricht ab“. */
49 async function dialogStellen(zielpfad: string | null): Promise<void> {
50 await app?.evaluate(({ dialog }, pfad) => {
51 (dialog as unknown as { showSaveDialog: unknown }).showSaveDialog = () =>
52 Promise.resolve(
53 pfad === null ? { canceled: true, filePath: '' } : { canceled: false, filePath: pfad },
54 );
55 }, zielpfad);
56 }
57
58 /** Löst den Export über die Brücke aus, so wie es die Oberfläche tut. */
59 async function exportieren(groesse: 'normal' | 'gross'): Promise<{
60 gespeichert: boolean;
61 pfad: string | null;
62 bytes: number;
63 }> {
64 return fenster.evaluate(async (g) => {
65 const api = (window as unknown as { lernApp: Record<string, unknown> }).lernApp;
66 const drucken = api['lernberichtDrucken'] as (
67 profilId: number,
68 groesse: string,
69 ) => Promise<{ gespeichert: boolean; pfad: string | null; bytes: number }>;
70 return drucken(1, g);
71 }, groesse);
72 }
73
74 test.describe('Lernbericht als PDF', () => {
75 test('schreibt eine PDF-Datei dorthin, wo der Dialog hinzeigt', async () => {
76 const ziel = join(ordner, 'bericht.pdf');
77 await dialogStellen(ziel);
78
79 const ergebnis = await exportieren('normal');
80
81 expect(ergebnis.gespeichert).toBe(true);
82 expect(ergebnis.pfad).toBe(ziel);
83 expect(existsSync(ziel)).toBe(true);
84 expect(ergebnis.bytes).toBeGreaterThan(2000);
85
86 const roh = readFileSync(ziel);
87 expect(roh.subarray(0, 5).toString('latin1')).toBe('%PDF-');
88 });
89
90 test('erzeugt einen Strukturbaum mit Sprachangabe und Lesezeichen', () => {
91 /*
92 Das ist der Unterschied zwischen einem lesbaren Dokument und einem Bild
93 von Buchstaben. Zugesichert ist damit KEINE PDF/UA-Konformität – Electron
94 führt beide Optionen als experimentell. Geprüft wird genau das, was
95 messbar ist.
96 */
97 const text = readFileSync(join(ordner, 'bericht.pdf')).toString('latin1');
98
99 expect(text, 'kein Strukturbaum – generateTaggedPDF hat nicht gewirkt').toContain(
100 '/StructTreeRoot',
101 );
102 expect(text).toContain('/Marked true');
103 expect(text, 'keine Sprachangabe – Screenreader raten dann die Sprache').toContain('/Lang');
104 expect(text, 'keine Lesezeichen – generateDocumentOutline hat nicht gewirkt').toContain(
105 '/Outlines',
106 );
107 });
108
109 test('der Druckstil wirkt wirklich', async () => {
110 /*
111 Der Nachweis, dass der eingebettete Stil nicht stillschweigend
112 blockiert wird. Wuerde er es, entstuende das PDF klaglos - gleiche
113 Struktur, gleiche Groessenordnung, nur ohne Formatierung. Es wirft
114 nichts, und nichts sieht falsch aus.
115
116 Geprueft wird deshalb ueber die WIRKUNG: Derselbe Bericht in Normal-
117 und in Grossdruck muss verschieden viele Seiten haben. Sind sie gleich,
118 hat das Stylesheet keinen Einfluss gehabt.
119
120 Verworfen wurde die naheliegende Variante, im PDF nach der im Stil
121 verlangten Schrift zu suchen. Gegengeprueft mit blockiertem Stil: Sie
122 steht auch dann drin - unter Windows ist Segoe UI ohnehin die
123 Standardschrift. Der Test waere gruen gewesen und haette nichts gesagt.
124 */
125 /*
126 Seitenzahl eines PDF - gezaehlt, nicht dem ersten Baumknoten geglaubt.
127
128 Hier stand bis Fassung 0.18.0 ein Griff nach der ersten Fundstelle von
129 `/Type /Pages ... /Count`. Chromium baut den Seitenbaum aber mehrstufig
130 aus Zwischenknoten mit je acht Kindern; die erste Fundstelle ist ein
131 solcher Zwischenknoten. Nachgemessen an erzeugten PDF lieferte der Griff
132 fuer JEDES Dokument ueber acht Seiten die Zahl 8 - auch fuer eines mit
133 982 Seiten. Der Test war gruen und verglich 8 gegen 8.
134
135 Gezaehlt werden deshalb die Seitenobjekte selbst. `(?![s])` schliesst
136 `/Type /Pages` aus, ohne das ein Zwischenknoten mitzaehlte.
137 */
138 const seiten = (datei: string): number =>
139 (readFileSync(datei, 'latin1').match(/\/Type\s*\/Page(?![s])/gu) ?? []).length;
140
141 const gross = join(ordner, 'gross.pdf');
142 await dialogStellen(gross);
143 await exportieren('gross');
144
145 const normalSeiten = seiten(join(ordner, 'bericht.pdf'));
146 const grossSeiten = seiten(gross);
147
148 expect(normalSeiten).toBeGreaterThan(0);
149 expect(
150 grossSeiten,
151 `Normal ${String(normalSeiten)} Seiten, Grossdruck ${String(grossSeiten)} - ` +
152 'gleich viele heisst: der Stil hat nicht gegriffen.',
153 ).toBeGreaterThan(normalSeiten);
154 });
155
156 test('schreibt nichts, wenn der Nutzer abbricht', async () => {
157 await dialogStellen(null);
158
159 const ergebnis = await exportieren('normal');
160
161 expect(ergebnis.gespeichert).toBe(false);
162 expect(ergebnis.pfad).toBeNull();
163 expect(ergebnis.bytes).toBe(0);
164 });
165
166 test('lässt kein Fenster zurück', async () => {
167 /* Ein verstecktes Fenster, das nicht abgeräumt wird, hält den Prozess
168 am Leben und frisst Speicher. */
169 const offen = await app?.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length);
170
171 expect(offen).toBe(1);
172 });
173 });