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

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

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

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