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:
+149
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user