From e9061d1b1f7b4331e1d2badffcc5d5741fbb0a23 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 7 May 2026 17:53:09 +0200 Subject: [PATCH] Erhoehe new-code-Coverage durch gezielte Tests fuer Reliability-Fixes Schliesst die durch die SonarQube-Reliability-Fixes (Commit 32e32a9) neu eingefuehrten Quellzeilen testseitig ab, damit das new-coverage Quality Gate von 80% wieder erreicht wird. PdfPreviewPaneRenderingTest: - erzeugt mit PDFBox echte ein- bzw. mehrseitige PDFs und ruft loadSource() auf, sodass loadAndRenderFirstPageOnWorker (inkl. AtomicReference-Setter fuer currentDocument/currentRenderer) und renderPageOnWorker (AtomicReference-Getter) tatsaechlich ausgefuehrt werden. BootstrapRunnerGuiContextInitFailureTest: - deckt die catch-Zweige fuer InvalidStartConfigurationException, DocumentPersistenceException und unspezifische RuntimeException in initializeApplicationRunContext ab. Damit werden die Pfade ausgefuehrt, in denen guiApplicationRunContext via AtomicReference.set(Optional.empty()) zurueckgesetzt wird. Keine Aenderungen an Produktivcode oder bestehenden Tests. --- .../batchrun/PdfPreviewPaneRenderingTest.java | 149 +++++++++++++ ...tstrapRunnerGuiContextInitFailureTest.java | 196 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPaneRenderingTest.java create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerGuiContextInitFailureTest.java diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPaneRenderingTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPaneRenderingTest.java new file mode 100644 index 0000000..fa5012f --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPaneRenderingTest.java @@ -0,0 +1,149 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javafx.application.Platform; + +/** + * Headless (Monocle) Tests, die echte PDF-Dateien rendern, damit die + * Worker-Thread-Pfade {@code loadAndRenderFirstPageOnWorker} und + * {@code renderPageOnWorker} tatsächlich ausgeführt werden. + */ +class PdfPreviewPaneRenderingTest { + + private static final long FX_TIMEOUT_SECONDS = 10; + private static final long WORKER_TIMEOUT_SECONDS = 15; + private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false); + + @BeforeAll + static void startPlatform() throws InterruptedException { + Platform.setImplicitExit(false); + if (PLATFORM_STARTED.compareAndSet(false, true)) { + CountDownLatch latch = new CountDownLatch(1); + try { + Platform.startup(latch::countDown); + } catch (IllegalStateException alreadyStarted) { + latch.countDown(); + } + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + } + + @Test + void loadSource_realSinglePagePdf_pageLabelShowsRenderedPage(@TempDir Path tempDir) throws Exception { + Path pdfFile = tempDir.resolve("single-page.pdf"); + createPdfWithPages(pdfFile, 1); + + AtomicReference paneRef = new AtomicReference<>(); + CountDownLatch firstPageRendered = new CountDownLatch(1); + + runOnFx(() -> { + PdfPreviewPane pane = new PdfPreviewPane(); + paneRef.set(pane); + pane.pageLabel().textProperty().addListener((obs, old, newText) -> { + if (newText != null && newText.contains("Seite 1 / 1")) { + firstPageRendered.countDown(); + } + }); + pane.loadSource(pdfFile); + }); + + assertTrue(firstPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "Erste Seite eines einseitigen PDFs muss innerhalb der Worker-Timeout-Frist gerendert werden"); + + runOnFx(() -> paneRef.get().shutdown()); + } + + @Test + void navigateToNextPage_multiPagePdf_rendersSecondPage(@TempDir Path tempDir) throws Exception { + Path pdfFile = tempDir.resolve("multi-page.pdf"); + createPdfWithPages(pdfFile, 3); + + AtomicReference paneRef = new AtomicReference<>(); + CountDownLatch firstPageRendered = new CountDownLatch(1); + CountDownLatch secondPageRendered = new CountDownLatch(1); + AtomicBoolean firstSeen = new AtomicBoolean(false); + + runOnFx(() -> { + PdfPreviewPane pane = new PdfPreviewPane(); + paneRef.set(pane); + pane.pageLabel().textProperty().addListener((obs, old, newText) -> { + if (newText == null) { + return; + } + if (newText.contains("Seite 1 / 3") && firstSeen.compareAndSet(false, true)) { + firstPageRendered.countDown(); + } else if (newText.contains("Seite 2 / 3")) { + secondPageRendered.countDown(); + } + }); + pane.loadSource(pdfFile); + }); + + assertTrue(firstPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "Erste Seite muss innerhalb der Worker-Timeout-Frist gerendert werden"); + + // Auf zweite Seite navigieren – triggert renderPageOnWorker + runOnFx(() -> paneRef.get().nextButton().fire()); + + assertTrue(secondPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "Zweite Seite muss nach Klick auf Weiter gerendert werden"); + + runOnFx(() -> paneRef.get().shutdown()); + } + + // ------------------------------------------------------------------------- + // Hilfsmethoden + // ------------------------------------------------------------------------- + + private static void createPdfWithPages(Path outputPath, int pages) throws IOException { + try (PDDocument doc = new PDDocument()) { + for (int i = 1; i <= pages; i++) { + PDPage page = new PDPage(); + doc.addPage(page); + try (PDPageContentStream stream = new PDPageContentStream(doc, page)) { + stream.beginText(); + stream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + stream.newLineAtOffset(50, 700); + stream.showText("Testseite " + i); + stream.endText(); + } + } + doc.save(outputPath.toFile()); + } + } + + private void runOnFx(Runnable action) throws InterruptedException { + CountDownLatch done = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + Platform.runLater(() -> { + try { + action.run(); + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "FX-Thread Timeout"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerGuiContextInitFailureTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerGuiContextInitFailureTest.java new file mode 100644 index 0000000..26f78d3 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerGuiContextInitFailureTest.java @@ -0,0 +1,196 @@ +package de.gecheckt.pdf.umbenenner.bootstrap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext; +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; +import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments; +import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode; + +/** + * Tests, die die einzelnen {@code catch}-Zweige von + * {@code BootstrapRunner.initializeApplicationRunContext()} abdecken. + *

+ * Der Pfad für {@code ConfigurationLoadingException} ist bereits durch + * {@link BootstrapRunnerConfigPathSemanticsTest} abgedeckt; dieser Test + * fokussiert auf die verbleibenden Fehlerklassen + * ({@link InvalidStartConfigurationException}, + * {@link DocumentPersistenceException} und {@link RuntimeException}), + * damit auch deren Zustandsübergänge auf + * {@code guiApplicationRunContext} ausgeführt werden. + */ +class BootstrapRunnerGuiContextInitFailureTest { + + @TempDir + Path tempDir; + + @Test + void guiStart_validatorThrowsInvalidStartConfiguration_contextErrorIsForwarded() throws Exception { + Path configFile = tempDir.resolve("invalid-validation.properties"); + Files.createFile(configFile); + + AtomicReference capturedContext = new AtomicReference<>(); + BootstrapRunner runner = guiRunner( + buildValidConfigPort(), + () -> new StartConfigurationValidator() { + @Override + public void validate(StartConfiguration config) { + throw new InvalidStartConfigurationException("Validierung fehlgeschlagen (Test)"); + } + }, + jdbcUrl -> () -> { /* no-op schema init */ }, + ctx -> capturedContext.set(ctx)); + + int exitCode = runner.run(new StartupArguments(StartupMode.GUI, + Optional.of(configFile.toString()))); + + assertEquals(0, exitCode, "GUI-Modus muss bei Validierungsfehler trotzdem regulär enden"); + GuiStartupContext context = capturedContext.get(); + assertNotNull(context, "GuiStartupContext muss an den GuiAdapter übergeben werden"); + assertTrue(context.applicationContextError().isPresent(), + "applicationContextError muss bei Validierungsfehler gesetzt sein"); + assertTrue(context.applicationContextError().get().contains("Konfiguration nicht lauffähig"), + "Fehlermeldung muss den Validierungspräfix enthalten"); + } + + @Test + void guiStart_schemaInitThrowsDocumentPersistence_contextErrorIsForwarded() throws Exception { + Path configFile = tempDir.resolve("invalid-schema.properties"); + Files.createFile(configFile); + + AtomicReference capturedContext = new AtomicReference<>(); + BootstrapRunner runner = guiRunner( + buildValidConfigPort(), + () -> new StartConfigurationValidator() { + @Override + public void validate(StartConfiguration config) { /* no-op valid validator */ } + }, + jdbcUrl -> () -> { + throw new DocumentPersistenceException("Schema-Init fehlgeschlagen (Test)"); + }, + ctx -> capturedContext.set(ctx)); + + int exitCode = runner.run(new StartupArguments(StartupMode.GUI, + Optional.of(configFile.toString()))); + + assertEquals(0, exitCode, "GUI-Modus muss bei SQLite-Fehler trotzdem regulär enden"); + GuiStartupContext context = capturedContext.get(); + assertNotNull(context); + assertTrue(context.applicationContextError().isPresent(), + "applicationContextError muss bei SQLite-Init-Fehler gesetzt sein"); + assertTrue(context.applicationContextError().get().contains("SQLite konnte nicht initialisiert werden"), + "Fehlermeldung muss den SQLite-Präfix enthalten"); + } + + @Test + void guiStart_unexpectedRuntimeException_contextErrorIsForwarded() throws Exception { + Path configFile = tempDir.resolve("invalid-runtime.properties"); + Files.createFile(configFile); + + AtomicReference capturedContext = new AtomicReference<>(); + BootstrapRunner runner = guiRunner( + buildValidConfigPort(), + () -> new StartConfigurationValidator() { + @Override + public void validate(StartConfiguration config) { + throw new IllegalStateException("Unerwarteter Initialisierungsfehler (Test)"); + } + }, + jdbcUrl -> () -> { /* unreachable */ }, + ctx -> capturedContext.set(ctx)); + + int exitCode = runner.run(new StartupArguments(StartupMode.GUI, + Optional.of(configFile.toString()))); + + assertEquals(0, exitCode, "GUI-Modus muss bei unerwartetem Fehler trotzdem regulär enden"); + GuiStartupContext context = capturedContext.get(); + assertNotNull(context); + assertTrue(context.applicationContextError().isPresent(), + "applicationContextError muss bei unerwartetem Fehler gesetzt sein"); + assertTrue(context.applicationContextError().get() + .contains("Unerwarteter Fehler bei der Kontextinitialisierung"), + "Fehlermeldung muss den allgemeinen Präfix enthalten"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private BootstrapRunner guiRunner( + BootstrapRunner.ConfigurationPortFactory configPortFactory, + BootstrapRunner.ValidatorFactory validatorFactory, + BootstrapRunner.SchemaInitializationPortFactory schemaInitFactory, + java.util.function.Consumer contextSink) { + return new BootstrapRunner( + path -> { /* no-op migration */ }, + configPortFactory, + lockFile -> { throw new AssertionError("RunLockPort must not be called in GUI mode"); }, + validatorFactory, + schemaInitFactory, + (config, lock) -> { throw new AssertionError("UseCaseFactory must not be called in GUI mode"); }, + useCase -> { throw new AssertionError("CommandFactory must not be called in GUI mode"); }, + () -> new GuiAdapter() { + @Override + public void start(GuiStartupContext startupContext) { + contextSink.accept(startupContext); + } + } + ); + } + + private BootstrapRunner.ConfigurationPortFactory buildValidConfigPort() { + return path -> { + try { + Path source = tempDir.resolve("source"); + Path target = tempDir.resolve("target"); + Files.createDirectories(source); + Files.createDirectories(target); + Path sqliteFile = tempDir.resolve("db.sqlite"); + if (!Files.exists(sqliteFile)) { + Files.createFile(sqliteFile); + } + Path promptFile = tempDir.resolve("prompt.txt"); + if (!Files.exists(promptFile)) { + Files.createFile(promptFile); + } + ProviderConfiguration providerConfig = new ProviderConfiguration( + "gpt-4", 30, "https://api.example.com", "test-key"); + MultiProviderConfiguration multiConfig = new MultiProviderConfiguration( + AiProviderFamily.OPENAI_COMPATIBLE, providerConfig, null); + StartConfiguration config = new StartConfiguration( + source, + target, + sqliteFile, + multiConfig, + 3, 100, 50000, 60, + promptFile, + tempDir.resolve("lock.lock"), + tempDir.resolve("logs"), + "INFO", + false + ); + return (ConfigurationPort) () -> config; + } catch (Exception e) { + throw new RuntimeException("Failed to build valid ConfigurationPort", e); + } + }; + } +}