M11 vollständig abgeschlossen (AP-001 bis AP-007)

- AP-001: Kernobjekte und Port-Verträge (ModelCatalog-Port, sealed
  Result-Typen, ApiKeyOrigin, GUI-Modell- und Meldungs-Records)
- AP-002: Provider-ComboBox, exklusiver Providerbereich,
  zustandsbewahrender Providerwechsel
- AP-003: HTTP-Adapter für Modellabruf (Claude, OpenAI-kompatibel)
  mit vollständigem Error-Mapping und Dispatcher im Bootstrap
- AP-004: Automatischer Modellabruf bei Providerwechsel, Aktion
  "Modelle neu laden", Umschaltung zwischen Modell-ComboBox und
  Modell-Textfeld, Worker-Thread-Kapselung
- AP-005: Automatische Editorvalidierung (Pflichtfelder,
  Warnschwellen max.text.characters, Plausibilitätshinweise
  max.pages, API-Key-Herkunftsauflösung mit Vorrangregel)
- AP-006: Zentraler Meldungsbereich mit vier Severity-Stufen,
  feldnahe rote Fehlermeldungen, API-Key-Herkunftsanzeige
- AP-007: Integrations- und Regressionstests, Timeout-Mapping-Tests,
  Replace-Semantik für wiederholte Modellabruf-Meldungen

Hexagonale Architektur eingehalten, Application- und Domain-Schicht
bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt.
Naming-Regel und JavaDoc-Standard durchgängig beachtet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:31:15 +02:00
parent bbb5c4da3a
commit aa067a3165
59 changed files with 8363 additions and 136 deletions
@@ -0,0 +1,277 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
import javafx.application.Platform;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Coordinates asynchronous model catalogue retrieval for the GUI provider section.
* <p>
* This coordinator is responsible for:
* <ul>
* <li>Triggering a background HTTP call via {@link AiModelCatalogPort} on a dedicated
* daemon thread named {@code gui-model-catalog}.</li>
* <li>Returning the result to the JavaFX Application Thread via {@code Platform.runLater}.</li>
* <li>Updating the per-provider {@link GuiModelFieldContainer} to show either a
* non-editable {@code ComboBox} (success) or a manual text field (all other cases).</li>
* <li>Appending a {@link GuiMessageEntry} to the supplied pending-messages list for each
* completed retrieval attempt, so later GUI layers can display the result.</li>
* </ul>
* <p>
* The worker thread factory is injectable so tests can supply a synchronous or latch-guarded
* executor without spinning a real OS thread.
* <p>
* This class is not thread-safe by itself. All methods intended to mutate GUI state must be
* called on the JavaFX Application Thread. Background threads only interact through
* {@code Platform.runLater}.
*/
public final class GuiModelCatalogCoordinator {
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
/** Default timeout used when no timeout is configured in the provider state. */
static final int DEFAULT_TIMEOUT_SECONDS = 10;
private final AiModelCatalogPort modelCatalogPort;
private final List<GuiMessageEntry> pendingMessages;
/**
* Factory for the background worker thread. Package-private to allow test substitution.
* The default creates a daemon thread named {@code gui-model-catalog}.
*/
Function<Runnable, Thread> modelCatalogThreadFactory;
/** Per-provider field containers; populated by the workspace when it builds provider blocks. */
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
new ConcurrentHashMap<>();
/**
* Consumer that delivers the retrieval result. In production this wraps the call in
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
* is applied immediately on the worker thread without needing an FX queue drain.
* Package-private to allow test substitution.
*/
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
/**
* Optional callback invoked on the JavaFX Application Thread after each retrieval result has
* been applied. The workspace uses this hook to refresh the central message area and field-error
* labels without coupling the coordinator to the workspace implementation.
* Package-private to allow substitution in tests.
*/
Runnable postResultCallback = () -> { };
/**
* Creates a coordinator backed by the given catalogue port and shared message list.
*
* @param modelCatalogPort port used for background HTTP calls; must not be {@code null}
* @param pendingMessages mutable list to append result messages to; must not be {@code null}
*/
public GuiModelCatalogCoordinator(AiModelCatalogPort modelCatalogPort,
List<GuiMessageEntry> pendingMessages) {
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
"modelCatalogPort must not be null");
this.pendingMessages = Objects.requireNonNull(pendingMessages,
"pendingMessages must not be null");
this.modelCatalogThreadFactory = task -> {
Thread t = new Thread(task, "gui-model-catalog");
t.setDaemon(true);
return t;
};
}
/**
* Registers a {@link GuiModelFieldContainer} for the given provider family.
* <p>
* Must be called on the JavaFX Application Thread before the first retrieval is triggered.
*
* @param family the provider family this container belongs to; must not be {@code null}
* @param container the container to register; must not be {@code null}
*/
public void registerFieldContainer(AiProviderFamily family, GuiModelFieldContainer container) {
Objects.requireNonNull(family, "family must not be null");
Objects.requireNonNull(container, "container must not be null");
fieldContainers.put(family, container);
}
/**
* Triggers an asynchronous model catalogue retrieval for the given provider family.
* <p>
* The retrieval is performed on a background worker thread. The result is delivered back
* to the JavaFX Application Thread via {@code Platform.runLater}. The registered
* {@link GuiModelFieldContainer} for the provider is updated accordingly, and a
* {@link GuiMessageEntry} is appended to the pending-messages list.
* <p>
* If no field container is registered for the provider, the call is a no-op.
* <p>
* Must be called on the JavaFX Application Thread.
*
* @param family the provider family to retrieve models for; must not be {@code null}
* @param providerState the current editor state for the provider; must not be {@code null}
*/
public void triggerModelRetrieval(AiProviderFamily family,
GuiProviderConfigurationState providerState) {
Objects.requireNonNull(family, "family must not be null");
Objects.requireNonNull(providerState, "providerState must not be null");
GuiModelFieldContainer container = fieldContainers.get(family);
if (container == null) {
LOG.debug("GUI-Modellabruf: Kein Feld-Container für Provider '{}' registriert übersprungen.",
family.getIdentifier());
return;
}
// Capture the current manual value before starting the background call.
String previousManualValue = container.currentModelValue();
// Build the request from the current editor state.
ModelCatalogRequest request = buildRequest(family, providerState);
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet.",
family.getIdentifier());
Runnable task = () -> {
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
resultDelivery.accept(() -> {
applyResult(family, container, result, previousManualValue);
postResultCallback.run();
});
};
Thread worker = modelCatalogThreadFactory.apply(task);
worker.start();
}
/**
* Applies the result of a completed model catalogue retrieval to the field container and
* appends a message entry to the pending-messages list.
* <p>
* Must only be called on the JavaFX Application Thread (via {@code Platform.runLater}).
*
* @param family the provider family that was queried; must not be {@code null}
* @param container the field container to update; must not be {@code null}
* @param result the retrieval result; must not be {@code null}
* @param previousManualValue the model value that was in the text field before the call
*/
private void applyResult(AiProviderFamily family,
GuiModelFieldContainer container,
ModelCatalogResult result,
String previousManualValue) {
// Remove any previous message entries from an earlier retrieval so messages do not
// accumulate across repeated triggers of the same retrieval action.
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
String displayName = displayNameFor(family);
switch (result) {
case ModelCatalogResult.Success success -> {
List<String> models = success.models();
container.applyModelList(models, previousManualValue);
String message = "Modellliste für " + displayName + " geladen ("
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
}
case ModelCatalogResult.EmptyList emptyList -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Provider " + displayName
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
}
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
+ ". Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
}
case ModelCatalogResult.TechnicalFailure failure -> {
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
+ "). Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
message, failure.errorDetail(), family.getIdentifier());
}
}
}
/**
* Builds a {@link ModelCatalogRequest} from the current provider editor state.
* <p>
* Missing or blank values are passed as {@code Optional.empty()} so the adapter can apply
* its own defaults or return {@link ModelCatalogResult.IncompleteConfiguration} if required
* values are absent.
*
* @param family the target provider family; must not be {@code null}
* @param providerState the current provider editor state; must not be {@code null}
* @return a new request; never {@code null}
*/
private static ModelCatalogRequest buildRequest(AiProviderFamily family,
GuiProviderConfigurationState providerState) {
Optional<String> baseUrl = Optional.ofNullable(providerState.baseUrl())
.filter(s -> !s.isBlank());
Optional<String> apiKey = Optional.ofNullable(providerState.apiKey())
.map(keyState -> keyState.propertyValue())
.filter(s -> !s.isBlank());
int timeout = DEFAULT_TIMEOUT_SECONDS;
String timeoutStr = providerState.timeoutSeconds();
if (timeoutStr != null && !timeoutStr.isBlank()) {
try {
int parsed = Integer.parseInt(timeoutStr.trim());
if (parsed > 0) {
timeout = parsed;
}
} catch (NumberFormatException ignored) {
// Use default.
}
}
return new ModelCatalogRequest(family.getIdentifier(), baseUrl, apiKey, timeout);
}
/**
* Returns a human-readable display name for the given provider family.
*
* @param family the provider family; must not be {@code null}
* @return the display name; never {@code null}
*/
private static String displayNameFor(AiProviderFamily family) {
return switch (family) {
case CLAUDE -> "Claude";
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
};
}
/**
* Returns an unmodifiable snapshot of the pending messages collected so far.
* <p>
* This method is intended for tests that need to inspect the message list after
* a retrieval completes.
*
* @return unmodifiable list of pending messages; never {@code null}
*/
public List<GuiMessageEntry> pendingMessagesSnapshot() {
return List.copyOf(pendingMessages);
}
}
@@ -5,26 +5,40 @@ import java.util.Optional;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
/**
* Immutable startup data for the GUI adapter.
* <p>
* Carries the initial editor state, the optional startup notice, the file-loading callback
* and the file-writing callback that the workspace uses for native save actions.
* Carries the initial editor state, the optional startup notice, the file-loading callback,
* the file-writing callback that the workspace uses for native save actions, the
* {@link AiModelCatalogPort} used to retrieve available AI model lists on demand, and the
* {@link ApiKeyResolutionPort} used by the editor validation to determine the effective
* API key provenance from environment variables.
* <p>
* All ports are supplied by Bootstrap so that the GUI adapter does not need to know about
* provider-specific HTTP details or adapter wiring.
*/
public record GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter) {
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort) {
/**
* Creates a startup context.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -33,10 +47,20 @@ public record GuiStartupContext(
"configurationFileLoader must not be null");
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
"configurationFileWriter must not be null");
modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
"modelCatalogPort must not be null");
apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
"apiKeyResolutionPort must not be null");
}
/**
* Creates a blank startup context with no loader or writer side effects.
* Creates a blank startup context with no loader or writer side effects, a no-op model
* catalogue port, and a no-op API key resolution port.
* <p>
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
* The no-op API key resolution port always returns {@code ABSENT}.
* This is safe for environments where no Bootstrap wiring is present, such as isolated
* GUI tests.
*
* @param startupNotice optional startup notice; {@code null} becomes empty
* @return a startup context for the unloaded editor start
@@ -46,6 +70,11 @@ public record GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
startupNotice,
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
(values, path) -> GuiConfigurationSaveResult.saved(path));
(values, path) -> GuiConfigurationSaveResult.saved(path),
request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration(
request.providerIdentifier(),
"Kein Modellkatalog in diesem Startkontext verfügbar."),
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent());
}
}
@@ -0,0 +1,63 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.util.StringConverter;
/**
* A JavaFX {@link StringConverter} that maps {@link AiProviderFamily} constants to
* German display labels and back.
* <p>
* Used by the provider selection {@code ComboBox} to show human-readable German names
* while keeping the underlying model type-safe. The reverse conversion
* ({@link #fromString(String)}) supports the same label strings produced by
* {@link #toString(AiProviderFamily)} so that a ComboBox configured as non-editable
* can still convert its selected text back to the enum constant when needed.
* <p>
* Returns {@code null} for inputs that do not match any known constant to signal an
* unrecognised display label.
*/
public final class AiProviderFamilyStringConverter extends StringConverter<AiProviderFamily> {
/**
* Creates a new converter instance.
*/
public AiProviderFamilyStringConverter() {
// Default constructor.
}
/**
* Returns the German display label for the given provider family.
*
* @param family the provider family to convert; may be {@code null}
* @return the German display label, or an empty string when {@code family} is {@code null}
*/
@Override
public String toString(AiProviderFamily family) {
if (family == null) {
return "";
}
return switch (family) {
case CLAUDE -> "Claude";
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
};
}
/**
* Resolves a German display label back to its {@link AiProviderFamily} constant.
*
* @param label the display label as produced by {@link #toString(AiProviderFamily)};
* may be {@code null}
* @return the matching constant, or {@code null} when the label is not recognised
*/
@Override
public AiProviderFamily fromString(String label) {
if (label == null) {
return null;
}
return switch (label) {
case "Claude" -> AiProviderFamily.CLAUDE;
case "OpenAI-kompatibel" -> AiProviderFamily.OPENAI_COMPATIBLE;
default -> null;
};
}
}
@@ -0,0 +1,93 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/**
* Represents the result of validating the current editor state in the GUI configuration editor.
* <p>
* Each validation run produces one immutable result containing all findings split into two
* complementary views:
* <ul>
* <li>{@code messages} a consolidated list of {@link GuiMessageEntry} objects that feed
* the central message area.</li>
* <li>{@code fieldFindings} field-specific {@link GuiFieldFinding} objects that are
* rendered directly below the affected input fields.</li>
* </ul>
* <p>
* A single root cause may appear in both lists: once as a central message (with full context)
* and once as a compact field finding (with a short, field-specific description).
* <p>
* The {@code evaluatedAt} timestamp records when the validation ran; the GUI may use it to
* determine whether a displayed result is still current.
* <p>
* This record contains no JavaFX references and can be created and inspected on any thread.
*
* @param messages consolidated list of message entries for the central message area;
* never {@code null}
* @param fieldFindings list of field-level findings; never {@code null}
* @param evaluatedAt instant at which the validation was performed; never {@code null}
*/
public record GuiEditorValidationResult(
List<GuiMessageEntry> messages,
List<GuiFieldFinding> fieldFindings,
Instant evaluatedAt) {
/**
* Creates a new validation result.
*
* @param messages central message entries; must not be {@code null}
* @param fieldFindings field-level findings; must not be {@code null}
* @param evaluatedAt validation timestamp; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public GuiEditorValidationResult {
Objects.requireNonNull(messages, "messages must not be null");
Objects.requireNonNull(fieldFindings, "fieldFindings must not be null");
Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null");
messages = List.copyOf(messages);
fieldFindings = List.copyOf(fieldFindings);
}
/**
* Returns an empty validation result representing the state before any validation has run.
* <p>
* Callers must not interpret an empty result as "no errors found"; they should wait for
* a non-empty result from the first actual validation run.
*
* @return an empty result with the current instant as timestamp; never {@code null}
*/
public static GuiEditorValidationResult empty() {
return new GuiEditorValidationResult(List.of(), List.of(), Instant.now());
}
/**
* Returns {@code true} when at least one message or field finding has severity
* {@link GuiMessageSeverity#ERROR}.
* <p>
* A result with errors indicates that the current editor state is not operational and
* should not be relied upon to start a processing run without correction.
*
* @return {@code true} when at least one error is present
*/
public boolean hasErrors() {
boolean messageError = messages.stream()
.anyMatch(m -> m.severity() == GuiMessageSeverity.ERROR);
boolean fieldError = fieldFindings.stream()
.anyMatch(f -> f.severity() == GuiMessageSeverity.ERROR);
return messageError || fieldError;
}
/**
* Returns {@code true} when the findings list contains at least one finding for the
* requested field key, regardless of severity.
*
* @param fieldKey the property key to look up; must not be {@code null}
* @return {@code true} when at least one finding refers to the requested field
*/
public boolean hasFieldFindingFor(String fieldKey) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
return fieldFindings.stream().anyMatch(f -> f.fieldKey().equals(fieldKey));
}
}
@@ -0,0 +1,68 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.Objects;
/**
* Represents a field-level validation finding that is displayed directly below the affected
* input field in the GUI configuration editor.
* <p>
* Field-level findings complement the central message area: the central area shows all findings
* in a consolidated list while this type carries the finding directly to the specific field it
* relates to, making it easier for the user to identify and correct the problem.
* <p>
* The {@code fieldKey} uses the property key as defined in the {@code .properties} file
* (e.g., {@code "source.folder"}, {@code "ai.provider.openai-compatible.apiKey"}).
* Using the property key as the field identifier keeps the validation model stable and independent
* of GUI layout changes.
* <p>
* Field-level findings are always rendered as small red German-language text directly beneath
* the affected control. Findings with severity {@link GuiMessageSeverity#INFO} or
* {@link GuiMessageSeverity#HINT} may also be shown field-near when the context is helpful.
* <p>
* This record contains no JavaFX references and is safe to create on any thread.
*
* @param fieldKey the property key identifying the affected configuration field; never {@code null}
* @param severity the severity of this finding; never {@code null}
* @param text short, German-language description of the problem; never {@code null}
*/
public record GuiFieldFinding(
String fieldKey,
GuiMessageSeverity severity,
String text) {
/**
* Creates a new field-level finding.
*
* @param fieldKey property key of the affected field; must not be {@code null}
* @param severity severity of the finding; must not be {@code null}
* @param text German-language problem description; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public GuiFieldFinding {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(text, "text must not be null");
}
/**
* Creates an error-severity field finding.
*
* @param fieldKey property key of the affected field; must not be {@code null}
* @param text German-language problem description; must not be {@code null}
* @return a new finding with severity {@link GuiMessageSeverity#ERROR}
*/
public static GuiFieldFinding error(String fieldKey, String text) {
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.ERROR, text);
}
/**
* Creates a warning-severity field finding.
*
* @param fieldKey property key of the affected field; must not be {@code null}
* @param text German-language problem description; must not be {@code null}
* @return a new finding with severity {@link GuiMessageSeverity#WARNING}
*/
public static GuiFieldFinding warning(String fieldKey, String text) {
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.WARNING, text);
}
}
@@ -0,0 +1,45 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.Objects;
/**
* Represents the model identifier entered manually by the user when no remote model list
* is available for the active provider.
* <p>
* This record captures both the provider context and the user-supplied model name so that
* later GUI layers can decide whether the value is still applicable after a provider change
* or a successful remote list retrieval.
* <p>
* A manually entered model name is discarded when a remote model list is subsequently loaded
* and the previously entered value does not appear in that list.
*
* @param providerIdentifier identifier of the provider for which the model was entered;
* never {@code null}
* @param modelName model identifier as typed by the user; never {@code null},
* but may be blank when the user has not yet entered anything
*/
public record GuiManualModelEntry(
String providerIdentifier,
String modelName) {
/**
* Creates a new manual model entry.
*
* @param providerIdentifier identifier of the provider; must not be {@code null}
* @param modelName model name as entered by the user; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public GuiManualModelEntry {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
Objects.requireNonNull(modelName, "modelName must not be null");
}
/**
* Returns whether the model name is non-blank, i.e. whether the user has entered something.
*
* @return {@code true} when the model name contains at least one non-whitespace character
*/
public boolean hasModelName() {
return !modelName.isBlank();
}
}
@@ -0,0 +1,68 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* Represents a single entry in the central message area of the GUI configuration editor.
* <p>
* Each entry carries a severity level, the message text, an optional source label that
* identifies the subsystem that produced the message (e.g., "Modellabruf", "Validierung"),
* and a timestamp. The GUI renders the {@link GuiMessageSeverity#getPrefixLabel() prefix} of
* the severity in colour while the message text itself remains black.
* <p>
* Instances are immutable and contain no JavaFX references; they are safe to create on
* background threads and pass to the JavaFX Application Thread via {@code Platform.runLater}.
*
* @param severity the severity of this message; never {@code null}
* @param text the message text; never {@code null}
* @param source optional label identifying the origin subsystem; empty when not applicable
* @param timestamp the instant at which the message was created; never {@code null}
*/
public record GuiMessageEntry(
GuiMessageSeverity severity,
String text,
Optional<String> source,
Instant timestamp) {
/**
* Creates a new message entry.
*
* @param severity severity level; must not be {@code null}
* @param text message text; must not be {@code null}
* @param source optional source label; {@code null} is treated as {@link Optional#empty()}
* @param timestamp creation timestamp; must not be {@code null}
* @throws NullPointerException if {@code severity}, {@code text}, or {@code timestamp} is {@code null}
*/
public GuiMessageEntry {
Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(text, "text must not be null");
Objects.requireNonNull(timestamp, "timestamp must not be null");
source = source == null ? Optional.empty() : source;
}
/**
* Creates a message entry without a source label, using the current instant as timestamp.
*
* @param severity severity level; must not be {@code null}
* @param text message text; must not be {@code null}
* @return a new entry; never {@code null}
*/
public static GuiMessageEntry of(GuiMessageSeverity severity, String text) {
return new GuiMessageEntry(severity, text, Optional.empty(), Instant.now());
}
/**
* Creates a message entry with a source label, using the current instant as timestamp.
*
* @param severity severity level; must not be {@code null}
* @param text message text; must not be {@code null}
* @param source source subsystem label; must not be {@code null}
* @return a new entry; never {@code null}
*/
public static GuiMessageEntry of(GuiMessageSeverity severity, String text, String source) {
Objects.requireNonNull(source, "source must not be null");
return new GuiMessageEntry(severity, text, Optional.of(source), Instant.now());
}
}
@@ -0,0 +1,71 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
/**
* Defines the four fixed severity levels for messages displayed in the central message area
* of the GUI configuration editor.
* <p>
* Each level carries a German-language prefix string that is displayed in colour at the start
* of each message line. The remainder of the message text is always rendered in black,
* regardless of severity.
* <p>
* The colour hints in this enum are expressed as CSS colour strings to avoid a compile-time
* dependency on JavaFX. Rendering code in the GUI layer must convert the hint to a
* {@code javafx.scene.paint.Color} or equivalent.
* <p>
* Severity levels ordered from least to most critical:
* <ol>
* <li>{@link #INFO}</li>
* <li>{@link #HINT}</li>
* <li>{@link #WARNING}</li>
* <li>{@link #ERROR}</li>
* </ol>
* <p>
* This enum contains no JavaFX references and is safe to use in unit-tested view-model code.
*/
public enum GuiMessageSeverity {
/** Neutral informational message, no action required. */
INFO("Info:", "#1565c0"),
/** Helpful hint that the user may want to act on. */
HINT("Hinweis:", "#558b2f"),
/** Configuration value is technically acceptable but risky or unusual. */
WARNING("Warnung:", "#e65100"),
/** Configuration value is invalid or the state is not operational. */
ERROR("Fehler:", "#b71c1c");
private final String prefixLabel;
private final String prefixCssColour;
GuiMessageSeverity(String prefixLabel, String prefixCssColour) {
this.prefixLabel = prefixLabel;
this.prefixCssColour = prefixCssColour;
}
/**
* Returns the German-language prefix label shown at the start of each message line of this severity.
* <p>
* Only the prefix is rendered in colour; the remaining message text is always black.
*
* @return the prefix label; never {@code null}
*/
public String getPrefixLabel() {
return prefixLabel;
}
/**
* Returns a CSS colour string hint that the GUI layer uses to render the prefix in the
* appropriate colour.
* <p>
* The returned value is a CSS hex colour (e.g., {@code "#b71c1c"}) that can be passed to
* {@code Color.web()} in JavaFX. The GUI layer is responsible for this conversion; this
* enum itself contains no JavaFX dependency.
*
* @return a CSS colour hint string; never {@code null}
*/
public String getPrefixCssColour() {
return prefixCssColour;
}
}
@@ -0,0 +1,190 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TextField;
import javafx.scene.layout.StackPane;
/**
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
* for model identifier input, depending on the current {@link GuiModelSource}.
* <p>
* When the model source is {@link GuiModelSource#LIST_REMOTE_SUCCESS}, a non-editable
* {@code ComboBox} is shown, pre-populated with the remote list and with the first model
* pre-selected. In all other cases (including {@link GuiModelSource#NOT_YET_LOADED}) the
* manual text field is shown, which may be empty or disabled depending on the source state.
* <p>
* Exactly one child is {@code visible} and {@code managed} at any time. The other child is
* kept in the scene graph with both flags set to {@code false} so that no blank space appears.
* <p>
* This class contains JavaFX references and must only be used on the JavaFX Application Thread.
*/
public final class GuiModelFieldContainer extends StackPane {
private final ComboBox<String> comboBox;
private final TextField textField;
private final Consumer<String> onModelChange;
private GuiModelSource currentSource;
/**
* Guard flag that suppresses the change callback while the text field value is being set
* programmatically via {@link #setTextFieldValue(String)}. The callback must still fire on
* genuine user edits, so the guard is scoped tightly around the programmatic write only.
*/
private boolean programmaticTextFieldSet = false;
/**
* Creates a new model field container.
*
* @param initialModelValue the initial model text shown in the text field; may be blank
* @param onModelChange callback invoked on every model-value change; must not be {@code null}
*/
public GuiModelFieldContainer(String initialModelValue, Consumer<String> onModelChange) {
this.onModelChange = Objects.requireNonNull(onModelChange, "onModelChange must not be null");
this.currentSource = GuiModelSource.NOT_YET_LOADED;
this.textField = new TextField(initialModelValue == null ? "" : initialModelValue);
this.textField.textProperty().addListener((obs, oldText, newText) -> {
if (!programmaticTextFieldSet && !newText.equals(oldText)) {
onModelChange.accept(newText);
}
});
this.comboBox = new ComboBox<>();
this.comboBox.setEditable(false);
this.comboBox.valueProperty().addListener((obs, oldVal, newVal) -> {
if (newVal != null && !newVal.equals(oldVal)) {
onModelChange.accept(newVal);
}
});
// Initial state: show text field (NOT_YET_LOADED → manual input)
applyVisibility(false);
getChildren().addAll(comboBox, textField);
}
/**
* Returns the currently displayed model value.
* <p>
* When the {@code ComboBox} is active, returns the selected item. When the text field
* is active, returns the text field content. Never returns {@code null}.
*
* @return the current model value; never {@code null}
*/
public String currentModelValue() {
if (currentSource == GuiModelSource.LIST_REMOTE_SUCCESS) {
String val = comboBox.getValue();
return val == null ? "" : val;
}
return textField.getText() == null ? "" : textField.getText();
}
/**
* Returns the current model source state.
*
* @return the current {@link GuiModelSource}; never {@code null}
*/
public GuiModelSource currentSource() {
return currentSource;
}
/**
* Applies a successful model list and switches to the non-editable {@link ComboBox}.
* <p>
* If the previously active manual text value is present in the new list it is kept as the
* selection; otherwise the first model in the list is pre-selected and the former manual
* value is discarded.
* <p>
* Must be called on the JavaFX Application Thread.
*
* @param models non-empty list of model identifiers; must not be {@code null} or empty
* @param previousManualValue the model text that was in the text field before this call;
* used to decide whether to preserve the selection
* @throws IllegalArgumentException if {@code models} is empty
*/
public void applyModelList(List<String> models, String previousManualValue) {
Objects.requireNonNull(models, "models must not be null");
if (models.isEmpty()) {
throw new IllegalArgumentException("models must not be empty");
}
comboBox.getItems().setAll(models);
// Preserve the previous value only when it appears in the new list.
String previous = previousManualValue == null ? "" : previousManualValue;
if (!previous.isBlank() && models.contains(previous)) {
comboBox.setValue(previous);
} else {
comboBox.setValue(models.get(0));
}
currentSource = GuiModelSource.LIST_REMOTE_SUCCESS;
applyVisibility(true);
// Notify the callback about the newly selected value.
String selected = comboBox.getValue();
if (selected != null) {
onModelChange.accept(selected);
}
}
/**
* Switches to the manual text field with the given fallback source state.
* <p>
* The text field retains whatever value it currently holds (or the value set programmatically
* via {@link #setTextFieldValue(String)}). Must be called on the JavaFX Application Thread.
*
* @param source the non-success source state; must not be {@link GuiModelSource#LIST_REMOTE_SUCCESS}
*/
public void applyManualFallback(GuiModelSource source) {
Objects.requireNonNull(source, "source must not be null");
if (source == GuiModelSource.LIST_REMOTE_SUCCESS) {
throw new IllegalArgumentException(
"applyManualFallback must not be called with LIST_REMOTE_SUCCESS");
}
currentSource = source;
applyVisibility(false);
}
/**
* Programmatically sets the text field value without triggering the change callback.
* <p>
* Useful for restoring a saved model value after a provider switch. Must be called on the
* JavaFX Application Thread.
*
* @param value the new text field value; {@code null} is treated as an empty string
*/
public void setTextFieldValue(String value) {
programmaticTextFieldSet = true;
try {
textField.setText(value == null ? "" : value);
} finally {
programmaticTextFieldSet = false;
}
}
/**
* Returns the JavaFX node that represents this container and can be added to the scene graph.
*
* @return {@code this} container; never {@code null}
*/
public Node asNode() {
return this;
}
/**
* Applies visibility to the ComboBox and TextField based on whether the list is active.
*
* @param listActive {@code true} to show the ComboBox, {@code false} to show the TextField
*/
private void applyVisibility(boolean listActive) {
comboBox.setVisible(listActive);
comboBox.setManaged(listActive);
textField.setVisible(!listActive);
textField.setManaged(!listActive);
}
}
@@ -0,0 +1,54 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
/**
* Describes the origin of the currently displayed model value in the GUI model selection area.
* <p>
* The GUI uses this enum to decide which control to show for the model input:
* <ul>
* <li>When the source is {@link #LIST_REMOTE_SUCCESS}, a non-editable {@code ComboBox}
* is shown, pre-populated with the remote list.</li>
* <li>When the source is {@link #LIST_UNAVAILABLE_MANUAL_INPUT} or
* {@link #LIST_FAILED_MANUAL_INPUT}, a plain text input field is shown instead,
* allowing the user to enter the model name manually.</li>
* <li>{@link #NOT_YET_LOADED} represents the initial state before the first retrieval
* attempt; the GUI should render a loading indicator or show the text field
* in a disabled/pending state.</li>
* </ul>
* <p>
* This enum is intentionally free of JavaFX references so it can be used in unit-tested
* view-model code without starting a JavaFX runtime.
*/
public enum GuiModelSource {
/**
* The model list was successfully retrieved from the remote provider endpoint.
* <p>
* A non-editable {@code ComboBox} is displayed, pre-selecting the first available model.
*/
LIST_REMOTE_SUCCESS,
/**
* No model list is available because the provider does not expose a model catalogue endpoint
* or because the configuration was incomplete.
* <p>
* A manual text input field is shown and the user must enter the model identifier by hand.
*/
LIST_UNAVAILABLE_MANUAL_INPUT,
/**
* A technical error occurred while retrieving the model list (e.g., HTTP error, timeout,
* authentication failure).
* <p>
* A manual text input field is shown so the user can still supply a model name; the GUI
* also reports the failure in the central message area.
*/
LIST_FAILED_MANUAL_INPUT,
/**
* The initial state before the first model retrieval attempt has been made.
* <p>
* The GUI should indicate that a retrieval is pending and must not present the manual
* input field as the definitive fallback until at least one retrieval attempt has completed.
*/
NOT_YET_LOADED
}
@@ -0,0 +1,98 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
/**
* Represents which provider section is currently visible in the GUI and preserves
* the editable configuration state of the section that is currently hidden.
* <p>
* The GUI shows exactly one provider section at a time. When the user switches the
* active provider, the previously visible section must not lose its field values.
* This record captures the current display context as an immutable snapshot so that
* view-model code can reason about visibility and data preservation without touching
* JavaFX nodes directly.
* <p>
* Instances of this record contain no JavaFX references and are safe to create and
* inspect from any thread, including unit-test threads.
*
* @param visibleProvider the provider family whose configuration section is
* currently rendered; never {@code null}
* @param visibleProviderState the editable configuration state currently displayed;
* never {@code null}
* @param hiddenProviderState the editable configuration state of the provider that
* is not shown, preserved here so it is not lost on switch;
* never {@code null}
* @param hiddenProvider the provider family whose section is currently hidden;
* never {@code null}
*/
public record GuiVisibleProviderSection(
AiProviderFamily visibleProvider,
GuiProviderConfigurationState visibleProviderState,
AiProviderFamily hiddenProvider,
GuiProviderConfigurationState hiddenProviderState) {
/**
* Creates a new visible-provider section snapshot.
*
* @param visibleProvider provider whose section is shown; must not be {@code null}
* @param visibleProviderState configuration state of the visible provider; must not be {@code null}
* @param hiddenProvider provider whose section is hidden; must not be {@code null}
* @param hiddenProviderState configuration state of the hidden provider; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
* @throws IllegalArgumentException if {@code visibleProvider} and {@code hiddenProvider} are equal
*/
public GuiVisibleProviderSection {
Objects.requireNonNull(visibleProvider, "visibleProvider must not be null");
Objects.requireNonNull(visibleProviderState, "visibleProviderState must not be null");
Objects.requireNonNull(hiddenProvider, "hiddenProvider must not be null");
Objects.requireNonNull(hiddenProviderState, "hiddenProviderState must not be null");
if (visibleProvider == hiddenProvider) {
throw new IllegalArgumentException(
"visibleProvider and hiddenProvider must be different, but both are: " + visibleProvider);
}
}
/**
* Returns the configuration state for the requested provider family.
*
* @param family the provider family to retrieve the state for; must not be {@code null}
* @return the state for the requested provider
* @throws IllegalArgumentException if the requested family is neither the visible nor the hidden provider
*/
public GuiProviderConfigurationState stateFor(AiProviderFamily family) {
Objects.requireNonNull(family, "family must not be null");
if (family == visibleProvider) {
return visibleProviderState;
}
if (family == hiddenProvider) {
return hiddenProviderState;
}
throw new IllegalArgumentException("Unknown provider family: " + family);
}
/**
* Returns a copy with the visible and hidden providers swapped, preserving both states.
* <p>
* The previously hidden provider becomes visible and the previously visible provider
* moves to hidden. No field values are lost during the switch.
*
* @return a new section snapshot with providers and their states swapped
*/
public GuiVisibleProviderSection switchProvider() {
return new GuiVisibleProviderSection(hiddenProvider, hiddenProviderState,
visibleProvider, visibleProviderState);
}
/**
* Returns a copy with a different configuration state for the visible provider.
*
* @param newState the updated configuration state; must not be {@code null}
* @return a new section snapshot with the visible provider's state replaced
*/
public GuiVisibleProviderSection withVisibleProviderState(GuiProviderConfigurationState newState) {
Objects.requireNonNull(newState, "newState must not be null");
return new GuiVisibleProviderSection(visibleProvider, newState, hiddenProvider, hiddenProviderState);
}
}
@@ -1,12 +1,41 @@
/**
* Editor state and template model for the JavaFX configuration editor.
* Editor state and view-model types for the JavaFX configuration editor.
* <p>
* This package contains the GUI-side representation of configuration data that can be edited
* independently from file I/O and validation. It separates loaded file snapshots, baseline
* editor values, current editor values, provider-specific API key state, and the derived
* dirty-state view used by the GUI.
* independently from file I/O and validation. It covers:
* <ul>
* <li>Loaded file snapshots, baseline editor values, current editor values and the derived
* dirty-state view ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues}).</li>
* <li>Provider-specific configuration state and API-key state
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState}).</li>
* <li>Provider section visibility and state preservation across provider switches
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection}).</li>
* <li>Model source classification, manual model entry, and the JavaFX model field container
* that switches between a non-editable {@code ComboBox} and a text field depending on
* retrieval outcome
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiManualModelEntry},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}).</li>
* <li>Message severity, central message entries and field-level validation findings
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiFieldFinding}).</li>
* <li>The consolidated validation result that feeds both the central message area and
* field-near error display
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult}).</li>
* </ul>
* <p>
* The classes in this package are intentionally free of JavaFX controls so they can be reused
* by later GUI layers without coupling the model to a particular layout implementation.
* Most classes in this package are intentionally free of JavaFX controls so they can be used
* in unit-tested view-model code without starting a JavaFX runtime. The exception is
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}, which
* extends a JavaFX {@code StackPane} and must be used only on the JavaFX Application Thread.
* <p>
* Types that are not GUI-specific (API-key origin provenance, model catalogue results and
* the corresponding port contract) live in
* {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog} to keep the
* Application module free of GUI dependencies while allowing future non-GUI consumers
* to reuse these types without depending on this adapter module.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
@@ -395,7 +395,9 @@ class GuiAdapterSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
testWriter);
testWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
workspaceRef.set(workspace);
@@ -322,7 +322,9 @@ class GuiEditorFieldBindingTest {
GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter);
capturingWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
ws.requestNewConfiguration();
@@ -115,7 +115,9 @@ class GuiEditorIntegrationTest {
GuiConfigurationEditorState loadedState = fileLoader.load(configFile);
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter);
GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
@@ -237,7 +239,9 @@ class GuiEditorIntegrationTest {
blankState,
Optional.of(notice),
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
noOpWriter);
noOpWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
@@ -186,7 +186,9 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationFileLoader loader = buildSnapshotLoader();
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState();
GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter);
GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>();
@@ -297,7 +299,9 @@ class GuiEditorRegressionSmokeTest {
stateWithFile,
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter);
capturingWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
@@ -393,7 +397,9 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter);
capturingWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
@@ -493,7 +499,9 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter);
capturingWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
@@ -564,7 +572,9 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
trackingWriter);
trackingWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
@@ -0,0 +1,456 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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 java.util.function.BooleanSupplier;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import javafx.application.Platform;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* Monocle-based headless smoke tests for the automatic editor validation.
* <p>
* These tests verify that the workspace triggers validation automatically when the editor
* state changes (via {@code applyEditorState} and {@code updateValues}) and that the
* {@link GuiEditorValidationResult} returned by {@code lastValidationResult()} reflects the
* current editor state.
*
* <h2>Covered scenarios</h2>
* <ul>
* <li>Opening an incomplete configuration (missing active provider) produces ERROR findings
* in {@code lastValidationResult} after the file is loaded.</li>
* <li>Opening an incomplete configuration populates {@code pendingFieldFindings} with a
* finding for {@code ai.provider.active}.</li>
* <li>After {@code requestNewConfiguration}: template values replace blank values, validation
* re-runs, {@code ai.provider.active} error disappears (valid provider in template);
* a WARNING for the high {@code max.text.characters} value (5000) is present.</li>
* <li>Changing a field via direct state update + re-applying state updates the validation
* result with new findings.</li>
* </ul>
*
* <h2>Threading</h2>
* <p>
* All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
* The {@code openConfigurationFile} method uses a background thread internally; tests that use
* it await file-load completion via a polling helper before verifying results.
* The Monocle headless configuration is activated by the Surefire JVM arguments.
*/
class GuiEditorValidationSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within timeout");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Existing JavaFX Platform must be reachable within timeout");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Shared platform do not call Platform.exit().
}
// =========================================================================
// Scenario: opening an incomplete configuration produces ERROR findings
// =========================================================================
/**
* Smoke test: when a properties file with an unknown (or empty) active-provider value is
* opened via {@link GuiConfigurationEditorWorkspace#openConfigurationFile}, the workspace
* calls {@code applyEditorState} after loading and runs validation automatically.
* <p>
* The resulting {@code lastValidationResult} must contain at least one ERROR because the
* active-provider field is empty.
*
* @param tempDir JUnit-provided temporary directory
* @throws Exception if the FX thread task fails or times out
*/
@Test
void openingIncompleteConfiguration_validationRunsAndProducesErrors(@TempDir Path tempDir)
throws Exception {
// Write a properties file with an empty active provider.
Path configFile = tempDir.resolve("incomplete.properties");
writePropertiesFile(configFile, "" /* empty active provider */);
GuiConfigurationFileLoader loader = buildSnapshotLoader();
GuiConfigurationEditorState blankState =
GuiConfigurationEditorStateFactory.createBlankStartState();
GuiStartupContext ctx = new GuiStartupContext(
blankState, Optional.empty(), loader,
(values, path) -> GuiConfigurationSaveResult.saved(path),
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) ->
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.EffectiveApiKeyDescriptor.absent());
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>();
// Create workspace and trigger file load on the FX thread.
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
wsRef.set(ws);
ws.openConfigurationFile(configFile);
} catch (Throwable t) {
error.set(t);
} finally {
setupLatch.countDown();
}
});
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Setup must complete within timeout");
rethrow(error);
// Wait for the background loader thread to apply the state.
waitFor(() -> {
AtomicBoolean ready = new AtomicBoolean(false);
CountDownLatch check = new CountDownLatch(1);
Platform.runLater(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
ready.set(true);
}
check.countDown();
});
try {
check.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return ready.get();
}, FX_TIMEOUT_SECONDS);
// Verify validation result on the FX thread.
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = wsRef.get();
GuiEditorValidationResult result = ws.lastValidationResult();
assertNotNull(result, "lastValidationResult must never be null");
assertTrue(result.hasErrors(),
"Loading a config with empty active provider must produce ERROR findings");
assertTrue(result.hasFieldFindingFor("ai.provider.active"),
"pendingFieldFindings must contain a finding for 'ai.provider.active'"
+ " when the active provider is empty in the loaded file");
} catch (Throwable t) {
error.set(t);
} finally {
verifyLatch.countDown();
}
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Verify latch must complete within timeout");
rethrow(error);
}
// =========================================================================
// Scenario: changing a field updates the validation result
// =========================================================================
/**
* Smoke test: when the active provider is changed from a valid value to an empty string via
* a direct state update followed by {@code requestNewConfiguration} (which calls
* {@code applyEditorState} and triggers {@code runEditorValidation}), the
* {@code lastValidationResult} is updated with findings that reflect the new state.
* <p>
* More concretely, this test demonstrates the field-change→re-validation flow by:
* <ol>
* <li>Starting with the standard template (valid provider → no provider error).</li>
* <li>Loading a file that has an empty provider (produces a provider ERROR).</li>
* <li>Verifying that {@code lastValidationResult} changed from "no error" to "error" as
* the result of loading the file with invalid values.</li>
* </ol>
*
* @param tempDir JUnit-provided temporary directory
* @throws Exception if the FX thread task fails or times out
*/
@Test
void changingField_revalidatesAndUpdatesLastValidationResult(@TempDir Path tempDir)
throws Exception {
Path invalidConfig = tempDir.resolve("invalid-provider.properties");
writePropertiesFile(invalidConfig, "" /* empty active provider */);
GuiConfigurationFileLoader loader = buildSnapshotLoader();
GuiConfigurationEditorState blankState =
GuiConfigurationEditorStateFactory.createBlankStartState();
GuiStartupContext ctx = new GuiStartupContext(
blankState, Optional.empty(), loader,
(values, path) -> GuiConfigurationSaveResult.saved(path),
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) ->
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.EffectiveApiKeyDescriptor.absent());
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
wsRef.set(ws);
// Step 1: apply template validation runs with valid values.
ws.requestNewConfiguration();
} catch (Throwable t) {
error.set(t);
} finally {
setupLatch.countDown();
}
});
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Setup timeout");
rethrow(error);
// Confirm valid state after template.
CountDownLatch checkValidLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = wsRef.get();
assertFalse(ws.lastValidationResult().hasFieldFindingFor("ai.provider.active"),
"After 'Neu' with valid template the active-provider field must have no error");
} catch (Throwable t) {
error.set(t);
} finally {
checkValidLatch.countDown();
}
});
assertTrue(checkValidLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Check timeout");
rethrow(error);
// Step 2: trigger field change by loading an invalid config file.
CountDownLatch loadLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
wsRef.get().openConfigurationFile(invalidConfig);
} catch (Throwable t) {
error.set(t);
} finally {
loadLatch.countDown();
}
});
assertTrue(loadLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Load trigger timeout");
rethrow(error);
// Wait for background loader.
waitFor(() -> {
AtomicBoolean ready = new AtomicBoolean(false);
CountDownLatch check = new CountDownLatch(1);
Platform.runLater(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
ready.set(true);
}
check.countDown();
});
try {
check.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return ready.get();
}, FX_TIMEOUT_SECONDS);
// Verify: invalid provider is now detected.
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = wsRef.get();
GuiEditorValidationResult result = ws.lastValidationResult();
assertTrue(result.hasErrors(),
"After loading a config with empty active provider, result must have errors");
assertTrue(result.hasFieldFindingFor("ai.provider.active"),
"After loading invalid config, active-provider finding must be present");
} catch (Throwable t) {
error.set(t);
} finally {
verifyLatch.countDown();
}
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Verify timeout");
rethrow(error);
}
// =========================================================================
// Scenario: standard template validation WARNING for max.text.characters
// =========================================================================
/**
* Smoke test: after {@code requestNewConfiguration}, the standard template values are active
* and validation runs. The template sets {@code max.text.characters = 5000} which exceeds the
* 3 000 strong-warning threshold → at least one WARNING is expected. The template also sets
* a valid active provider → no ERROR for that field.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void requestNewConfiguration_triggersValidation_templateProducesWarningForHighCharLimit()
throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
GuiEditorValidationResult result = ws.lastValidationResult();
assertNotNull(result, "lastValidationResult must not be null after 'Neu'");
// Template has valid provider → no field finding for ai.provider.active.
assertFalse(result.hasFieldFindingFor("ai.provider.active"),
"Standard template has a valid provider; 'ai.provider.active' must have"
+ " no field finding");
// Template max.text.characters = 5000 (>3000) → at least one WARNING.
boolean hasWarningOrAbove = result.messages().stream()
.anyMatch(m -> m.severity() == GuiMessageSeverity.WARNING
|| m.severity() == GuiMessageSeverity.ERROR);
assertTrue(hasWarningOrAbove,
"Standard template with max.text.characters=5000 must produce at least"
+ " one WARNING in the validation messages");
});
}
// =========================================================================
// Scenario: pendingFieldFindings updated by applyEditorState
// =========================================================================
/**
* Smoke test: after {@code requestNewConfiguration}, the {@code pendingFieldFindings} list is
* updated and the template's valid provider is not flagged.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void requestNewConfiguration_pendingFieldFindings_noProviderError() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
assertNotNull(ws.pendingFieldFindings, "pendingFieldFindings must never be null");
assertFalse(ws.pendingFieldFindings.stream()
.anyMatch(f -> "ai.provider.active".equals(f.fieldKey())),
"Standard template has a valid provider; no field finding expected for"
+ " 'ai.provider.active'");
});
}
// =========================================================================
// Helpers
// =========================================================================
private static GuiConfigurationFileLoader buildSnapshotLoader() {
return path -> {
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
Properties props = new Properties();
props.load(new StringReader(content));
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(path, props);
return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(
snapshot, Optional.empty());
} catch (IOException e) {
throw new GuiConfigurationLoadException("Failed to load " + path, e);
}
};
}
private static void writePropertiesFile(Path path, String activeProvider) throws IOException {
String content = "source.folder=./work/source\n"
+ "target.folder=./work/target\n"
+ "ai.provider.active=" + activeProvider + "\n"
+ "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n"
+ "max.pages=10\n"
+ "max.text.characters=500\n"
+ "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8);
}
private static void runOnFx(ThrowingRunnable task) throws Exception {
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
task.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
rethrow(error);
}
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
Throwable t = error.get();
if (t == null) {
return;
}
if (t instanceof Exception ex) {
throw ex;
}
throw new AssertionError("Unexpected error", t);
}
private static void waitFor(BooleanSupplier condition, long timeoutSeconds)
throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (!condition.getAsBoolean()) {
assertTrue(System.currentTimeMillis() < deadline,
"Condition was not met within the timeout");
Thread.sleep(50);
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -0,0 +1,710 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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 java.util.function.BooleanSupplier;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
import javafx.application.Platform;
import javafx.scene.control.Label;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* Monocle-based headless smoke tests for the central message area, field-level error labels
* and API-key origin display introduced in the message-area integration step.
*
* <h2>Covered scenarios</h2>
* <ul>
* <li>After opening an incomplete configuration, ERROR entries are visible in the central
* message area (non-zero child count in {@code messagesAreaBox}).</li>
* <li>The first child of an ERROR row is a coloured {@link Text} prefix node; the second
* child (body) carries black fill.</li>
* <li>After opening a configuration with a missing source folder, the field-level error label
* registered for {@code source.folder} is visible.</li>
* <li>After the standard template is applied via {@code requestNewConfiguration()}, the
* {@code source.folder} error label is hidden.</li>
* <li>The WARNING threshold for {@code max.text.characters} (10013000) appears in the
* central message area with a WARNING-coloured prefix.</li>
* <li>After synchronous model-catalogue retrieval, the central message area is updated via the
* post-result callback.</li>
* <li>When the API-key resolution port reports an ENV-variable origin for Claude, the
* api-key origin label is visible and references the variable name.</li>
* <li>The field-error label for {@code ai.provider.active} is registered and shown when the
* active provider is empty.</li>
* </ul>
*
* <h2>Threading</h2>
* All workspace interactions run on the JavaFX Application Thread via {@link Platform#runLater}.
* Model-catalogue retrieval is made synchronous via the coordinator's injectable factories so no
* real background threads are used and results are delivered inline.
*/
class GuiMessageAreaSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within timeout");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Existing JavaFX Platform must be reachable within timeout");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Shared platform — do not call Platform.exit().
}
// =========================================================================
// Scenario: central message area has ERROR entries for incomplete config
// =========================================================================
/**
* Smoke test: after opening a properties file with an empty active-provider value, the
* central {@code messagesAreaBox} contains at least one row, and at least one row has an
* ERROR-coloured prefix node.
*/
@Test
void incompleteConfig_messagesAreaContainsErrorRow(@TempDir Path tempDir) throws Exception {
Path configFile = tempDir.resolve("incomplete.properties");
writePropertiesFile(configFile, "" /* empty active provider */, "500");
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
openConfigAndWait(configFile, wsRef);
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
assertFalse(ws.messagesAreaBox.getChildren().isEmpty(),
"Central message area must not be empty after loading an incomplete configuration");
boolean foundErrorRow = ws.messagesAreaBox.getChildren().stream()
.filter(n -> n instanceof TextFlow)
.map(n -> (TextFlow) n)
.anyMatch(tf -> {
if (tf.getChildren().isEmpty()) {
return false;
}
Object first = tf.getChildren().get(0);
if (first instanceof Text t) {
return t.getStyle().contains(GuiMessageSeverity.ERROR.getPrefixCssColour());
}
return false;
});
assertTrue(foundErrorRow,
"At least one TextFlow row must have an ERROR-coloured prefix node");
});
}
/**
* Smoke test: the body-text node of any message row must carry the black fill style.
*/
@Test
void messageRow_bodyTextIsBlack(@TempDir Path tempDir) throws Exception {
Path configFile = tempDir.resolve("incomplete2.properties");
writePropertiesFile(configFile, "" /* empty active provider */, "500");
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
openConfigAndWait(configFile, wsRef);
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
Optional<TextFlow> anyRow = ws.messagesAreaBox.getChildren().stream()
.filter(n -> n instanceof TextFlow)
.map(n -> (TextFlow) n)
.filter(tf -> tf.getChildren().size() >= 2)
.findFirst();
assertTrue(anyRow.isPresent(), "Expected at least one TextFlow with two children");
Object bodyNode = anyRow.get().getChildren().get(1);
assertTrue(bodyNode instanceof Text, "Second child of a message row must be a Text node");
String bodyStyle = ((Text) bodyNode).getStyle();
// Body must be explicitly styled black or have no colour override at all.
assertTrue(bodyStyle.contains("black") || bodyStyle.contains("#000000")
|| bodyStyle.contains("000"),
"Body text must be rendered in black; style: " + bodyStyle);
});
}
// =========================================================================
// Scenario: field-level error label for source.folder
// =========================================================================
/**
* Smoke test: when a configuration with a blank source folder is opened, the field-error label
* for {@code source.folder} is visible and non-blank.
*/
@Test
void blankSourceFolder_fieldErrorLabelVisible(@TempDir Path tempDir) throws Exception {
Path configFile = tempDir.resolve("nosrc.properties");
writePropertiesFileBlankSourceFolder(configFile);
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
openConfigAndWait(configFile, wsRef);
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
Label errorLabel = ws.fieldErrorLabels.get("source.folder");
assertNotNull(errorLabel,
"A field-error label must be registered for 'source.folder'");
assertTrue(errorLabel.isVisible(),
"source.folder error label must be visible when the field is blank");
assertFalse(errorLabel.getText().isBlank(),
"source.folder error label must carry a non-blank error text");
});
}
/**
* Smoke test: after applying the standard template via {@code requestNewConfiguration()}, the
* source.folder error label is hidden because the template supplies a non-blank value.
*/
@Test
void standardTemplate_sourceFolderErrorLabelHidden() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
Label errorLabel = ws.fieldErrorLabels.get("source.folder");
assertNotNull(errorLabel,
"A field-error label must be registered for 'source.folder' after 'Neu'");
assertFalse(errorLabel.isVisible(),
"source.folder error label must be hidden when the template provides a non-blank value");
});
}
// =========================================================================
// Scenario: WARNING for max.text.characters between 1001 and 3000
// =========================================================================
/**
* Smoke test: loading a config with {@code max.text.characters = 1500} (between 1001 and 3000)
* must produce at least one WARNING entry in the central message area.
*/
@Test
void maxTextCharacters_warningThreshold_warningInMessages(@TempDir Path tempDir)
throws Exception {
Path configFile = tempDir.resolve("warning-chars.properties");
writePropertiesFile(configFile, "claude", "1500");
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
openConfigAndWait(configFile, wsRef);
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
boolean hasWarning = ws.messagesAreaBox.getChildren().stream()
.filter(n -> n instanceof TextFlow)
.map(n -> (TextFlow) n)
.anyMatch(tf -> {
if (tf.getChildren().isEmpty()) {
return false;
}
Object first = tf.getChildren().get(0);
if (first instanceof Text t) {
return t.getStyle().contains(GuiMessageSeverity.WARNING.getPrefixCssColour());
}
return false;
});
assertTrue(hasWarning,
"max.text.characters=1500 must produce at least one WARNING row in the message area");
});
}
// =========================================================================
// Scenario: model-catalogue result updates the message area via postResultCallback
// =========================================================================
/**
* Smoke test: after a synchronous (inline) model-catalogue retrieval that returns an
* {@link ModelCatalogResult.IncompleteConfiguration} result, the central message area is
* updated and contains the coordinator's message with source "Modellabruf".
* <p>
* Both the thread factory and the result-delivery mechanism are replaced with synchronous
* implementations so the entire retrieval+delivery cycle completes within the FX thread call.
*/
@Test
void modelCatalogResult_updatesMessageArea() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Make retrieval fully synchronous: run the task inline and deliver result inline.
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
@Override
public void start() {
// Execute the task inline on the calling thread instead of starting a new thread.
this.run();
}
};
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
// Trigger retrieval for Claude — stub port returns IncompleteConfiguration.
ws.modelCatalogCoordinator.triggerModelRetrieval(
AiProviderFamily.CLAUDE,
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState.blank());
// The post-result callback must have called refreshMessagesArea().
assertFalse(ws.messagesAreaBox.getChildren().isEmpty(),
"messagesAreaBox must not be empty after model-catalogue result was applied");
boolean hasModelCatalogEntry = ws.pendingMessages.stream()
.anyMatch(m -> m.source().isPresent()
&& "Modellabruf".equals(m.source().get()));
assertTrue(hasModelCatalogEntry,
"pendingMessages must contain at least one entry from source 'Modellabruf'"
+ " after retrieval");
});
}
// =========================================================================
// Scenario: API-key ENV-variable origin label
// =========================================================================
/**
* Smoke test: when the API-key resolution port reports that the Claude key comes from an
* environment variable, the api-key origin label below the Claude API-key field is visible
* and references the variable name or the concept of an environment variable.
*/
@Test
void apiKeyFromEnvVariable_originLabelVisible() throws Exception {
GuiStartupContext ctx = new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
path -> { throw new GuiConfigurationLoadException("not used in test", null); },
(values, path) -> GuiConfigurationSaveResult.saved(path),
req -> new ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> {
if (family == AiProviderFamily.CLAUDE) {
return EffectiveApiKeyDescriptor.fromProviderEnvVar("CLAUDE_API_KEY");
}
return EffectiveApiKeyDescriptor.absent();
});
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
ws.requestNewConfiguration();
Label originLabel = ws.apiKeyOriginLabels.get(AiProviderFamily.CLAUDE);
assertNotNull(originLabel,
"An api-key origin label must be registered for AiProviderFamily.CLAUDE");
assertTrue(originLabel.isVisible(),
"Claude api-key origin label must be visible when key comes from ENV-variable");
String labelText = originLabel.getText();
assertTrue(labelText.contains("CLAUDE_API_KEY")
|| labelText.contains("Umgebungsvariable"),
"Claude api-key origin label must reference the ENV-variable name or type;"
+ " got: " + labelText);
});
}
// =========================================================================
// Scenario: INFO-coloured prefix in the message area (model-catalogue success)
// =========================================================================
/**
* Smoke test: after a successful model-catalogue retrieval (stub returns Success), the central
* message area must contain at least one row whose prefix node carries the INFO colour.
* <p>
* This verifies that the INFO severity level is rendered with its defined CSS colour and not
* accidentally displayed with the ERROR or WARNING colour.
*/
@Test
void successfulModelRetrieval_messagesAreaContainsInfoRow() throws Exception {
runOnFx(() -> {
GuiStartupContext ctx = new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
path -> { throw new GuiConfigurationLoadException("not used in test", null); },
(values, path) -> GuiConfigurationSaveResult.saved(path),
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.Success(
req.providerIdentifier(),
java.util.List.of("claude-3-5-sonnet"),
java.time.Instant.now()),
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent());
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
// Make retrieval synchronous.
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
@Override
public void start() {
this.run();
}
};
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
ws.requestNewConfiguration();
// Trigger retrieval so an INFO message is added.
ws.modelCatalogCoordinator.triggerModelRetrieval(
AiProviderFamily.CLAUDE,
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor
.GuiProviderConfigurationState.blank());
boolean foundInfoRow = ws.messagesAreaBox.getChildren().stream()
.filter(n -> n instanceof TextFlow)
.map(n -> (TextFlow) n)
.anyMatch(tf -> {
if (tf.getChildren().isEmpty()) {
return false;
}
Object first = tf.getChildren().get(0);
if (first instanceof Text t) {
return t.getStyle().contains(GuiMessageSeverity.INFO.getPrefixCssColour());
}
return false;
});
assertTrue(foundInfoRow,
"After successful model retrieval at least one TextFlow row must have an"
+ " INFO-coloured prefix node");
});
}
// =========================================================================
// Scenario: HINT-coloured prefix in the message area (empty model list)
// =========================================================================
/**
* Smoke test: after a model-catalogue retrieval that returns {@code EmptyList}, the central
* message area must contain at least one row whose prefix node carries the HINT colour.
* <p>
* This verifies that the HINT severity level is correctly propagated from the coordinator to
* the rendered message area.
*/
@Test
void emptyModelList_messagesAreaContainsHintRow() throws Exception {
runOnFx(() -> {
GuiStartupContext ctx = new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
path -> { throw new GuiConfigurationLoadException("not used in test", null); },
(values, path) -> GuiConfigurationSaveResult.saved(path),
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.EmptyList(
req.providerIdentifier(), java.time.Instant.now()),
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent());
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
@Override
public void start() {
this.run();
}
};
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
ws.requestNewConfiguration();
ws.modelCatalogCoordinator.triggerModelRetrieval(
AiProviderFamily.CLAUDE,
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor
.GuiProviderConfigurationState.blank());
boolean foundHintRow = ws.messagesAreaBox.getChildren().stream()
.filter(n -> n instanceof TextFlow)
.map(n -> (TextFlow) n)
.anyMatch(tf -> {
if (tf.getChildren().isEmpty()) {
return false;
}
Object first = tf.getChildren().get(0);
if (first instanceof Text t) {
return t.getStyle().contains(GuiMessageSeverity.HINT.getPrefixCssColour());
}
return false;
});
assertTrue(foundHintRow,
"After EmptyList model retrieval at least one TextFlow row must have a"
+ " HINT-coloured prefix node");
});
}
// =========================================================================
// Scenario: strong WARNING for max.text.characters > 3000
// =========================================================================
/**
* Smoke test: loading a config with {@code max.text.characters = 3001} (above the 3000 strong
* warning threshold) must produce at least one WARNING entry in the central message area.
* <p>
* This verifies the upper threshold of the economic warning logic: values strictly above 3000
* trigger the strong warning level.
*/
@Test
void maxTextCharacters_strongWarningThreshold_warningInMessages(@TempDir Path tempDir)
throws Exception {
Path configFile = tempDir.resolve("strong-warning-chars.properties");
writePropertiesFile(configFile, "claude", "3001");
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
openConfigAndWait(configFile, wsRef);
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
boolean hasWarning = ws.messagesAreaBox.getChildren().stream()
.filter(n -> n instanceof TextFlow)
.map(n -> (TextFlow) n)
.anyMatch(tf -> {
if (tf.getChildren().isEmpty()) {
return false;
}
Object first = tf.getChildren().get(0);
if (first instanceof Text t) {
return t.getStyle().contains(GuiMessageSeverity.WARNING.getPrefixCssColour());
}
return false;
});
assertTrue(hasWarning,
"max.text.characters=3001 must produce at least one WARNING row in the"
+ " message area (strong-warning threshold)");
});
}
// =========================================================================
// Scenario: max.pages > 100 produces no ERROR field-finding (HINT only)
// =========================================================================
/**
* Smoke test: loading a config with {@code max.pages = 101} must not produce an ERROR
* field-finding for the {@code max.pages} key. High page counts are treated as
* plausibility/performance hints and must never block the configuration from being
* considered operational from the editor's perspective.
* <p>
* This complements the unit-level validation tests by verifying the finding is correctly
* mapped through the workspace pipeline and not accidentally escalated to ERROR.
*/
@Test
void maxPages_over100_noErrorFieldFinding(@TempDir Path tempDir)
throws Exception {
Path configFile = tempDir.resolve("highpages.properties");
writePropertiesFileWithMaxPages(configFile, "claude", "500", "101");
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
openConfigAndWait(configFile, wsRef);
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
// The field-finding for max.pages must not be ERROR.
boolean hasErrorFindingForMaxPages = ws.pendingFieldFindings.stream()
.anyMatch(f -> "max.pages".equals(f.fieldKey())
&& f.severity() == GuiMessageSeverity.ERROR);
assertFalse(hasErrorFindingForMaxPages,
"max.pages=101 must not produce an ERROR field-finding; high page limits"
+ " are treated as plausibility hints only");
});
}
// =========================================================================
// Scenario: ai.provider.active field-error label is registered and shown
// =========================================================================
/**
* Smoke test: when the active provider is empty, the field-error label for
* {@code ai.provider.active} must be registered and visible.
*/
@Test
void incompleteConfig_activeProviderFieldErrorLabelVisible(@TempDir Path tempDir)
throws Exception {
Path configFile = tempDir.resolve("noprovider.properties");
writePropertiesFile(configFile, "" /* empty */, "500");
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
openConfigAndWait(configFile, wsRef);
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
Label errorLabel = ws.fieldErrorLabels.get("ai.provider.active");
assertNotNull(errorLabel,
"A field-error label must be registered for 'ai.provider.active'");
assertTrue(errorLabel.isVisible(),
"'ai.provider.active' error label must be visible when provider is empty");
});
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Opens {@code configFile} in a freshly created workspace and waits for the background loader
* to complete. The workspace reference is stored in {@code wsRef}.
*/
private static void openConfigAndWait(Path configFile,
AtomicReference<GuiConfigurationEditorWorkspace> wsRef)
throws Exception {
GuiConfigurationFileLoader loader = buildSnapshotLoader();
GuiStartupContext ctx = new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
loader,
(values, path) -> GuiConfigurationSaveResult.saved(path),
req -> new ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent());
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
wsRef.set(ws);
ws.openConfigurationFile(configFile);
} finally {
setupLatch.countDown();
}
});
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Setup must complete within timeout");
waitFor(() -> {
AtomicBoolean ready = new AtomicBoolean(false);
CountDownLatch check = new CountDownLatch(1);
Platform.runLater(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
ready.set(true);
}
check.countDown();
});
try {
check.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return ready.get();
}, FX_TIMEOUT_SECONDS);
}
private static GuiConfigurationFileLoader buildSnapshotLoader() {
return path -> {
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
Properties props = new Properties();
props.load(new StringReader(content));
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(path, props);
return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(
snapshot, Optional.empty());
} catch (IOException e) {
throw new GuiConfigurationLoadException("Failed to load " + path, e);
}
};
}
private static void writePropertiesFile(Path path, String activeProvider,
String maxTextCharacters) throws IOException {
String content = "source.folder=./work/source\n"
+ "target.folder=./work/target\n"
+ "ai.provider.active=" + activeProvider + "\n"
+ "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n"
+ "max.pages=10\n"
+ "max.text.characters=" + maxTextCharacters + "\n"
+ "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8);
}
private static void writePropertiesFileWithMaxPages(Path path, String activeProvider,
String maxTextCharacters,
String maxPages) throws IOException {
String content = "source.folder=./work/source\n"
+ "target.folder=./work/target\n"
+ "ai.provider.active=" + activeProvider + "\n"
+ "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n"
+ "max.pages=" + maxPages + "\n"
+ "max.text.characters=" + maxTextCharacters + "\n"
+ "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8);
}
private static void writePropertiesFileBlankSourceFolder(Path path) throws IOException {
String content = "source.folder=\n"
+ "target.folder=./work/target\n"
+ "ai.provider.active=claude\n"
+ "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n"
+ "max.pages=10\n"
+ "max.text.characters=500\n"
+ "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8);
}
private static void runOnFx(ThrowingRunnable task) throws Exception {
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
task.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
rethrow(error);
}
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
Throwable t = error.get();
if (t == null) {
return;
}
if (t instanceof Exception ex) {
throw ex;
}
throw new AssertionError("Unexpected error", t);
}
private static void waitFor(BooleanSupplier condition, long timeoutSeconds)
throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (!condition.getAsBoolean()) {
assertTrue(System.currentTimeMillis() < deadline,
"Condition was not met within the timeout");
Thread.sleep(50);
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -0,0 +1,639 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Smoke tests for the automatic model catalogue retrieval, the "Modelle neu laden" button,
* and the ComboBox/TextField switching behaviour in the provider section of the editor workspace.
*
* <p>All tests run on the JavaFX Application Thread under Monocle headless. The model catalogue
* port is replaced with a synchronous stub so no real HTTP calls are made and the tests are
* fully deterministic.
*
* <p>The coordinator's thread factory and result-delivery mechanism are both replaced with
* synchronous implementations so retrieval and result application happen inline on the calling
* thread (the FX thread in these tests). This avoids any async boundary and makes assertions
* immediately consistent after each trigger call.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiModelCatalogSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
// =========================================================================
// JavaFX Platform lifecycle
// =========================================================================
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within timeout");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Existing JavaFX Platform must be reachable within timeout");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Shared platform — do not call Platform.exit().
}
// =========================================================================
// Test: Success result → ComboBox shown, first model pre-selected
// =========================================================================
/**
* When the model catalogue port returns a {@link ModelCatalogResult.Success}, the provider
* block's model field must switch to a non-editable ComboBox pre-selecting the first model.
*/
@Test
@Order(1)
void successResult_comboBoxIsShownWithFirstModelSelected() throws Exception {
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS after successful retrieval");
assertEquals("claude-3-5-sonnet", container.currentModelValue(),
"First model must be pre-selected");
});
}
// =========================================================================
// Test: EmptyList result → TextField shown
// =========================================================================
/**
* When the model catalogue port returns {@link ModelCatalogResult.EmptyList}, the provider
* block's model field must show the manual text field.
*/
@Test
@Order(2)
void emptyListResult_textFieldIsShown() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
"Source must be LIST_UNAVAILABLE_MANUAL_INPUT for EmptyList result");
});
}
// =========================================================================
// Test: IncompleteConfiguration result → TextField shown
// =========================================================================
/**
* When the model catalogue port returns {@link ModelCatalogResult.IncompleteConfiguration},
* the provider block's model field must show the manual text field.
*/
@Test
@Order(3)
void incompleteConfigResult_textFieldIsShown() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "API-Key fehlt.");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
"Source must be LIST_UNAVAILABLE_MANUAL_INPUT for IncompleteConfiguration");
});
}
// =========================================================================
// Test: TechnicalFailure result → TextField shown with FAILED state
// =========================================================================
/**
* When the model catalogue port returns {@link ModelCatalogResult.TechnicalFailure},
* the provider block's model field must show the manual text field in the failed state.
*/
@Test
@Order(4)
void technicalFailureResult_textFieldIsShownWithFailedState() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "HTTP_ERROR",
"Status 503");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_FAILED_MANUAL_INPUT, container.currentSource(),
"Source must be LIST_FAILED_MANUAL_INPUT for TechnicalFailure");
});
}
// =========================================================================
// Test: Manual value discarded when not in new list
// =========================================================================
/**
* When a manual model name is present in the text field and a subsequent successful
* retrieval returns a list that does NOT contain that name, the value must be discarded
* and the first item in the new list must be selected.
*/
@Test
@Order(5)
void successResult_manualValueDiscardedWhenNotInList() throws Exception {
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
// Pre-set a manual value not present in the incoming list.
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container);
container.setTextFieldValue("my-custom-model");
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS after successful retrieval");
assertEquals("claude-3-5-sonnet", container.currentModelValue(),
"Manual value not in list must be discarded; first list item selected");
});
}
// =========================================================================
// Test: Manual value preserved when present in new list
// =========================================================================
/**
* When a manual model name is present in the text field and a subsequent successful
* retrieval returns a list that DOES contain that name, the selection must be preserved.
*/
@Test
@Order(6)
void successResult_manualValuePreservedWhenInList() throws Exception {
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container);
// Pre-set a value that IS in the incoming list.
container.setTextFieldValue("claude-3-haiku");
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS");
assertEquals("claude-3-haiku", container.currentModelValue(),
"Manual value present in list must be preserved as selection");
});
}
// =========================================================================
// Test: Provider switch triggers automatic model retrieval
// =========================================================================
/**
* Switching the provider ComboBox must automatically trigger a model retrieval for the
* newly selected provider without requiring the user to press "Modelle neu laden".
*/
@Test
@Order(7)
void providerSwitch_triggersAutomaticModelRetrieval() throws Exception {
List<String> openAiModels = List.of("gpt-4o", "gpt-4-turbo");
AiModelCatalogPort stub = req -> {
if (AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier().equals(req.providerIdentifier())) {
return new ModelCatalogResult.Success(req.providerIdentifier(), openAiModels,
Instant.now());
}
return new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
"Test-Stub: kein Claude-Abruf in diesem Test.");
};
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
// Switch the provider ComboBox from Claude to OpenAI; the listener auto-triggers retrieval.
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
assertNotNull(comboBox, "Provider ComboBox must be present");
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
// Because resultDelivery is synchronous, retrieval and result application happened inline.
GuiModelFieldContainer openAiContainer =
ws.modelFieldContainers.get(AiProviderFamily.OPENAI_COMPATIBLE);
assertNotNull(openAiContainer, "OpenAI model field container must be present");
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, openAiContainer.currentSource(),
"OpenAI model source must be LIST_REMOTE_SUCCESS after automatic retrieval on switch");
assertEquals("gpt-4o", openAiContainer.currentModelValue(),
"First OpenAI model must be pre-selected after automatic retrieval");
});
}
// =========================================================================
// Test: "Modelle neu laden" button triggers retrieval
// =========================================================================
/**
* Pressing the "Modelle neu laden" button must trigger the same retrieval path as the
* automatic trigger on provider switch.
*/
@Test
@Order(8)
void reloadModelsButton_triggersModelRetrieval() throws Exception {
List<String> models = List.of("claude-opus-4");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
Button reloadButton = findNodeById(ws, "modelle-neu-laden-button", Button.class);
assertNotNull(reloadButton, "\"Modelle neu laden\" button must be present in the scene graph");
reloadButton.fire();
// Because resultDelivery is synchronous, result is applied immediately.
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS after pressing \"Modelle neu laden\"");
assertEquals("claude-opus-4", container.currentModelValue(),
"Model returned by stub must be selected after reload");
});
}
// =========================================================================
// Test: pendingMessages list receives entry after each retrieval
// =========================================================================
/**
* After each model catalogue retrieval a {@link GuiMessageEntry} must be appended to
* {@link GuiConfigurationEditorWorkspace#pendingMessages}, regardless of the result type.
*/
@Test
@Order(9)
void pendingMessages_entryAppendedAfterEachRetrieval() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "TIMEOUT",
"Zeitüberschreitung");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
int before = ws.pendingMessages.size();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertEquals(before + 1, ws.pendingMessages.size(),
"Exactly one message entry must be appended after retrieval");
GuiMessageEntry entry = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.ERROR, entry.severity(),
"TechnicalFailure must produce an ERROR message entry");
assertTrue(entry.source().isPresent(), "Message must have a source label");
assertEquals("Modellabruf", entry.source().get(),
"Message source must be \"Modellabruf\"");
});
}
// =========================================================================
// Test: Success pendingMessage has INFO severity
// =========================================================================
/**
* A successful model list retrieval must append a message entry with {@link GuiMessageSeverity#INFO}.
*/
@Test
@Order(10)
void pendingMessages_successProducesInfoEntry() throws Exception {
List<String> models = List.of("claude-3-5-sonnet");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.INFO, last.severity(),
"Successful retrieval must produce an INFO message");
});
}
// =========================================================================
// Test: IncompleteConfiguration pendingMessage has WARNING severity
// =========================================================================
/**
* An {@link ModelCatalogResult.IncompleteConfiguration} result must append a
* {@link GuiMessageSeverity#WARNING} entry.
*/
@Test
@Order(11)
void pendingMessages_incompleteConfigProducesWarningEntry() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
"Kein API-Key.");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.WARNING, last.severity(),
"IncompleteConfiguration must produce a WARNING message");
});
}
// =========================================================================
// Test: EmptyList pendingMessage has HINT severity
// =========================================================================
/**
* An {@link ModelCatalogResult.EmptyList} result must append a
* {@link GuiMessageSeverity#HINT} entry.
*/
@Test
@Order(12)
void pendingMessages_emptyListProducesHintEntry() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.HINT, last.severity(),
"EmptyList must produce a HINT message");
});
}
// =========================================================================
// Test: repeated retrieval replaces previous message entry, not accumulates
// =========================================================================
/**
* Triggering model retrieval twice must not accumulate two "Modellabruf" entries in
* {@code pendingMessages}. The second trigger must replace the entry from the first trigger
* so that exactly one entry with source "Modellabruf" is present after both calls.
* <p>
* This verifies the fix that removes old "Modellabruf" entries at the start of
* {@code applyResult} before appending the new one.
*/
@Test
@Order(13)
void pendingMessages_repeatedRetrieval_replacesNotAccumulates() throws Exception {
List<String> models = List.of("claude-3-5-sonnet");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
// Trigger retrieval twice for the same provider.
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
long modellabrufCount = ws.pendingMessages.stream()
.filter(m -> m.source().isPresent()
&& "Modellabruf".equals(m.source().get()))
.count();
assertEquals(1L, modellabrufCount,
"After two retrieval triggers, exactly one 'Modellabruf' entry must remain in"
+ " pendingMessages (replace semantics, not accumulate)");
});
}
// =========================================================================
// Helpers: workspace creation with stub
// =========================================================================
/**
* Creates a workspace whose model catalogue coordinator is backed by the given stub port.
* Both the thread factory and result delivery are replaced with synchronous implementations
* so retrieval and result application happen inline without any async boundary.
*
* @param stub the stub port returning deterministic results; must not be {@code null}
* @return a workspace ready for testing; never {@code null}
*/
private static GuiConfigurationEditorWorkspace createWorkspaceWithStub(AiModelCatalogPort stub) {
GuiStartupContext ctx = new GuiStartupContext(
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
.createBlankStartState(),
Optional.empty(),
path -> de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
.createBlankStartState(),
(values, path) -> GuiConfigurationSaveResult.saved(path),
stub,
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
// Synchronous thread factory: run the task directly instead of starting an OS thread.
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(task, "gui-model-catalog-test") {
@Override
public synchronized void start() {
run(); // run synchronously on the calling thread
}
};
// Synchronous result delivery: execute the callback directly instead of via Platform.runLater.
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
return ws;
}
/**
* Triggers model retrieval for the given family using the current editor state.
* Because the coordinator uses synchronous delivery, the result is applied immediately.
*
* @param ws the workspace to trigger retrieval on; must not be {@code null}
* @param family the provider family to retrieve models for; must not be {@code null}
*/
private static void triggerRetrieval(GuiConfigurationEditorWorkspace ws, AiProviderFamily family) {
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState pState =
Optional.ofNullable(ws.editorState().values().providerConfiguration(family))
.orElse(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState
.blank());
ws.modelCatalogCoordinator.triggerModelRetrieval(family, pState);
}
// =========================================================================
// Helpers: scene graph traversal
// =========================================================================
@SuppressWarnings("unchecked")
private static ComboBox<AiProviderFamily> findProviderComboBox(GuiConfigurationEditorWorkspace ws) {
return (ComboBox<AiProviderFamily>) findNodeDeep(ws.tabPane, ComboBox.class);
}
@SuppressWarnings("unchecked")
private static <T extends Node> T findNodeById(GuiConfigurationEditorWorkspace ws,
String id, Class<T> type) {
return (T) findNodeByIdDeep(ws.tabPane, id);
}
private static Node findNodeByIdDeep(Node root, String id) {
if (id.equals(root.getId())) {
return root;
}
if (root instanceof javafx.scene.control.ScrollPane sp) {
Node content = sp.getContent();
if (content != null) {
Node found = findNodeByIdDeep(content, id);
if (found != null) return found;
}
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
if (tab.getContent() != null) {
Node found = findNodeByIdDeep(tab.getContent(), id);
if (found != null) return found;
}
}
} else if (root instanceof javafx.scene.Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
Node found = findNodeByIdDeep(child, id);
if (found != null) return found;
}
}
return null;
}
private static Node findNodeDeep(Node root, Class<?> nodeType) {
if (nodeType.isInstance(root)) {
return root;
}
if (root instanceof javafx.scene.control.ScrollPane sp) {
Node content = sp.getContent();
if (content != null) {
Node found = findNodeDeep(content, nodeType);
if (found != null) return found;
}
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
if (tab.getContent() != null) {
Node found = findNodeDeep(tab.getContent(), nodeType);
if (found != null) return found;
}
}
} else if (root instanceof javafx.scene.Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
Node found = findNodeDeep(child, nodeType);
if (found != null) return found;
}
}
return null;
}
// =========================================================================
// Threading helper
// =========================================================================
private static void runOnFx(ThrowingRunnable task) throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
task.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
if (error.get() != null) {
Throwable t = error.get();
if (t instanceof Exception e) {
throw e;
}
throw new AssertionError("Unexpected error on FX thread", t);
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -0,0 +1,452 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
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.adapter.in.gui.editor.GuiVisibleProviderSection;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.application.Platform;
import javafx.scene.control.ComboBox;
import javafx.scene.Node;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Smoke tests for the provider selection ComboBox, provider block visibility management
* and state preservation on provider switch.
*
* <p>All tests run on the JavaFX Application Thread under Monocle headless. The tests verify:
* <ul>
* <li>Initial ComboBox selection matches the active provider from the editor state.</li>
* <li>Only the active provider block is visible; the other is not visible and not managed.</li>
* <li>After a provider switch the previously hidden provider's data is still intact in the
* editor state (no data loss on switch).</li>
* <li>After a provider switch the {@code ai.provider.active} value is updated correctly.</li>
* <li>The provider ComboBox is not editable.</li>
* <li>{@link GuiVisibleProviderSection} correctly reflects the visible/hidden split.</li>
* </ul>
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiProviderSelectionSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within timeout");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Existing JavaFX Platform must be reachable within timeout");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Shared platform — do not call Platform.exit().
}
// =========================================================================
// Initial state: ComboBox selects the active provider
// =========================================================================
/**
* After loading the standard template (active provider: Claude) the provider ComboBox
* must pre-select Claude and the Claude block must be visible.
*/
@Test
@Order(1)
void afterNew_comboBoxSelectsClaudeAndClaudeBlockIsVisible() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Standard template uses Claude as active provider.
assertEquals(AiProviderFamily.CLAUDE.getIdentifier(),
ws.editorState().values().activeProviderFamily(),
"Precondition: standard template active provider must be Claude");
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
assertNotNull(comboBox, "Provider ComboBox must be present in the section");
assertEquals(AiProviderFamily.CLAUDE, comboBox.getValue(),
"ComboBox must pre-select Claude when active provider is Claude");
// Exactly one block must be visible.
VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);
assertNotNull(claudeBlock, "Claude block must exist in the section");
assertNotNull(openaiBlock, "OpenAI block must exist in the section");
assertTrue(claudeBlock.isVisible(), "Claude block must be visible");
assertTrue(claudeBlock.isManaged(), "Claude block must be managed");
assertFalse(openaiBlock.isVisible(), "OpenAI block must not be visible");
assertFalse(openaiBlock.isManaged(), "OpenAI block must not be managed");
});
}
// =========================================================================
// ComboBox is not editable
// =========================================================================
/**
* The provider ComboBox must not be editable so the user cannot type arbitrary text
* into the selection field.
*/
@Test
@Order(2)
void providerComboBox_isNotEditable() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
assertNotNull(comboBox, "Provider ComboBox must be present");
assertFalse(comboBox.isEditable(), "Provider ComboBox must not be editable");
});
}
// =========================================================================
// Provider switch: visibility toggles correctly
// =========================================================================
/**
* After switching from Claude to OpenAI-kompatibel the OpenAI block must become visible
* and managed, and the Claude block must become invisible and unmanaged.
*/
@Test
@Order(3)
void switchToOpenAi_openAiBlockBecomesVisibleClaudeBlockHides() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
assertNotNull(comboBox, "Provider ComboBox must be present");
assertEquals(AiProviderFamily.CLAUDE, comboBox.getValue(),
"Precondition: Claude must be pre-selected");
// Simulate user switching the ComboBox to OpenAI-compatible.
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);
assertFalse(claudeBlock.isVisible(), "Claude block must be hidden after switch to OpenAI");
assertFalse(claudeBlock.isManaged(), "Claude block must be unmanaged after switch to OpenAI");
assertTrue(openaiBlock.isVisible(), "OpenAI block must be visible after switch");
assertTrue(openaiBlock.isManaged(), "OpenAI block must be managed after switch");
});
}
// =========================================================================
// Provider switch: ai.provider.active is updated
// =========================================================================
/**
* After switching the provider ComboBox to OpenAI-compatible the {@code ai.provider.active}
* value in the editor state must be updated to the OpenAI-compatible identifier.
*/
@Test
@Order(4)
void switchToOpenAi_activeProviderValueUpdatedInEditorState() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier(),
ws.editorState().values().activeProviderFamily(),
"ai.provider.active must reflect the newly selected provider");
});
}
// =========================================================================
// Provider switch: hidden provider data is preserved
// =========================================================================
/**
* After switching the provider the previously hidden provider's configuration data must
* remain intact in the editor state.
* <p>
* This test explicitly sets a distinct model name on the Claude provider, switches to
* OpenAI-compatible, and then verifies the Claude model name is still present in the
* editor state after the switch.
*/
@Test
@Order(5)
void switchProvider_hiddenProviderDataIsPreserved() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Record the original OpenAI model from the standard template.
String originalOpenAiModel = ws.editorState().values()
.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model();
// Set a distinctive model name on the Claude provider (visible at this point).
GuiProviderConfigurationState currentClaude =
ws.editorState().values().providerConfiguration(AiProviderFamily.CLAUDE);
String distinctiveClaudeModel = "claude-test-model-preserved";
GuiProviderConfigurationState updatedClaude = new GuiProviderConfigurationState(
currentClaude.baseUrl(),
distinctiveClaudeModel,
currentClaude.timeoutSeconds(),
currentClaude.apiKey());
ws.editorState = ws.editorState().withValues(
ws.editorState().values().withProviderConfiguration(AiProviderFamily.CLAUDE, updatedClaude));
// Switch from Claude to OpenAI-compatible.
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
// Claude's model must still be present in the editor state after the switch.
String claudeModelAfterSwitch = ws.editorState().values()
.providerConfiguration(AiProviderFamily.CLAUDE).model();
assertEquals(distinctiveClaudeModel, claudeModelAfterSwitch,
"Claude model must not be lost when switching to OpenAI-compatible");
// OpenAI model must also be untouched.
String openAiModelAfterSwitch = ws.editorState().values()
.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model();
assertEquals(originalOpenAiModel, openAiModelAfterSwitch,
"OpenAI model must remain unchanged after switch");
});
}
// =========================================================================
// Provider switch: switch back restores first provider visibility
// =========================================================================
/**
* Switching to OpenAI and then back to Claude must restore the Claude block as visible
* and hide the OpenAI block again. The {@code ai.provider.active} value must reflect Claude.
*/
@Test
@Order(6)
void switchBackToClaude_claudeBlockVisibleActiveProviderUpdated() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
comboBox.setValue(AiProviderFamily.CLAUDE);
VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);
assertTrue(claudeBlock.isVisible(), "Claude block must be visible after switching back");
assertTrue(claudeBlock.isManaged(), "Claude block must be managed after switching back");
assertFalse(openaiBlock.isVisible(), "OpenAI block must be hidden after switching back");
assertFalse(openaiBlock.isManaged(), "OpenAI block must be unmanaged after switching back");
assertEquals(AiProviderFamily.CLAUDE.getIdentifier(),
ws.editorState().values().activeProviderFamily(),
"ai.provider.active must reflect Claude after switching back");
});
}
// =========================================================================
// visibleProviderSection reflects current state
// =========================================================================
/**
* After loading the standard template {@link GuiConfigurationEditorWorkspace#visibleProviderSection}
* must reflect Claude as the visible provider.
*/
@Test
@Order(7)
void afterNew_visibleProviderSectionReflectsClaudeAsVisible() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
GuiVisibleProviderSection section = ws.visibleProviderSection;
assertNotNull(section, "visibleProviderSection must not be null after loading a configuration");
assertEquals(AiProviderFamily.CLAUDE, section.visibleProvider(),
"Visible provider in the section snapshot must be Claude");
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, section.hiddenProvider(),
"Hidden provider in the section snapshot must be OpenAI-compatible");
});
}
// =========================================================================
// Helper: find provider ComboBox and blocks inside the workspace
// =========================================================================
/**
* Locates the provider {@link ComboBox} by traversing the workspace scene graph.
* Uses a deep traversal that also visits {@link javafx.scene.control.ScrollPane} viewport
* content and {@link javafx.scene.control.TabPane} tab content nodes, which are not
* accessible via {@code getChildrenUnmodifiable()} on their parent containers.
*
* @param ws the workspace whose root is searched; must not be {@code null}
* @return the ComboBox, or {@code null} when not found
*/
@SuppressWarnings("unchecked")
private static ComboBox<AiProviderFamily> findProviderComboBox(GuiConfigurationEditorWorkspace ws) {
return (ComboBox<AiProviderFamily>) findNodeDeep(ws.tabPane, ComboBox.class);
}
/**
* Locates the provider block {@link VBox} for the given family.
* <p>
* Provider blocks are identified by the presence of {@code -fx-border-color: #c8c8c8}
* in their inline style. Claude is the first block, OpenAI-compatible the second.
*
* @param ws the workspace to search; must not be {@code null}
* @param family the provider family to locate; must not be {@code null}
* @return the block VBox, or {@code null} when not found
*/
private static VBox findProviderBlock(GuiConfigurationEditorWorkspace ws, AiProviderFamily family) {
java.util.List<VBox> blocks = collectProviderBlocks(ws.tabPane);
if (family == AiProviderFamily.CLAUDE) {
return blocks.isEmpty() ? null : blocks.get(0);
} else {
return blocks.size() < 2 ? null : blocks.get(1);
}
}
/**
* Collects all provider block VBoxes identified by the provider-block inline style.
*
* @param root the starting node; must not be {@code null}
* @return ordered list of provider block VBoxes
*/
private static java.util.List<VBox> collectProviderBlocks(Node root) {
java.util.List<VBox> result = new java.util.ArrayList<>();
collectProviderBlocksInto(root, result);
return result;
}
private static void collectProviderBlocksInto(Node node, java.util.List<VBox> result) {
if (node instanceof VBox vbox) {
String style = vbox.getStyle();
if (style != null && style.contains("-fx-border-color: #c8c8c8")) {
result.add(vbox);
// Do NOT recurse into provider blocks themselves to avoid nested matches.
return;
}
}
if (node instanceof javafx.scene.control.ScrollPane sp) {
Node content = sp.getContent();
if (content != null) {
collectProviderBlocksInto(content, result);
}
} else if (node instanceof javafx.scene.control.TabPane tabPane) {
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
if (tab.getContent() != null) {
collectProviderBlocksInto(tab.getContent(), result);
}
}
} else if (node instanceof javafx.scene.Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
collectProviderBlocksInto(child, result);
}
}
}
/**
* Finds the first node of the requested type using a deep traversal that visits
* {@link javafx.scene.control.ScrollPane} and {@link javafx.scene.control.TabPane} content.
*
* @param root the starting node; must not be {@code null}
* @param nodeType the type to search for; must not be {@code null}
* @return the first matching node, or {@code null} when not found
*/
private static Node findNodeDeep(Node root, Class<?> nodeType) {
if (nodeType.isInstance(root)) {
return root;
}
if (root instanceof javafx.scene.control.ScrollPane sp) {
Node content = sp.getContent();
if (content != null) {
Node found = findNodeDeep(content, nodeType);
if (found != null) {
return found;
}
}
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
if (tab.getContent() != null) {
Node found = findNodeDeep(tab.getContent(), nodeType);
if (found != null) {
return found;
}
}
}
} else if (root instanceof javafx.scene.Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
Node found = findNodeDeep(child, nodeType);
if (found != null) {
return found;
}
}
}
return null;
}
// =========================================================================
// Threading helper
// =========================================================================
private static void runOnFx(ThrowingRunnable task) throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
task.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
if (error.get() != null) {
Throwable t = error.get();
if (t instanceof Exception e) {
throw e;
}
throw new AssertionError("Unexpected error on FX thread", t);
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -789,7 +789,9 @@ class GuiUnsavedChangesGuardSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
writer);
writer,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
ws.requestNewConfiguration();
return ws;
@@ -806,7 +808,9 @@ class GuiUnsavedChangesGuardSmokeTest {
stateWithFile,
Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
writer);
writer,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
return new GuiConfigurationEditorWorkspace(context);
}
@@ -0,0 +1,114 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link GuiEditorValidationResult}.
*/
class GuiEditorValidationResultTest {
@Test
void empty_producesResultWithNoFindingsAndCurrentTimestamp() {
var result = GuiEditorValidationResult.empty();
assertThat(result.messages()).isEmpty();
assertThat(result.fieldFindings()).isEmpty();
assertThat(result.evaluatedAt()).isNotNull();
}
@Test
void hasErrors_falseWhenNoMessages() {
var result = GuiEditorValidationResult.empty();
assertThat(result.hasErrors()).isFalse();
}
@Test
void hasErrors_trueWhenMessageWithErrorSeverity() {
var messages = List.of(GuiMessageEntry.of(GuiMessageSeverity.ERROR, "API-Key fehlt"));
var result = new GuiEditorValidationResult(messages, List.of(), Instant.now());
assertThat(result.hasErrors()).isTrue();
}
@Test
void hasErrors_trueWhenFieldFindingWithErrorSeverity() {
var fieldFindings = List.of(GuiFieldFinding.error("source.folder", "Pflichtfeld fehlt"));
var result = new GuiEditorValidationResult(List.of(), fieldFindings, Instant.now());
assertThat(result.hasErrors()).isTrue();
}
@Test
void hasErrors_falseWhenOnlyWarnings() {
var messages = List.of(GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Hohe Zeichenzahl"));
var fieldFindings = List.of(GuiFieldFinding.warning("max.text.characters", "Warnung"));
var result = new GuiEditorValidationResult(messages, fieldFindings, Instant.now());
assertThat(result.hasErrors()).isFalse();
}
@Test
void hasFieldFindingFor_trueWhenFindingExists() {
var fieldFindings = List.of(GuiFieldFinding.error("source.folder", "Pflichtfeld fehlt"));
var result = new GuiEditorValidationResult(List.of(), fieldFindings, Instant.now());
assertThat(result.hasFieldFindingFor("source.folder")).isTrue();
}
@Test
void hasFieldFindingFor_falseWhenFindingAbsent() {
var result = GuiEditorValidationResult.empty();
assertThat(result.hasFieldFindingFor("source.folder")).isFalse();
}
@Test
void messages_isDefensiveCopy() {
var mutableMessages = new java.util.ArrayList<>(
List.of(GuiMessageEntry.of(GuiMessageSeverity.INFO, "info")));
var result = new GuiEditorValidationResult(mutableMessages, List.of(), Instant.now());
mutableMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, "error"));
assertThat(result.messages()).hasSize(1);
}
@Test
void fieldFindings_isDefensiveCopy() {
var mutableFindings = new java.util.ArrayList<>(
List.of(GuiFieldFinding.error("f", "t")));
var result = new GuiEditorValidationResult(List.of(), mutableFindings, Instant.now());
mutableFindings.add(GuiFieldFinding.warning("g", "t2"));
assertThat(result.fieldFindings()).hasSize(1);
}
@Test
void rejectsNullMessages() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiEditorValidationResult(null, List.of(), Instant.now()));
}
@Test
void rejectsNullFieldFindings() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiEditorValidationResult(List.of(), null, Instant.now()));
}
@Test
void rejectsNullTimestamp() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiEditorValidationResult(List.of(), List.of(), null));
}
@Test
void hasFieldFindingFor_rejectsNullFieldKey() {
var result = GuiEditorValidationResult.empty();
assertThatNullPointerException()
.isThrownBy(() -> result.hasFieldFindingFor(null));
}
}
@@ -0,0 +1,66 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link GuiFieldFinding}.
*/
class GuiFieldFindingTest {
@Test
void storesAllFields() {
var finding = new GuiFieldFinding("source.folder", GuiMessageSeverity.ERROR, "Pflichtfeld fehlt");
assertThat(finding.fieldKey()).isEqualTo("source.folder");
assertThat(finding.severity()).isEqualTo(GuiMessageSeverity.ERROR);
assertThat(finding.text()).isEqualTo("Pflichtfeld fehlt");
}
@Test
void errorFactory_createsFindingWithErrorSeverity() {
var finding = GuiFieldFinding.error("target.folder", "Ordner nicht vorhanden");
assertThat(finding.severity()).isEqualTo(GuiMessageSeverity.ERROR);
assertThat(finding.fieldKey()).isEqualTo("target.folder");
assertThat(finding.text()).isEqualTo("Ordner nicht vorhanden");
}
@Test
void warningFactory_createsFindingWithWarningSeverity() {
var finding = GuiFieldFinding.warning("max.text.characters", "Sehr hohe Zeichenzahl konfiguriert");
assertThat(finding.severity()).isEqualTo(GuiMessageSeverity.WARNING);
assertThat(finding.fieldKey()).isEqualTo("max.text.characters");
}
@Test
void rejectsNullFieldKey() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiFieldFinding(null, GuiMessageSeverity.ERROR, "text"));
}
@Test
void rejectsNullSeverity() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiFieldFinding("field", null, "text"));
}
@Test
void rejectsNullText() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiFieldFinding("field", GuiMessageSeverity.ERROR, null));
}
@Test
void equality_basedOnAllFields() {
var a = GuiFieldFinding.error("source.folder", "fehlt");
var b = GuiFieldFinding.error("source.folder", "fehlt");
var c = GuiFieldFinding.error("target.folder", "fehlt");
assertThat(a).isEqualTo(b);
assertThat(a).isNotEqualTo(c);
}
}
@@ -0,0 +1,53 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link GuiManualModelEntry}.
*/
class GuiManualModelEntryTest {
@Test
void storesProviderIdentifierAndModelName() {
var entry = new GuiManualModelEntry("claude", "claude-3-5-sonnet");
assertThat(entry.providerIdentifier()).isEqualTo("claude");
assertThat(entry.modelName()).isEqualTo("claude-3-5-sonnet");
}
@Test
void hasModelName_trueWhenNonBlank() {
assertThat(new GuiManualModelEntry("claude", "some-model").hasModelName()).isTrue();
}
@Test
void hasModelName_falseWhenBlank() {
assertThat(new GuiManualModelEntry("claude", "").hasModelName()).isFalse();
assertThat(new GuiManualModelEntry("claude", " ").hasModelName()).isFalse();
}
@Test
void rejectsNullProviderIdentifier() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiManualModelEntry(null, "model"));
}
@Test
void rejectsNullModelName() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiManualModelEntry("claude", null));
}
@Test
void equality_basedOnAllFields() {
var a = new GuiManualModelEntry("claude", "model-x");
var b = new GuiManualModelEntry("claude", "model-x");
var c = new GuiManualModelEntry("openai-compatible", "model-x");
assertThat(a).isEqualTo(b);
assertThat(a).isNotEqualTo(c);
}
}
@@ -0,0 +1,69 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link GuiMessageEntry}.
*/
class GuiMessageEntryTest {
@Test
void fullConstructor_storesAllFields() {
var now = Instant.now();
var entry = new GuiMessageEntry(
GuiMessageSeverity.ERROR,
"Quellordner fehlt",
Optional.of("Validierung"),
now);
assertThat(entry.severity()).isEqualTo(GuiMessageSeverity.ERROR);
assertThat(entry.text()).isEqualTo("Quellordner fehlt");
assertThat(entry.source()).contains("Validierung");
assertThat(entry.timestamp()).isEqualTo(now);
}
@Test
void nullSourceBecomesEmpty() {
var entry = new GuiMessageEntry(GuiMessageSeverity.INFO, "text", null, Instant.now());
assertThat(entry.source()).isEmpty();
}
@Test
void factoryOf_withoutSource_hasEmptySource() {
var entry = GuiMessageEntry.of(GuiMessageSeverity.INFO, "Konfiguration geladen");
assertThat(entry.source()).isEmpty();
assertThat(entry.severity()).isEqualTo(GuiMessageSeverity.INFO);
assertThat(entry.text()).isEqualTo("Konfiguration geladen");
assertThat(entry.timestamp()).isNotNull();
}
@Test
void factoryOf_withSource_storesSource() {
var entry = GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Lange Zeichenzahl", "Validierung");
assertThat(entry.source()).contains("Validierung");
}
@Test
void rejectsNullSeverity() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiMessageEntry(null, "text", Optional.empty(), Instant.now()));
}
@Test
void rejectsNullText() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiMessageEntry(GuiMessageSeverity.INFO, null, Optional.empty(), Instant.now()));
}
@Test
void rejectsNullTimestamp() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiMessageEntry(GuiMessageSeverity.INFO, "text", Optional.empty(), null));
}
}
@@ -0,0 +1,38 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link GuiMessageSeverity}.
*/
class GuiMessageSeverityTest {
@Test
void allValuesHaveGermanPrefix() {
assertThat(GuiMessageSeverity.INFO.getPrefixLabel()).isEqualTo("Info:");
assertThat(GuiMessageSeverity.HINT.getPrefixLabel()).isEqualTo("Hinweis:");
assertThat(GuiMessageSeverity.WARNING.getPrefixLabel()).isEqualTo("Warnung:");
assertThat(GuiMessageSeverity.ERROR.getPrefixLabel()).isEqualTo("Fehler:");
}
@Test
void allValuesHaveCssColour() {
for (GuiMessageSeverity severity : GuiMessageSeverity.values()) {
assertThat(severity.getPrefixCssColour())
.as("CSS colour for %s must be a hex colour string", severity)
.isNotBlank()
.startsWith("#");
}
}
@Test
void allFourValuesPresent() {
assertThat(GuiMessageSeverity.values()).containsExactly(
GuiMessageSeverity.INFO,
GuiMessageSeverity.HINT,
GuiMessageSeverity.WARNING,
GuiMessageSeverity.ERROR);
}
}
@@ -0,0 +1,28 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link GuiModelSource}.
*/
class GuiModelSourceTest {
@Test
void allValuesPresent() {
assertThat(GuiModelSource.values()).containsExactlyInAnyOrder(
GuiModelSource.LIST_REMOTE_SUCCESS,
GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT,
GuiModelSource.LIST_FAILED_MANUAL_INPUT,
GuiModelSource.NOT_YET_LOADED);
}
@Test
void enumLookupByName() {
assertThat(GuiModelSource.valueOf("LIST_REMOTE_SUCCESS")).isEqualTo(GuiModelSource.LIST_REMOTE_SUCCESS);
assertThat(GuiModelSource.valueOf("LIST_UNAVAILABLE_MANUAL_INPUT")).isEqualTo(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
assertThat(GuiModelSource.valueOf("LIST_FAILED_MANUAL_INPUT")).isEqualTo(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
assertThat(GuiModelSource.valueOf("NOT_YET_LOADED")).isEqualTo(GuiModelSource.NOT_YET_LOADED);
}
}
@@ -0,0 +1,117 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link GuiVisibleProviderSection}.
*/
class GuiVisibleProviderSectionTest {
private static final GuiProviderConfigurationState CLAUDE_STATE =
new GuiProviderConfigurationState("https://api.anthropic.com", "claude-3-5-sonnet", "30",
GuiProviderApiKeyState.unresolved("claude-key"));
private static final GuiProviderConfigurationState OPENAI_STATE =
new GuiProviderConfigurationState("https://api.openai.com", "gpt-4o", "60",
GuiProviderApiKeyState.unresolved("openai-key"));
@Test
void storesAllFields() {
var section = new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
assertThat(section.visibleProvider()).isEqualTo(AiProviderFamily.CLAUDE);
assertThat(section.visibleProviderState()).isEqualTo(CLAUDE_STATE);
assertThat(section.hiddenProvider()).isEqualTo(AiProviderFamily.OPENAI_COMPATIBLE);
assertThat(section.hiddenProviderState()).isEqualTo(OPENAI_STATE);
}
@Test
void stateFor_returnsVisibleProviderState() {
var section = new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
assertThat(section.stateFor(AiProviderFamily.CLAUDE)).isEqualTo(CLAUDE_STATE);
}
@Test
void stateFor_returnsHiddenProviderState() {
var section = new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
assertThat(section.stateFor(AiProviderFamily.OPENAI_COMPATIBLE)).isEqualTo(OPENAI_STATE);
}
@Test
void switchProvider_swapsVisibleAndHiddenWithoutLosingValues() {
var section = new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
var switched = section.switchProvider();
assertThat(switched.visibleProvider()).isEqualTo(AiProviderFamily.OPENAI_COMPATIBLE);
assertThat(switched.visibleProviderState()).isEqualTo(OPENAI_STATE);
assertThat(switched.hiddenProvider()).isEqualTo(AiProviderFamily.CLAUDE);
assertThat(switched.hiddenProviderState()).isEqualTo(CLAUDE_STATE);
}
@Test
void withVisibleProviderState_replacesOnlyVisibleState() {
var section = new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
var updatedState = GuiProviderConfigurationState.blank();
var updated = section.withVisibleProviderState(updatedState);
assertThat(updated.visibleProviderState()).isEqualTo(updatedState);
assertThat(updated.hiddenProviderState()).isEqualTo(OPENAI_STATE); // unchanged
assertThat(updated.visibleProvider()).isEqualTo(AiProviderFamily.CLAUDE);
assertThat(updated.hiddenProvider()).isEqualTo(AiProviderFamily.OPENAI_COMPATIBLE);
}
@Test
void rejectsSameVisibleAndHiddenProvider() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
AiProviderFamily.CLAUDE, OPENAI_STATE));
}
@Test
void rejectsNullVisibleProvider() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiVisibleProviderSection(
null, CLAUDE_STATE,
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE));
}
@Test
void rejectsNullHiddenProvider() {
assertThatNullPointerException()
.isThrownBy(() -> new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
null, OPENAI_STATE));
}
@Test
void stateFor_rejectsUnknownFamily() {
var section = new GuiVisibleProviderSection(
AiProviderFamily.CLAUDE, CLAUDE_STATE,
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
// Use a mock/non-existing provider — since we only have 2 values and both are used,
// we can test with a null check instead to verify the guard runs
assertThatNullPointerException()
.isThrownBy(() -> section.stateFor(null));
}
}