/** * Der PDF-Export im echten Electron. * * Die Einheitentests prüfen das HTML. Hier wird gemessen, was Chromium daraus * macht – und zwar am erzeugten Artefakt, nicht an der Abwesenheit einer * Ausnahme. * * **Die stille Falle.** Wird der eingebettete Stil blockiert, entsteht das PDF * trotzdem: gleiche Struktur, gleiche Größenordnung, nur eben ohne * Formatierung. `printToPDF` wirft dabei nicht. Deshalb prüft dieser Lauf * nicht nur die Struktur, sondern auch, dass der Stil überhaupt eine Wirkung * hat – gemessen daran, dass Normal- und Großdruck verschieden viele Seiten * ergeben. * * Der Speicherdialog wird im Hauptprozess durch eine Attrappe ersetzt: Ein * Testlauf darf kein Fenster öffnen, auf das niemand klickt. */ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type ElectronApplication, type Page } from '@playwright/test'; import { appStarten, bauPruefen } from './electron-hilfe'; import { expect, test } from './konsolenwache'; let app: ElectronApplication | undefined; let fenster: Page; let ordner = ''; test.beforeAll(async () => { // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. bauPruefen(); ({ app, fenster } = await appStarten()); ordner = mkdtempSync(join(tmpdir(), 'wsk-druck-e2e-')); }); test.afterAll(async () => { await app?.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufräumen ist Kür. */ } }); /** Ersetzt den Speicherdialog; `null` bedeutet „Nutzer bricht ab“. */ async function dialogStellen(zielpfad: string | null): Promise { await app?.evaluate(({ dialog }, pfad) => { (dialog as unknown as { showSaveDialog: unknown }).showSaveDialog = () => Promise.resolve( pfad === null ? { canceled: true, filePath: '' } : { canceled: false, filePath: pfad }, ); }, zielpfad); } /** Löst den Export über die Brücke aus, so wie es die Oberfläche tut. */ async function exportieren(groesse: 'normal' | 'gross'): Promise<{ gespeichert: boolean; pfad: string | null; bytes: number; }> { return fenster.evaluate(async (g) => { const api = (window as unknown as { lernApp: Record }).lernApp; const drucken = api['lernberichtDrucken'] as ( profilId: number, groesse: string, ) => Promise<{ gespeichert: boolean; pfad: string | null; bytes: number }>; return drucken(1, g); }, groesse); } test.describe('Lernbericht als PDF', () => { test('schreibt eine PDF-Datei dorthin, wo der Dialog hinzeigt', async () => { const ziel = join(ordner, 'bericht.pdf'); await dialogStellen(ziel); const ergebnis = await exportieren('normal'); expect(ergebnis.gespeichert).toBe(true); expect(ergebnis.pfad).toBe(ziel); expect(existsSync(ziel)).toBe(true); expect(ergebnis.bytes).toBeGreaterThan(2000); const roh = readFileSync(ziel); expect(roh.subarray(0, 5).toString('latin1')).toBe('%PDF-'); }); test('erzeugt einen Strukturbaum mit Sprachangabe und Lesezeichen', () => { /* Das ist der Unterschied zwischen einem lesbaren Dokument und einem Bild von Buchstaben. Zugesichert ist damit KEINE PDF/UA-Konformität – Electron führt beide Optionen als experimentell. Geprüft wird genau das, was messbar ist. */ const text = readFileSync(join(ordner, 'bericht.pdf')).toString('latin1'); expect(text, 'kein Strukturbaum – generateTaggedPDF hat nicht gewirkt').toContain( '/StructTreeRoot', ); expect(text).toContain('/Marked true'); expect(text, 'keine Sprachangabe – Screenreader raten dann die Sprache').toContain('/Lang'); expect(text, 'keine Lesezeichen – generateDocumentOutline hat nicht gewirkt').toContain( '/Outlines', ); }); test('der Druckstil wirkt wirklich', async () => { /* Der Nachweis, dass der eingebettete Stil nicht stillschweigend blockiert wird. Wuerde er es, entstuende das PDF klaglos - gleiche Struktur, gleiche Groessenordnung, nur ohne Formatierung. Es wirft nichts, und nichts sieht falsch aus. Geprueft wird deshalb ueber die WIRKUNG: Derselbe Bericht in Normal- und in Grossdruck muss verschieden viele Seiten haben. Sind sie gleich, hat das Stylesheet keinen Einfluss gehabt. Verworfen wurde die naheliegende Variante, im PDF nach der im Stil verlangten Schrift zu suchen. Gegengeprueft mit blockiertem Stil: Sie steht auch dann drin - unter Windows ist Segoe UI ohnehin die Standardschrift. Der Test waere gruen gewesen und haette nichts gesagt. */ /* Seitenzahl eines PDF - gezaehlt, nicht dem ersten Baumknoten geglaubt. Hier stand bis Fassung 0.18.0 ein Griff nach der ersten Fundstelle von `/Type /Pages ... /Count`. Chromium baut den Seitenbaum aber mehrstufig aus Zwischenknoten mit je acht Kindern; die erste Fundstelle ist ein solcher Zwischenknoten. Nachgemessen an erzeugten PDF lieferte der Griff fuer JEDES Dokument ueber acht Seiten die Zahl 8 - auch fuer eines mit 982 Seiten. Der Test war gruen und verglich 8 gegen 8. Gezaehlt werden deshalb die Seitenobjekte selbst. `(?![s])` schliesst `/Type /Pages` aus, ohne das ein Zwischenknoten mitzaehlte. */ const seiten = (datei: string): number => (readFileSync(datei, 'latin1').match(/\/Type\s*\/Page(?![s])/gu) ?? []).length; const gross = join(ordner, 'gross.pdf'); await dialogStellen(gross); await exportieren('gross'); const normalSeiten = seiten(join(ordner, 'bericht.pdf')); const grossSeiten = seiten(gross); expect(normalSeiten).toBeGreaterThan(0); expect( grossSeiten, `Normal ${String(normalSeiten)} Seiten, Grossdruck ${String(grossSeiten)} - ` + 'gleich viele heisst: der Stil hat nicht gegriffen.', ).toBeGreaterThan(normalSeiten); }); test('schreibt nichts, wenn der Nutzer abbricht', async () => { await dialogStellen(null); const ergebnis = await exportieren('normal'); expect(ergebnis.gespeichert).toBe(false); expect(ergebnis.pfad).toBeNull(); expect(ergebnis.bytes).toBe(0); }); test('lässt kein Fenster zurück', async () => { /* Ein verstecktes Fenster, das nicht abgeräumt wird, hält den Prozess am Leben und frisst Speicher. */ const offen = await app?.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length); expect(offen).toBe(1); }); });