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
+5
View File
@@ -62,6 +62,11 @@
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!--
Monocle: headless JavaFX platform for GUI smoke tests.
Provides the Glass platform implementation that runs JavaFX without a
@@ -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));
}
}
@@ -0,0 +1,315 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
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;
/**
* Adapter implementing {@link AiModelCatalogPort} for the native Anthropic Claude provider family.
* <p>
* Fetches the list of available Claude models from the Anthropic models endpoint
* ({@code GET {baseUrl}/v1/models}). Authentication uses the Anthropic-specific
* {@code x-api-key} header together with the {@code anthropic-version} header.
* <p>
* <strong>Default base URL:</strong> When the request carries no base URL,
* {@code https://api.anthropic.com} is used automatically.
* <p>
* <strong>Error handling:</strong> All expected error conditions (missing API key,
* HTTP errors, timeouts, parse failures) are returned as specific
* {@link ModelCatalogResult} sub-types. No exception is thrown to the caller.
* <p>
* <strong>Thread safety:</strong> This adapter is stateless. All configuration
* values are read from the {@link ModelCatalogRequest} at call time. Multiple
* threads may call {@link #fetchAvailableModels(ModelCatalogRequest)} concurrently
* without synchronisation.
* <p>
* <strong>Non-goals:</strong>
* <ul>
* <li>Retry logic the caller is responsible for retry decisions.</li>
* <li>Caching a fresh HTTP call is made on every invocation.</li>
* <li>Shared implementation with the OpenAI-compatible adapter.</li>
* </ul>
*/
public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class);
/** Anthropic models list endpoint path. */
private static final String MODELS_ENDPOINT = "/v1/models";
/** Default base URL for the Anthropic API, applied when the request carries no base URL. */
static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
/** Anthropic-specific authentication header. */
private static final String API_KEY_HEADER = "x-api-key";
/** Required Anthropic API version header name. */
private static final String ANTHROPIC_VERSION_HEADER = "anthropic-version";
/** Required Anthropic API version header value. */
private static final String ANTHROPIC_VERSION_VALUE = "2023-06-01";
private static final String PROVIDER_ID = "claude";
/**
* Creates a new stateless Claude model catalogue adapter.
* <p>
* No configuration is held in the instance. All request parameters are
* supplied per call via {@link ModelCatalogRequest}.
*/
public ClaudeModelCatalogAdapter() {
// stateless no fields to initialise
}
/**
* Fetches the list of available Claude models from the Anthropic models endpoint.
* <p>
* The adapter:
* <ol>
* <li>Validates that the request carries a non-blank API key.</li>
* <li>Resolves the base URL (falls back to {@value #DEFAULT_BASE_URL}).</li>
* <li>Sends {@code GET {baseUrl}/v1/models} with Anthropic authentication headers.</li>
* <li>Maps HTTP 200 + non-empty {@code data} array to {@link ModelCatalogResult.Success}.</li>
* <li>Maps HTTP 200 + empty array to {@link ModelCatalogResult.EmptyList}.</li>
* <li>Maps HTTP 401 / 403 to {@link ModelCatalogResult.TechnicalFailure} with
* {@code AUTHENTICATION_FAILED}.</li>
* <li>Maps HTTP 404 to {@code ENDPOINT_NOT_FOUND}.</li>
* <li>Maps HTTP 5xx to {@code SERVER_ERROR}.</li>
* <li>Maps timeouts and connection failures to {@code CONNECTION_FAILURE}.</li>
* <li>Maps unparseable responses to {@code INVALID_RESPONSE}.</li>
* </ol>
*
* @param request all values needed to contact the provider; must not be {@code null}
* @return a non-{@code null} result encoding the outcome
* @throws NullPointerException if {@code request} is {@code null}
*/
@Override
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
java.util.Objects.requireNonNull(request, "request must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
LOG.warn("Claude model catalogue: API key is missing cannot fetch model list");
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
LOG.info("Claude model catalogue: fetching models from {}", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(API_KEY_HEADER, apiKey)
.header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
LOG.warn("Claude model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
LOG.warn("Claude model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
LOG.warn("Claude model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
LOG.warn("Claude model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("Claude model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
/**
* Maps the HTTP response to the appropriate {@link ModelCatalogResult} sub-type.
*
* @param response the HTTP response from the Anthropic models endpoint
* @return the mapped result; never {@code null}
*/
private ModelCatalogResult handleResponse(HttpResponse<String> response) {
int status = response.statusCode();
if (status == 401 || status == 403) {
LOG.warn("Claude model catalogue: authentication failed HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "AUTHENTICATION_FAILED",
"Authentifizierung fehlgeschlagen (HTTP " + status + ")");
}
if (status == 404) {
LOG.warn("Claude model catalogue: endpoint not found HTTP 404");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "ENDPOINT_NOT_FOUND",
"Endpunkt nicht gefunden (HTTP 404)");
}
if (status >= 500) {
LOG.warn("Claude model catalogue: server error HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "SERVER_ERROR",
"Serverfehler beim Modellabruf (HTTP " + status + ")");
}
if (status != 200) {
LOG.warn("Claude model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter HTTP-Status: " + status);
}
return parseModelsResponse(response.body());
}
/**
* Parses the JSON response body from the Anthropic models endpoint.
* <p>
* Expects a top-level {@code data} array where each element has an {@code id} field.
*
* @param responseBody the raw JSON response body
* @return {@link ModelCatalogResult.Success} or {@link ModelCatalogResult.EmptyList}
* on parse success; {@link ModelCatalogResult.TechnicalFailure} with
* {@code INVALID_RESPONSE} on parse failure
*/
private ModelCatalogResult parseModelsResponse(String responseBody) {
try {
JSONObject json = new JSONObject(responseBody);
JSONArray dataArray = json.getJSONArray("data");
List<String> modelIds = new ArrayList<>();
for (int i = 0; i < dataArray.length(); i++) {
JSONObject entry = dataArray.getJSONObject(i);
String id = entry.optString("id", "").trim();
if (!id.isEmpty()) {
modelIds.add(id);
}
}
if (modelIds.isEmpty()) {
LOG.warn("Claude model catalogue: provider returned empty model list");
return new ModelCatalogResult.EmptyList(PROVIDER_ID, Instant.now());
}
LOG.info("Claude model catalogue: loaded {} model(s)", modelIds.size());
return new ModelCatalogResult.Success(PROVIDER_ID, modelIds, Instant.now());
} catch (JSONException e) {
LOG.warn("Claude model catalogue: response could not be parsed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "INVALID_RESPONSE",
"Antwort konnte nicht verarbeitet werden: " + e.getMessage());
}
}
/**
* Builds the full endpoint URI for the Anthropic models endpoint.
*
* @param baseUrl the resolved base URL (never blank)
* @return the complete endpoint URI
*/
private URI buildEndpointUri(String baseUrl) {
URI base = URI.create(baseUrl);
String path = base.getPath().replaceAll("/$", "") + MODELS_ENDPOINT;
return URI.create(base.getScheme() + "://"
+ base.getHost()
+ (base.getPort() > 0 ? ":" + base.getPort() : "")
+ path);
}
/**
* Package-private factory method for test injection of a custom {@link HttpClient}.
* <p>
* <strong>For testing only.</strong> Allows tests to provide a mock or stub client
* without network access while exercising the full request-building and response-mapping
* logic.
*
* @param request the model catalogue request
* @param httpClient the HTTP client to use instead of creating a new one
* @return the mapped result
*/
ModelCatalogResult fetchAvailableModelsWithClient(ModelCatalogRequest request, HttpClient httpClient) {
java.util.Objects.requireNonNull(request, "request must not be null");
java.util.Objects.requireNonNull(httpClient, "httpClient must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
LOG.warn("Claude model catalogue: API key is missing cannot fetch model list");
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
LOG.info("Claude model catalogue: fetching models from {} (test client)", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(API_KEY_HEADER, apiKey)
.header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
}
@@ -0,0 +1,309 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
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;
/**
* Adapter implementing {@link AiModelCatalogPort} for the OpenAI-compatible provider family.
* <p>
* Fetches the list of available models from the OpenAI-compatible models endpoint
* ({@code GET {baseUrl}/v1/models}). Authentication uses the standard
* {@code Authorization: Bearer <apiKey>} header.
* <p>
* <strong>Default base URL:</strong> When the request carries no base URL,
* {@code https://api.openai.com} is used automatically.
* <p>
* <strong>Error handling:</strong> All expected error conditions (missing API key,
* HTTP errors, timeouts, parse failures) are returned as specific
* {@link ModelCatalogResult} sub-types. No exception is thrown to the caller.
* <p>
* <strong>Thread safety:</strong> This adapter is stateless. All configuration
* values are read from the {@link ModelCatalogRequest} at call time. Multiple
* threads may call {@link #fetchAvailableModels(ModelCatalogRequest)} concurrently
* without synchronisation.
* <p>
* <strong>Non-goals:</strong>
* <ul>
* <li>Retry logic the caller is responsible for retry decisions.</li>
* <li>Caching a fresh HTTP call is made on every invocation.</li>
* <li>Shared implementation with the Claude adapter.</li>
* </ul>
*/
public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class);
/** OpenAI-compatible models list endpoint path. */
private static final String MODELS_ENDPOINT = "/v1/models";
/** Default base URL for the OpenAI API, applied when the request carries no base URL. */
static final String DEFAULT_BASE_URL = "https://api.openai.com";
/** Standard OAuth2 bearer authorization header name. */
private static final String AUTHORIZATION_HEADER = "Authorization";
/** Bearer token prefix for the Authorization header. */
private static final String BEARER_PREFIX = "Bearer ";
private static final String PROVIDER_ID = "openai-compatible";
/**
* Creates a new stateless OpenAI-compatible model catalogue adapter.
* <p>
* No configuration is held in the instance. All request parameters are
* supplied per call via {@link ModelCatalogRequest}.
*/
public OpenAiCompatibleModelCatalogAdapter() {
// stateless no fields to initialise
}
/**
* Fetches the list of available models from the OpenAI-compatible models endpoint.
* <p>
* The adapter:
* <ol>
* <li>Validates that the request carries a non-blank API key.</li>
* <li>Resolves the base URL (falls back to {@value #DEFAULT_BASE_URL}).</li>
* <li>Sends {@code GET {baseUrl}/v1/models} with {@code Authorization: Bearer} header.</li>
* <li>Maps HTTP 200 + non-empty {@code data} array to {@link ModelCatalogResult.Success}.</li>
* <li>Maps HTTP 200 + empty array to {@link ModelCatalogResult.EmptyList}.</li>
* <li>Maps HTTP 401 / 403 to {@link ModelCatalogResult.TechnicalFailure} with
* {@code AUTHENTICATION_FAILED}.</li>
* <li>Maps HTTP 404 to {@code ENDPOINT_NOT_FOUND}.</li>
* <li>Maps HTTP 5xx to {@code SERVER_ERROR}.</li>
* <li>Maps timeouts and connection failures to {@code CONNECTION_FAILURE}.</li>
* <li>Maps unparseable responses to {@code INVALID_RESPONSE}.</li>
* </ol>
*
* @param request all values needed to contact the provider; must not be {@code null}
* @return a non-{@code null} result encoding the outcome
* @throws NullPointerException if {@code request} is {@code null}
*/
@Override
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
java.util.Objects.requireNonNull(request, "request must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
LOG.warn("OpenAI-compatible model catalogue: API key is missing cannot fetch model list");
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
LOG.info("OpenAI-compatible model catalogue: fetching models from {}", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
LOG.warn("OpenAI-compatible model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
LOG.warn("OpenAI-compatible model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
LOG.warn("OpenAI-compatible model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("OpenAI-compatible model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
/**
* Maps the HTTP response to the appropriate {@link ModelCatalogResult} sub-type.
*
* @param response the HTTP response from the OpenAI-compatible models endpoint
* @return the mapped result; never {@code null}
*/
private ModelCatalogResult handleResponse(HttpResponse<String> response) {
int status = response.statusCode();
if (status == 401 || status == 403) {
LOG.warn("OpenAI-compatible model catalogue: authentication failed HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "AUTHENTICATION_FAILED",
"Authentifizierung fehlgeschlagen (HTTP " + status + ")");
}
if (status == 404) {
LOG.warn("OpenAI-compatible model catalogue: endpoint not found HTTP 404");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "ENDPOINT_NOT_FOUND",
"Endpunkt nicht gefunden (HTTP 404)");
}
if (status >= 500) {
LOG.warn("OpenAI-compatible model catalogue: server error HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "SERVER_ERROR",
"Serverfehler beim Modellabruf (HTTP " + status + ")");
}
if (status != 200) {
LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter HTTP-Status: " + status);
}
return parseModelsResponse(response.body());
}
/**
* Parses the JSON response body from the OpenAI-compatible models endpoint.
* <p>
* Expects a top-level {@code data} array where each element has an {@code id} field.
*
* @param responseBody the raw JSON response body
* @return {@link ModelCatalogResult.Success} or {@link ModelCatalogResult.EmptyList}
* on parse success; {@link ModelCatalogResult.TechnicalFailure} with
* {@code INVALID_RESPONSE} on parse failure
*/
private ModelCatalogResult parseModelsResponse(String responseBody) {
try {
JSONObject json = new JSONObject(responseBody);
JSONArray dataArray = json.getJSONArray("data");
List<String> modelIds = new ArrayList<>();
for (int i = 0; i < dataArray.length(); i++) {
JSONObject entry = dataArray.getJSONObject(i);
String id = entry.optString("id", "").trim();
if (!id.isEmpty()) {
modelIds.add(id);
}
}
if (modelIds.isEmpty()) {
LOG.warn("OpenAI-compatible model catalogue: provider returned empty model list");
return new ModelCatalogResult.EmptyList(PROVIDER_ID, Instant.now());
}
LOG.info("OpenAI-compatible model catalogue: loaded {} model(s)", modelIds.size());
return new ModelCatalogResult.Success(PROVIDER_ID, modelIds, Instant.now());
} catch (JSONException e) {
LOG.warn("OpenAI-compatible model catalogue: response could not be parsed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "INVALID_RESPONSE",
"Antwort konnte nicht verarbeitet werden: " + e.getMessage());
}
}
/**
* Builds the full endpoint URI for the OpenAI-compatible models endpoint.
*
* @param baseUrl the resolved base URL (never blank)
* @return the complete endpoint URI
*/
private URI buildEndpointUri(String baseUrl) {
URI base = URI.create(baseUrl);
String path = base.getPath().replaceAll("/$", "") + MODELS_ENDPOINT;
return URI.create(base.getScheme() + "://"
+ base.getHost()
+ (base.getPort() > 0 ? ":" + base.getPort() : "")
+ path);
}
/**
* Package-private factory method for test injection of a custom {@link HttpClient}.
* <p>
* <strong>For testing only.</strong> Allows tests to provide a mock or stub client
* without network access while exercising the full request-building and response-mapping
* logic.
*
* @param request the model catalogue request
* @param httpClient the HTTP client to use instead of creating a new one
* @return the mapped result
*/
ModelCatalogResult fetchAvailableModelsWithClient(ModelCatalogRequest request, HttpClient httpClient) {
java.util.Objects.requireNonNull(request, "request must not be null");
java.util.Objects.requireNonNull(httpClient, "httpClient must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
LOG.info("OpenAI-compatible model catalogue: fetching models from {} (test client)", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
}
@@ -0,0 +1,13 @@
/**
* Adapter implementations for the {@code AiModelCatalogPort} outbound port.
* <p>
* This package contains one concrete adapter per supported AI provider family.
* Each adapter translates a {@code ModelCatalogRequest} into a provider-specific
* HTTP request, maps the response to a {@code ModelCatalogResult} sub-type, and
* never throws checked or runtime exceptions for expected error conditions.
* <p>
* Provider-specific HTTP details (endpoints, authentication schemes, response
* structures) are encapsulated entirely within the respective adapter class.
* The Application and GUI layers remain free of any provider knowledge.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
@@ -0,0 +1,109 @@
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
import java.util.Objects;
import java.util.function.Function;
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.validation.editor.ApiKeyResolutionPort;
/**
* Implementierung des {@link ApiKeyResolutionPort}, die Umgebungsvariablen aus der
* Systemprozessumgebung liest und den effektiven API-Key-Herkunftsdeskriptor zurückgibt.
* <p>
* Die Vorrangregel lautet:
* <ol>
* <li>Providerspezifische Umgebungsvariable ({@code ANTHROPIC_API_KEY} für Claude,
* {@code OPENAI_COMPATIBLE_API_KEY} für OpenAI-kompatibel)</li>
* <li>Bei {@code openai-compatible}: Legacy-Variable {@code PDF_UMBENENNER_API_KEY}</li>
* <li>Property-Wert aus dem Editor-Feld ({@code propertyValue})</li>
* <li>{@code ABSENT}, wenn keine Quelle einen Wert liefert</li>
* </ol>
* <p>
* Verwendet dieselben ENV-Variablen-Namen wie der bestehende headless Konfigurationspfad, so dass
* GUI-Validierung und headless Bootstrap identisch auflösen.
* <p>
* Für Tests kann der Umgebungslookup injiziert werden, ohne den echten Prozess-Umgebungszustand
* zu verändern.
*/
public class EnvironmentApiKeyResolutionAdapter implements ApiKeyResolutionPort {
/** Providerspezifische Umgebungsvariable für den Claude-Provider. */
static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY";
/** Providerspezifische Umgebungsvariable für den OpenAI-kompatiblen Provider. */
static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY";
/** Legacy-Umgebungsvariable für den OpenAI-kompatiblen Provider (Rückwärtskompatibilität). */
static final String ENV_LEGACY_OPENAI_API_KEY = "PDF_UMBENENNER_API_KEY";
private final Function<String, String> environmentLookup;
/**
* Erstellt einen Adapter, der die echte Prozessumgebung liest.
*/
public EnvironmentApiKeyResolutionAdapter() {
this(System::getenv);
}
/**
* Erstellt einen Adapter mit einem injizierten Umgebungslookup.
* <p>
* Dieser Konstruktor erlaubt deterministisches Testen ohne Änderungen am echten
* Prozess-Umgebungszustand.
*
* @param environmentLookup Funktion zum Lesen von Umgebungsvariablen; darf nicht {@code null} sein
*/
EnvironmentApiKeyResolutionAdapter(Function<String, String> environmentLookup) {
this.environmentLookup = Objects.requireNonNull(environmentLookup,
"environmentLookup must not be null");
}
/**
* Ermittelt die Herkunft des effektiven API-Schlüssels für den angegebenen Provider.
*
* @param family die Provider-Familie; darf nicht {@code null} sein
* @param propertyValue aktueller Property-Wert aus dem Editor; darf nicht {@code null} sein
* @return der Herkunftsdeskriptor; nie {@code null}
*/
@Override
public EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue) {
Objects.requireNonNull(family, "family must not be null");
Objects.requireNonNull(propertyValue, "propertyValue must not be null");
return switch (family) {
case CLAUDE -> resolveClaude(propertyValue);
case OPENAI_COMPATIBLE -> resolveOpenAiCompatible(propertyValue);
};
}
private EffectiveApiKeyDescriptor resolveClaude(String propertyValue) {
String envValue = environmentLookup.apply(ENV_CLAUDE_API_KEY);
if (isPresent(envValue)) {
return EffectiveApiKeyDescriptor.fromProviderEnvVar(ENV_CLAUDE_API_KEY);
}
if (isPresent(propertyValue)) {
return EffectiveApiKeyDescriptor.fromPropertyFile();
}
return EffectiveApiKeyDescriptor.absent();
}
private EffectiveApiKeyDescriptor resolveOpenAiCompatible(String propertyValue) {
String primaryEnv = environmentLookup.apply(ENV_OPENAI_API_KEY);
if (isPresent(primaryEnv)) {
return EffectiveApiKeyDescriptor.fromProviderEnvVar(ENV_OPENAI_API_KEY);
}
String legacyEnv = environmentLookup.apply(ENV_LEGACY_OPENAI_API_KEY);
if (isPresent(legacyEnv)) {
return EffectiveApiKeyDescriptor.fromLegacyEnvVar(ENV_LEGACY_OPENAI_API_KEY);
}
if (isPresent(propertyValue)) {
return EffectiveApiKeyDescriptor.fromPropertyFile();
}
return EffectiveApiKeyDescriptor.absent();
}
private static boolean isPresent(String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,8 @@
/**
* Adapter-Out-Implementierungen für Validierungsinfrastruktur.
* <p>
* Dieses Package enthält konkrete Implementierungen der Outbound-Ports,
* die für die editornahe Konfigurationsvalidierung benötigt werden, insbesondere
* die Auflösung der API-Key-Herkunft aus Umgebungsvariablen.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
@@ -0,0 +1,258 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import java.net.ConnectException;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Unit tests for {@link ClaudeModelCatalogAdapter}.
* <p>
* All tests inject a mock {@link HttpClient} via the package-private
* {@code fetchAvailableModelsWithClient} method to avoid network access.
*/
@ExtendWith(MockitoExtension.class)
class ClaudeModelCatalogAdapterTest {
private static final String API_KEY = "test-api-key";
private static final String BASE_URL = "http://localhost:9999";
private static final int TIMEOUT = 5;
private static final String PROVIDER_ID = "claude";
@Mock
private HttpClient httpClient;
@Mock
@SuppressWarnings("unchecked")
private HttpResponse<String> httpResponse;
private ClaudeModelCatalogAdapter adapter;
@BeforeEach
void setUp() {
adapter = new ClaudeModelCatalogAdapter();
}
@Test
@DisplayName("HTTP 200 with non-empty model list returns Success")
void fetchModels_http200WithModels_returnsSuccess() throws Exception {
String responseBody = """
{"data":[{"id":"claude-3-opus","type":"model"},{"id":"claude-3-sonnet","type":"model"}]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.Success.class);
ModelCatalogResult.Success success = (ModelCatalogResult.Success) result;
assertThat(success.models()).containsExactly("claude-3-opus", "claude-3-sonnet");
assertThat(success.providerIdentifier()).isEqualTo(PROVIDER_ID);
}
@Test
@DisplayName("HTTP 200 with empty data array returns EmptyList")
void fetchModels_http200EmptyDataArray_returnsEmptyList() throws Exception {
String responseBody = """
{"data":[]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.EmptyList.class);
}
@Test
@DisplayName("HTTP 401 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http401_returnsAuthenticationFailed() throws Exception {
doReturn(401).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
ModelCatalogResult.TechnicalFailure failure = (ModelCatalogResult.TechnicalFailure) result;
assertThat(failure.errorCategory()).isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 403 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http403_returnsAuthenticationFailed() throws Exception {
doReturn(403).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 404 returns TechnicalFailure with ENDPOINT_NOT_FOUND")
void fetchModels_http404_returnsEndpointNotFound() throws Exception {
doReturn(404).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("ENDPOINT_NOT_FOUND");
}
@Test
@DisplayName("HTTP 500 returns TechnicalFailure with SERVER_ERROR")
void fetchModels_http500_returnsServerError() throws Exception {
doReturn(500).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("SERVER_ERROR");
}
@Test
@DisplayName("ConnectException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_connectException_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new ConnectException("connection refused"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("Unparseable JSON response returns TechnicalFailure with INVALID_RESPONSE")
void fetchModels_invalidJson_returnsInvalidResponse() throws Exception {
doReturn(200).when(httpResponse).statusCode();
doReturn("this is not json at all!!!").when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("INVALID_RESPONSE");
}
@Test
@DisplayName("Missing API key returns IncompleteConfiguration")
void fetchModels_missingApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.empty(), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
ModelCatalogResult.IncompleteConfiguration incomplete =
(ModelCatalogResult.IncompleteConfiguration) result;
assertThat(incomplete.missingReason()).containsIgnoringCase("API-Schlüssel");
}
@Test
@DisplayName("Blank API key returns IncompleteConfiguration")
void fetchModels_blankApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(" "), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
}
@Test
@DisplayName("No base URL falls back to Anthropic default")
void fetchModels_noBaseUrl_usesDefault() {
// Verify that no exception is thrown when building the URI with no base URL.
// The adapter should fall back to https://api.anthropic.com without crashing.
// (We don't send a real HTTP request here; we just verify the adapter doesn't throw
// during the URI construction phase before the send call.)
assertThat(ClaudeModelCatalogAdapter.DEFAULT_BASE_URL).isEqualTo("https://api.anthropic.com");
}
@Test
@DisplayName("UnknownHostException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_unknownHost_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new UnknownHostException("unknown host"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("HttpTimeoutException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_httpTimeout_returnsConnectionFailure() throws Exception {
// The adapter groups HTTP timeouts together with other connection failures under
// the CONNECTION_FAILURE category (no separate TIMEOUT category).
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new HttpTimeoutException("request timed out"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
}
@@ -0,0 +1,254 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import java.net.ConnectException;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Unit tests for {@link OpenAiCompatibleModelCatalogAdapter}.
* <p>
* All tests inject a mock {@link HttpClient} via the package-private
* {@code fetchAvailableModelsWithClient} method to avoid network access.
*/
@ExtendWith(MockitoExtension.class)
class OpenAiCompatibleModelCatalogAdapterTest {
private static final String API_KEY = "test-openai-key";
private static final String BASE_URL = "http://localhost:8888";
private static final int TIMEOUT = 5;
private static final String PROVIDER_ID = "openai-compatible";
@Mock
private HttpClient httpClient;
@Mock
@SuppressWarnings("unchecked")
private HttpResponse<String> httpResponse;
private OpenAiCompatibleModelCatalogAdapter adapter;
@BeforeEach
void setUp() {
adapter = new OpenAiCompatibleModelCatalogAdapter();
}
@Test
@DisplayName("HTTP 200 with non-empty model list returns Success")
void fetchModels_http200WithModels_returnsSuccess() throws Exception {
String responseBody = """
{"object":"list","data":[{"id":"gpt-4o","object":"model"},{"id":"gpt-4","object":"model"}]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.Success.class);
ModelCatalogResult.Success success = (ModelCatalogResult.Success) result;
assertThat(success.models()).containsExactly("gpt-4o", "gpt-4");
assertThat(success.providerIdentifier()).isEqualTo(PROVIDER_ID);
}
@Test
@DisplayName("HTTP 200 with empty data array returns EmptyList")
void fetchModels_http200EmptyDataArray_returnsEmptyList() throws Exception {
String responseBody = """
{"object":"list","data":[]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.EmptyList.class);
}
@Test
@DisplayName("HTTP 401 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http401_returnsAuthenticationFailed() throws Exception {
doReturn(401).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 403 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http403_returnsAuthenticationFailed() throws Exception {
doReturn(403).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 404 returns TechnicalFailure with ENDPOINT_NOT_FOUND")
void fetchModels_http404_returnsEndpointNotFound() throws Exception {
doReturn(404).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("ENDPOINT_NOT_FOUND");
}
@Test
@DisplayName("HTTP 500 returns TechnicalFailure with SERVER_ERROR")
void fetchModels_http500_returnsServerError() throws Exception {
doReturn(500).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("SERVER_ERROR");
}
@Test
@DisplayName("ConnectException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_connectException_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new ConnectException("connection refused"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("Unparseable JSON response returns TechnicalFailure with INVALID_RESPONSE")
void fetchModels_invalidJson_returnsInvalidResponse() throws Exception {
doReturn(200).when(httpResponse).statusCode();
doReturn("not-a-json-body").when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("INVALID_RESPONSE");
}
@Test
@DisplayName("Missing API key returns IncompleteConfiguration")
void fetchModels_missingApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.empty(), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
ModelCatalogResult.IncompleteConfiguration incomplete =
(ModelCatalogResult.IncompleteConfiguration) result;
assertThat(incomplete.missingReason()).containsIgnoringCase("API-Schlüssel");
}
@Test
@DisplayName("Blank API key returns IncompleteConfiguration")
void fetchModels_blankApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(" "), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
}
@Test
@DisplayName("No base URL falls back to OpenAI default")
void fetchModels_noBaseUrl_usesDefault() {
assertThat(OpenAiCompatibleModelCatalogAdapter.DEFAULT_BASE_URL)
.isEqualTo("https://api.openai.com");
}
@Test
@DisplayName("UnknownHostException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_unknownHost_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new UnknownHostException("unknown host"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("HttpTimeoutException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_httpTimeout_returnsConnectionFailure() throws Exception {
// The adapter groups HTTP timeouts together with other connection failures under
// the CONNECTION_FAILURE category (no separate TIMEOUT category).
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new HttpTimeoutException("request timed out"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
}
@@ -0,0 +1,207 @@
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link EnvironmentApiKeyResolutionAdapter}.
* <p>
* The environment lookup is injected via the package-private constructor so that tests
* are deterministic and do not depend on real process environment variables.
*/
class EnvironmentApiKeyResolutionAdapterTest {
// =========================================================================
// Helper
// =========================================================================
private static EnvironmentApiKeyResolutionAdapter adapterWith(Map<String, String> env) {
Function<String, String> lookup = env::get;
return new EnvironmentApiKeyResolutionAdapter(lookup);
}
// =========================================================================
// Claude provider env var takes precedence
// =========================================================================
@Test
void claude_envVarPresent_returnsFromProviderEnvVar() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, "sk-ant-test");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.CLAUDE, "");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY,
result.envVarName().orElseThrow());
}
@Test
void claude_envVarPresentAndPropertyAlsoPresent_envVarWins() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, "sk-ant-env");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.CLAUDE, "sk-ant-property");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
}
// =========================================================================
// Claude property file fallback
// =========================================================================
@Test
void claude_noEnvVar_propertyPresent_returnsFromPropertyFile() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.CLAUDE, "sk-ant-property");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
// =========================================================================
// Claude absent
// =========================================================================
@Test
void claude_noEnvVar_noProperty_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.CLAUDE, "");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
@Test
void claude_noEnvVar_blankPropertyOnly_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.CLAUDE, " ");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
// =========================================================================
// OpenAI-compatible primary env var takes precedence
// =========================================================================
@Test
void openai_primaryEnvVarPresent_returnsFromProviderEnvVar() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "sk-openai-test");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY,
result.envVarName().orElseThrow());
}
@Test
void openai_primaryEnvVarAndLegacyBothPresent_primaryWins() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "sk-primary",
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY,
result.envVarName().orElseThrow());
}
// =========================================================================
// OpenAI-compatible legacy env var
// =========================================================================
@Test
void openai_noPrimaryEnvVar_legacyPresent_returnsFromLegacyEnvVar() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy-key");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.FROM_LEGACY_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY,
result.envVarName().orElseThrow());
}
@Test
void openai_noPrimaryEnvVar_legacyPresentAndPropertyAlsoPresent_legacyWins() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-property");
assertEquals(ApiKeyOrigin.FROM_LEGACY_ENV_VAR, result.origin());
}
// =========================================================================
// OpenAI-compatible property file fallback
// =========================================================================
@Test
void openai_noEnvVars_propertyPresent_returnsFromPropertyFile() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-openai-property");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
// =========================================================================
// OpenAI-compatible absent
// =========================================================================
@Test
void openai_noEnvVars_noProperty_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
@Test
void openai_noEnvVars_blankProperty_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, " ");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
// =========================================================================
// Blank env value treated as absent
// =========================================================================
@Test
void claude_envVarPresentButBlank_treatedAsAbsent_fallsBackToProperty() {
Map<String, String> env = new HashMap<>();
env.put(EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, " ");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.CLAUDE, "sk-prop");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
@Test
void openai_primaryEnvVarBlank_legacyAbsent_propertyPresent_returnsFromPropertyFile() {
Map<String, String> env = new HashMap<>();
env.put(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-prop");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
}
@@ -0,0 +1,40 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
/**
* Outbound port for retrieving the list of available AI models from a provider endpoint.
* <p>
* This port is used exclusively by the GUI layer to populate the model selection control.
* The headless batch path does not call this port; the model name is read directly from
* the configuration file in that path.
* <p>
* <strong>Infrastructure neutrality:</strong> The port signature contains no HTTP client,
* no JSON type, and no JavaFX reference. Implementations in {@code pdf-umbenenner-adapter-out}
* translate the {@link ModelCatalogRequest} into provider-specific HTTP calls and map the
* response back to a {@link ModelCatalogResult}.
* <p>
* <strong>Error handling contract:</strong> Implementations must never throw exceptions for
* expected error conditions such as missing configuration values, authentication failures, or
* unreachable endpoints. All such conditions are encoded as specific {@link ModelCatalogResult}
* sub-types so the GUI can display them without crash handling.
* <p>
* <strong>Blocking behaviour:</strong> Implementations are expected to block the calling thread
* until the result is available or the configured timeout expires. The GUI adapter must therefore
* invoke this port on a background worker thread and must not call it on the JavaFX Application
* Thread.
*/
public interface AiModelCatalogPort {
/**
* Fetches the list of available model identifiers for the provider described by the request.
* <p>
* The method always returns a non-{@code null} {@link ModelCatalogResult}. The caller
* uses pattern matching over the sealed type hierarchy to distinguish success, empty list,
* incomplete configuration, and technical failure without catching exceptions.
*
* @param request all information required to contact the provider endpoint;
* must not be {@code null}
* @return the result of the catalogue retrieval; never {@code null}
* @throws NullPointerException if {@code request} is {@code null}
*/
ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request);
}
@@ -0,0 +1,51 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
/**
* Describes the origin of the effective API key value for a provider.
* <p>
* The resolution order is defined in the application configuration rules:
* <ol>
* <li>A provider-specific environment variable takes the highest precedence.</li>
* <li>For the {@code openai-compatible} provider family a legacy environment variable
* is evaluated as a secondary fallback for backward compatibility.</li>
* <li>The property value from the {@code .properties} file is used when no
* environment variable is present.</li>
* <li>{@link #ABSENT} is returned when none of the above sources supplies a value.</li>
* </ol>
* <p>
* This enum is placed in the Application module because the resolution order is a fachliche
* rule that applies independently of the GUI. Bootstrap uses the same precedence, and
* future diagnostic components outside the GUI may need to report key provenance without
* depending on GUI-layer types.
*/
public enum ApiKeyOrigin {
/**
* The effective key comes from the provider-specific environment variable
* (e.g., {@code ANTHROPIC_API_KEY} for Claude or {@code OPENAI_API_KEY} for
* OpenAI-compatible providers).
*/
FROM_PROVIDER_ENV_VAR,
/**
* The effective key comes from the legacy environment variable accepted for
* backward compatibility.
* <p>
* This origin applies only to the {@code openai-compatible} provider family. The
* legacy variable name is defined by the adapter and is not fixed in this enum.
*/
FROM_LEGACY_ENV_VAR,
/**
* The effective key comes from the property value stored in the {@code .properties} file.
*/
FROM_PROPERTY_FILE,
/**
* No API key value was found in any of the supported sources.
* <p>
* The provider configuration is incomplete; the batch run cannot start and the GUI
* must indicate that the key is missing.
*/
ABSENT
}
@@ -0,0 +1,101 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
import java.util.Objects;
import java.util.Optional;
/**
* Describes the provenance of the effective API key for a provider, including the name of the
* environment variable that supplies the key when applicable.
* <p>
* The GUI uses this record to display provenance information to the user (e.g., "Schlüssel
* kommt aus Umgebungsvariable ANTHROPIC_API_KEY") without coupling the display logic to the
* concrete variable names.
* <p>
* The {@code envVarName} field is present when the origin is
* {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} or {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR};
* it is absent for {@link ApiKeyOrigin#FROM_PROPERTY_FILE} and {@link ApiKeyOrigin#ABSENT}.
*
* @param origin the source from which the effective key value comes; never {@code null}
* @param envVarName the name of the environment variable that provides the key when applicable;
* empty when the origin is {@code FROM_PROPERTY_FILE} or {@code ABSENT}
*/
public record EffectiveApiKeyDescriptor(
ApiKeyOrigin origin,
Optional<String> envVarName) {
/**
* Creates a new descriptor.
*
* @param origin key origin; must not be {@code null}
* @param envVarName optional environment variable name; {@code null} becomes empty
* @throws NullPointerException if {@code origin} is {@code null}
*/
public EffectiveApiKeyDescriptor {
Objects.requireNonNull(origin, "origin must not be null");
envVarName = envVarName == null ? Optional.empty() : envVarName;
}
/**
* Creates a descriptor for a key that comes from a provider-specific environment variable.
*
* @param variableName the name of the environment variable; must not be {@code null}
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR}
* @throws NullPointerException if {@code variableName} is {@code null}
*/
public static EffectiveApiKeyDescriptor fromProviderEnvVar(String variableName) {
Objects.requireNonNull(variableName, "variableName must not be null");
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR,
Optional.of(variableName));
}
/**
* Creates a descriptor for a key that comes from the legacy environment variable.
*
* @param variableName the name of the legacy environment variable; must not be {@code null}
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
* @throws NullPointerException if {@code variableName} is {@code null}
*/
public static EffectiveApiKeyDescriptor fromLegacyEnvVar(String variableName) {
Objects.requireNonNull(variableName, "variableName must not be null");
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_LEGACY_ENV_VAR,
Optional.of(variableName));
}
/**
* Creates a descriptor for a key that comes from the properties file.
*
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROPERTY_FILE}
*/
public static EffectiveApiKeyDescriptor fromPropertyFile() {
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROPERTY_FILE, Optional.empty());
}
/**
* Creates a descriptor indicating that no key value is available.
*
* @return a new descriptor with origin {@link ApiKeyOrigin#ABSENT}
*/
public static EffectiveApiKeyDescriptor absent() {
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.ABSENT, Optional.empty());
}
/**
* Returns {@code true} when the effective key comes from any environment variable.
*
* @return {@code true} for {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} and
* {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
*/
public boolean isFromEnvironmentVariable() {
return origin == ApiKeyOrigin.FROM_PROVIDER_ENV_VAR
|| origin == ApiKeyOrigin.FROM_LEGACY_ENV_VAR;
}
/**
* Returns {@code true} when no key is available from any source.
*
* @return {@code true} for {@link ApiKeyOrigin#ABSENT}
*/
public boolean isAbsent() {
return origin == ApiKeyOrigin.ABSENT;
}
}
@@ -0,0 +1,51 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
import java.util.Objects;
import java.util.Optional;
/**
* Carries all information needed by an {@link AiModelCatalogPort} implementation to fetch
* the list of available models from a provider endpoint.
* <p>
* This record is infrastructure-neutral: it contains no HTTP client, no JSON type, and no
* JavaFX reference. The adapter translates the values into provider-specific request structures.
* <p>
* The {@code apiKey} field is {@code Optional}: when the key is absent the adapter is expected
* to perform the request without authentication and return an appropriate
* {@link ModelCatalogResult} sub-type (typically
* {@link ModelCatalogResult.IncompleteConfiguration}) rather than throwing an exception.
*
* @param providerIdentifier identifier string of the target provider family as used in the
* {@code ai.provider.active} configuration property
* (e.g., {@code "claude"} or {@code "openai-compatible"});
* must not be {@code null}
* @param baseUrl optional base URL of the provider endpoint; when absent the adapter
* must apply its own built-in default (e.g., for Claude)
* @param apiKey optional API key; when absent the adapter must not fabricate a key
* @param timeoutSeconds HTTP timeout in seconds; must be a positive integer
*/
public record ModelCatalogRequest(
String providerIdentifier,
Optional<String> baseUrl,
Optional<String> apiKey,
int timeoutSeconds) {
/**
* Creates a new model catalogue request.
*
* @param providerIdentifier identifier string of the target provider family; must not be {@code null}
* @param baseUrl optional base URL; {@code null} is treated as {@link Optional#empty()}
* @param apiKey optional API key; {@code null} is treated as {@link Optional#empty()}
* @param timeoutSeconds HTTP timeout in seconds; must be positive
* @throws NullPointerException if {@code providerIdentifier} is {@code null}
* @throws IllegalArgumentException if {@code timeoutSeconds} is not positive
*/
public ModelCatalogRequest {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
if (timeoutSeconds <= 0) {
throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds);
}
baseUrl = baseUrl == null ? Optional.empty() : baseUrl;
apiKey = apiKey == null ? Optional.empty() : apiKey;
}
}
@@ -0,0 +1,161 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/**
* Sealed result type for a model catalogue retrieval operation performed via
* {@link AiModelCatalogPort}.
* <p>
* Each permitted sub-type represents one distinct outcome:
* <ul>
* <li>{@link Success} the provider returned a non-empty list of model identifiers.</li>
* <li>{@link EmptyList} the provider responded successfully but returned no models.</li>
* <li>{@link IncompleteConfiguration} the request could not be sent because a required
* configuration value (e.g., API key, base URL) was missing.</li>
* <li>{@link TechnicalFailure} the HTTP call, authentication, or response parsing failed.</li>
* </ul>
* <p>
* The GUI adapter uses this result directly; no separate GUI-layer translation type is needed
* because the structure matches the GUI's display needs without containing any JavaFX reference.
* This design decision is documented here to avoid introducing a redundant mapping layer.
* <p>
* Callers are expected to use pattern-matching {@code switch} expressions over all permitted types
* to ensure exhaustive handling as new sub-types might be added in future expansions.
*/
public sealed interface ModelCatalogResult
permits ModelCatalogResult.Success,
ModelCatalogResult.EmptyList,
ModelCatalogResult.IncompleteConfiguration,
ModelCatalogResult.TechnicalFailure {
/**
* The provider returned a non-empty list of available model identifiers.
* <p>
* The list is guaranteed to contain at least one entry. Callers may safely use
* the first element as a default selection.
*
* @param providerIdentifier identifier of the provider that returned the list; never {@code null}
* @param models non-empty, ordered list of model identifier strings; never {@code null}
* @param loadedAt timestamp when the list was successfully retrieved; never {@code null}
*/
record Success(
String providerIdentifier,
List<String> models,
Instant loadedAt) implements ModelCatalogResult {
/**
* Creates a successful model catalogue result.
*
* @param providerIdentifier identifier of the provider; must not be {@code null}
* @param models list of model identifiers; must not be {@code null} or empty
* @param loadedAt retrieval timestamp; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
* @throws IllegalArgumentException if {@code models} is empty
*/
public Success {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
Objects.requireNonNull(models, "models must not be null");
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
if (models.isEmpty()) {
throw new IllegalArgumentException(
"models must not be empty; use EmptyList for an empty response");
}
models = List.copyOf(models);
}
}
/**
* The provider responded successfully but returned no model identifiers.
* <p>
* This case is distinct from {@link TechnicalFailure}: the HTTP exchange succeeded and the
* response was parseable, but the list of models was empty. The GUI should fall back to
* manual text input.
*
* @param providerIdentifier identifier of the provider; never {@code null}
* @param loadedAt timestamp of the (technically successful) response; never {@code null}
*/
record EmptyList(
String providerIdentifier,
Instant loadedAt) implements ModelCatalogResult {
/**
* Creates an empty-list result.
*
* @param providerIdentifier identifier of the provider; must not be {@code null}
* @param loadedAt retrieval timestamp; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public EmptyList {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
}
}
/**
* The model catalogue request could not be sent because a required configuration value
* was absent.
* <p>
* Typical causes: missing API key, missing base URL for a provider family that requires one.
* The adapter must not throw an exception for this case; it must return this result type
* instead so the GUI can display a user-friendly hint without crashing.
*
* @param providerIdentifier identifier of the provider for which configuration is incomplete;
* never {@code null}
* @param missingReason human-readable description of which configuration value is missing;
* never {@code null}
*/
record IncompleteConfiguration(
String providerIdentifier,
String missingReason) implements ModelCatalogResult {
/**
* Creates an incomplete-configuration result.
*
* @param providerIdentifier identifier of the provider; must not be {@code null}
* @param missingReason description of the missing value; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public IncompleteConfiguration {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
Objects.requireNonNull(missingReason, "missingReason must not be null");
}
}
/**
* A technical error occurred during the model catalogue retrieval.
* <p>
* Covers HTTP errors, authentication failures, network timeouts, and response parsing
* failures. The adapter classifies the error into a short category string (e.g.,
* {@code "HTTP_ERROR"}, {@code "AUTH_FAILURE"}, {@code "TIMEOUT"}, {@code "PARSE_ERROR"})
* and provides a human-readable detail message.
* <p>
* This result does not trigger a retry; the GUI offers an explicit
* "reload models" action that the user can invoke after fixing the underlying issue.
*
* @param providerIdentifier identifier of the provider that was contacted; never {@code null}
* @param errorCategory short, stable category string for programmatic discrimination;
* never {@code null}
* @param errorDetail human-readable error description; never {@code null}
*/
record TechnicalFailure(
String providerIdentifier,
String errorCategory,
String errorDetail) implements ModelCatalogResult {
/**
* Creates a technical-failure result.
*
* @param providerIdentifier identifier of the provider; must not be {@code null}
* @param errorCategory short category string; must not be {@code null}
* @param errorDetail human-readable detail; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public TechnicalFailure {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
Objects.requireNonNull(errorCategory, "errorCategory must not be null");
Objects.requireNonNull(errorDetail, "errorDetail must not be null");
}
}
}
@@ -0,0 +1,12 @@
/**
* Contracts and result types for the provider-dependent model catalogue retrieval.
* <p>
* This package defines the outbound port {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort}
* that the GUI adapter uses to fetch the list of available AI models from the active provider.
* All types in this package are infrastructure-neutral: they contain no HTTP, JSON, or JavaFX
* references.
* <p>
* The GUI adapter is the sole consumer of this port in the current implementation scope;
* the headless batch path does not perform model catalogue lookups.
*/
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
@@ -0,0 +1,40 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
/**
* Outbound-Port für die Auflösung der API-Key-Herkunft pro Provider-Familie.
* <p>
* Gibt zurück, aus welcher Quelle der effektive API-Schlüssel für einen angegebenen Provider
* stammt. Die Vorrangregel lautet:
* <ol>
* <li>Providerspezifische Umgebungsvariable (höchste Priorität)</li>
* <li>Bei {@code openai-compatible}: zusätzlich die Legacy-Umgebungsvariable als Fallback</li>
* <li>Property-Wert aus der {@code .properties}-Datei</li>
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT}
* wenn keine Quelle einen Wert liefert</li>
* </ol>
* <p>
* Der Port kennt keine Datei-Inhalte und kein Property-Parsing; er liest ausschließlich
* Umgebungsvariablen. Der Property-Wert wird von der GUI als separates Eingabeargument übergeben
* und vom Validator mit dem Port-Ergebnis kombiniert.
* <p>
* Implementierungen dieses Ports liegen im Adapter-Out-Modul.
*/
public interface ApiKeyResolutionPort {
/**
* Ermittelt die Herkunft des effektiven API-Schlüssels für den angegebenen Provider.
* <p>
* Gibt nur dann {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#FROM_PROPERTY_FILE}
* zurück, wenn {@code propertyValue} nicht leer ist und keine Umgebungsvariable greift.
* Gibt {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT} zurück,
* wenn weder Umgebungsvariable noch {@code propertyValue} einen Wert liefern.
*
* @param family die Provider-Familie; darf nicht {@code null} sein
* @param propertyValue aktueller Property-Wert aus dem Editor (kann leer sein); darf nicht {@code null} sein
* @return der Descriptor für die effektive Schlüsselherkunft; nie {@code null}
*/
EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue);
}
@@ -0,0 +1,345 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
/**
* Zentraler Validierungsbaustein für den aktuellen Editorzustand des Konfigurationseditors.
* <p>
* Dieser Validator arbeitet ausschließlich auf den übergebenen String-Werten des
* {@link EditorValidationInput}, ohne Dateisystemzugriffe, Datenbankroundtrips oder
* Netzwerkkommunikation. Er erzeugt Befunde der Stufen Fehler, Warnung, Hinweis und Info.
* <p>
* Folgende Prüfungen sind ausdrücklich ausgeschlossen (gehören in spätere technische Gesamtprüfungen):
* <ul>
* <li>Pfad-Existenzprüfungen (Quellordner, Zielordner, SQLite-Datei, Prompt-Datei)</li>
* <li>SQLite-Roundtrips</li>
* <li>Netzwerkverbindungen (Modellabruf, API-Erreichbarkeit)</li>
* </ul>
* <p>
* API-Key-Vorrangregel (fachlich verbindlich):
* <ol>
* <li>Providerspezifische Umgebungsvariable</li>
* <li>Bei {@code openai-compatible}: Legacy-Umgebungsvariable als Fallback</li>
* <li>Property-Wert aus der Datei</li>
* <li>ABSENT, wenn keine Quelle einen Wert liefert</li>
* </ol>
* <p>
* Warnlogik für {@code max.text.characters}:
* <ul>
* <li>11.000: unkritisch (kein Befund)</li>
* <li>1.0013.000: Warnung</li>
* <li>ab 3.001: starke Warnung</li>
* </ul>
*/
public class EditorConfigurationValidator {
// Property-Schlüssel als Konstanten (identisch mit den .properties-Schlüsseln)
static final String FIELD_ACTIVE_PROVIDER = "ai.provider.active";
static final String FIELD_SOURCE_FOLDER = "source.folder";
static final String FIELD_TARGET_FOLDER = "target.folder";
static final String FIELD_SQLITE_FILE = "sqlite.file";
static final String FIELD_PROMPT_FILE = "prompt.template.file";
static final String FIELD_MAX_RETRIES = "max.retries.transient";
static final String FIELD_MAX_PAGES = "max.pages";
static final String FIELD_MAX_CHARS = "max.text.characters";
static final String FIELD_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
static final String FIELD_CLAUDE_MODEL = "ai.provider.claude.model";
static final String FIELD_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
static final String FIELD_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
static final String FIELD_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
static final String FIELD_OPENAI_MODEL = "ai.provider.openai-compatible.model";
static final String FIELD_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
static final String FIELD_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
private static final int MAX_CHARS_WARNING_THRESHOLD = 1_000;
private static final int MAX_CHARS_STRONG_WARNING_THRESHOLD = 3_000;
private static final int MAX_PAGES_HINT_THRESHOLD = 100;
/**
* Erstellt eine neue Instanz des Validators.
* <p>
* Dieser Validator benötigt keine Abhängigkeiten; alle Prüfungen sind rein in-memory.
*/
public EditorConfigurationValidator() {
// Kein State nötig; alle Prüfungen arbeiten auf dem übergebenen Input.
}
/**
* Validiert den aktuellen Editorzustand und liefert einen Befund-Bericht.
* <p>
* Die Methode ist schnell (keine I/O) und darf auf dem JavaFX Application Thread
* aufgerufen werden. Der zurückgegebene Bericht ist immutable.
*
* @param input der aktuelle Editorzustand; darf nicht {@code null} sein
* @return der Validierungsbericht mit allen gefundenen Befunden; nie {@code null}
* @throws NullPointerException wenn {@code input} {@code null} ist
*/
public EditorValidationReport validate(EditorValidationInput input) {
Objects.requireNonNull(input, "input must not be null");
List<EditorValidationFinding> findings = new ArrayList<>();
validateActiveProvider(input, findings);
validateRequiredPaths(input, findings);
validateNumericLimits(input, findings);
validateActiveProviderFields(input, findings);
return new EditorValidationReport(findings);
}
// =========================================================================
// Aktiver Provider
// =========================================================================
private void validateActiveProvider(EditorValidationInput input,
List<EditorValidationFinding> findings) {
String identifier = input.activeProviderIdentifier();
if (identifier.isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
"Es muss ein aktiver Provider ausgewählt sein."));
return;
}
Optional<AiProviderFamily> resolved = AiProviderFamily.fromIdentifier(identifier);
if (resolved.isEmpty()) {
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
"Der angegebene Provider '" + identifier + "' ist nicht bekannt. "
+ "Erlaubt sind: 'claude' und 'openai-compatible'."));
}
}
// =========================================================================
// Pflichtpfade
// =========================================================================
private void validateRequiredPaths(EditorValidationInput input,
List<EditorValidationFinding> findings) {
if (input.sourceFolder().isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_SOURCE_FOLDER,
"Quellordner darf nicht leer sein."));
}
if (input.targetFolder().isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_TARGET_FOLDER,
"Zielordner darf nicht leer sein."));
}
if (input.sqliteFile().isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_SQLITE_FILE,
"SQLite-Datei darf nicht leer sein."));
}
if (input.promptTemplateFile().isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_PROMPT_FILE,
"Prompt-Datei darf nicht leer sein."));
}
}
// =========================================================================
// Numerische Limits
// =========================================================================
private void validateNumericLimits(EditorValidationInput input,
List<EditorValidationFinding> findings) {
validateMaxRetriesTransient(input.maxRetriesTransient(), findings);
validateMaxPages(input.maxPages(), findings);
validateMaxTextCharacters(input.maxTextCharacters(), findings);
}
private void validateMaxRetriesTransient(String rawValue, List<EditorValidationFinding> findings) {
if (rawValue.isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
"Maximale transiente Retries darf nicht leer sein."));
return;
}
try {
int value = Integer.parseInt(rawValue.strip());
if (value < 1) {
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
"Maximale transiente Retries muss mindestens 1 sein (aktuell: " + value + "). "
+ "Der Wert 0 ist unzulässig."));
}
} catch (NumberFormatException e) {
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
"Maximale transiente Retries muss eine ganze Zahl >= 1 sein."));
}
}
private void validateMaxPages(String rawValue, List<EditorValidationFinding> findings) {
if (rawValue.isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
"Maximale Seitenzahl darf nicht leer sein."));
return;
}
try {
int value = Integer.parseInt(rawValue.strip());
if (value <= 0) {
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
"Maximale Seitenzahl muss positiv sein (aktuell: " + value + ")."));
} else if (value > MAX_PAGES_HINT_THRESHOLD) {
findings.add(EditorValidationFinding.hint(FIELD_MAX_PAGES,
"Plausibilitätshinweis: Über " + MAX_PAGES_HINT_THRESHOLD
+ " Seiten je Datei könnten die Verarbeitung verlangsamen (aktuell: " + value + ")."));
}
} catch (NumberFormatException e) {
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
"Maximale Seitenzahl muss eine positive ganze Zahl sein."));
}
}
private void validateMaxTextCharacters(String rawValue, List<EditorValidationFinding> findings) {
if (rawValue.isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
"Maximale Zeichenzahl darf nicht leer sein."));
return;
}
try {
int value = Integer.parseInt(rawValue.strip());
if (value <= 0) {
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
"Maximale Zeichenzahl muss positiv sein (aktuell: " + value + ")."));
} else if (value > MAX_CHARS_STRONG_WARNING_THRESHOLD) {
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
"Stark erhöhte Zeichenmenge: " + value + " Zeichen können zu riskant hohen "
+ "API-Kosten je Verarbeitungsaufruf führen."));
} else if (value > MAX_CHARS_WARNING_THRESHOLD) {
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
"Erhöhte Zeichenmenge: " + value + " Zeichen. Beachten Sie mögliche Auswirkungen "
+ "auf die API-Kosten je Verarbeitungsaufruf."));
}
// 11000: unkritisch, kein Befund
} catch (NumberFormatException e) {
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
"Maximale Zeichenzahl muss eine positive ganze Zahl sein."));
}
}
// =========================================================================
// Aktiver Provider providerabhängige Felder
// =========================================================================
private void validateActiveProviderFields(EditorValidationInput input,
List<EditorValidationFinding> findings) {
Optional<AiProviderFamily> resolvedProvider =
AiProviderFamily.fromIdentifier(input.activeProviderIdentifier());
if (resolvedProvider.isEmpty()) {
// Provider unbekannt feldspezifische Provider-Prüfungen nicht möglich
return;
}
AiProviderFamily family = resolvedProvider.get();
switch (family) {
case CLAUDE -> validateClaudeFields(input, findings);
case OPENAI_COMPATIBLE -> validateOpenAiFields(input, findings);
}
}
private void validateClaudeFields(EditorValidationInput input,
List<EditorValidationFinding> findings) {
// Basis-URL: leer ist Warnung (Default-URL wird verwendet)
if (input.claudeBaseUrl().isBlank()) {
findings.add(EditorValidationFinding.warning(FIELD_CLAUDE_BASE_URL,
"Basis-URL nicht gesetzt es wird die Standard-URL verwendet."));
}
// Modell: Pflichtfeld
if (input.claudeModel().isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_CLAUDE_MODEL,
"Modellname darf nicht leer sein."));
}
// Timeout
validateTimeoutField(input.claudeTimeoutSeconds(), FIELD_CLAUDE_TIMEOUT, findings);
// API-Key
validateApiKeyFindings(input.claudeApiKeyDescriptor(), FIELD_CLAUDE_API_KEY, findings);
}
private void validateOpenAiFields(EditorValidationInput input,
List<EditorValidationFinding> findings) {
// Basis-URL: leer ist Warnung
if (input.openaiBaseUrl().isBlank()) {
findings.add(EditorValidationFinding.warning(FIELD_OPENAI_BASE_URL,
"Basis-URL nicht gesetzt es wird die Standard-URL verwendet."));
}
// Modell: Pflichtfeld
if (input.openaiModel().isBlank()) {
findings.add(EditorValidationFinding.error(FIELD_OPENAI_MODEL,
"Modellname darf nicht leer sein."));
}
// Timeout
validateTimeoutField(input.openaiTimeoutSeconds(), FIELD_OPENAI_TIMEOUT, findings);
// API-Key
validateApiKeyFindings(input.openaiApiKeyDescriptor(), FIELD_OPENAI_API_KEY, findings);
}
private void validateTimeoutField(String rawValue, String fieldKey,
List<EditorValidationFinding> findings) {
if (rawValue.isBlank()) {
findings.add(EditorValidationFinding.error(fieldKey,
"Timeout-Wert darf nicht leer sein."));
return;
}
try {
int value = Integer.parseInt(rawValue.strip());
if (value <= 0) {
findings.add(EditorValidationFinding.error(fieldKey,
"Timeout-Wert muss positiv sein (aktuell: " + value + ")."));
}
} catch (NumberFormatException e) {
findings.add(EditorValidationFinding.error(fieldKey,
"Timeout-Wert muss eine positive ganze Zahl sein."));
}
}
/**
* Erzeugt API-Key-Befunde gemäß der Vorrangregel.
* <p>
* Vorrangregel:
* <ol>
* <li>ENV-Variable aktiv: INFO-Befund mit Variablenname</li>
* <li>Property-Wert in Datei, keine ENV: normal (kein Befund)</li>
* <li>Leeres Property-Feld und keine ENV: WARNING</li>
* </ol>
*
* @param descriptor Herkunft des effektiven API-Schlüssels
* @param fieldKey Property-Schlüssel des API-Key-Felds
* @param findings Zielliste für neue Befunde
*/
private void validateApiKeyFindings(EffectiveApiKeyDescriptor descriptor,
String fieldKey,
List<EditorValidationFinding> findings) {
ApiKeyOrigin origin = descriptor.origin();
switch (origin) {
case FROM_PROVIDER_ENV_VAR -> {
String varName = descriptor.envVarName().orElse("unbekannte ENV-Variable");
findings.add(EditorValidationFinding.info(fieldKey,
"API-Schlüssel stammt aus Umgebungsvariable " + varName
+ " (hat Vorrang vor dem Datei-Wert)."));
}
case FROM_LEGACY_ENV_VAR -> {
String varName = descriptor.envVarName().orElse("unbekannte Legacy-ENV-Variable");
findings.add(EditorValidationFinding.info(fieldKey,
"API-Schlüssel stammt aus Legacy-Umgebungsvariable " + varName
+ " (hat Vorrang vor dem Datei-Wert)."));
}
case FROM_PROPERTY_FILE -> {
// Property-Wert vorhanden, kein ENV-Override: alles in Ordnung, kein Befund
}
case ABSENT -> {
findings.add(EditorValidationFinding.warning(fieldKey,
"Kein API-Schlüssel hinterlegt. Ohne Schlüssel kann der Provider "
+ "nicht genutzt werden."));
}
}
}
}
@@ -0,0 +1,108 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
import java.util.Objects;
import java.util.Optional;
/**
* Ein einzelner Validierungsbefund für den aktuellen Editorzustand.
* <p>
* Ein Befund beschreibt eine erkannte Auffälligkeit in der Konfiguration mit einem
* Schweregrad, einem Feldbezug und einer deutschen Beschreibung. Das {@code fieldKey}-Feld
* verwendet denselben Property-Schlüssel wie die {@code .properties}-Datei
* (z. B. {@code "source.folder"} oder {@code "ai.provider.openai-compatible.apiKey"}).
* <p>
* Ein Befund kann entweder feld-spezifisch sein (mit gesetztem {@code fieldKey}) oder
* allgemein (mit leerem {@code fieldKey}), wenn er sich nicht auf ein einzelnes Feld bezieht.
* <p>
* Befunde sind immutable und enthalten keine JavaFX-Typen.
*
* @param fieldKey optionaler Property-Schlüssel des betroffenen Felds; leer wenn feld-unabhängig
* @param severity Schweregrad des Befunds; nie {@code null}
* @param message deutschsprachige Beschreibung; nie {@code null}
*/
public record EditorValidationFinding(
Optional<String> fieldKey,
EditorValidationSeverity severity,
String message) {
/**
* Erstellt einen neuen Validierungsbefund.
*
* @param fieldKey optionaler Property-Schlüssel; {@code null} wird zu {@link Optional#empty()}
* @param severity Schweregrad; darf nicht {@code null} sein
* @param message deutschsprachige Beschreibung; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code severity} oder {@code message} {@code null} sind
*/
public EditorValidationFinding {
Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(message, "message must not be null");
fieldKey = fieldKey == null ? Optional.empty() : fieldKey;
}
/**
* Erstellt einen feldbezogenen Fehler-Befund.
*
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
* @param message deutschsprachige Fehlerbeschreibung; darf nicht {@code null} sein
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#ERROR}
*/
public static EditorValidationFinding error(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.ERROR, message);
}
/**
* Erstellt einen feldbezogenen Warn-Befund.
*
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
* @param message deutschsprachige Warnbeschreibung; darf nicht {@code null} sein
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#WARNING}
*/
public static EditorValidationFinding warning(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.WARNING, message);
}
/**
* Erstellt einen feldbezogenen Hinweis-Befund.
*
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
* @param message deutschsprachige Hinweisbeschreibung; darf nicht {@code null} sein
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#HINT}
*/
public static EditorValidationFinding hint(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.HINT, message);
}
/**
* Erstellt einen allgemeinen Informationsbefund ohne Feldbezug.
*
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
*/
public static EditorValidationFinding info(String message) {
return new EditorValidationFinding(Optional.empty(), EditorValidationSeverity.INFO, message);
}
/**
* Erstellt einen feldbezogenen Informationsbefund.
*
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
*/
public static EditorValidationFinding info(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.INFO, message);
}
/**
* Gibt an, ob dieser Befund feld-spezifisch ist.
*
* @return {@code true} wenn ein {@code fieldKey} gesetzt ist
*/
public boolean hasFieldKey() {
return fieldKey.isPresent();
}
}
@@ -0,0 +1,99 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
/**
* Eingabedaten für den {@link EditorConfigurationValidator}.
* <p>
* Enthält den gesamten aktuellen Editorzustand als String-Werte, so wie sie im Editor
* vorliegen ohne Parsing, Typumwandlung oder Existenzprüfungen. Der Validator bewertet
* ausschließlich diese Werte.
* <p>
* Pfad-Existenzprüfungen und Roundtrips (SQLite, Prompt-Datei, Netzwerk) sind explizit
* ausgeschlossen und gehören in spätere technische Gesamtprüfungen.
* <p>
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
*
* @param activeProviderIdentifier Rohtextwert von {@code ai.provider.active}
* @param sourceFolder Rohtextwert von {@code source.folder}
* @param targetFolder Rohtextwert von {@code target.folder}
* @param sqliteFile Rohtextwert von {@code sqlite.file}
* @param promptTemplateFile Rohtextwert von {@code prompt.template.file}
* @param maxRetriesTransient Rohtextwert von {@code max.retries.transient}
* @param maxPages Rohtextwert von {@code max.pages}
* @param maxTextCharacters Rohtextwert von {@code max.text.characters}
* @param claudeBaseUrl Rohtextwert der Claude-Basis-URL
* @param claudeModel Rohtextwert des Claude-Modellnamens
* @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts
* @param claudeApiKeyDescriptor API-Key-Herkunft für den Claude-Provider; nie {@code null}
* @param openaiBaseUrl Rohtextwert der OpenAI-kompatiblen Basis-URL
* @param openaiModel Rohtextwert des OpenAI-kompatiblen Modellnamens
* @param openaiTimeoutSeconds Rohtextwert des OpenAI-kompatiblen Timeouts
* @param openaiApiKeyDescriptor API-Key-Herkunft für den OpenAI-kompatiblen Provider; nie {@code null}
*/
public record EditorValidationInput(
String activeProviderIdentifier,
String sourceFolder,
String targetFolder,
String sqliteFile,
String promptTemplateFile,
String maxRetriesTransient,
String maxPages,
String maxTextCharacters,
String claudeBaseUrl,
String claudeModel,
String claudeTimeoutSeconds,
EffectiveApiKeyDescriptor claudeApiKeyDescriptor,
String openaiBaseUrl,
String openaiModel,
String openaiTimeoutSeconds,
EffectiveApiKeyDescriptor openaiApiKeyDescriptor) {
/**
* Erstellt eine neue Eingabe für den Validator.
*
* @param activeProviderIdentifier aktiver Provider-Bezeichner; {@code null} wird zu leerem String
* @param sourceFolder Quellordner-Pfad; {@code null} wird zu leerem String
* @param targetFolder Zielordner-Pfad; {@code null} wird zu leerem String
* @param sqliteFile SQLite-Dateipfad; {@code null} wird zu leerem String
* @param promptTemplateFile Prompt-Dateipfad; {@code null} wird zu leerem String
* @param maxRetriesTransient max. transiente Retries; {@code null} wird zu leerem String
* @param maxPages max. Seitenzahl; {@code null} wird zu leerem String
* @param maxTextCharacters max. Zeichenzahl; {@code null} wird zu leerem String
* @param claudeBaseUrl Claude-Basis-URL; {@code null} wird zu leerem String
* @param claudeModel Claude-Modellname; {@code null} wird zu leerem String
* @param claudeTimeoutSeconds Claude-Timeout; {@code null} wird zu leerem String
* @param claudeApiKeyDescriptor Claude-API-Key-Herkunft; darf nicht {@code null} sein
* @param openaiBaseUrl OpenAI-Basis-URL; {@code null} wird zu leerem String
* @param openaiModel OpenAI-Modellname; {@code null} wird zu leerem String
* @param openaiTimeoutSeconds OpenAI-Timeout; {@code null} wird zu leerem String
* @param openaiApiKeyDescriptor OpenAI-API-Key-Herkunft; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code claudeApiKeyDescriptor} oder {@code openaiApiKeyDescriptor} {@code null} sind
*/
public EditorValidationInput {
activeProviderIdentifier = normalizeText(activeProviderIdentifier);
sourceFolder = normalizeText(sourceFolder);
targetFolder = normalizeText(targetFolder);
sqliteFile = normalizeText(sqliteFile);
promptTemplateFile = normalizeText(promptTemplateFile);
maxRetriesTransient = normalizeText(maxRetriesTransient);
maxPages = normalizeText(maxPages);
maxTextCharacters = normalizeText(maxTextCharacters);
claudeBaseUrl = normalizeText(claudeBaseUrl);
claudeModel = normalizeText(claudeModel);
claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds);
claudeApiKeyDescriptor = Objects.requireNonNull(claudeApiKeyDescriptor,
"claudeApiKeyDescriptor must not be null");
openaiBaseUrl = normalizeText(openaiBaseUrl);
openaiModel = normalizeText(openaiModel);
openaiTimeoutSeconds = normalizeText(openaiTimeoutSeconds);
openaiApiKeyDescriptor = Objects.requireNonNull(openaiApiKeyDescriptor,
"openaiApiKeyDescriptor must not be null");
}
private static String normalizeText(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,63 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
import java.util.List;
import java.util.Objects;
/**
* Ergebnis einer editornahen Konfigurationsvalidierung.
* <p>
* Enthält alle Befunde, die der {@link EditorConfigurationValidator} aus dem aktuellen
* Editorzustand abgeleitet hat. Das Ergebnis ist immutable.
* <p>
* Befunde können über {@link #findings()} als vollständige Liste abgerufen werden.
* Feldbezogene Befunde lassen sich über {@link EditorValidationFinding#hasFieldKey()} filtern.
*
* @param findings alle Validierungsbefunde; nie {@code null}
*/
public record EditorValidationReport(List<EditorValidationFinding> findings) {
/**
* Erstellt ein Validierungsergebnis.
*
* @param findings Befundliste; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code findings} {@code null} ist
*/
public EditorValidationReport {
Objects.requireNonNull(findings, "findings must not be null");
findings = List.copyOf(findings);
}
/**
* Erstellt ein leeres Ergebnis ohne Befunde.
*
* @return ein leeres Ergebnis; nie {@code null}
*/
public static EditorValidationReport empty() {
return new EditorValidationReport(List.of());
}
/**
* Gibt an, ob mindestens ein Befund mit Schweregrad {@link EditorValidationSeverity#ERROR} vorhanden ist.
* <p>
* Wenn {@code true}, gilt die Konfiguration als nicht lauffähig. Das Speichern ist jedoch
* trotzdem erlaubt.
*
* @return {@code true} wenn mindestens ein Fehler-Befund vorliegt
*/
public boolean hasErrors() {
return findings.stream().anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
/**
* Gibt alle Befunde zurück, die sich auf das angegebene Feld beziehen.
*
* @param fieldKey Property-Schlüssel des gesuchten Felds; darf nicht {@code null} sein
* @return unveränderliche Liste der feldbezogenen Befunde; nie {@code null}
*/
public List<EditorValidationFinding> findingsFor(String fieldKey) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
return findings.stream()
.filter(f -> f.fieldKey().isPresent() && f.fieldKey().get().equals(fieldKey))
.toList();
}
}
@@ -0,0 +1,30 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
/**
* Schweregrade für Befunde der editornahen Konfigurationsvalidierung.
* <p>
* Die Reihenfolge entspricht aufsteigender Kritikalität:
* <ol>
* <li>{@link #INFO} neutraler Informationshinweis, keine Handlung erforderlich</li>
* <li>{@link #HINT} nützlicher Hinweis, den der Benutzer berücksichtigen sollte</li>
* <li>{@link #WARNING} riskante, aber formal zulässige Einstellung</li>
* <li>{@link #ERROR} ungültige oder fehlende Pflichtangabe, Konfiguration nicht lauffähig</li>
* </ol>
* <p>
* Warnungen und Hinweise verhindern das Speichern nicht. Fehler markieren den Stand als
* nicht lauffähig, erlauben aber ebenfalls das Speichern.
*/
public enum EditorValidationSeverity {
/** Neutraler Informationshinweis. */
INFO,
/** Nützlicher Hinweis, den der Benutzer beachten sollte. */
HINT,
/** Riskante, aber formal zulässige Einstellung. */
WARNING,
/** Ungültige oder fehlende Pflichtangabe Konfiguration ist nicht lauffähig. */
ERROR
}
@@ -0,0 +1,16 @@
/**
* Editornahe Validierungskomponenten für den Konfigurationseditor.
* <p>
* Dieses Package enthält den zentralen {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator},
* der den aktuellen Editorzustand gegen fachliche und technische Regeln prüft und
* Befunde der Stufen Fehler, Warnung, Hinweis und Info erzeugt.
* <p>
* Die Komponenten sind infrastrukturneutral: Sie kennen keine JavaFX-Typen, keine
* Dateisystempfade, keine Datenbankzugriffe und keine HTTP-Kommunikation. Alle Prüfungen
* arbeiten ausschließlich auf den übergebenen String-Werten des aktuellen Editorzustands.
* <p>
* Die Ergebnistypen ({@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding}
* und {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport}) sind immutable
* Records und enthalten keine Infrastrukturabhängigkeiten.
*/
package de.gecheckt.pdf.umbenenner.application.validation.editor;
@@ -0,0 +1,28 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ApiKeyOrigin}.
*/
class ApiKeyOriginTest {
@Test
void allValuesPresent() {
assertThat(ApiKeyOrigin.values()).containsExactlyInAnyOrder(
ApiKeyOrigin.FROM_PROVIDER_ENV_VAR,
ApiKeyOrigin.FROM_LEGACY_ENV_VAR,
ApiKeyOrigin.FROM_PROPERTY_FILE,
ApiKeyOrigin.ABSENT);
}
@Test
void enumLookupByName() {
assertThat(ApiKeyOrigin.valueOf("FROM_PROVIDER_ENV_VAR")).isEqualTo(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR);
assertThat(ApiKeyOrigin.valueOf("FROM_LEGACY_ENV_VAR")).isEqualTo(ApiKeyOrigin.FROM_LEGACY_ENV_VAR);
assertThat(ApiKeyOrigin.valueOf("FROM_PROPERTY_FILE")).isEqualTo(ApiKeyOrigin.FROM_PROPERTY_FILE);
assertThat(ApiKeyOrigin.valueOf("ABSENT")).isEqualTo(ApiKeyOrigin.ABSENT);
}
}
@@ -0,0 +1,88 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link EffectiveApiKeyDescriptor}.
*/
class EffectiveApiKeyDescriptorTest {
@Test
void fromProviderEnvVar_setsOriginAndVarName() {
var descriptor = EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY");
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR);
assertThat(descriptor.envVarName()).contains("ANTHROPIC_API_KEY");
assertThat(descriptor.isFromEnvironmentVariable()).isTrue();
assertThat(descriptor.isAbsent()).isFalse();
}
@Test
void fromLegacyEnvVar_setsOriginAndVarName() {
var descriptor = EffectiveApiKeyDescriptor.fromLegacyEnvVar("OPENAI_API_KEY");
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_LEGACY_ENV_VAR);
assertThat(descriptor.envVarName()).contains("OPENAI_API_KEY");
assertThat(descriptor.isFromEnvironmentVariable()).isTrue();
assertThat(descriptor.isAbsent()).isFalse();
}
@Test
void fromPropertyFile_setsOriginAndEmptyVarName() {
var descriptor = EffectiveApiKeyDescriptor.fromPropertyFile();
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_PROPERTY_FILE);
assertThat(descriptor.envVarName()).isEmpty();
assertThat(descriptor.isFromEnvironmentVariable()).isFalse();
assertThat(descriptor.isAbsent()).isFalse();
}
@Test
void absent_setsAbsentOriginAndEmptyVarName() {
var descriptor = EffectiveApiKeyDescriptor.absent();
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.ABSENT);
assertThat(descriptor.envVarName()).isEmpty();
assertThat(descriptor.isFromEnvironmentVariable()).isFalse();
assertThat(descriptor.isAbsent()).isTrue();
}
@Test
void nullEnvVarNameBecomesEmpty() {
var descriptor = new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROPERTY_FILE, null);
assertThat(descriptor.envVarName()).isEqualTo(Optional.empty());
}
@Test
void rejectsNullOrigin() {
assertThatNullPointerException()
.isThrownBy(() -> new EffectiveApiKeyDescriptor(null, Optional.empty()));
}
@Test
void fromProviderEnvVar_rejectsNullName() {
assertThatNullPointerException()
.isThrownBy(() -> EffectiveApiKeyDescriptor.fromProviderEnvVar(null));
}
@Test
void fromLegacyEnvVar_rejectsNullName() {
assertThatNullPointerException()
.isThrownBy(() -> EffectiveApiKeyDescriptor.fromLegacyEnvVar(null));
}
@Test
void equality_basedOnAllFields() {
var a = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_A");
var b = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_A");
var c = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_B");
assertThat(a).isEqualTo(b);
assertThat(a).isNotEqualTo(c);
}
}
@@ -0,0 +1,59 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
import org.junit.jupiter.api.Test;
import java.util.Optional;
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 ModelCatalogRequest}.
*/
class ModelCatalogRequestTest {
@Test
void storesAllProvidedValues() {
var request = new ModelCatalogRequest(
"claude",
Optional.of("https://api.anthropic.com"),
Optional.of("test-key"),
30);
assertThat(request.providerIdentifier()).isEqualTo("claude");
assertThat(request.baseUrl()).contains("https://api.anthropic.com");
assertThat(request.apiKey()).contains("test-key");
assertThat(request.timeoutSeconds()).isEqualTo(30);
}
@Test
void nullBaseUrlBecomesEmpty() {
var request = new ModelCatalogRequest("claude", null, Optional.of("key"), 10);
assertThat(request.baseUrl()).isEmpty();
}
@Test
void nullApiKeyBecomesEmpty() {
var request = new ModelCatalogRequest("claude", Optional.empty(), null, 10);
assertThat(request.apiKey()).isEmpty();
}
@Test
void rejectsNullProviderIdentifier() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogRequest(null, Optional.empty(), Optional.empty(), 10));
}
@Test
void rejectsZeroTimeout() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ModelCatalogRequest("claude", Optional.empty(), Optional.empty(), 0));
}
@Test
void rejectsNegativeTimeout() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ModelCatalogRequest("claude", Optional.empty(), Optional.empty(), -5));
}
}
@@ -0,0 +1,159 @@
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
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;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link ModelCatalogResult} and its permitted sub-types.
*/
class ModelCatalogResultTest {
// Success
@Test
void success_storesAllFields() {
var now = Instant.now();
var result = new ModelCatalogResult.Success("claude", List.of("claude-3-5-sonnet", "claude-3-opus"), now);
assertThat(result.providerIdentifier()).isEqualTo("claude");
assertThat(result.models()).containsExactly("claude-3-5-sonnet", "claude-3-opus");
assertThat(result.loadedAt()).isEqualTo(now);
}
@Test
void success_modelListIsDefensiveCopy() {
var mutable = new java.util.ArrayList<>(List.of("model-a"));
var result = new ModelCatalogResult.Success("claude", mutable, Instant.now());
mutable.add("model-b");
assertThat(result.models()).containsExactly("model-a");
}
@Test
void success_rejectsNullProviderIdentifier() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.Success(null, List.of("m"), Instant.now()));
}
@Test
void success_rejectsNullModelList() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.Success("claude", null, Instant.now()));
}
@Test
void success_rejectsEmptyModelList() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ModelCatalogResult.Success("claude", List.of(), Instant.now()));
}
@Test
void success_rejectsNullTimestamp() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.Success("claude", List.of("m"), null));
}
// EmptyList
@Test
void emptyList_storesAllFields() {
var now = Instant.now();
var result = new ModelCatalogResult.EmptyList("openai-compatible", now);
assertThat(result.providerIdentifier()).isEqualTo("openai-compatible");
assertThat(result.loadedAt()).isEqualTo(now);
}
@Test
void emptyList_rejectsNullProviderIdentifier() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.EmptyList(null, Instant.now()));
}
@Test
void emptyList_rejectsNullTimestamp() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.EmptyList("claude", null));
}
// IncompleteConfiguration
@Test
void incompleteConfiguration_storesAllFields() {
var result = new ModelCatalogResult.IncompleteConfiguration("claude", "API-Schluessel fehlt");
assertThat(result.providerIdentifier()).isEqualTo("claude");
assertThat(result.missingReason()).isEqualTo("API-Schluessel fehlt");
}
@Test
void incompleteConfiguration_rejectsNullProviderIdentifier() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.IncompleteConfiguration(null, "reason"));
}
@Test
void incompleteConfiguration_rejectsNullMissingReason() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.IncompleteConfiguration("claude", null));
}
// TechnicalFailure
@Test
void technicalFailure_storesAllFields() {
var result = new ModelCatalogResult.TechnicalFailure("openai-compatible", "HTTP_ERROR", "503 Service Unavailable");
assertThat(result.providerIdentifier()).isEqualTo("openai-compatible");
assertThat(result.errorCategory()).isEqualTo("HTTP_ERROR");
assertThat(result.errorDetail()).isEqualTo("503 Service Unavailable");
}
@Test
void technicalFailure_rejectsNullProviderIdentifier() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure(null, "cat", "detail"));
}
@Test
void technicalFailure_rejectsNullErrorCategory() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure("claude", null, "detail"));
}
@Test
void technicalFailure_rejectsNullErrorDetail() {
assertThatNullPointerException()
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure("claude", "cat", null));
}
// Pattern-matching exhaustiveness
@Test
void patternMatchingSwitchCoversAllPermittedSubtypes() {
ModelCatalogResult success = new ModelCatalogResult.Success("claude", List.of("m"), Instant.now());
ModelCatalogResult empty = new ModelCatalogResult.EmptyList("claude", Instant.now());
ModelCatalogResult incomplete = new ModelCatalogResult.IncompleteConfiguration("claude", "reason");
ModelCatalogResult failure = new ModelCatalogResult.TechnicalFailure("claude", "TIMEOUT", "detail");
assertThat(classifyResult(success)).isEqualTo("success");
assertThat(classifyResult(empty)).isEqualTo("empty");
assertThat(classifyResult(incomplete)).isEqualTo("incomplete");
assertThat(classifyResult(failure)).isEqualTo("failure");
}
private String classifyResult(ModelCatalogResult result) {
return switch (result) {
case ModelCatalogResult.Success s -> "success";
case ModelCatalogResult.EmptyList e -> "empty";
case ModelCatalogResult.IncompleteConfiguration i -> "incomplete";
case ModelCatalogResult.TechnicalFailure t -> "failure";
};
}
}
@@ -0,0 +1,512 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
/**
* Unit-Tests für den {@link EditorConfigurationValidator}.
* <p>
* Prüft alle Validierungsregeln auf Basis synthetischer Eingaben ohne I/O.
*/
class EditorConfigurationValidatorTest {
private EditorConfigurationValidator validator;
@BeforeEach
void setUp() {
validator = new EditorConfigurationValidator();
}
// =========================================================================
// Hilfsmethode: minimale gültige Eingabe
// =========================================================================
private static EditorValidationInput minimalValidInput() {
return new EditorValidationInput(
"claude", // activeProviderIdentifier
"C:/source", // sourceFolder
"C:/target", // targetFolder
"C:/db.sqlite", // sqliteFile
"C:/prompt.txt", // promptTemplateFile
"3", // maxRetriesTransient
"10", // maxPages
"500", // maxTextCharacters
"https://api.anthropic.com", // claudeBaseUrl
"claude-3-5-sonnet", // claudeModel
"30", // claudeTimeoutSeconds
EffectiveApiKeyDescriptor.fromPropertyFile(), // claudeApiKeyDescriptor
"https://api.openai.com", // openaiBaseUrl
"gpt-4", // openaiModel
"30", // openaiTimeoutSeconds
EffectiveApiKeyDescriptor.fromPropertyFile() // openaiApiKeyDescriptor
);
}
// =========================================================================
// Leere Eingabe / Null-Checks
// =========================================================================
@Test
void validate_emptyActiveProvider_producesError() {
EditorValidationInput input = new EditorValidationInput(
"", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.hasErrors()).isTrue();
assertThat(report.findings()).anyMatch(f ->
f.fieldKey().isPresent()
&& f.fieldKey().get().equals(EditorConfigurationValidator.FIELD_ACTIVE_PROVIDER)
&& f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_unknownActiveProvider_producesError() {
EditorValidationInput input = new EditorValidationInput(
"unknown-provider", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.hasErrors()).isTrue();
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_ACTIVE_PROVIDER))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
// =========================================================================
// Pflichtpfade
// =========================================================================
@Test
void validate_emptySourceFolder_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_SOURCE_FOLDER))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_emptyTargetFolder_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_TARGET_FOLDER))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_emptySqliteFile_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_SQLITE_FILE))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_emptyPromptFile_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_PROMPT_FILE))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
// =========================================================================
// max.retries.transient
// =========================================================================
@Test
void validate_maxRetriesTransient_zero_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"0", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_maxRetriesTransient_negative_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"-1", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_maxRetriesTransient_one_producesNoError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"1", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
List<EditorValidationFinding> retryFindings =
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES);
assertThat(retryFindings).noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_maxRetriesTransient_nonNumeric_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"abc", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
// =========================================================================
// max.pages
// =========================================================================
@Test
void validate_maxPages_zero_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "0", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_maxPages_over100_producesHint() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "101", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES))
.anyMatch(f -> f.severity() == EditorValidationSeverity.HINT);
}
@Test
void validate_maxPages_exactly100_producesNoHintAndNoError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "100", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
List<EditorValidationFinding> pageFindings =
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES);
assertThat(pageFindings).noneMatch(f ->
f.severity() == EditorValidationSeverity.ERROR
|| f.severity() == EditorValidationSeverity.HINT);
}
// =========================================================================
// max.text.characters Wirtschaftliche Warnlogik
// =========================================================================
@Test
void validate_maxTextCharacters_1000_producesNoFinding() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "1000",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
.isEmpty();
}
@Test
void validate_maxTextCharacters_1001_producesWarning() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "1001",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
}
@Test
void validate_maxTextCharacters_3000_producesWarning() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "3000",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
}
@Test
void validate_maxTextCharacters_3001_producesStrongWarning() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "3001",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
List<EditorValidationFinding> charFindings =
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS);
assertThat(charFindings).anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
// Starke Warnung enthält "stark" oder "riskant"
assertThat(charFindings).anyMatch(f ->
f.message().toLowerCase().contains("stark")
|| f.message().toLowerCase().contains("riskant"));
}
@Test
void validate_maxTextCharacters_zero_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "0",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
// =========================================================================
// Provider-Felder: Claude
// =========================================================================
@Test
void validate_claude_emptyModel_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_MODEL))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
@Test
void validate_claude_emptyBaseUrl_producesWarning() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_BASE_URL))
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
}
@Test
void validate_claude_negativeTimeout_producesError() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "-5",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_TIMEOUT))
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
// =========================================================================
// API-Key-Vorrangregel
// =========================================================================
@Test
void validate_claude_absent_apiKey_producesWarning() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.absent(),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
}
@Test
void validate_claude_fromPropertyFile_producesNoFinding() {
EditorValidationInput input = minimalValidInput();
// minimalValidInput() already uses fromPropertyFile() for Claude
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
.isEmpty();
}
@Test
void validate_claude_fromEnvVar_producesInfoFinding() {
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY"),
"", "", "30", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
.anyMatch(f -> f.severity() == EditorValidationSeverity.INFO
&& f.message().contains("ANTHROPIC_API_KEY"));
}
@Test
void validate_openai_fromLegacyEnvVar_producesInfoFinding() {
EditorValidationInput input = new EditorValidationInput(
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(),
"https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY"));
EditorValidationReport report = validator.validate(input);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_API_KEY))
.anyMatch(f -> f.severity() == EditorValidationSeverity.INFO
&& f.message().contains("PDF_UMBENENNER_API_KEY"));
}
// =========================================================================
// Vollständig gültige Konfiguration
// =========================================================================
@Test
void validate_fullyValidClaudeConfig_producesNoErrors() {
EditorValidationInput input = minimalValidInput();
EditorValidationReport report = validator.validate(input);
assertThat(report.hasErrors()).isFalse();
}
@Test
void validate_fullyValidOpenAiConfig_producesNoErrors() {
EditorValidationInput input = new EditorValidationInput(
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(),
"https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.fromPropertyFile());
EditorValidationReport report = validator.validate(input);
assertThat(report.hasErrors()).isFalse();
}
// =========================================================================
// Inaktiver Provider keine Fehler für inaktiven Block
// =========================================================================
@Test
void validate_claude_active_openaiBlockEmpty_producesNoOpenaiErrors() {
// Claude ist aktiv; OpenAI-Felder sind leer darf keinen FEHLER für OpenAI-Felder geben
EditorValidationInput input = new EditorValidationInput(
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(),
"", "", "", EffectiveApiKeyDescriptor.absent());
EditorValidationReport report = validator.validate(input);
// OpenAI-Felder dürfen keinen Fehler produzieren (Provider nicht aktiv)
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_MODEL))
.noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_TIMEOUT))
.noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
}
}
@@ -20,7 +20,11 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.ClaudeModelCatalogAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.OpenAiCompatibleModelCatalogAdapter;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
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.editor.GuiConfigurationFileSnapshot;
@@ -616,13 +620,18 @@ public class BootstrapRunner {
private GuiStartupContext buildGuiStartupContext(Optional<String> configPathOverride) {
GuiConfigurationFileLoader loader = this::loadGuiConfigurationState;
GuiConfigurationFileWriter writer = new GuiConfigurationPropertiesWriter();
AiModelCatalogPort modelCatalogPort = buildModelCatalogDispatcher();
de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort apiKeyResolutionPort =
new de.gecheckt.pdf.umbenenner.adapter.out.validation.EnvironmentApiKeyResolutionAdapter();
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
loader,
writer);
writer,
modelCatalogPort,
apiKeyResolutionPort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -634,13 +643,16 @@ public class BootstrapRunner {
Optional.of("Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
+ "\nDie GUI startet ohne Konfigurationsdatei."),
loader,
writer);
writer,
modelCatalogPort,
apiKeyResolutionPort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
try {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -648,10 +660,29 @@ public class BootstrapRunner {
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.of("Konfiguration konnte nicht geladen werden: " + e.getMessage()),
loader,
writer);
writer,
modelCatalogPort,
apiKeyResolutionPort);
}
}
/**
* Creates the {@link AiModelCatalogPort} dispatcher for use in the GUI startup context.
* <p>
* Instantiates one stateless concrete adapter per supported provider family and wires
* them into an {@link AiModelCatalogDispatcher}. The adapters are stateless: all
* provider-specific values (API key, base URL, timeout) are supplied per call via the
* {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest},
* so no provider configuration needs to be resolved at this point.
*
* @return a ready-to-use model catalogue port; never {@code null}
*/
private AiModelCatalogPort buildModelCatalogDispatcher() {
return new AiModelCatalogDispatcher(
new ClaudeModelCatalogAdapter(),
new OpenAiCompatibleModelCatalogAdapter());
}
private GuiConfigurationEditorState loadGuiConfigurationState(Path configFilePath) {
try {
boolean legacyDetected = detectedLegacyConfiguration(configFilePath);
@@ -0,0 +1,91 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
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;
/**
* Composition-root dispatcher that routes {@link AiModelCatalogPort#fetchAvailableModels}
* calls to the concrete adapter matching the provider identifier carried in the request.
* <p>
* This class is not a new architectural layer between the port contract and its adapters.
* It is a Bootstrap-only wiring convenience that allows the GUI to call a single
* {@link AiModelCatalogPort} instance regardless of which provider the user has currently
* selected. The concrete adapters remain independent of each other and of this class.
* <p>
* <strong>Dispatching rule:</strong> The value of
* {@link ModelCatalogRequest#providerIdentifier()} is matched case-sensitively against
* the known provider identifiers:
* <ul>
* <li>{@code "claude"} routed to the Claude adapter</li>
* <li>{@code "openai-compatible"} routed to the OpenAI-compatible adapter</li>
* <li>any other value returns {@link ModelCatalogResult.IncompleteConfiguration}
* with a human-readable explanation</li>
* </ul>
* <p>
* <strong>Thread safety:</strong> The dispatcher holds only immutable references to
* the two concrete adapters and is trivially thread-safe.
*/
public class AiModelCatalogDispatcher implements AiModelCatalogPort {
private static final Logger LOG = LogManager.getLogger(AiModelCatalogDispatcher.class);
private static final String PROVIDER_CLAUDE = "claude";
private static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
private final AiModelCatalogPort claudeAdapter;
private final AiModelCatalogPort openAiCompatibleAdapter;
/**
* Creates a dispatcher wired with both supported adapter implementations.
*
* @param claudeAdapter the adapter for the Claude provider family;
* must not be {@code null}
* @param openAiCompatibleAdapter the adapter for the OpenAI-compatible provider family;
* must not be {@code null}
* @throws NullPointerException if either adapter is {@code null}
*/
public AiModelCatalogDispatcher(AiModelCatalogPort claudeAdapter,
AiModelCatalogPort openAiCompatibleAdapter) {
this.claudeAdapter = Objects.requireNonNull(claudeAdapter,
"claudeAdapter must not be null");
this.openAiCompatibleAdapter = Objects.requireNonNull(openAiCompatibleAdapter,
"openAiCompatibleAdapter must not be null");
}
/**
* Routes the model catalogue request to the concrete adapter for the provider
* identified by {@link ModelCatalogRequest#providerIdentifier()}.
* <p>
* If the provider identifier does not match any known provider, returns
* {@link ModelCatalogResult.IncompleteConfiguration} without calling any adapter.
* This keeps the GUI informed without any crash or exception.
*
* @param request all values needed to contact the provider; must not be {@code null}
* @return the result from the matched adapter, or {@link ModelCatalogResult.IncompleteConfiguration}
* when the provider identifier is unknown; never {@code null}
* @throws NullPointerException if {@code request} is {@code null}
*/
@Override
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
Objects.requireNonNull(request, "request must not be null");
String provider = request.providerIdentifier();
LOG.debug("Model catalogue dispatch: routing request for provider '{}'", provider);
return switch (provider) {
case PROVIDER_CLAUDE -> claudeAdapter.fetchAvailableModels(request);
case PROVIDER_OPENAI_COMPATIBLE -> openAiCompatibleAdapter.fetchAvailableModels(request);
default -> {
LOG.warn("Model catalogue dispatch: unknown provider identifier '{}'", provider);
yield new ModelCatalogResult.IncompleteConfiguration(provider,
"Unbekannter Provider: '" + provider + "'");
}
};
}
}
@@ -0,0 +1,145 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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;
/**
* Unit tests for {@link AiModelCatalogDispatcher}.
* <p>
* Verifies that the dispatcher correctly routes requests to the matching concrete
* adapter based on the provider identifier in the request, and that unknown providers
* are handled as {@link ModelCatalogResult.IncompleteConfiguration} without calling
* either adapter.
*/
@ExtendWith(MockitoExtension.class)
class AiModelCatalogDispatcherTest {
@Mock
private AiModelCatalogPort claudeAdapter;
@Mock
private AiModelCatalogPort openAiCompatibleAdapter;
private AiModelCatalogDispatcher dispatcher;
@BeforeEach
void setUp() {
dispatcher = new AiModelCatalogDispatcher(claudeAdapter, openAiCompatibleAdapter);
}
@Test
@DisplayName("Request with provider 'claude' is routed to the Claude adapter")
void dispatch_claudeProvider_routesToClaudeAdapter() {
ModelCatalogRequest request = new ModelCatalogRequest(
"claude", Optional.empty(), Optional.of("key"), 5);
ModelCatalogResult expected = new ModelCatalogResult.Success(
"claude", List.of("claude-3-opus"), Instant.now());
when(claudeAdapter.fetchAvailableModels(request)).thenReturn(expected);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(expected);
verify(claudeAdapter).fetchAvailableModels(request);
verifyNoInteractions(openAiCompatibleAdapter);
}
@Test
@DisplayName("Request with provider 'openai-compatible' is routed to the OpenAI-compatible adapter")
void dispatch_openAiCompatibleProvider_routesToOpenAiAdapter() {
ModelCatalogRequest request = new ModelCatalogRequest(
"openai-compatible", Optional.empty(), Optional.of("key"), 5);
ModelCatalogResult expected = new ModelCatalogResult.Success(
"openai-compatible", List.of("gpt-4o"), Instant.now());
when(openAiCompatibleAdapter.fetchAvailableModels(request)).thenReturn(expected);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(expected);
verify(openAiCompatibleAdapter).fetchAvailableModels(request);
verifyNoInteractions(claudeAdapter);
}
@Test
@DisplayName("Unknown provider identifier returns IncompleteConfiguration without calling any adapter")
void dispatch_unknownProvider_returnsIncompleteConfigurationWithoutCallingAdapters() {
ModelCatalogRequest request = new ModelCatalogRequest(
"unknown-provider", Optional.empty(), Optional.of("key"), 5);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
ModelCatalogResult.IncompleteConfiguration incomplete =
(ModelCatalogResult.IncompleteConfiguration) result;
assertThat(incomplete.providerIdentifier()).isEqualTo("unknown-provider");
assertThat(incomplete.missingReason()).containsIgnoringCase("Unbekannter Provider");
verifyNoInteractions(claudeAdapter);
verifyNoInteractions(openAiCompatibleAdapter);
}
@Test
@DisplayName("Null request throws NullPointerException")
void dispatch_nullRequest_throwsNullPointerException() {
assertThatNullPointerException()
.isThrownBy(() -> dispatcher.fetchAvailableModels(null));
}
@Test
@DisplayName("Null Claude adapter throws NullPointerException at construction")
void constructor_nullClaudeAdapter_throwsNullPointerException() {
assertThatNullPointerException()
.isThrownBy(() -> new AiModelCatalogDispatcher(null, openAiCompatibleAdapter));
}
@Test
@DisplayName("Null OpenAI-compatible adapter throws NullPointerException at construction")
void constructor_nullOpenAiAdapter_throwsNullPointerException() {
assertThatNullPointerException()
.isThrownBy(() -> new AiModelCatalogDispatcher(claudeAdapter, null));
}
@Test
@DisplayName("Dispatcher propagates adapter result unchanged for claude")
void dispatch_claudeAdapter_propagatesResultUnchanged() {
ModelCatalogRequest request = new ModelCatalogRequest(
"claude", Optional.empty(), Optional.of("k"), 10);
ModelCatalogResult.TechnicalFailure failure = new ModelCatalogResult.TechnicalFailure(
"claude", "AUTHENTICATION_FAILED", "Bad credentials");
when(claudeAdapter.fetchAvailableModels(request)).thenReturn(failure);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(failure);
}
@Test
@DisplayName("Dispatcher propagates adapter result unchanged for openai-compatible")
void dispatch_openAiCompatibleAdapter_propagatesResultUnchanged() {
ModelCatalogRequest request = new ModelCatalogRequest(
"openai-compatible", Optional.empty(), Optional.of("k"), 10);
ModelCatalogResult.EmptyList emptyList = new ModelCatalogResult.EmptyList(
"openai-compatible", Instant.now());
when(openAiCompatibleAdapter.fetchAvailableModels(request)).thenReturn(emptyList);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(emptyList);
}
}