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:
@@ -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
|
||||
|
||||
+778
-108
File diff suppressed because it is too large
Load Diff
+277
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
-7
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+93
@@ -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));
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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();
|
||||
}
|
||||
}
|
||||
+68
@@ -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());
|
||||
}
|
||||
}
|
||||
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+190
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
-6
@@ -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;
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
|
||||
+6
-2
@@ -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);
|
||||
|
||||
+15
-5
@@ -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(() -> {
|
||||
|
||||
+456
@@ -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;
|
||||
}
|
||||
}
|
||||
+710
@@ -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} (1001–3000) 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;
|
||||
}
|
||||
}
|
||||
+639
@@ -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;
|
||||
}
|
||||
}
|
||||
+452
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+114
@@ -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));
|
||||
}
|
||||
}
|
||||
+66
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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));
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+117
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user