#50: Statuszeile mit Version, Provider und Konfigurationsdateipfad
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+13
@@ -277,6 +277,15 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
Consumer<String> titleUpdateListener = title -> { };
|
Consumer<String> titleUpdateListener = title -> { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener der bei jedem Zustandswechsel des Editor-Zustands aufgerufen wird und
|
||||||
|
* den neuen Zustand an die Statuszeile weiterleitet.
|
||||||
|
* <p>
|
||||||
|
* Package-private, damit {@link PdfUmbenennerGuiApplication} die Statuszeile verdrahten kann.
|
||||||
|
* Standard ist ein No-Op, damit der Workspace auch ohne Statuszeile funktioniert.
|
||||||
|
*/
|
||||||
|
Consumer<GuiConfigurationEditorState> statusBarStateListener = state -> { };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-provider {@link GuiModelFieldContainer} instances, one for each known provider family.
|
* Per-provider {@link GuiModelFieldContainer} instances, one for each known provider family.
|
||||||
* Populated in {@link #createProviderBlock(String, AiProviderFamily)} and registered with
|
* Populated in {@link #createProviderBlock(String, AiProviderFamily)} and registered with
|
||||||
@@ -1095,6 +1104,8 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
|
|
||||||
this.editorState = completion.newState();
|
this.editorState = completion.newState();
|
||||||
refreshHeader();
|
refreshHeader();
|
||||||
|
// Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein)
|
||||||
|
statusBarStateListener.accept(this.editorState);
|
||||||
|
|
||||||
if (result.hasApiKeyPreservationNote()) {
|
if (result.hasApiKeyPreservationNote()) {
|
||||||
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
|
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
|
||||||
@@ -1190,6 +1201,8 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
pendingMessages.clear();
|
pendingMessages.clear();
|
||||||
refreshView();
|
refreshView();
|
||||||
runEditorValidation();
|
runEditorValidation();
|
||||||
|
// Statuszeile über den neuen Zustand informieren
|
||||||
|
statusBarStateListener.accept(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureRoot() {
|
private void configureRoot() {
|
||||||
|
|||||||
+13
-6
@@ -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
|
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
||||||
* folder for documents that have not yet been successfully processed, and
|
* folder for documents that have not yet been successfully processed, and
|
||||||
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
* 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.
|
||||||
* <p>
|
* <p>
|
||||||
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
* 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.
|
* know about provider-specific HTTP details or adapter wiring.
|
||||||
@@ -65,7 +66,8 @@ public record GuiStartupContext(
|
|||||||
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||||
GuiManualFileRenamePort manualFileRenamePort,
|
GuiManualFileRenamePort manualFileRenamePort,
|
||||||
GuiManualFileCopyPort manualFileCopyPort,
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
|
String applicationVersion) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fully wired startup context.
|
* Creates a fully wired startup context.
|
||||||
@@ -92,6 +94,8 @@ public record GuiStartupContext(
|
|||||||
* must not be {@code null}
|
* must not be {@code null}
|
||||||
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
||||||
* for skipped documents; must not be {@code null}
|
* 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 {
|
public GuiStartupContext {
|
||||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||||
@@ -124,6 +128,8 @@ public record GuiStartupContext(
|
|||||||
"manualFileCopyPort must not be null");
|
"manualFileCopyPort must not be null");
|
||||||
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
||||||
"historicalDocumentContextPort must not be null");
|
"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,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(), "dev");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +207,7 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(), "dev");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,7 +243,7 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService,
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(), "dev");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -351,6 +357,7 @@ public record GuiStartupContext(
|
|||||||
rejectingResetPort(),
|
rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(),
|
rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(),
|
||||||
|
"dev");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+196
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* Die Statuszeile zeigt immer drei Segmente:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Links:</b> Anwendungsversion im Format {@code V<version>}, z. B. {@code Vdev}.</li>
|
||||||
|
* <li><b>Mitte:</b> Aktiver Provider und Modellname aus der geladenen Konfiguration.</li>
|
||||||
|
* <li><b>Rechts:</b> Pfad der geladenen Konfigurationsdatei.</li>
|
||||||
|
* </ul>
|
||||||
|
* Wenn keine Konfiguration geladen ist, zeigen Mitte und Rechts den Text
|
||||||
|
* {@value #KEIN_PROFIL_TEXT}. Die Versionsanzeige ist stets sichtbar.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt.
|
||||||
|
* Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt
|
||||||
|
* und angezeigt.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* Die Versionsanzeige bleibt unverändert.
|
||||||
|
* <p>
|
||||||
|
* 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}).
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* Das Format ist: {@code Provider: <AnzeigeName> · <Modellname>}, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -8,6 +8,7 @@ import javafx.application.Platform;
|
|||||||
import javafx.event.EventHandler;
|
import javafx.event.EventHandler;
|
||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
import javafx.stage.WindowEvent;
|
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.
|
// Wire the title-update listener so the stage title stays in sync with the dirty state.
|
||||||
workspace.titleUpdateListener = primaryStage::setTitle;
|
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.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
|
||||||
primaryStage.setScene(scene);
|
primaryStage.setScene(scene);
|
||||||
|
|
||||||
|
|||||||
+343
@@ -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}.
|
||||||
|
* <p>
|
||||||
|
* Überprüft die Versionsanzeige, den Provider-Text und den Konfigurationspfad-Text
|
||||||
|
* in den verschiedenen Zuständen (ohne und mit geladener Konfiguration).
|
||||||
|
* <p>
|
||||||
|
* 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<String> 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<String> 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<String> 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<String> providerText = new AtomicReference<>();
|
||||||
|
AtomicReference<String> 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<String> providerText = new AtomicReference<>();
|
||||||
|
AtomicReference<String> 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<String> 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: <Name> · <Modell>' 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<String> 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<String> 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<String> 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<String> 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<Throwable> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-4
@@ -806,6 +806,8 @@ public class BootstrapRunner {
|
|||||||
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
||||||
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
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()) {
|
if (configPathOverride.isEmpty()) {
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
@@ -824,7 +826,8 @@ public class BootstrapRunner {
|
|||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort,
|
||||||
|
applicationVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -848,7 +851,8 @@ public class BootstrapRunner {
|
|||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort,
|
||||||
|
applicationVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||||
@@ -858,7 +862,7 @@ public class BootstrapRunner {
|
|||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort, applicationVersion);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -878,7 +882,8 @@ public class BootstrapRunner {
|
|||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort,
|
||||||
|
applicationVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user