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
@@ -20,7 +20,11 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.ClaudeModelCatalogAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.OpenAiCompatibleModelCatalogAdapter;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
@@ -616,13 +620,18 @@ public class BootstrapRunner {
private GuiStartupContext buildGuiStartupContext(Optional<String> configPathOverride) {
GuiConfigurationFileLoader loader = this::loadGuiConfigurationState;
GuiConfigurationFileWriter writer = new GuiConfigurationPropertiesWriter();
AiModelCatalogPort modelCatalogPort = buildModelCatalogDispatcher();
de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort apiKeyResolutionPort =
new de.gecheckt.pdf.umbenenner.adapter.out.validation.EnvironmentApiKeyResolutionAdapter();
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
loader,
writer);
writer,
modelCatalogPort,
apiKeyResolutionPort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -634,13 +643,16 @@ public class BootstrapRunner {
Optional.of("Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
+ "\nDie GUI startet ohne Konfigurationsdatei."),
loader,
writer);
writer,
modelCatalogPort,
apiKeyResolutionPort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
try {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -648,10 +660,29 @@ public class BootstrapRunner {
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.of("Konfiguration konnte nicht geladen werden: " + e.getMessage()),
loader,
writer);
writer,
modelCatalogPort,
apiKeyResolutionPort);
}
}
/**
* Creates the {@link AiModelCatalogPort} dispatcher for use in the GUI startup context.
* <p>
* Instantiates one stateless concrete adapter per supported provider family and wires
* them into an {@link AiModelCatalogDispatcher}. The adapters are stateless: all
* provider-specific values (API key, base URL, timeout) are supplied per call via the
* {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest},
* so no provider configuration needs to be resolved at this point.
*
* @return a ready-to-use model catalogue port; never {@code null}
*/
private AiModelCatalogPort buildModelCatalogDispatcher() {
return new AiModelCatalogDispatcher(
new ClaudeModelCatalogAdapter(),
new OpenAiCompatibleModelCatalogAdapter());
}
private GuiConfigurationEditorState loadGuiConfigurationState(Path configFilePath) {
try {
boolean legacyDetected = detectedLegacyConfiguration(configFilePath);
@@ -0,0 +1,91 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Composition-root dispatcher that routes {@link AiModelCatalogPort#fetchAvailableModels}
* calls to the concrete adapter matching the provider identifier carried in the request.
* <p>
* This class is not a new architectural layer between the port contract and its adapters.
* It is a Bootstrap-only wiring convenience that allows the GUI to call a single
* {@link AiModelCatalogPort} instance regardless of which provider the user has currently
* selected. The concrete adapters remain independent of each other and of this class.
* <p>
* <strong>Dispatching rule:</strong> The value of
* {@link ModelCatalogRequest#providerIdentifier()} is matched case-sensitively against
* the known provider identifiers:
* <ul>
* <li>{@code "claude"} — routed to the Claude adapter</li>
* <li>{@code "openai-compatible"} — routed to the OpenAI-compatible adapter</li>
* <li>any other value — returns {@link ModelCatalogResult.IncompleteConfiguration}
* with a human-readable explanation</li>
* </ul>
* <p>
* <strong>Thread safety:</strong> The dispatcher holds only immutable references to
* the two concrete adapters and is trivially thread-safe.
*/
public class AiModelCatalogDispatcher implements AiModelCatalogPort {
private static final Logger LOG = LogManager.getLogger(AiModelCatalogDispatcher.class);
private static final String PROVIDER_CLAUDE = "claude";
private static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
private final AiModelCatalogPort claudeAdapter;
private final AiModelCatalogPort openAiCompatibleAdapter;
/**
* Creates a dispatcher wired with both supported adapter implementations.
*
* @param claudeAdapter the adapter for the Claude provider family;
* must not be {@code null}
* @param openAiCompatibleAdapter the adapter for the OpenAI-compatible provider family;
* must not be {@code null}
* @throws NullPointerException if either adapter is {@code null}
*/
public AiModelCatalogDispatcher(AiModelCatalogPort claudeAdapter,
AiModelCatalogPort openAiCompatibleAdapter) {
this.claudeAdapter = Objects.requireNonNull(claudeAdapter,
"claudeAdapter must not be null");
this.openAiCompatibleAdapter = Objects.requireNonNull(openAiCompatibleAdapter,
"openAiCompatibleAdapter must not be null");
}
/**
* Routes the model catalogue request to the concrete adapter for the provider
* identified by {@link ModelCatalogRequest#providerIdentifier()}.
* <p>
* If the provider identifier does not match any known provider, returns
* {@link ModelCatalogResult.IncompleteConfiguration} without calling any adapter.
* This keeps the GUI informed without any crash or exception.
*
* @param request all values needed to contact the provider; must not be {@code null}
* @return the result from the matched adapter, or {@link ModelCatalogResult.IncompleteConfiguration}
* when the provider identifier is unknown; never {@code null}
* @throws NullPointerException if {@code request} is {@code null}
*/
@Override
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
Objects.requireNonNull(request, "request must not be null");
String provider = request.providerIdentifier();
LOG.debug("Model catalogue dispatch: routing request for provider '{}'", provider);
return switch (provider) {
case PROVIDER_CLAUDE -> claudeAdapter.fetchAvailableModels(request);
case PROVIDER_OPENAI_COMPATIBLE -> openAiCompatibleAdapter.fetchAvailableModels(request);
default -> {
LOG.warn("Model catalogue dispatch: unknown provider identifier '{}'", provider);
yield new ModelCatalogResult.IncompleteConfiguration(provider,
"Unbekannter Provider: '" + provider + "'");
}
};
}
}
@@ -0,0 +1,145 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Unit tests for {@link AiModelCatalogDispatcher}.
* <p>
* Verifies that the dispatcher correctly routes requests to the matching concrete
* adapter based on the provider identifier in the request, and that unknown providers
* are handled as {@link ModelCatalogResult.IncompleteConfiguration} without calling
* either adapter.
*/
@ExtendWith(MockitoExtension.class)
class AiModelCatalogDispatcherTest {
@Mock
private AiModelCatalogPort claudeAdapter;
@Mock
private AiModelCatalogPort openAiCompatibleAdapter;
private AiModelCatalogDispatcher dispatcher;
@BeforeEach
void setUp() {
dispatcher = new AiModelCatalogDispatcher(claudeAdapter, openAiCompatibleAdapter);
}
@Test
@DisplayName("Request with provider 'claude' is routed to the Claude adapter")
void dispatch_claudeProvider_routesToClaudeAdapter() {
ModelCatalogRequest request = new ModelCatalogRequest(
"claude", Optional.empty(), Optional.of("key"), 5);
ModelCatalogResult expected = new ModelCatalogResult.Success(
"claude", List.of("claude-3-opus"), Instant.now());
when(claudeAdapter.fetchAvailableModels(request)).thenReturn(expected);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(expected);
verify(claudeAdapter).fetchAvailableModels(request);
verifyNoInteractions(openAiCompatibleAdapter);
}
@Test
@DisplayName("Request with provider 'openai-compatible' is routed to the OpenAI-compatible adapter")
void dispatch_openAiCompatibleProvider_routesToOpenAiAdapter() {
ModelCatalogRequest request = new ModelCatalogRequest(
"openai-compatible", Optional.empty(), Optional.of("key"), 5);
ModelCatalogResult expected = new ModelCatalogResult.Success(
"openai-compatible", List.of("gpt-4o"), Instant.now());
when(openAiCompatibleAdapter.fetchAvailableModels(request)).thenReturn(expected);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(expected);
verify(openAiCompatibleAdapter).fetchAvailableModels(request);
verifyNoInteractions(claudeAdapter);
}
@Test
@DisplayName("Unknown provider identifier returns IncompleteConfiguration without calling any adapter")
void dispatch_unknownProvider_returnsIncompleteConfigurationWithoutCallingAdapters() {
ModelCatalogRequest request = new ModelCatalogRequest(
"unknown-provider", Optional.empty(), Optional.of("key"), 5);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
ModelCatalogResult.IncompleteConfiguration incomplete =
(ModelCatalogResult.IncompleteConfiguration) result;
assertThat(incomplete.providerIdentifier()).isEqualTo("unknown-provider");
assertThat(incomplete.missingReason()).containsIgnoringCase("Unbekannter Provider");
verifyNoInteractions(claudeAdapter);
verifyNoInteractions(openAiCompatibleAdapter);
}
@Test
@DisplayName("Null request throws NullPointerException")
void dispatch_nullRequest_throwsNullPointerException() {
assertThatNullPointerException()
.isThrownBy(() -> dispatcher.fetchAvailableModels(null));
}
@Test
@DisplayName("Null Claude adapter throws NullPointerException at construction")
void constructor_nullClaudeAdapter_throwsNullPointerException() {
assertThatNullPointerException()
.isThrownBy(() -> new AiModelCatalogDispatcher(null, openAiCompatibleAdapter));
}
@Test
@DisplayName("Null OpenAI-compatible adapter throws NullPointerException at construction")
void constructor_nullOpenAiAdapter_throwsNullPointerException() {
assertThatNullPointerException()
.isThrownBy(() -> new AiModelCatalogDispatcher(claudeAdapter, null));
}
@Test
@DisplayName("Dispatcher propagates adapter result unchanged for claude")
void dispatch_claudeAdapter_propagatesResultUnchanged() {
ModelCatalogRequest request = new ModelCatalogRequest(
"claude", Optional.empty(), Optional.of("k"), 10);
ModelCatalogResult.TechnicalFailure failure = new ModelCatalogResult.TechnicalFailure(
"claude", "AUTHENTICATION_FAILED", "Bad credentials");
when(claudeAdapter.fetchAvailableModels(request)).thenReturn(failure);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(failure);
}
@Test
@DisplayName("Dispatcher propagates adapter result unchanged for openai-compatible")
void dispatch_openAiCompatibleAdapter_propagatesResultUnchanged() {
ModelCatalogRequest request = new ModelCatalogRequest(
"openai-compatible", Optional.empty(), Optional.of("k"), 10);
ModelCatalogResult.EmptyList emptyList = new ModelCatalogResult.EmptyList(
"openai-compatible", Instant.now());
when(openAiCompatibleAdapter.fetchAvailableModels(request)).thenReturn(emptyList);
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
assertThat(result).isSameAs(emptyList);
}
}