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.
This commit is contained in:
2026-05-07 17:53:09 +02:00
parent 32e32a9b27
commit e9061d1b1f
2 changed files with 345 additions and 0 deletions
@@ -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<PdfPreviewPane> 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<PdfPreviewPane> 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<Throwable> 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());
}
}
}
@@ -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.
* <p>
* 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<GuiStartupContext> 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<GuiStartupContext> 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<GuiStartupContext> 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<GuiStartupContext> 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);
}
};
}
}