diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 8d19d6a..5e0f9f5 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -277,6 +277,15 @@ public final class GuiConfigurationEditorWorkspace { */ Consumer titleUpdateListener = title -> { }; + /** + * Listener der bei jedem Zustandswechsel des Editor-Zustands aufgerufen wird und + * den neuen Zustand an die Statuszeile weiterleitet. + *

+ * Package-private, damit {@link PdfUmbenennerGuiApplication} die Statuszeile verdrahten kann. + * Standard ist ein No-Op, damit der Workspace auch ohne Statuszeile funktioniert. + */ + Consumer statusBarStateListener = state -> { }; + /** * Per-provider {@link GuiModelFieldContainer} instances, one for each known provider family. * Populated in {@link #createProviderBlock(String, AiProviderFamily)} and registered with @@ -1095,6 +1104,8 @@ public final class GuiConfigurationEditorWorkspace { this.editorState = completion.newState(); refreshHeader(); + // Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein) + statusBarStateListener.accept(this.editorState); if (result.hasApiKeyPreservationNote()) { LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, " @@ -1190,6 +1201,8 @@ public final class GuiConfigurationEditorWorkspace { pendingMessages.clear(); refreshView(); runEditorValidation(); + // Statuszeile über den neuen Zustand informieren + statusBarStateListener.accept(newState); } private void configureRoot() { diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 529b8fb..8a78fa6 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -44,7 +44,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; * the {@link GuiManualFileCopyPort} used to manually copy a source file to the target * folder for documents that have not yet been successfully processed, and * the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing - * context for documents that were skipped in the current run. + * context for documents that were skipped in the current run, and the resolved application + * version string that the status bar displays at the bottom of the main window. *

* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to * know about provider-specific HTTP details or adapter wiring. @@ -65,7 +66,8 @@ public record GuiStartupContext( GuiResetDocumentStatusPort resetDocumentStatusPort, GuiManualFileRenamePort manualFileRenamePort, GuiManualFileCopyPort manualFileCopyPort, - GuiHistoricalDocumentContextPort historicalDocumentContextPort) { + GuiHistoricalDocumentContextPort historicalDocumentContextPort, + String applicationVersion) { /** * Creates a fully wired startup context. @@ -92,6 +94,8 @@ public record GuiStartupContext( * must not be {@code null} * @param historicalDocumentContextPort bridge that resolves the historical processing context * for skipped documents; must not be {@code null} + * @param applicationVersion resolved application version string shown in the status + * bar; {@code null} defaults to {@code "dev"} */ public GuiStartupContext { initialState = Objects.requireNonNull(initialState, "initialState must not be null"); @@ -124,6 +128,8 @@ public record GuiStartupContext( "manualFileCopyPort must not be null"); historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort, "historicalDocumentContextPort must not be null"); + // Null-Fallback für Testumgebungen ohne gepacktes JAR + applicationVersion = applicationVersion == null ? "dev" : applicationVersion; } /** @@ -165,7 +171,7 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort()); + noOpHistoricalDocumentContextPort(), "dev"); } /** @@ -201,7 +207,7 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort()); + noOpHistoricalDocumentContextPort(), "dev"); } /** @@ -237,7 +243,7 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort()); + noOpHistoricalDocumentContextPort(), "dev"); } private static GuiBatchRunLauncher rejectingBatchRunLauncher() { @@ -351,6 +357,7 @@ public record GuiStartupContext( rejectingResetPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort()); + noOpHistoricalDocumentContextPort(), + "dev"); } } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStatusBar.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStatusBar.java new file mode 100644 index 0000000..be2a5ec --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStatusBar.java @@ -0,0 +1,196 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; + +/** + * Permanente Statuszeile am unteren Rand des Hauptfensters. + *

+ * Die Statuszeile zeigt immer drei Segmente: + *

+ * Wenn keine Konfiguration geladen ist, zeigen Mitte und Rechts den Text + * {@value #KEIN_PROFIL_TEXT}. Die Versionsanzeige ist stets sichtbar. + *

+ * Alle Aktualisierungen dieser Komponente müssen auf dem JavaFX Application Thread erfolgen. + * Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung. + */ +public final class GuiStatusBar { + + /** Anzeigetext wenn keine Konfiguration geladen ist. */ + static final String KEIN_PROFIL_TEXT = "Kein Profil geladen"; + + /** Präfix vor der Versionsnummer in der linken Statuszeilen-Zelle. */ + private static final String VERSION_PREFIX = "V"; + + private static final AiProviderFamilyStringConverter PROVIDER_CONVERTER = + new AiProviderFamilyStringConverter(); + + private final String applicationVersion; + private final BorderPane root; + private final Label versionLabel; + private final Label providerLabel; + private final Label configPathLabel; + + /** + * Erstellt eine neue Statuszeile mit der angegebenen Anwendungsversion. + * + * @param applicationVersion die aufgelöste Versionsnummer; {@code null} oder leer führt zum + * Fallback {@code "dev"} + */ + public GuiStatusBar(String applicationVersion) { + this.applicationVersion = (applicationVersion == null || applicationVersion.isBlank()) + ? "dev" + : applicationVersion; + + // Linkes Segment: Versionsanzeige + this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion); + this.versionLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;"); + + // Mittleres Segment: Provider und Modell + this.providerLabel = new Label(KEIN_PROFIL_TEXT); + this.providerLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;"); + this.providerLabel.setAlignment(Pos.CENTER); + + // Rechtes Segment: Konfigurationspfad + this.configPathLabel = new Label(KEIN_PROFIL_TEXT); + this.configPathLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;"); + this.configPathLabel.setAlignment(Pos.CENTER_RIGHT); + + // Abstandhalter zwischen den Segmenten + Region leftSpacer = new Region(); + Region rightSpacer = new Region(); + HBox.setHgrow(leftSpacer, Priority.ALWAYS); + HBox.setHgrow(rightSpacer, Priority.ALWAYS); + + HBox content = new HBox(16, + versionLabel, leftSpacer, + providerLabel, rightSpacer, + configPathLabel); + content.setAlignment(Pos.CENTER_LEFT); + content.setPadding(new Insets(4, 12, 4, 12)); + content.setStyle("-fx-background-color: #f5f5f5;"); + + Separator topSeparator = new Separator(); + + this.root = new BorderPane(); + this.root.setTop(topSeparator); + this.root.setCenter(content); + } + + /** + * Gibt den Wurzelknoten der Statuszeile zurück, der in das Hauptfenster eingebettet wird. + * + * @return der Wurzelknoten; nie {@code null} + */ + public BorderPane root() { + return root; + } + + /** + * Aktualisiert die Statuszeile anhand des aktuellen Editor-Zustands. + *

+ * Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt. + * Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt + * und angezeigt. + *

+ * Muss auf dem JavaFX Application Thread aufgerufen werden. + * + * @param state der aktuelle Editor-Zustand; darf nicht {@code null} sein + */ + public void applyEditorState(GuiConfigurationEditorState state) { + if (state == null || !state.hasLoadedFileSnapshot()) { + clearConfiguration(); + return; + } + String configPath = state.configurationPathText(); + String providerText = resolveProviderText(state); + providerLabel.setText(providerText); + configPathLabel.setText(configPath.isBlank() ? KEIN_PROFIL_TEXT : configPath); + } + + /** + * Setzt Mitte und Rechts der Statuszeile auf den Text {@link #KEIN_PROFIL_TEXT} zurück. + *

+ * Die Versionsanzeige bleibt unverändert. + *

+ * Muss auf dem JavaFX Application Thread aufgerufen werden. + */ + public void clearConfiguration() { + providerLabel.setText(KEIN_PROFIL_TEXT); + configPathLabel.setText(KEIN_PROFIL_TEXT); + } + + /** + * Gibt den aktuell angezeigten Versionstext zurück (inkl. Präfix {@code V}). + *

+ * Für Tests zugänglich. + * + * @return der angezeigte Versionstext; nie {@code null} + */ + String versionText() { + return versionLabel.getText(); + } + + /** + * Gibt den aktuell angezeigten Provider-Text zurück. + *

+ * Für Tests zugänglich. + * + * @return der angezeigte Provider-Text; nie {@code null} + */ + String providerText() { + return providerLabel.getText(); + } + + /** + * Gibt den aktuell angezeigten Konfigurationspfad-Text zurück. + *

+ * Für Tests zugänglich. + * + * @return der angezeigte Konfigurationspfad-Text; nie {@code null} + */ + String configPathText() { + return configPathLabel.getText(); + } + + /** + * Ermittelt den anzuzeigenden Provider-Text aus dem Editor-Zustand. + *

+ * Das Format ist: {@code Provider: · }, wobei der Modellname + * weggelassen wird, wenn er leer ist. + * + * @param state der Editor-Zustand; darf nicht {@code null} sein + * @return der formatierte Provider-Text; nie {@code null} + */ + private static String resolveProviderText(GuiConfigurationEditorState state) { + String activeIdentifier = state.values().activeProviderFamily(); + if (activeIdentifier == null || activeIdentifier.isBlank()) { + return KEIN_PROFIL_TEXT; + } + AiProviderFamily family = AiProviderFamily.fromIdentifier(activeIdentifier).orElse(null); + if (family == null) { + return KEIN_PROFIL_TEXT; + } + String displayName = PROVIDER_CONVERTER.toString(family); + GuiProviderConfigurationState providerState = state.values().providerConfiguration(family); + String model = providerState != null ? providerState.model() : ""; + if (model == null || model.isBlank()) { + return "Provider: " + displayName; + } + return "Provider: " + displayName + " · " + model; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java index 332f659..d0b79ac 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java @@ -8,6 +8,7 @@ import javafx.application.Platform; import javafx.event.EventHandler; import javafx.scene.Scene; import javafx.scene.image.Image; +import javafx.scene.layout.BorderPane; import javafx.stage.Stage; import javafx.stage.WindowEvent; @@ -69,7 +70,16 @@ public class PdfUmbenennerGuiApplication extends Application { // Wire the title-update listener so the stage title stays in sync with the dirty state. workspace.titleUpdateListener = primaryStage::setTitle; - Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT); + // Statuszeile anlegen und mit dem Workspace verdrahten + GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion()); + workspace.statusBarStateListener = statusBar::applyEditorState; + + // Statuszeile unterhalb des Workspace-Inhalts einbetten + BorderPane outerLayout = new BorderPane(); + outerLayout.setCenter(workspace.root()); + outerLayout.setBottom(statusBar.root()); + + Scene scene = new Scene(outerLayout, DEFAULT_WIDTH, DEFAULT_HEIGHT); primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState())); primaryStage.setScene(scene); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStatusBarTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStatusBarTest.java new file mode 100644 index 0000000..30f5c96 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStatusBarTest.java @@ -0,0 +1,343 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +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.Path; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import javafx.application.Platform; + +/** + * Tests für die Statuszeilen-Komponente {@link GuiStatusBar}. + *

+ * Überprüft die Versionsanzeige, den Provider-Text und den Konfigurationspfad-Text + * in den verschiedenen Zuständen (ohne und mit geladener Konfiguration). + *

+ * Die Tests laufen unter Monocle (Headless-JavaFX), da {@link GuiStatusBar} JavaFX-Controls erzeugt. + */ +class GuiStatusBarTest { + + private static final long FX_TIMEOUT_SECONDS = 10; + private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false); + + /** + * Initialisiert die JavaFX-Plattform einmalig für alle Tests dieser Klasse. + * + * @throws InterruptedException falls der Thread beim Warten unterbrochen wird + */ + @BeforeAll + static void setUpJavaFxPlatform() throws InterruptedException { + Platform.setImplicitExit(false); + CountDownLatch startLatch = new CountDownLatch(1); + try { + Platform.startup(() -> { + PLATFORM_STARTED.set(true); + startLatch.countDown(); + }); + } catch (IllegalStateException alreadyInitialized) { + // JavaFX wurde bereits durch einen anderen Test gestartet + PLATFORM_STARTED.set(true); + startLatch.countDown(); + } + assertTrue( + startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "JavaFX-Plattform muss innerhalb des Timeouts starten"); + } + + /** Plattform bleibt für nachfolgende Tests am Leben. */ + @AfterAll + static void tearDownJavaFxPlatform() { + // Absichtlich kein Platform.exit() – damit andere Smoke-Tests weiterhin die Plattform nutzen können. + } + + // ========================================================================= + // Versionsanzeige + // ========================================================================= + + /** + * Prüft, dass die Versionsanzeige das korrekte Präfix und die übergebene Version enthält. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void versionLabel_zeigtVersionMitPraefix() throws Exception { + AtomicReference versionText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("3.0.42"); + versionText.set(bar.versionText()); + }); + assertEquals("V3.0.42", versionText.get(), + "Die Versionsanzeige muss das Präfix 'V' gefolgt von der Versionsnummer enthalten"); + } + + /** + * Prüft, dass ein {@code null}-Wert für die Version als {@code "dev"} angezeigt wird. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void versionLabel_mitNullFaellzurueckAufDev() throws Exception { + AtomicReference versionText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar(null); + versionText.set(bar.versionText()); + }); + assertEquals("Vdev", versionText.get(), + "Ein null-Wert muss als Fallback 'dev' angezeigt werden"); + } + + /** + * Prüft, dass ein leerer String für die Version als {@code "dev"} angezeigt wird. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void versionLabel_mitLeeremStringFaellzurueckAufDev() throws Exception { + AtomicReference versionText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar(" "); + versionText.set(bar.versionText()); + }); + assertEquals("Vdev", versionText.get(), + "Ein leerer String muss als Fallback 'dev' angezeigt werden"); + } + + // ========================================================================= + // Standardzustand ohne geladene Konfiguration + // ========================================================================= + + /** + * Prüft, dass Mitte und Rechts den Text „Kein Profil geladen" zeigen, wenn keine + * Konfiguration geladen ist. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void ohneKonfiguration_zeigtKeinProfilGeladen() throws Exception { + AtomicReference providerText = new AtomicReference<>(); + AtomicReference configPathText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + providerText.set(bar.providerText()); + configPathText.set(bar.configPathText()); + }); + assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(), + "Ohne geladene Konfiguration muss 'Kein Profil geladen' als Provider-Text erscheinen"); + assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(), + "Ohne geladene Konfiguration muss 'Kein Profil geladen' als Konfigurationspfad erscheinen"); + } + + /** + * Prüft, dass {@link GuiStatusBar#clearConfiguration()} Mitte und Rechts zurücksetzt. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void clearConfiguration_setztMitteUndRechtsZurueck() throws Exception { + AtomicReference providerText = new AtomicReference<>(); + AtomicReference configPathText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + // Zustand mit Konfiguration setzen, dann löschen + GuiConfigurationEditorState state = buildStateWithConfiguration( + "config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7"); + bar.applyEditorState(state); + bar.clearConfiguration(); + providerText.set(bar.providerText()); + configPathText.set(bar.configPathText()); + }); + assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(), + "Nach clearConfiguration() muss 'Kein Profil geladen' als Provider-Text erscheinen"); + assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(), + "Nach clearConfiguration() muss 'Kein Profil geladen' als Konfigurationspfad erscheinen"); + } + + // ========================================================================= + // Zustand nach Laden einer Konfiguration + // ========================================================================= + + /** + * Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Provider-Text + * mit Modell angezeigt wird. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void applyEditorState_mitClaudeUndModell_zeigtKorrektesFormat() throws Exception { + AtomicReference providerText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + GuiConfigurationEditorState state = buildStateWithConfiguration( + "config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7"); + bar.applyEditorState(state); + providerText.set(bar.providerText()); + }); + assertEquals("Provider: Claude · claude-opus-4-7", providerText.get(), + "Der Provider-Text muss das Format 'Provider: · ' haben"); + } + + /** + * Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Konfigurationspfad + * angezeigt wird. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void applyEditorState_mitKonfigurationspfad_zeigtKonfiguationspfad() throws Exception { + AtomicReference configPathText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + GuiConfigurationEditorState state = buildStateWithConfiguration( + "config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7"); + bar.applyEditorState(state); + configPathText.set(bar.configPathText()); + }); + assertTrue(configPathText.get().contains("application.properties"), + "Der Konfigurationspfad muss den Dateinamen enthalten"); + } + + /** + * Prüft, dass ein OpenAI-kompatibler Provider korrekt angezeigt wird. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void applyEditorState_mitOpenAiUndModell_zeigtKorrektesFormat() throws Exception { + AtomicReference providerText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + GuiConfigurationEditorState state = buildStateWithConfiguration( + "config/application.properties", AiProviderFamily.OPENAI_COMPATIBLE, "gpt-4o"); + bar.applyEditorState(state); + providerText.set(bar.providerText()); + }); + assertEquals("Provider: OpenAI-kompatibel · gpt-4o", providerText.get(), + "Der Provider-Text muss für OpenAI-kompatibel den deutschen Anzeigenamen verwenden"); + } + + /** + * Prüft, dass beim Übergeben eines {@code null}-Zustands kein Absturz erfolgt und der + * Text „Kein Profil geladen" erscheint. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void applyEditorState_mitNull_keinAbsturz() throws Exception { + AtomicReference providerText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + bar.applyEditorState(null); + providerText.set(bar.providerText()); + }); + assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(), + "Ein null-Zustand darf keinen Absturz verursachen"); + } + + /** + * Prüft, dass ohne geladenen Dateisnapshot „Kein Profil geladen" angezeigt wird, + * auch wenn Konfigurationswerte vorhanden sind. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void applyEditorState_ohneSnapshot_zeigtKeinProfilGeladen() throws Exception { + AtomicReference providerText = new AtomicReference<>(); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + // Standard-Template hat keinen Snapshot + GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate(); + bar.applyEditorState(state); + providerText.set(bar.providerText()); + }); + assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(), + "Ohne geladenen Dateisnapshot muss 'Kein Profil geladen' erscheinen"); + } + + /** + * Prüft, dass der Wurzelknoten der Statuszeile nicht null ist. + * + * @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird + */ + @Test + void root_istNichtNull() throws Exception { + AtomicBoolean rootNotNull = new AtomicBoolean(false); + runOnFxThread(() -> { + GuiStatusBar bar = new GuiStatusBar("1.0.0"); + rootNotNull.set(bar.root() != null); + }); + assertTrue(rootNotNull.get(), "Der Wurzelknoten der Statuszeile darf nicht null sein"); + } + + // ========================================================================= + // Hilfsmethoden + // ========================================================================= + + /** + * Führt eine Aktion synchron auf dem JavaFX Application Thread aus und wartet auf Abschluss. + * + * @param action die auszuführende Aktion + * @throws Exception falls die Aktion einen Fehler wirft oder das Timeout überschritten wird + */ + private static void runOnFxThread(Runnable action) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + Platform.runLater(() -> { + try { + action.run(); + } catch (Throwable t) { + error.set(t); + } finally { + latch.countDown(); + } + }); + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "FX-Thread-Task muss innerhalb des Timeouts abgeschlossen werden"); + if (error.get() != null) { + throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", error.get()); + } + } + + /** + * Erstellt einen Editor-Zustand mit geladenem Dateisnapshot für den angegebenen + * Konfigurationspfad, Provider und Modell. + * + * @param configPath relativer Konfigurationsdateipfad + * @param family aktive Provider-Familie + * @param model Modellbezeichner + * @return ein Editor-Zustand mit Snapshot + */ + private static GuiConfigurationEditorState buildStateWithConfiguration( + String configPath, AiProviderFamily family, String model) { + GuiConfigurationEditorState template = GuiConfigurationTemplateFactory.createStandardTemplate(); + // Provider und Modell setzen + GuiProviderConfigurationState providerState = new GuiProviderConfigurationState( + "https://api.example.com", model, "30", + de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState.unresolved()); + GuiConfigurationValues values = template.values() + .withActiveProviderFamily(family.getIdentifier()) + .withProviderConfiguration(family, providerState); + // Snapshot anlegen + GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot( + Path.of(configPath), new Properties()); + return new GuiConfigurationEditorState( + Optional.of(snapshot), values, values, Optional.empty()); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 10a8e27..44156dc 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -806,6 +806,8 @@ public class BootstrapRunner { GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename; GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy; GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui; + // Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start + String applicationVersion = ApplicationVersionProvider.resolveVersion(); if (configPathOverride.isEmpty()) { return new GuiStartupContext( @@ -824,7 +826,8 @@ public class BootstrapRunner { resetPort, manualRenamePort, manualCopyPort, - historicalDocumentContextPort); + historicalDocumentContextPort, + applicationVersion); } Path configPath = Paths.get(configPathOverride.get()); @@ -848,7 +851,8 @@ public class BootstrapRunner { resetPort, manualRenamePort, manualCopyPort, - historicalDocumentContextPort); + historicalDocumentContextPort, + applicationVersion); } LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath()); @@ -858,7 +862,7 @@ public class BootstrapRunner { modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetPort, manualRenamePort, manualCopyPort, - historicalDocumentContextPort); + historicalDocumentContextPort, applicationVersion); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -878,7 +882,8 @@ public class BootstrapRunner { resetPort, manualRenamePort, manualCopyPort, - historicalDocumentContextPort); + historicalDocumentContextPort, + applicationVersion); } }