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:
+40
@@ -0,0 +1,40 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
/**
|
||||
* Outbound port for retrieving the list of available AI models from a provider endpoint.
|
||||
* <p>
|
||||
* This port is used exclusively by the GUI layer to populate the model selection control.
|
||||
* The headless batch path does not call this port; the model name is read directly from
|
||||
* the configuration file in that path.
|
||||
* <p>
|
||||
* <strong>Infrastructure neutrality:</strong> The port signature contains no HTTP client,
|
||||
* no JSON type, and no JavaFX reference. Implementations in {@code pdf-umbenenner-adapter-out}
|
||||
* translate the {@link ModelCatalogRequest} into provider-specific HTTP calls and map the
|
||||
* response back to a {@link ModelCatalogResult}.
|
||||
* <p>
|
||||
* <strong>Error handling contract:</strong> Implementations must never throw exceptions for
|
||||
* expected error conditions such as missing configuration values, authentication failures, or
|
||||
* unreachable endpoints. All such conditions are encoded as specific {@link ModelCatalogResult}
|
||||
* sub-types so the GUI can display them without crash handling.
|
||||
* <p>
|
||||
* <strong>Blocking behaviour:</strong> Implementations are expected to block the calling thread
|
||||
* until the result is available or the configured timeout expires. The GUI adapter must therefore
|
||||
* invoke this port on a background worker thread and must not call it on the JavaFX Application
|
||||
* Thread.
|
||||
*/
|
||||
public interface AiModelCatalogPort {
|
||||
|
||||
/**
|
||||
* Fetches the list of available model identifiers for the provider described by the request.
|
||||
* <p>
|
||||
* The method always returns a non-{@code null} {@link ModelCatalogResult}. The caller
|
||||
* uses pattern matching over the sealed type hierarchy to distinguish success, empty list,
|
||||
* incomplete configuration, and technical failure without catching exceptions.
|
||||
*
|
||||
* @param request all information required to contact the provider endpoint;
|
||||
* must not be {@code null}
|
||||
* @return the result of the catalogue retrieval; never {@code null}
|
||||
* @throws NullPointerException if {@code request} is {@code null}
|
||||
*/
|
||||
ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
/**
|
||||
* Describes the origin of the effective API key value for a provider.
|
||||
* <p>
|
||||
* The resolution order is defined in the application configuration rules:
|
||||
* <ol>
|
||||
* <li>A provider-specific environment variable takes the highest precedence.</li>
|
||||
* <li>For the {@code openai-compatible} provider family a legacy environment variable
|
||||
* is evaluated as a secondary fallback for backward compatibility.</li>
|
||||
* <li>The property value from the {@code .properties} file is used when no
|
||||
* environment variable is present.</li>
|
||||
* <li>{@link #ABSENT} is returned when none of the above sources supplies a value.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* This enum is placed in the Application module because the resolution order is a fachliche
|
||||
* rule that applies independently of the GUI. Bootstrap uses the same precedence, and
|
||||
* future diagnostic components outside the GUI may need to report key provenance without
|
||||
* depending on GUI-layer types.
|
||||
*/
|
||||
public enum ApiKeyOrigin {
|
||||
|
||||
/**
|
||||
* The effective key comes from the provider-specific environment variable
|
||||
* (e.g., {@code ANTHROPIC_API_KEY} for Claude or {@code OPENAI_API_KEY} for
|
||||
* OpenAI-compatible providers).
|
||||
*/
|
||||
FROM_PROVIDER_ENV_VAR,
|
||||
|
||||
/**
|
||||
* The effective key comes from the legacy environment variable accepted for
|
||||
* backward compatibility.
|
||||
* <p>
|
||||
* This origin applies only to the {@code openai-compatible} provider family. The
|
||||
* legacy variable name is defined by the adapter and is not fixed in this enum.
|
||||
*/
|
||||
FROM_LEGACY_ENV_VAR,
|
||||
|
||||
/**
|
||||
* The effective key comes from the property value stored in the {@code .properties} file.
|
||||
*/
|
||||
FROM_PROPERTY_FILE,
|
||||
|
||||
/**
|
||||
* No API key value was found in any of the supported sources.
|
||||
* <p>
|
||||
* The provider configuration is incomplete; the batch run cannot start and the GUI
|
||||
* must indicate that the key is missing.
|
||||
*/
|
||||
ABSENT
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Describes the provenance of the effective API key for a provider, including the name of the
|
||||
* environment variable that supplies the key when applicable.
|
||||
* <p>
|
||||
* The GUI uses this record to display provenance information to the user (e.g., "Schlüssel
|
||||
* kommt aus Umgebungsvariable ANTHROPIC_API_KEY") without coupling the display logic to the
|
||||
* concrete variable names.
|
||||
* <p>
|
||||
* The {@code envVarName} field is present when the origin is
|
||||
* {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} or {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR};
|
||||
* it is absent for {@link ApiKeyOrigin#FROM_PROPERTY_FILE} and {@link ApiKeyOrigin#ABSENT}.
|
||||
*
|
||||
* @param origin the source from which the effective key value comes; never {@code null}
|
||||
* @param envVarName the name of the environment variable that provides the key when applicable;
|
||||
* empty when the origin is {@code FROM_PROPERTY_FILE} or {@code ABSENT}
|
||||
*/
|
||||
public record EffectiveApiKeyDescriptor(
|
||||
ApiKeyOrigin origin,
|
||||
Optional<String> envVarName) {
|
||||
|
||||
/**
|
||||
* Creates a new descriptor.
|
||||
*
|
||||
* @param origin key origin; must not be {@code null}
|
||||
* @param envVarName optional environment variable name; {@code null} becomes empty
|
||||
* @throws NullPointerException if {@code origin} is {@code null}
|
||||
*/
|
||||
public EffectiveApiKeyDescriptor {
|
||||
Objects.requireNonNull(origin, "origin must not be null");
|
||||
envVarName = envVarName == null ? Optional.empty() : envVarName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor for a key that comes from a provider-specific environment variable.
|
||||
*
|
||||
* @param variableName the name of the environment variable; must not be {@code null}
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR}
|
||||
* @throws NullPointerException if {@code variableName} is {@code null}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor fromProviderEnvVar(String variableName) {
|
||||
Objects.requireNonNull(variableName, "variableName must not be null");
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR,
|
||||
Optional.of(variableName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor for a key that comes from the legacy environment variable.
|
||||
*
|
||||
* @param variableName the name of the legacy environment variable; must not be {@code null}
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
|
||||
* @throws NullPointerException if {@code variableName} is {@code null}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor fromLegacyEnvVar(String variableName) {
|
||||
Objects.requireNonNull(variableName, "variableName must not be null");
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_LEGACY_ENV_VAR,
|
||||
Optional.of(variableName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor for a key that comes from the properties file.
|
||||
*
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROPERTY_FILE}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor fromPropertyFile() {
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROPERTY_FILE, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor indicating that no key value is available.
|
||||
*
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#ABSENT}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor absent() {
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.ABSENT, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when the effective key comes from any environment variable.
|
||||
*
|
||||
* @return {@code true} for {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} and
|
||||
* {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
|
||||
*/
|
||||
public boolean isFromEnvironmentVariable() {
|
||||
return origin == ApiKeyOrigin.FROM_PROVIDER_ENV_VAR
|
||||
|| origin == ApiKeyOrigin.FROM_LEGACY_ENV_VAR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when no key is available from any source.
|
||||
*
|
||||
* @return {@code true} for {@link ApiKeyOrigin#ABSENT}
|
||||
*/
|
||||
public boolean isAbsent() {
|
||||
return origin == ApiKeyOrigin.ABSENT;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Carries all information needed by an {@link AiModelCatalogPort} implementation to fetch
|
||||
* the list of available models from a provider endpoint.
|
||||
* <p>
|
||||
* This record is infrastructure-neutral: it contains no HTTP client, no JSON type, and no
|
||||
* JavaFX reference. The adapter translates the values into provider-specific request structures.
|
||||
* <p>
|
||||
* The {@code apiKey} field is {@code Optional}: when the key is absent the adapter is expected
|
||||
* to perform the request without authentication and return an appropriate
|
||||
* {@link ModelCatalogResult} sub-type (typically
|
||||
* {@link ModelCatalogResult.IncompleteConfiguration}) rather than throwing an exception.
|
||||
*
|
||||
* @param providerIdentifier identifier string of the target provider family as used in the
|
||||
* {@code ai.provider.active} configuration property
|
||||
* (e.g., {@code "claude"} or {@code "openai-compatible"});
|
||||
* must not be {@code null}
|
||||
* @param baseUrl optional base URL of the provider endpoint; when absent the adapter
|
||||
* must apply its own built-in default (e.g., for Claude)
|
||||
* @param apiKey optional API key; when absent the adapter must not fabricate a key
|
||||
* @param timeoutSeconds HTTP timeout in seconds; must be a positive integer
|
||||
*/
|
||||
public record ModelCatalogRequest(
|
||||
String providerIdentifier,
|
||||
Optional<String> baseUrl,
|
||||
Optional<String> apiKey,
|
||||
int timeoutSeconds) {
|
||||
|
||||
/**
|
||||
* Creates a new model catalogue request.
|
||||
*
|
||||
* @param providerIdentifier identifier string of the target provider family; must not be {@code null}
|
||||
* @param baseUrl optional base URL; {@code null} is treated as {@link Optional#empty()}
|
||||
* @param apiKey optional API key; {@code null} is treated as {@link Optional#empty()}
|
||||
* @param timeoutSeconds HTTP timeout in seconds; must be positive
|
||||
* @throws NullPointerException if {@code providerIdentifier} is {@code null}
|
||||
* @throws IllegalArgumentException if {@code timeoutSeconds} is not positive
|
||||
*/
|
||||
public ModelCatalogRequest {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
if (timeoutSeconds <= 0) {
|
||||
throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds);
|
||||
}
|
||||
baseUrl = baseUrl == null ? Optional.empty() : baseUrl;
|
||||
apiKey = apiKey == null ? Optional.empty() : apiKey;
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Sealed result type for a model catalogue retrieval operation performed via
|
||||
* {@link AiModelCatalogPort}.
|
||||
* <p>
|
||||
* Each permitted sub-type represents one distinct outcome:
|
||||
* <ul>
|
||||
* <li>{@link Success} – the provider returned a non-empty list of model identifiers.</li>
|
||||
* <li>{@link EmptyList} – the provider responded successfully but returned no models.</li>
|
||||
* <li>{@link IncompleteConfiguration} – the request could not be sent because a required
|
||||
* configuration value (e.g., API key, base URL) was missing.</li>
|
||||
* <li>{@link TechnicalFailure} – the HTTP call, authentication, or response parsing failed.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The GUI adapter uses this result directly; no separate GUI-layer translation type is needed
|
||||
* because the structure matches the GUI's display needs without containing any JavaFX reference.
|
||||
* This design decision is documented here to avoid introducing a redundant mapping layer.
|
||||
* <p>
|
||||
* Callers are expected to use pattern-matching {@code switch} expressions over all permitted types
|
||||
* to ensure exhaustive handling as new sub-types might be added in future expansions.
|
||||
*/
|
||||
public sealed interface ModelCatalogResult
|
||||
permits ModelCatalogResult.Success,
|
||||
ModelCatalogResult.EmptyList,
|
||||
ModelCatalogResult.IncompleteConfiguration,
|
||||
ModelCatalogResult.TechnicalFailure {
|
||||
|
||||
/**
|
||||
* The provider returned a non-empty list of available model identifiers.
|
||||
* <p>
|
||||
* The list is guaranteed to contain at least one entry. Callers may safely use
|
||||
* the first element as a default selection.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider that returned the list; never {@code null}
|
||||
* @param models non-empty, ordered list of model identifier strings; never {@code null}
|
||||
* @param loadedAt timestamp when the list was successfully retrieved; never {@code null}
|
||||
*/
|
||||
record Success(
|
||||
String providerIdentifier,
|
||||
List<String> models,
|
||||
Instant loadedAt) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates a successful model catalogue result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param models list of model identifiers; must not be {@code null} or empty
|
||||
* @param loadedAt retrieval timestamp; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
* @throws IllegalArgumentException if {@code models} is empty
|
||||
*/
|
||||
public Success {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(models, "models must not be null");
|
||||
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||
if (models.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"models must not be empty; use EmptyList for an empty response");
|
||||
}
|
||||
models = List.copyOf(models);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider responded successfully but returned no model identifiers.
|
||||
* <p>
|
||||
* This case is distinct from {@link TechnicalFailure}: the HTTP exchange succeeded and the
|
||||
* response was parseable, but the list of models was empty. The GUI should fall back to
|
||||
* manual text input.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; never {@code null}
|
||||
* @param loadedAt timestamp of the (technically successful) response; never {@code null}
|
||||
*/
|
||||
record EmptyList(
|
||||
String providerIdentifier,
|
||||
Instant loadedAt) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates an empty-list result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param loadedAt retrieval timestamp; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public EmptyList {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model catalogue request could not be sent because a required configuration value
|
||||
* was absent.
|
||||
* <p>
|
||||
* Typical causes: missing API key, missing base URL for a provider family that requires one.
|
||||
* The adapter must not throw an exception for this case; it must return this result type
|
||||
* instead so the GUI can display a user-friendly hint without crashing.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider for which configuration is incomplete;
|
||||
* never {@code null}
|
||||
* @param missingReason human-readable description of which configuration value is missing;
|
||||
* never {@code null}
|
||||
*/
|
||||
record IncompleteConfiguration(
|
||||
String providerIdentifier,
|
||||
String missingReason) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates an incomplete-configuration result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param missingReason description of the missing value; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public IncompleteConfiguration {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(missingReason, "missingReason must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A technical error occurred during the model catalogue retrieval.
|
||||
* <p>
|
||||
* Covers HTTP errors, authentication failures, network timeouts, and response parsing
|
||||
* failures. The adapter classifies the error into a short category string (e.g.,
|
||||
* {@code "HTTP_ERROR"}, {@code "AUTH_FAILURE"}, {@code "TIMEOUT"}, {@code "PARSE_ERROR"})
|
||||
* and provides a human-readable detail message.
|
||||
* <p>
|
||||
* This result does not trigger a retry; the GUI offers an explicit
|
||||
* "reload models" action that the user can invoke after fixing the underlying issue.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider that was contacted; never {@code null}
|
||||
* @param errorCategory short, stable category string for programmatic discrimination;
|
||||
* never {@code null}
|
||||
* @param errorDetail human-readable error description; never {@code null}
|
||||
*/
|
||||
record TechnicalFailure(
|
||||
String providerIdentifier,
|
||||
String errorCategory,
|
||||
String errorDetail) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates a technical-failure result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param errorCategory short category string; must not be {@code null}
|
||||
* @param errorDetail human-readable detail; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public TechnicalFailure {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(errorCategory, "errorCategory must not be null");
|
||||
Objects.requireNonNull(errorDetail, "errorDetail must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Contracts and result types for the provider-dependent model catalogue retrieval.
|
||||
* <p>
|
||||
* This package defines the outbound port {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort}
|
||||
* that the GUI adapter uses to fetch the list of available AI models from the active provider.
|
||||
* All types in this package are infrastructure-neutral: they contain no HTTP, JSON, or JavaFX
|
||||
* references.
|
||||
* <p>
|
||||
* The GUI adapter is the sole consumer of this port in the current implementation scope;
|
||||
* the headless batch path does not perform model catalogue lookups.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
|
||||
/**
|
||||
* Outbound-Port für die Auflösung der API-Key-Herkunft pro Provider-Familie.
|
||||
* <p>
|
||||
* Gibt zurück, aus welcher Quelle der effektive API-Schlüssel für einen angegebenen Provider
|
||||
* stammt. Die Vorrangregel lautet:
|
||||
* <ol>
|
||||
* <li>Providerspezifische Umgebungsvariable (höchste Priorität)</li>
|
||||
* <li>Bei {@code openai-compatible}: zusätzlich die Legacy-Umgebungsvariable als Fallback</li>
|
||||
* <li>Property-Wert aus der {@code .properties}-Datei</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT}
|
||||
* wenn keine Quelle einen Wert liefert</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Der Port kennt keine Datei-Inhalte und kein Property-Parsing; er liest ausschließlich
|
||||
* Umgebungsvariablen. Der Property-Wert wird von der GUI als separates Eingabeargument übergeben
|
||||
* und vom Validator mit dem Port-Ergebnis kombiniert.
|
||||
* <p>
|
||||
* Implementierungen dieses Ports liegen im Adapter-Out-Modul.
|
||||
*/
|
||||
public interface ApiKeyResolutionPort {
|
||||
|
||||
/**
|
||||
* Ermittelt die Herkunft des effektiven API-Schlüssels für den angegebenen Provider.
|
||||
* <p>
|
||||
* Gibt nur dann {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#FROM_PROPERTY_FILE}
|
||||
* zurück, wenn {@code propertyValue} nicht leer ist und keine Umgebungsvariable greift.
|
||||
* Gibt {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT} zurück,
|
||||
* wenn weder Umgebungsvariable noch {@code propertyValue} einen Wert liefern.
|
||||
*
|
||||
* @param family die Provider-Familie; darf nicht {@code null} sein
|
||||
* @param propertyValue aktueller Property-Wert aus dem Editor (kann leer sein); darf nicht {@code null} sein
|
||||
* @return der Descriptor für die effektive Schlüsselherkunft; nie {@code null}
|
||||
*/
|
||||
EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue);
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
|
||||
/**
|
||||
* Zentraler Validierungsbaustein für den aktuellen Editorzustand des Konfigurationseditors.
|
||||
* <p>
|
||||
* Dieser Validator arbeitet ausschließlich auf den übergebenen String-Werten des
|
||||
* {@link EditorValidationInput}, ohne Dateisystemzugriffe, Datenbankroundtrips oder
|
||||
* Netzwerkkommunikation. Er erzeugt Befunde der Stufen Fehler, Warnung, Hinweis und Info.
|
||||
* <p>
|
||||
* Folgende Prüfungen sind ausdrücklich ausgeschlossen (gehören in spätere technische Gesamtprüfungen):
|
||||
* <ul>
|
||||
* <li>Pfad-Existenzprüfungen (Quellordner, Zielordner, SQLite-Datei, Prompt-Datei)</li>
|
||||
* <li>SQLite-Roundtrips</li>
|
||||
* <li>Netzwerkverbindungen (Modellabruf, API-Erreichbarkeit)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* API-Key-Vorrangregel (fachlich verbindlich):
|
||||
* <ol>
|
||||
* <li>Providerspezifische Umgebungsvariable</li>
|
||||
* <li>Bei {@code openai-compatible}: Legacy-Umgebungsvariable als Fallback</li>
|
||||
* <li>Property-Wert aus der Datei</li>
|
||||
* <li>ABSENT, wenn keine Quelle einen Wert liefert</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Warnlogik für {@code max.text.characters}:
|
||||
* <ul>
|
||||
* <li>1–1.000: unkritisch (kein Befund)</li>
|
||||
* <li>1.001–3.000: Warnung</li>
|
||||
* <li>ab 3.001: starke Warnung</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class EditorConfigurationValidator {
|
||||
|
||||
// Property-Schlüssel als Konstanten (identisch mit den .properties-Schlüsseln)
|
||||
static final String FIELD_ACTIVE_PROVIDER = "ai.provider.active";
|
||||
static final String FIELD_SOURCE_FOLDER = "source.folder";
|
||||
static final String FIELD_TARGET_FOLDER = "target.folder";
|
||||
static final String FIELD_SQLITE_FILE = "sqlite.file";
|
||||
static final String FIELD_PROMPT_FILE = "prompt.template.file";
|
||||
static final String FIELD_MAX_RETRIES = "max.retries.transient";
|
||||
static final String FIELD_MAX_PAGES = "max.pages";
|
||||
static final String FIELD_MAX_CHARS = "max.text.characters";
|
||||
|
||||
static final String FIELD_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
|
||||
static final String FIELD_CLAUDE_MODEL = "ai.provider.claude.model";
|
||||
static final String FIELD_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
|
||||
static final String FIELD_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
|
||||
|
||||
static final String FIELD_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
|
||||
static final String FIELD_OPENAI_MODEL = "ai.provider.openai-compatible.model";
|
||||
static final String FIELD_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
|
||||
static final String FIELD_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
|
||||
|
||||
private static final int MAX_CHARS_WARNING_THRESHOLD = 1_000;
|
||||
private static final int MAX_CHARS_STRONG_WARNING_THRESHOLD = 3_000;
|
||||
private static final int MAX_PAGES_HINT_THRESHOLD = 100;
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Instanz des Validators.
|
||||
* <p>
|
||||
* Dieser Validator benötigt keine Abhängigkeiten; alle Prüfungen sind rein in-memory.
|
||||
*/
|
||||
public EditorConfigurationValidator() {
|
||||
// Kein State nötig; alle Prüfungen arbeiten auf dem übergebenen Input.
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert den aktuellen Editorzustand und liefert einen Befund-Bericht.
|
||||
* <p>
|
||||
* Die Methode ist schnell (keine I/O) und darf auf dem JavaFX Application Thread
|
||||
* aufgerufen werden. Der zurückgegebene Bericht ist immutable.
|
||||
*
|
||||
* @param input der aktuelle Editorzustand; darf nicht {@code null} sein
|
||||
* @return der Validierungsbericht mit allen gefundenen Befunden; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code input} {@code null} ist
|
||||
*/
|
||||
public EditorValidationReport validate(EditorValidationInput input) {
|
||||
Objects.requireNonNull(input, "input must not be null");
|
||||
|
||||
List<EditorValidationFinding> findings = new ArrayList<>();
|
||||
|
||||
validateActiveProvider(input, findings);
|
||||
validateRequiredPaths(input, findings);
|
||||
validateNumericLimits(input, findings);
|
||||
validateActiveProviderFields(input, findings);
|
||||
|
||||
return new EditorValidationReport(findings);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aktiver Provider
|
||||
// =========================================================================
|
||||
|
||||
private void validateActiveProvider(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
String identifier = input.activeProviderIdentifier();
|
||||
if (identifier.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
|
||||
"Es muss ein aktiver Provider ausgewählt sein."));
|
||||
return;
|
||||
}
|
||||
Optional<AiProviderFamily> resolved = AiProviderFamily.fromIdentifier(identifier);
|
||||
if (resolved.isEmpty()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
|
||||
"Der angegebene Provider '" + identifier + "' ist nicht bekannt. "
|
||||
+ "Erlaubt sind: 'claude' und 'openai-compatible'."));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pflichtpfade
|
||||
// =========================================================================
|
||||
|
||||
private void validateRequiredPaths(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
if (input.sourceFolder().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_SOURCE_FOLDER,
|
||||
"Quellordner darf nicht leer sein."));
|
||||
}
|
||||
if (input.targetFolder().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_TARGET_FOLDER,
|
||||
"Zielordner darf nicht leer sein."));
|
||||
}
|
||||
if (input.sqliteFile().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_SQLITE_FILE,
|
||||
"SQLite-Datei darf nicht leer sein."));
|
||||
}
|
||||
if (input.promptTemplateFile().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_PROMPT_FILE,
|
||||
"Prompt-Datei darf nicht leer sein."));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Numerische Limits
|
||||
// =========================================================================
|
||||
|
||||
private void validateNumericLimits(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
validateMaxRetriesTransient(input.maxRetriesTransient(), findings);
|
||||
validateMaxPages(input.maxPages(), findings);
|
||||
validateMaxTextCharacters(input.maxTextCharacters(), findings);
|
||||
}
|
||||
|
||||
private void validateMaxRetriesTransient(String rawValue, List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||
"Maximale transiente Retries darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value < 1) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||
"Maximale transiente Retries muss mindestens 1 sein (aktuell: " + value + "). "
|
||||
+ "Der Wert 0 ist unzulässig."));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||
"Maximale transiente Retries muss eine ganze Zahl >= 1 sein."));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMaxPages(String rawValue, List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||
"Maximale Seitenzahl darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value <= 0) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||
"Maximale Seitenzahl muss positiv sein (aktuell: " + value + ")."));
|
||||
} else if (value > MAX_PAGES_HINT_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.hint(FIELD_MAX_PAGES,
|
||||
"Plausibilitätshinweis: Über " + MAX_PAGES_HINT_THRESHOLD
|
||||
+ " Seiten je Datei könnten die Verarbeitung verlangsamen (aktuell: " + value + ")."));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||
"Maximale Seitenzahl muss eine positive ganze Zahl sein."));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMaxTextCharacters(String rawValue, List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||
"Maximale Zeichenzahl darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value <= 0) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||
"Maximale Zeichenzahl muss positiv sein (aktuell: " + value + ")."));
|
||||
} else if (value > MAX_CHARS_STRONG_WARNING_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
|
||||
"Stark erhöhte Zeichenmenge: " + value + " Zeichen können zu riskant hohen "
|
||||
+ "API-Kosten je Verarbeitungsaufruf führen."));
|
||||
} else if (value > MAX_CHARS_WARNING_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
|
||||
"Erhöhte Zeichenmenge: " + value + " Zeichen. Beachten Sie mögliche Auswirkungen "
|
||||
+ "auf die API-Kosten je Verarbeitungsaufruf."));
|
||||
}
|
||||
// 1–1000: unkritisch, kein Befund
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||
"Maximale Zeichenzahl muss eine positive ganze Zahl sein."));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aktiver Provider – providerabhängige Felder
|
||||
// =========================================================================
|
||||
|
||||
private void validateActiveProviderFields(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
Optional<AiProviderFamily> resolvedProvider =
|
||||
AiProviderFamily.fromIdentifier(input.activeProviderIdentifier());
|
||||
if (resolvedProvider.isEmpty()) {
|
||||
// Provider unbekannt – feldspezifische Provider-Prüfungen nicht möglich
|
||||
return;
|
||||
}
|
||||
|
||||
AiProviderFamily family = resolvedProvider.get();
|
||||
switch (family) {
|
||||
case CLAUDE -> validateClaudeFields(input, findings);
|
||||
case OPENAI_COMPATIBLE -> validateOpenAiFields(input, findings);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateClaudeFields(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
// Basis-URL: leer ist Warnung (Default-URL wird verwendet)
|
||||
if (input.claudeBaseUrl().isBlank()) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_CLAUDE_BASE_URL,
|
||||
"Basis-URL nicht gesetzt – es wird die Standard-URL verwendet."));
|
||||
}
|
||||
|
||||
// Modell: Pflichtfeld
|
||||
if (input.claudeModel().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_CLAUDE_MODEL,
|
||||
"Modellname darf nicht leer sein."));
|
||||
}
|
||||
|
||||
// Timeout
|
||||
validateTimeoutField(input.claudeTimeoutSeconds(), FIELD_CLAUDE_TIMEOUT, findings);
|
||||
|
||||
// API-Key
|
||||
validateApiKeyFindings(input.claudeApiKeyDescriptor(), FIELD_CLAUDE_API_KEY, findings);
|
||||
}
|
||||
|
||||
private void validateOpenAiFields(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
// Basis-URL: leer ist Warnung
|
||||
if (input.openaiBaseUrl().isBlank()) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_OPENAI_BASE_URL,
|
||||
"Basis-URL nicht gesetzt – es wird die Standard-URL verwendet."));
|
||||
}
|
||||
|
||||
// Modell: Pflichtfeld
|
||||
if (input.openaiModel().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_OPENAI_MODEL,
|
||||
"Modellname darf nicht leer sein."));
|
||||
}
|
||||
|
||||
// Timeout
|
||||
validateTimeoutField(input.openaiTimeoutSeconds(), FIELD_OPENAI_TIMEOUT, findings);
|
||||
|
||||
// API-Key
|
||||
validateApiKeyFindings(input.openaiApiKeyDescriptor(), FIELD_OPENAI_API_KEY, findings);
|
||||
}
|
||||
|
||||
private void validateTimeoutField(String rawValue, String fieldKey,
|
||||
List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(fieldKey,
|
||||
"Timeout-Wert darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value <= 0) {
|
||||
findings.add(EditorValidationFinding.error(fieldKey,
|
||||
"Timeout-Wert muss positiv sein (aktuell: " + value + ")."));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(fieldKey,
|
||||
"Timeout-Wert muss eine positive ganze Zahl sein."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt API-Key-Befunde gemäß der Vorrangregel.
|
||||
* <p>
|
||||
* Vorrangregel:
|
||||
* <ol>
|
||||
* <li>ENV-Variable aktiv: INFO-Befund mit Variablenname</li>
|
||||
* <li>Property-Wert in Datei, keine ENV: normal (kein Befund)</li>
|
||||
* <li>Leeres Property-Feld und keine ENV: WARNING</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param descriptor Herkunft des effektiven API-Schlüssels
|
||||
* @param fieldKey Property-Schlüssel des API-Key-Felds
|
||||
* @param findings Zielliste für neue Befunde
|
||||
*/
|
||||
private void validateApiKeyFindings(EffectiveApiKeyDescriptor descriptor,
|
||||
String fieldKey,
|
||||
List<EditorValidationFinding> findings) {
|
||||
ApiKeyOrigin origin = descriptor.origin();
|
||||
|
||||
switch (origin) {
|
||||
case FROM_PROVIDER_ENV_VAR -> {
|
||||
String varName = descriptor.envVarName().orElse("unbekannte ENV-Variable");
|
||||
findings.add(EditorValidationFinding.info(fieldKey,
|
||||
"API-Schlüssel stammt aus Umgebungsvariable " + varName
|
||||
+ " (hat Vorrang vor dem Datei-Wert)."));
|
||||
}
|
||||
case FROM_LEGACY_ENV_VAR -> {
|
||||
String varName = descriptor.envVarName().orElse("unbekannte Legacy-ENV-Variable");
|
||||
findings.add(EditorValidationFinding.info(fieldKey,
|
||||
"API-Schlüssel stammt aus Legacy-Umgebungsvariable " + varName
|
||||
+ " (hat Vorrang vor dem Datei-Wert)."));
|
||||
}
|
||||
case FROM_PROPERTY_FILE -> {
|
||||
// Property-Wert vorhanden, kein ENV-Override: alles in Ordnung, kein Befund
|
||||
}
|
||||
case ABSENT -> {
|
||||
findings.add(EditorValidationFinding.warning(fieldKey,
|
||||
"Kein API-Schlüssel hinterlegt. Ohne Schlüssel kann der Provider "
|
||||
+ "nicht genutzt werden."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Ein einzelner Validierungsbefund für den aktuellen Editorzustand.
|
||||
* <p>
|
||||
* Ein Befund beschreibt eine erkannte Auffälligkeit in der Konfiguration mit einem
|
||||
* Schweregrad, einem Feldbezug und einer deutschen Beschreibung. Das {@code fieldKey}-Feld
|
||||
* verwendet denselben Property-Schlüssel wie die {@code .properties}-Datei
|
||||
* (z. B. {@code "source.folder"} oder {@code "ai.provider.openai-compatible.apiKey"}).
|
||||
* <p>
|
||||
* Ein Befund kann entweder feld-spezifisch sein (mit gesetztem {@code fieldKey}) oder
|
||||
* allgemein (mit leerem {@code fieldKey}), wenn er sich nicht auf ein einzelnes Feld bezieht.
|
||||
* <p>
|
||||
* Befunde sind immutable und enthalten keine JavaFX-Typen.
|
||||
*
|
||||
* @param fieldKey optionaler Property-Schlüssel des betroffenen Felds; leer wenn feld-unabhängig
|
||||
* @param severity Schweregrad des Befunds; nie {@code null}
|
||||
* @param message deutschsprachige Beschreibung; nie {@code null}
|
||||
*/
|
||||
public record EditorValidationFinding(
|
||||
Optional<String> fieldKey,
|
||||
EditorValidationSeverity severity,
|
||||
String message) {
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Validierungsbefund.
|
||||
*
|
||||
* @param fieldKey optionaler Property-Schlüssel; {@code null} wird zu {@link Optional#empty()}
|
||||
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Beschreibung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code severity} oder {@code message} {@code null} sind
|
||||
*/
|
||||
public EditorValidationFinding {
|
||||
Objects.requireNonNull(severity, "severity must not be null");
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
fieldKey = fieldKey == null ? Optional.empty() : fieldKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Fehler-Befund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Fehlerbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#ERROR}
|
||||
*/
|
||||
public static EditorValidationFinding error(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.ERROR, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Warn-Befund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Warnbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#WARNING}
|
||||
*/
|
||||
public static EditorValidationFinding warning(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.WARNING, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Hinweis-Befund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Hinweisbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#HINT}
|
||||
*/
|
||||
public static EditorValidationFinding hint(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.HINT, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen allgemeinen Informationsbefund ohne Feldbezug.
|
||||
*
|
||||
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
|
||||
*/
|
||||
public static EditorValidationFinding info(String message) {
|
||||
return new EditorValidationFinding(Optional.empty(), EditorValidationSeverity.INFO, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Informationsbefund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
|
||||
*/
|
||||
public static EditorValidationFinding info(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.INFO, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob dieser Befund feld-spezifisch ist.
|
||||
*
|
||||
* @return {@code true} wenn ein {@code fieldKey} gesetzt ist
|
||||
*/
|
||||
public boolean hasFieldKey() {
|
||||
return fieldKey.isPresent();
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
|
||||
/**
|
||||
* Eingabedaten für den {@link EditorConfigurationValidator}.
|
||||
* <p>
|
||||
* Enthält den gesamten aktuellen Editorzustand als String-Werte, so wie sie im Editor
|
||||
* vorliegen – ohne Parsing, Typumwandlung oder Existenzprüfungen. Der Validator bewertet
|
||||
* ausschließlich diese Werte.
|
||||
* <p>
|
||||
* Pfad-Existenzprüfungen und Roundtrips (SQLite, Prompt-Datei, Netzwerk) sind explizit
|
||||
* ausgeschlossen und gehören in spätere technische Gesamtprüfungen.
|
||||
* <p>
|
||||
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
|
||||
*
|
||||
* @param activeProviderIdentifier Rohtextwert von {@code ai.provider.active}
|
||||
* @param sourceFolder Rohtextwert von {@code source.folder}
|
||||
* @param targetFolder Rohtextwert von {@code target.folder}
|
||||
* @param sqliteFile Rohtextwert von {@code sqlite.file}
|
||||
* @param promptTemplateFile Rohtextwert von {@code prompt.template.file}
|
||||
* @param maxRetriesTransient Rohtextwert von {@code max.retries.transient}
|
||||
* @param maxPages Rohtextwert von {@code max.pages}
|
||||
* @param maxTextCharacters Rohtextwert von {@code max.text.characters}
|
||||
* @param claudeBaseUrl Rohtextwert der Claude-Basis-URL
|
||||
* @param claudeModel Rohtextwert des Claude-Modellnamens
|
||||
* @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts
|
||||
* @param claudeApiKeyDescriptor API-Key-Herkunft für den Claude-Provider; nie {@code null}
|
||||
* @param openaiBaseUrl Rohtextwert der OpenAI-kompatiblen Basis-URL
|
||||
* @param openaiModel Rohtextwert des OpenAI-kompatiblen Modellnamens
|
||||
* @param openaiTimeoutSeconds Rohtextwert des OpenAI-kompatiblen Timeouts
|
||||
* @param openaiApiKeyDescriptor API-Key-Herkunft für den OpenAI-kompatiblen Provider; nie {@code null}
|
||||
*/
|
||||
public record EditorValidationInput(
|
||||
String activeProviderIdentifier,
|
||||
String sourceFolder,
|
||||
String targetFolder,
|
||||
String sqliteFile,
|
||||
String promptTemplateFile,
|
||||
String maxRetriesTransient,
|
||||
String maxPages,
|
||||
String maxTextCharacters,
|
||||
String claudeBaseUrl,
|
||||
String claudeModel,
|
||||
String claudeTimeoutSeconds,
|
||||
EffectiveApiKeyDescriptor claudeApiKeyDescriptor,
|
||||
String openaiBaseUrl,
|
||||
String openaiModel,
|
||||
String openaiTimeoutSeconds,
|
||||
EffectiveApiKeyDescriptor openaiApiKeyDescriptor) {
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Eingabe für den Validator.
|
||||
*
|
||||
* @param activeProviderIdentifier aktiver Provider-Bezeichner; {@code null} wird zu leerem String
|
||||
* @param sourceFolder Quellordner-Pfad; {@code null} wird zu leerem String
|
||||
* @param targetFolder Zielordner-Pfad; {@code null} wird zu leerem String
|
||||
* @param sqliteFile SQLite-Dateipfad; {@code null} wird zu leerem String
|
||||
* @param promptTemplateFile Prompt-Dateipfad; {@code null} wird zu leerem String
|
||||
* @param maxRetriesTransient max. transiente Retries; {@code null} wird zu leerem String
|
||||
* @param maxPages max. Seitenzahl; {@code null} wird zu leerem String
|
||||
* @param maxTextCharacters max. Zeichenzahl; {@code null} wird zu leerem String
|
||||
* @param claudeBaseUrl Claude-Basis-URL; {@code null} wird zu leerem String
|
||||
* @param claudeModel Claude-Modellname; {@code null} wird zu leerem String
|
||||
* @param claudeTimeoutSeconds Claude-Timeout; {@code null} wird zu leerem String
|
||||
* @param claudeApiKeyDescriptor Claude-API-Key-Herkunft; darf nicht {@code null} sein
|
||||
* @param openaiBaseUrl OpenAI-Basis-URL; {@code null} wird zu leerem String
|
||||
* @param openaiModel OpenAI-Modellname; {@code null} wird zu leerem String
|
||||
* @param openaiTimeoutSeconds OpenAI-Timeout; {@code null} wird zu leerem String
|
||||
* @param openaiApiKeyDescriptor OpenAI-API-Key-Herkunft; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code claudeApiKeyDescriptor} oder {@code openaiApiKeyDescriptor} {@code null} sind
|
||||
*/
|
||||
public EditorValidationInput {
|
||||
activeProviderIdentifier = normalizeText(activeProviderIdentifier);
|
||||
sourceFolder = normalizeText(sourceFolder);
|
||||
targetFolder = normalizeText(targetFolder);
|
||||
sqliteFile = normalizeText(sqliteFile);
|
||||
promptTemplateFile = normalizeText(promptTemplateFile);
|
||||
maxRetriesTransient = normalizeText(maxRetriesTransient);
|
||||
maxPages = normalizeText(maxPages);
|
||||
maxTextCharacters = normalizeText(maxTextCharacters);
|
||||
claudeBaseUrl = normalizeText(claudeBaseUrl);
|
||||
claudeModel = normalizeText(claudeModel);
|
||||
claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds);
|
||||
claudeApiKeyDescriptor = Objects.requireNonNull(claudeApiKeyDescriptor,
|
||||
"claudeApiKeyDescriptor must not be null");
|
||||
openaiBaseUrl = normalizeText(openaiBaseUrl);
|
||||
openaiModel = normalizeText(openaiModel);
|
||||
openaiTimeoutSeconds = normalizeText(openaiTimeoutSeconds);
|
||||
openaiApiKeyDescriptor = Objects.requireNonNull(openaiApiKeyDescriptor,
|
||||
"openaiApiKeyDescriptor must not be null");
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis einer editornahen Konfigurationsvalidierung.
|
||||
* <p>
|
||||
* Enthält alle Befunde, die der {@link EditorConfigurationValidator} aus dem aktuellen
|
||||
* Editorzustand abgeleitet hat. Das Ergebnis ist immutable.
|
||||
* <p>
|
||||
* Befunde können über {@link #findings()} als vollständige Liste abgerufen werden.
|
||||
* Feldbezogene Befunde lassen sich über {@link EditorValidationFinding#hasFieldKey()} filtern.
|
||||
*
|
||||
* @param findings alle Validierungsbefunde; nie {@code null}
|
||||
*/
|
||||
public record EditorValidationReport(List<EditorValidationFinding> findings) {
|
||||
|
||||
/**
|
||||
* Erstellt ein Validierungsergebnis.
|
||||
*
|
||||
* @param findings Befundliste; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code findings} {@code null} ist
|
||||
*/
|
||||
public EditorValidationReport {
|
||||
Objects.requireNonNull(findings, "findings must not be null");
|
||||
findings = List.copyOf(findings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein leeres Ergebnis ohne Befunde.
|
||||
*
|
||||
* @return ein leeres Ergebnis; nie {@code null}
|
||||
*/
|
||||
public static EditorValidationReport empty() {
|
||||
return new EditorValidationReport(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob mindestens ein Befund mit Schweregrad {@link EditorValidationSeverity#ERROR} vorhanden ist.
|
||||
* <p>
|
||||
* Wenn {@code true}, gilt die Konfiguration als nicht lauffähig. Das Speichern ist jedoch
|
||||
* trotzdem erlaubt.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein Fehler-Befund vorliegt
|
||||
*/
|
||||
public boolean hasErrors() {
|
||||
return findings.stream().anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Befunde zurück, die sich auf das angegebene Feld beziehen.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des gesuchten Felds; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der feldbezogenen Befunde; nie {@code null}
|
||||
*/
|
||||
public List<EditorValidationFinding> findingsFor(String fieldKey) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return findings.stream()
|
||||
.filter(f -> f.fieldKey().isPresent() && f.fieldKey().get().equals(fieldKey))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
/**
|
||||
* Schweregrade für Befunde der editornahen Konfigurationsvalidierung.
|
||||
* <p>
|
||||
* Die Reihenfolge entspricht aufsteigender Kritikalität:
|
||||
* <ol>
|
||||
* <li>{@link #INFO} – neutraler Informationshinweis, keine Handlung erforderlich</li>
|
||||
* <li>{@link #HINT} – nützlicher Hinweis, den der Benutzer berücksichtigen sollte</li>
|
||||
* <li>{@link #WARNING} – riskante, aber formal zulässige Einstellung</li>
|
||||
* <li>{@link #ERROR} – ungültige oder fehlende Pflichtangabe, Konfiguration nicht lauffähig</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Warnungen und Hinweise verhindern das Speichern nicht. Fehler markieren den Stand als
|
||||
* nicht lauffähig, erlauben aber ebenfalls das Speichern.
|
||||
*/
|
||||
public enum EditorValidationSeverity {
|
||||
|
||||
/** Neutraler Informationshinweis. */
|
||||
INFO,
|
||||
|
||||
/** Nützlicher Hinweis, den der Benutzer beachten sollte. */
|
||||
HINT,
|
||||
|
||||
/** Riskante, aber formal zulässige Einstellung. */
|
||||
WARNING,
|
||||
|
||||
/** Ungültige oder fehlende Pflichtangabe – Konfiguration ist nicht lauffähig. */
|
||||
ERROR
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Editornahe Validierungskomponenten für den Konfigurationseditor.
|
||||
* <p>
|
||||
* Dieses Package enthält den zentralen {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator},
|
||||
* der den aktuellen Editorzustand gegen fachliche und technische Regeln prüft und
|
||||
* Befunde der Stufen Fehler, Warnung, Hinweis und Info erzeugt.
|
||||
* <p>
|
||||
* Die Komponenten sind infrastrukturneutral: Sie kennen keine JavaFX-Typen, keine
|
||||
* Dateisystempfade, keine Datenbankzugriffe und keine HTTP-Kommunikation. Alle Prüfungen
|
||||
* arbeiten ausschließlich auf den übergebenen String-Werten des aktuellen Editorzustands.
|
||||
* <p>
|
||||
* Die Ergebnistypen ({@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding}
|
||||
* und {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport}) sind immutable
|
||||
* Records und enthalten keine Infrastrukturabhängigkeiten.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link ApiKeyOrigin}.
|
||||
*/
|
||||
class ApiKeyOriginTest {
|
||||
|
||||
@Test
|
||||
void allValuesPresent() {
|
||||
assertThat(ApiKeyOrigin.values()).containsExactlyInAnyOrder(
|
||||
ApiKeyOrigin.FROM_PROVIDER_ENV_VAR,
|
||||
ApiKeyOrigin.FROM_LEGACY_ENV_VAR,
|
||||
ApiKeyOrigin.FROM_PROPERTY_FILE,
|
||||
ApiKeyOrigin.ABSENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enumLookupByName() {
|
||||
assertThat(ApiKeyOrigin.valueOf("FROM_PROVIDER_ENV_VAR")).isEqualTo(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR);
|
||||
assertThat(ApiKeyOrigin.valueOf("FROM_LEGACY_ENV_VAR")).isEqualTo(ApiKeyOrigin.FROM_LEGACY_ENV_VAR);
|
||||
assertThat(ApiKeyOrigin.valueOf("FROM_PROPERTY_FILE")).isEqualTo(ApiKeyOrigin.FROM_PROPERTY_FILE);
|
||||
assertThat(ApiKeyOrigin.valueOf("ABSENT")).isEqualTo(ApiKeyOrigin.ABSENT);
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests for {@link EffectiveApiKeyDescriptor}.
|
||||
*/
|
||||
class EffectiveApiKeyDescriptorTest {
|
||||
|
||||
@Test
|
||||
void fromProviderEnvVar_setsOriginAndVarName() {
|
||||
var descriptor = EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY");
|
||||
|
||||
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR);
|
||||
assertThat(descriptor.envVarName()).contains("ANTHROPIC_API_KEY");
|
||||
assertThat(descriptor.isFromEnvironmentVariable()).isTrue();
|
||||
assertThat(descriptor.isAbsent()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromLegacyEnvVar_setsOriginAndVarName() {
|
||||
var descriptor = EffectiveApiKeyDescriptor.fromLegacyEnvVar("OPENAI_API_KEY");
|
||||
|
||||
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_LEGACY_ENV_VAR);
|
||||
assertThat(descriptor.envVarName()).contains("OPENAI_API_KEY");
|
||||
assertThat(descriptor.isFromEnvironmentVariable()).isTrue();
|
||||
assertThat(descriptor.isAbsent()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromPropertyFile_setsOriginAndEmptyVarName() {
|
||||
var descriptor = EffectiveApiKeyDescriptor.fromPropertyFile();
|
||||
|
||||
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_PROPERTY_FILE);
|
||||
assertThat(descriptor.envVarName()).isEmpty();
|
||||
assertThat(descriptor.isFromEnvironmentVariable()).isFalse();
|
||||
assertThat(descriptor.isAbsent()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void absent_setsAbsentOriginAndEmptyVarName() {
|
||||
var descriptor = EffectiveApiKeyDescriptor.absent();
|
||||
|
||||
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.ABSENT);
|
||||
assertThat(descriptor.envVarName()).isEmpty();
|
||||
assertThat(descriptor.isFromEnvironmentVariable()).isFalse();
|
||||
assertThat(descriptor.isAbsent()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullEnvVarNameBecomesEmpty() {
|
||||
var descriptor = new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROPERTY_FILE, null);
|
||||
assertThat(descriptor.envVarName()).isEqualTo(Optional.empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullOrigin() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new EffectiveApiKeyDescriptor(null, Optional.empty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromProviderEnvVar_rejectsNullName() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> EffectiveApiKeyDescriptor.fromProviderEnvVar(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromLegacyEnvVar_rejectsNullName() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> EffectiveApiKeyDescriptor.fromLegacyEnvVar(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equality_basedOnAllFields() {
|
||||
var a = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_A");
|
||||
var b = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_A");
|
||||
var c = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_B");
|
||||
|
||||
assertThat(a).isEqualTo(b);
|
||||
assertThat(a).isNotEqualTo(c);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests for {@link ModelCatalogRequest}.
|
||||
*/
|
||||
class ModelCatalogRequestTest {
|
||||
|
||||
@Test
|
||||
void storesAllProvidedValues() {
|
||||
var request = new ModelCatalogRequest(
|
||||
"claude",
|
||||
Optional.of("https://api.anthropic.com"),
|
||||
Optional.of("test-key"),
|
||||
30);
|
||||
|
||||
assertThat(request.providerIdentifier()).isEqualTo("claude");
|
||||
assertThat(request.baseUrl()).contains("https://api.anthropic.com");
|
||||
assertThat(request.apiKey()).contains("test-key");
|
||||
assertThat(request.timeoutSeconds()).isEqualTo(30);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullBaseUrlBecomesEmpty() {
|
||||
var request = new ModelCatalogRequest("claude", null, Optional.of("key"), 10);
|
||||
assertThat(request.baseUrl()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullApiKeyBecomesEmpty() {
|
||||
var request = new ModelCatalogRequest("claude", Optional.empty(), null, 10);
|
||||
assertThat(request.apiKey()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullProviderIdentifier() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogRequest(null, Optional.empty(), Optional.empty(), 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsZeroTimeout() {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> new ModelCatalogRequest("claude", Optional.empty(), Optional.empty(), 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNegativeTimeout() {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> new ModelCatalogRequest("claude", Optional.empty(), Optional.empty(), -5));
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
|
||||
/**
|
||||
* Tests for {@link ModelCatalogResult} and its permitted sub-types.
|
||||
*/
|
||||
class ModelCatalogResultTest {
|
||||
|
||||
// ── Success ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void success_storesAllFields() {
|
||||
var now = Instant.now();
|
||||
var result = new ModelCatalogResult.Success("claude", List.of("claude-3-5-sonnet", "claude-3-opus"), now);
|
||||
|
||||
assertThat(result.providerIdentifier()).isEqualTo("claude");
|
||||
assertThat(result.models()).containsExactly("claude-3-5-sonnet", "claude-3-opus");
|
||||
assertThat(result.loadedAt()).isEqualTo(now);
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_modelListIsDefensiveCopy() {
|
||||
var mutable = new java.util.ArrayList<>(List.of("model-a"));
|
||||
var result = new ModelCatalogResult.Success("claude", mutable, Instant.now());
|
||||
mutable.add("model-b");
|
||||
|
||||
assertThat(result.models()).containsExactly("model-a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_rejectsNullProviderIdentifier() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.Success(null, List.of("m"), Instant.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_rejectsNullModelList() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.Success("claude", null, Instant.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_rejectsEmptyModelList() {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.Success("claude", List.of(), Instant.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_rejectsNullTimestamp() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.Success("claude", List.of("m"), null));
|
||||
}
|
||||
|
||||
// ── EmptyList ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void emptyList_storesAllFields() {
|
||||
var now = Instant.now();
|
||||
var result = new ModelCatalogResult.EmptyList("openai-compatible", now);
|
||||
|
||||
assertThat(result.providerIdentifier()).isEqualTo("openai-compatible");
|
||||
assertThat(result.loadedAt()).isEqualTo(now);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyList_rejectsNullProviderIdentifier() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.EmptyList(null, Instant.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyList_rejectsNullTimestamp() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.EmptyList("claude", null));
|
||||
}
|
||||
|
||||
// ── IncompleteConfiguration ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void incompleteConfiguration_storesAllFields() {
|
||||
var result = new ModelCatalogResult.IncompleteConfiguration("claude", "API-Schluessel fehlt");
|
||||
|
||||
assertThat(result.providerIdentifier()).isEqualTo("claude");
|
||||
assertThat(result.missingReason()).isEqualTo("API-Schluessel fehlt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteConfiguration_rejectsNullProviderIdentifier() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.IncompleteConfiguration(null, "reason"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteConfiguration_rejectsNullMissingReason() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.IncompleteConfiguration("claude", null));
|
||||
}
|
||||
|
||||
// ── TechnicalFailure ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void technicalFailure_storesAllFields() {
|
||||
var result = new ModelCatalogResult.TechnicalFailure("openai-compatible", "HTTP_ERROR", "503 Service Unavailable");
|
||||
|
||||
assertThat(result.providerIdentifier()).isEqualTo("openai-compatible");
|
||||
assertThat(result.errorCategory()).isEqualTo("HTTP_ERROR");
|
||||
assertThat(result.errorDetail()).isEqualTo("503 Service Unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void technicalFailure_rejectsNullProviderIdentifier() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure(null, "cat", "detail"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void technicalFailure_rejectsNullErrorCategory() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure("claude", null, "detail"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void technicalFailure_rejectsNullErrorDetail() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure("claude", "cat", null));
|
||||
}
|
||||
|
||||
// ── Pattern-matching exhaustiveness ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void patternMatchingSwitchCoversAllPermittedSubtypes() {
|
||||
ModelCatalogResult success = new ModelCatalogResult.Success("claude", List.of("m"), Instant.now());
|
||||
ModelCatalogResult empty = new ModelCatalogResult.EmptyList("claude", Instant.now());
|
||||
ModelCatalogResult incomplete = new ModelCatalogResult.IncompleteConfiguration("claude", "reason");
|
||||
ModelCatalogResult failure = new ModelCatalogResult.TechnicalFailure("claude", "TIMEOUT", "detail");
|
||||
|
||||
assertThat(classifyResult(success)).isEqualTo("success");
|
||||
assertThat(classifyResult(empty)).isEqualTo("empty");
|
||||
assertThat(classifyResult(incomplete)).isEqualTo("incomplete");
|
||||
assertThat(classifyResult(failure)).isEqualTo("failure");
|
||||
}
|
||||
|
||||
private String classifyResult(ModelCatalogResult result) {
|
||||
return switch (result) {
|
||||
case ModelCatalogResult.Success s -> "success";
|
||||
case ModelCatalogResult.EmptyList e -> "empty";
|
||||
case ModelCatalogResult.IncompleteConfiguration i -> "incomplete";
|
||||
case ModelCatalogResult.TechnicalFailure t -> "failure";
|
||||
};
|
||||
}
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
|
||||
/**
|
||||
* Unit-Tests für den {@link EditorConfigurationValidator}.
|
||||
* <p>
|
||||
* Prüft alle Validierungsregeln auf Basis synthetischer Eingaben ohne I/O.
|
||||
*/
|
||||
class EditorConfigurationValidatorTest {
|
||||
|
||||
private EditorConfigurationValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = new EditorConfigurationValidator();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hilfsmethode: minimale gültige Eingabe
|
||||
// =========================================================================
|
||||
|
||||
private static EditorValidationInput minimalValidInput() {
|
||||
return new EditorValidationInput(
|
||||
"claude", // activeProviderIdentifier
|
||||
"C:/source", // sourceFolder
|
||||
"C:/target", // targetFolder
|
||||
"C:/db.sqlite", // sqliteFile
|
||||
"C:/prompt.txt", // promptTemplateFile
|
||||
"3", // maxRetriesTransient
|
||||
"10", // maxPages
|
||||
"500", // maxTextCharacters
|
||||
"https://api.anthropic.com", // claudeBaseUrl
|
||||
"claude-3-5-sonnet", // claudeModel
|
||||
"30", // claudeTimeoutSeconds
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(), // claudeApiKeyDescriptor
|
||||
"https://api.openai.com", // openaiBaseUrl
|
||||
"gpt-4", // openaiModel
|
||||
"30", // openaiTimeoutSeconds
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile() // openaiApiKeyDescriptor
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Leere Eingabe / Null-Checks
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_emptyActiveProvider_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.hasErrors()).isTrue();
|
||||
assertThat(report.findings()).anyMatch(f ->
|
||||
f.fieldKey().isPresent()
|
||||
&& f.fieldKey().get().equals(EditorConfigurationValidator.FIELD_ACTIVE_PROVIDER)
|
||||
&& f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_unknownActiveProvider_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"unknown-provider", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.hasErrors()).isTrue();
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_ACTIVE_PROVIDER))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pflichtpfade
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_emptySourceFolder_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_SOURCE_FOLDER))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_emptyTargetFolder_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_TARGET_FOLDER))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_emptySqliteFile_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_SQLITE_FILE))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_emptyPromptFile_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_PROMPT_FILE))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// max.retries.transient
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_maxRetriesTransient_zero_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"0", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxRetriesTransient_negative_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"-1", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxRetriesTransient_one_producesNoError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"1", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
List<EditorValidationFinding> retryFindings =
|
||||
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES);
|
||||
assertThat(retryFindings).noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxRetriesTransient_nonNumeric_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"abc", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// max.pages
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_maxPages_zero_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "0", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxPages_over100_producesHint() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "101", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.HINT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxPages_exactly100_producesNoHintAndNoError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "100", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
List<EditorValidationFinding> pageFindings =
|
||||
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES);
|
||||
assertThat(pageFindings).noneMatch(f ->
|
||||
f.severity() == EditorValidationSeverity.ERROR
|
||||
|| f.severity() == EditorValidationSeverity.HINT);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// max.text.characters – Wirtschaftliche Warnlogik
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_maxTextCharacters_1000_producesNoFinding() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "1000",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxTextCharacters_1001_producesWarning() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "1001",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxTextCharacters_3000_producesWarning() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "3000",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxTextCharacters_3001_producesStrongWarning() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "3001",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
List<EditorValidationFinding> charFindings =
|
||||
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS);
|
||||
assertThat(charFindings).anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||
// Starke Warnung enthält "stark" oder "riskant"
|
||||
assertThat(charFindings).anyMatch(f ->
|
||||
f.message().toLowerCase().contains("stark")
|
||||
|| f.message().toLowerCase().contains("riskant"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_maxTextCharacters_zero_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "0",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Provider-Felder: Claude
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_claude_emptyModel_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_MODEL))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_claude_emptyBaseUrl_producesWarning() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_BASE_URL))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_claude_negativeTimeout_producesError() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "-5",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_TIMEOUT))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// API-Key-Vorrangregel
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_claude_absent_apiKey_producesWarning() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.absent(),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_claude_fromPropertyFile_producesNoFinding() {
|
||||
EditorValidationInput input = minimalValidInput();
|
||||
// minimalValidInput() already uses fromPropertyFile() for Claude
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_claude_fromEnvVar_producesInfoFinding() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY"),
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.INFO
|
||||
&& f.message().contains("ANTHROPIC_API_KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_openai_fromLegacyEnvVar_producesInfoFinding() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY"));
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_API_KEY))
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.INFO
|
||||
&& f.message().contains("PDF_UMBENENNER_API_KEY"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Vollständig gültige Konfiguration
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_fullyValidClaudeConfig_producesNoErrors() {
|
||||
EditorValidationInput input = minimalValidInput();
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_fullyValidOpenAiConfig_producesNoErrors() {
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
assertThat(report.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Inaktiver Provider – keine Fehler für inaktiven Block
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void validate_claude_active_openaiBlockEmpty_producesNoOpenaiErrors() {
|
||||
// Claude ist aktiv; OpenAI-Felder sind leer – darf keinen FEHLER für OpenAI-Felder geben
|
||||
EditorValidationInput input = new EditorValidationInput(
|
||||
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||
"", "", "", EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
EditorValidationReport report = validator.validate(input);
|
||||
|
||||
// OpenAI-Felder dürfen keinen Fehler produzieren (Provider nicht aktiv)
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_MODEL))
|
||||
.noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_TIMEOUT))
|
||||
.noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user