diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
index 0d9f372..9e51f3b 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
@@ -48,12 +48,16 @@ import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
+import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
+import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Separator;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
@@ -328,7 +332,7 @@ public final class GuiConfigurationEditorWorkspace {
this.configurationFileLoader = effectiveContext.configurationFileLoader();
this.configurationFileWriter = effectiveContext.configurationFileWriter();
this.editorState = effectiveContext.initialState();
- this.welcomeGuidanceVisible = editorState.isNewConfiguration();
+ this.welcomeGuidanceVisible = false;
this.apiKeyResolutionPort = effectiveContext.apiKeyResolutionPort();
this.modelCatalogCoordinator = new GuiModelCatalogCoordinator(
@@ -1587,10 +1591,12 @@ public final class GuiConfigurationEditorWorkspace {
claudeState.model(),
claudeState.timeoutSeconds(),
claudeKeyDescriptor,
+ claudeState.apiKey().propertyValue(),
openaiState.baseUrl(),
openaiState.model(),
openaiState.timeoutSeconds(),
- openaiKeyDescriptor);
+ openaiKeyDescriptor,
+ openaiState.apiKey().propertyValue());
}
/**
@@ -1644,11 +1650,10 @@ public final class GuiConfigurationEditorWorkspace {
* writing anything to disk or making any network or filesystem calls. It is therefore
* safe to call on the FX Application Thread at any time.
*
- * In addition to updating the findings shown by the automatic validation, this action
- * appends a dedicated INFO message to the central message area to confirm to the user
- * that the action was explicitly executed and to report the number of findings found.
- * The message uses a distinct source tag so that it can be replaced on subsequent
- * executions without removing messages from other sources.
+ * In addition to updating the field-level findings shown by the automatic validation,
+ * this action appends a dedicated INFO confirmation message plus one message per concrete
+ * finding to the central message area. These entries accumulate across repeated clicks
+ * so the user can compare successive runs.
*
* Differences from the automatic background validation:
*
@@ -1663,24 +1668,35 @@ public final class GuiConfigurationEditorWorkspace {
private void runValidationAction() {
LOG.info("Aktion Validieren ausgeführt.");
- // Re-run in-memory validation; this updates pendingMessages, pendingFieldFindings
- // and lastValidationResult identically to the automatic background check.
- runEditorValidation();
+ EditorValidationInput input = buildValidationInput();
+ EditorValidationReport report = editorValidator.validate(input);
- // Replace any previous action-confirmation message; preserve all other messages.
- pendingMessages.removeIf(m -> m.source().isPresent()
- && "Validierung-Aktion".equals(m.source().get()));
-
- int findingCount = lastValidationResult.fieldFindings().size();
- String confirmationText;
- if (findingCount == 0) {
- confirmationText = "Aktion Validieren wurde ausgeführt. Keine Befunde.";
- } else {
- confirmationText = "Aktion Validieren wurde ausgeführt. "
- + findingCount + " Befund" + (findingCount == 1 ? "" : "e") + " gefunden.";
+ // Update field-level findings (drives red labels under problematic inputs).
+ pendingFieldFindings.clear();
+ for (EditorValidationFinding finding : report.findings()) {
+ if (finding.hasFieldKey()) {
+ pendingFieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(),
+ toGuiSeverity(finding.severity()), finding.message()));
+ }
}
+
+ // Drop silent auto-validation entries so the central message area is not flooded
+ // by keystroke-level background checks; explicit action entries always accumulate.
+ pendingMessages.removeIf(m -> m.source().isPresent()
+ && "Validierung".equals(m.source().get()));
+
+ // Append a timestamped confirmation plus each concrete finding as its own entry.
+ int findingCount = report.findings().size();
+ String confirmationText = findingCount == 0
+ ? "Aktion Validieren wurde ausgeführt. Keine Befunde."
+ : "Aktion Validieren wurde ausgeführt. "
+ + findingCount + " Befund" + (findingCount == 1 ? "" : "e") + " gefunden:";
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.INFO, confirmationText, "Validierung-Aktion"));
+ for (EditorValidationFinding finding : report.findings()) {
+ pendingMessages.add(GuiMessageEntry.of(
+ toGuiSeverity(finding.severity()), finding.message(), "Validierung-Aktion"));
+ }
lastValidationResult = new GuiEditorValidationResult(
List.copyOf(pendingMessages),
@@ -1727,13 +1743,19 @@ public final class GuiConfigurationEditorWorkspace {
* list.
*
* Each message is rendered as one {@link TextFlow} row. The severity prefix is coloured using
- * the CSS colour from {@link GuiMessageSeverity#getPrefixCssColour()}; the remainder of the
- * message text is always black. The placeholder text is shown when the list is empty.
+ * the CSS colour from {@link GuiMessageSeverity#getPrefixCssColour()} and carries the message
+ * timestamp in {@code [HH:mm:ss]} form; the remainder of the message text is always black.
+ * The placeholder text is shown when the list is empty.
+ *
+ * Each message row exposes a context menu with a "Kopieren" action that copies the full line
+ * (timestamp, severity label, body text) to the system clipboard. The container also offers
+ * an "Alles kopieren" menu entry that concatenates all current messages in display order.
*
* Must be called on the JavaFX Application Thread.
*/
void refreshMessagesArea() {
messagesAreaBox.getChildren().clear();
+ attachAllCopyContextMenu();
if (pendingMessages.isEmpty()) {
Text placeholder = new Text("Keine Meldungen vorhanden.");
placeholder.setStyle("-fx-fill: #888888;");
@@ -1741,17 +1763,100 @@ public final class GuiConfigurationEditorWorkspace {
return;
}
for (GuiMessageEntry entry : pendingMessages) {
- Text prefix = new Text(entry.severity().getPrefixLabel() + " ");
+ String timestampLabel = formatTimestamp(entry.timestamp());
+ String severityLabel = entry.severity().getPrefixLabel();
+ Text prefix = new Text(timestampLabel + " " + severityLabel + " ");
prefix.setStyle("-fx-fill: " + entry.severity().getPrefixCssColour() + ";"
+ " -fx-font-weight: bold;");
Text body = new Text(entry.text());
body.setStyle("-fx-fill: black;");
TextFlow row = new TextFlow(prefix, body);
row.setStyle("-fx-padding: 1px 4px;");
+ String fullLine = timestampLabel + " " + severityLabel + " " + entry.text();
+ attachRowCopyContextMenu(row, fullLine);
messagesAreaBox.getChildren().add(row);
}
}
+ /**
+ * Installs a context menu on the messages container that copies all rendered lines to the
+ * system clipboard. Each rendered row receives its own context menu; this menu handles
+ * clicks on empty areas of the container.
+ */
+ private void attachAllCopyContextMenu() {
+ ContextMenu menu = new ContextMenu();
+ MenuItem copyAll = new MenuItem("Alles kopieren");
+ copyAll.setOnAction(e -> copyAllMessagesToClipboard());
+ menu.getItems().add(copyAll);
+ messagesAreaBox.setOnContextMenuRequested(e -> {
+ if (!pendingMessages.isEmpty()) {
+ menu.show(messagesAreaBox, e.getScreenX(), e.getScreenY());
+ }
+ });
+ }
+
+ /**
+ * Installs a per-row context menu that copies the row's full textual content and also
+ * offers the container-level "Alles kopieren" action.
+ *
+ * @param row the message row node; must not be {@code null}
+ * @param fullLine the full text of this row to copy when "Kopieren" is selected
+ */
+ private void attachRowCopyContextMenu(TextFlow row, String fullLine) {
+ ContextMenu menu = new ContextMenu();
+ MenuItem copyRow = new MenuItem("Kopieren");
+ copyRow.setOnAction(e -> writeToClipboard(fullLine));
+ MenuItem copyAll = new MenuItem("Alles kopieren");
+ copyAll.setOnAction(e -> copyAllMessagesToClipboard());
+ menu.getItems().addAll(copyRow, copyAll);
+ row.setOnContextMenuRequested(e ->
+ menu.show(row, e.getScreenX(), e.getScreenY()));
+ }
+
+ /**
+ * Copies all current message lines to the system clipboard in display order.
+ */
+ private void copyAllMessagesToClipboard() {
+ if (pendingMessages.isEmpty()) {
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (GuiMessageEntry entry : pendingMessages) {
+ sb.append(formatTimestamp(entry.timestamp()))
+ .append(" ")
+ .append(entry.severity().getPrefixLabel())
+ .append(" ")
+ .append(entry.text())
+ .append(System.lineSeparator());
+ }
+ writeToClipboard(sb.toString().trim());
+ }
+
+ /**
+ * Writes plain text to the system clipboard.
+ *
+ * @param text the text to copy; {@code null} is treated as empty
+ */
+ private void writeToClipboard(String text) {
+ ClipboardContent content = new ClipboardContent();
+ content.putString(text == null ? "" : text);
+ Clipboard.getSystemClipboard().setContent(content);
+ }
+
+ /**
+ * Formats a message timestamp as {@code [HH:mm:ss]} in the system default zone.
+ *
+ * @param instant the source instant; must not be {@code null}
+ * @return the formatted prefix; never {@code null}
+ */
+ private static String formatTimestamp(java.time.Instant instant) {
+ return "[" + TIMESTAMP_FORMATTER.format(instant) + "]";
+ }
+
+ private static final java.time.format.DateTimeFormatter TIMESTAMP_FORMATTER =
+ java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")
+ .withZone(java.time.ZoneId.systemDefault());
+
// =========================================================================
// Field-level error label rendering
// =========================================================================
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java
index e429664..74d2427 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java
@@ -145,17 +145,16 @@ public final class GuiTechnicalTestCoordinator {
/**
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
*
- * Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jeden
- * Checkpoint-Ergebnis einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
- * angehängt.
+ * Fügt für jedes Checkpoint-Ergebnis einen neuen Eintrag zur geteilten Nachrichtenliste
+ * hinzu; vorhandene Einträge bleiben erhalten, sodass die Meldungen über mehrere
+ * Testläufe hinweg akkumulieren. Zusätzlich wird eine Zusammenfassung angehängt.
*
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
*
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
*/
private void applyResult(TechnicalTestReport report) {
- // Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
- pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
+ // Akkumulieren: Vorherige Einträge anderer Läufe bleiben erhalten.
long successCount = 0;
long failureErrorCount = 0;
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java
index c42a7e0..dc0c518 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java
@@ -48,15 +48,28 @@ public final class GuiConfigurationTemplateFactory {
}
/**
- * Creates the empty editor state used when the GUI starts without a loaded configuration.
+ * Creates the editor state used when the GUI starts without a loaded configuration.
*
- * This start state intentionally does not show the standard template yet. The template
- * is reserved for the explicit {@code Neu} action so the GUI starts without an implicit
- * draft and only shows the welcome guidance until the user requests a new configuration.
+ * The start state contains the standard configuration template so the GUI shows the
+ * default values immediately, equivalent to the explicit {@code Neu} action having been
+ * triggered. No file snapshot is associated with the state.
*
- * @return a clean editor state without a loaded file snapshot and without template values
+ * @return a clean editor state with the standard template values and no loaded file snapshot
*/
public static GuiConfigurationEditorState createBlankStartState() {
+ return createStandardTemplate();
+ }
+
+ /**
+ * Creates a truly empty editor state without any template values.
+ *
+ * This factory is reserved for tests that intentionally need an editor state with empty
+ * field values and no provider configurations. Production startup uses
+ * {@link #createBlankStartState()} which returns the standard template instead.
+ *
+ * @return a clean editor state without any template values
+ */
+ public static GuiConfigurationEditorState createEmptyStartState() {
GuiConfigurationValues blankValues = new GuiConfigurationValues(
"",
"",
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiModelFieldContainer.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiModelFieldContainer.java
index 590d5fd..3b1e7d0 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiModelFieldContainer.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiModelFieldContainer.java
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
+import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TextField;
@@ -65,6 +66,7 @@ public final class GuiModelFieldContainer extends StackPane {
// Initial state: show text field (NOT_YET_LOADED → manual input)
applyVisibility(false);
+ setAlignment(Pos.CENTER_LEFT);
getChildren().addAll(comboBox, textField);
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
index 1ae9f4a..e5b6c64 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
@@ -206,14 +206,14 @@ class GuiAdapterSmokeTest {
// =========================================================================
/**
- * Verifies that the editor workspace starts without a loaded configuration, shows the
- * welcome guidance, and exposes the fixed GUI structure of the current shell.
+ * Verifies that the editor workspace starts without a loaded configuration, immediately
+ * shows the standard template defaults, and exposes the fixed GUI structure of the current shell.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
@Order(5)
- void editorWorkspace_startStateShowsEmptyHeaderWelcomeGuidanceAndOneTab() throws Exception {
+ void editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference fxError = new AtomicReference<>();
AtomicReference workspaceReference = new AtomicReference<>();
@@ -225,10 +225,8 @@ class GuiAdapterSmokeTest {
assertEquals("", workspace.configurationPathText(),
"The header path must stay empty before any configuration is loaded");
- assertTrue(workspace.isWelcomeGuidanceVisible(),
- "The welcome guidance must be visible in the unloaded start state");
- assertTrue(workspace.welcomeText().contains("Willkommen"),
- "The welcome text must be shown in German");
+ assertFalse(workspace.isWelcomeGuidanceVisible(),
+ "The welcome guidance must stay hidden because the standard template is shown immediately");
assertNotNull(workspace.root(),
"The workspace root must be available");
assertEquals("Neu", workspace.newButton().getText(),
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java
index fe087fa..f0a3759 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java
@@ -196,14 +196,14 @@ class GuiEditorIntegrationTest {
// =========================================================================
/**
- * Verifies that starting the GUI without a {@code --config} argument produces the defined
- * blank welcome state: header path is empty, welcome guidance is visible, and the editor is
- * not in dirty state.
+ * Verifies that starting the GUI without a {@code --config} argument shows the standard
+ * template defaults immediately: header path is empty, welcome guidance is hidden, the
+ * editor is not in dirty state, and the standard default values are populated.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
- void guiStartup_withoutConfigPath_showsBlankWelcomeState() throws Exception {
+ void guiStartup_withoutConfigPath_showsStandardTemplateDefaults() throws Exception {
GuiStartupContext blankContext = GuiStartupContext.blank(Optional.empty());
AtomicReference error = new AtomicReference<>();
@@ -215,14 +215,14 @@ class GuiEditorIntegrationTest {
assertEquals("", workspace.configurationPathText(),
"Header path must be empty when no configuration is loaded");
- assertTrue(workspace.isWelcomeGuidanceVisible(),
- "Welcome guidance must be visible when no configuration is loaded");
+ assertFalse(workspace.isWelcomeGuidanceVisible(),
+ "Welcome guidance must stay hidden because the standard template is shown immediately");
assertFalse(workspace.editorState().hasLoadedFileSnapshot(),
- "Editor state must have no file snapshot in blank start state");
+ "Editor state must have no file snapshot in default start state");
assertFalse(workspace.editorState().isDirty(),
- "Blank start state must not be dirty");
- assertTrue(workspace.welcomeText().contains("Willkommen"),
- "Welcome text must be shown in German");
+ "Default start state must not be dirty");
+ assertEquals("./work/local/source", workspace.editorState().values().sourceFolder(),
+ "Default start state must populate the standard source folder");
} catch (Throwable t) {
error.set(t);
@@ -302,8 +302,8 @@ class GuiEditorIntegrationTest {
try {
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
- assertTrue(workspace.isWelcomeGuidanceVisible(),
- "Welcome guidance must be visible when config path does not exist");
+ assertFalse(workspace.isWelcomeGuidanceVisible(),
+ "Welcome guidance must stay hidden because the standard template is shown immediately");
assertEquals("", workspace.configurationPathText(),
"Header path must be empty when config file was not found");
assertFalse(workspace.editorState().hasLoadedFileSnapshot(),
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java
index bbc3ead..27ca39d 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java
@@ -97,28 +97,29 @@ class GuiEditorRegressionSmokeTest {
// =========================================================================
/**
- * Regression: starting without a configuration produces the blank welcome state.
+ * Regression: starting without a configuration immediately shows the standard template defaults.
*
- * The workspace must display the welcome guidance, the header path must be empty, and
- * the editor state must not have a file snapshot. "Neu" and "Öffnen" must be present.
+ * The workspace must keep the welcome guidance hidden because the standard template values
+ * are populated right away. The header path stays empty and no file snapshot is associated
+ * with the editor state. "Neu" and "Öffnen" must be present.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
@Order(1)
- void guiStart_withoutConfig_showsBlankWelcomeStateAndExposesNeuAndOeffnenButtons()
+ void guiStart_withoutConfig_showsStandardTemplateDefaultsAndExposesNeuAndOeffnenButtons()
throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
- assertTrue(ws.isWelcomeGuidanceVisible(),
- "Welcome guidance must be visible on blank start");
+ assertFalse(ws.isWelcomeGuidanceVisible(),
+ "Welcome guidance must stay hidden because the standard template is shown immediately");
assertEquals("", ws.configurationPathText(),
- "Header path must be empty on blank start");
+ "Header path must be empty on default start");
assertFalse(ws.editorState().hasLoadedFileSnapshot(),
- "No file snapshot must exist on blank start");
+ "No file snapshot must exist on default start");
assertFalse(ws.editorState().isDirty(),
- "Blank start state must not be dirty");
+ "Default start state must not be dirty");
assertEquals("Neu", ws.newButton().getText(),
"'Neu' button must be present");
assertEquals("Öffnen", ws.openButton().getText(),
@@ -131,23 +132,24 @@ class GuiEditorRegressionSmokeTest {
// =========================================================================
/**
- * Regression: "Neu" switches the workspace to the standard template, hides the welcome
- * guidance, and leaves the state clean with all template fields populated.
+ * Regression: "Neu" reloads the standard template values, keeps the welcome guidance
+ * hidden, and leaves the state clean with all template fields populated.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
@Order(2)
- void neu_withStandardTemplate_populatesFieldsAndHidesWelcomeGuidance() throws Exception {
+ void neu_withStandardTemplate_populatesFieldsAndKeepsWelcomeHidden() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
- assertTrue(ws.isWelcomeGuidanceVisible(), "Precondition: welcome must be visible");
+ assertFalse(ws.isWelcomeGuidanceVisible(),
+ "Precondition: welcome must already be hidden because the start state shows defaults");
ws.requestNewConfiguration();
assertFalse(ws.isWelcomeGuidanceVisible(),
- "Welcome guidance must be hidden after 'Neu'");
+ "Welcome guidance must remain hidden after 'Neu'");
assertEquals("", ws.editorState().configurationPathText(),
"Path must remain empty after 'Neu' (no file saved yet)");
assertFalse(ws.editorState().isDirty(),
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java
index 46fe0f9..f5433cf 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java
@@ -39,7 +39,7 @@ import javafx.scene.control.Button;
* {@code technical-tests-button}.
*
- Triggering the coordinator synchronously populates {@code pendingMessages}
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.
- * - A second trigger replaces the previous test entries (replace semantics).
+ * - A second trigger appends a fresh batch of test entries (accumulation semantics).
* - The post-result callback is invoked after the result is applied.
*
*
@@ -162,17 +162,18 @@ class GuiTechnicalTestCoordinatorSmokeTest {
}
// =========================================================================
- // Scenario: replace semantics – second trigger replaces previous entries
+ // Scenario: accumulation semantics – second trigger appends fresh entries
// =========================================================================
/**
- * Smoke test: triggering the coordinator twice replaces the previous SOURCE_TAG
- * entries; the count remains the same as after a single trigger.
+ * Smoke test: triggering the coordinator twice accumulates both runs; the
+ * second trigger appends a fresh batch of SOURCE_TAG entries without
+ * removing the first batch.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
- void trigger_twice_replacesPreviousTestEntries() throws Exception {
+ void trigger_twice_accumulatesTestEntries() throws Exception {
runOnFx(() -> {
List messages = new ArrayList<>();
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
@@ -189,8 +190,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
- assertEquals(countAfterFirst, countAfterSecond,
- "Second trigger must replace (not append) the previous test entries");
+ assertEquals(countAfterFirst * 2, countAfterSecond,
+ "Second trigger must append a fresh batch, doubling the SOURCE_TAG entries");
});
}
@@ -247,9 +248,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30",
- EffectiveApiKeyDescriptor.absent(),
+ EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.absent()));
+ EffectiveApiKeyDescriptor.absent(), ""));
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
new EditorConfigurationValidator(),
@@ -283,9 +284,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30",
- EffectiveApiKeyDescriptor.absent(),
+ EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.absent()));
+ EffectiveApiKeyDescriptor.absent(), ""));
// Second trigger with the updated (unsaved) input.
coordinator.triggerTechnicalTests();
@@ -370,9 +371,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000",
"https://api.anthropic.com", "claude-3-sonnet", "30",
- EffectiveApiKeyDescriptor.absent(),
+ EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.absent(), "");
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
orchestrator,
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java
index c42030f..b91f9b4 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java
@@ -18,6 +18,7 @@ import org.junit.jupiter.api.Test;
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.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
@@ -34,11 +35,11 @@ import javafx.scene.control.Button;
* Covered scenarios
*
* - Clicking "Validieren" with an incomplete configuration produces ERROR findings and
- * an INFO message reporting the finding count.
+ * an INFO message reporting the finding count plus one entry per concrete finding.
* - Clicking "Validieren" with a valid template configuration produces no ERRORs and
* an INFO message reporting "Keine Befunde." or a zero count.
- * - Clicking "Validieren" twice replaces the previous action-confirmation INFO message
- * (replace semantics; the message appears exactly once).
+ * - Clicking "Validieren" twice appends a second action-confirmation INFO message
+ * (accumulation semantics; each click adds a fresh snapshot).
* - Clicking "Validieren" does not trigger any file write (the writer stub records no
* calls).
* - The button is findable by its CSS ID {@code validate-button}.
@@ -112,8 +113,8 @@ class GuiValidateActionSmokeTest {
/**
* Smoke test: after clicking "Validieren" on a workspace whose editor state has
* an empty active-provider value, the last validation result contains at least one
- * ERROR and the central message area contains an INFO message with source
- * "Validierung-Aktion" that reports the number of findings.
+ * ERROR and the central message area contains one INFO confirmation with source
+ * "Validierung-Aktion" plus one entry per concrete finding with the same source.
*
* @throws Exception if the FX thread task fails or times out
*/
@@ -122,8 +123,9 @@ class GuiValidateActionSmokeTest {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = buildWorkspace();
- // Force an incomplete state: start with blank (no active provider).
- // The blank start state already has an empty active provider → errors expected.
+ // Force an incomplete state: replace the editor state with a truly empty one
+ // (no active provider, no template values) so validation produces errors.
+ ws.editorState = GuiConfigurationTemplateFactory.createEmptyStartState();
ws.validateButton.fire();
@@ -136,15 +138,17 @@ class GuiValidateActionSmokeTest {
&& ACTION_SOURCE.equals(m.source().get()))
.toList();
- assertEquals(1, actionMessages.size(),
- "Exactly one action-confirmation INFO message must be present");
- GuiMessageEntry msg = actionMessages.get(0);
- assertEquals(GuiMessageSeverity.INFO, msg.severity(),
- "Action-confirmation message must have INFO severity");
- assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
+ assertFalse(actionMessages.isEmpty(),
+ "At least one action message must be present");
+ GuiMessageEntry confirmation = actionMessages.get(0);
+ assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
+ "First action message must be the INFO confirmation");
+ assertTrue(confirmation.text().startsWith("Aktion Validieren wurde ausgeführt."),
"Action-confirmation message text must start with expected prefix");
- assertFalse(msg.text().contains("Keine Befunde"),
- "With errors present the message must NOT say 'Keine Befunde'");
+ assertFalse(confirmation.text().contains("Keine Befunde"),
+ "With errors present the confirmation must NOT say 'Keine Befunde'");
+ assertTrue(actionMessages.size() > 1,
+ "Each concrete finding must be listed as its own action message");
});
}
@@ -176,13 +180,13 @@ class GuiValidateActionSmokeTest {
&& ACTION_SOURCE.equals(m.source().get()))
.toList();
- assertEquals(1, actionMessages.size(),
- "Exactly one action-confirmation INFO message must be present");
- GuiMessageEntry msg = actionMessages.get(0);
- assertEquals(GuiMessageSeverity.INFO, msg.severity(),
- "Action-confirmation message must have INFO severity");
+ assertFalse(actionMessages.isEmpty(),
+ "At least one action message must be present");
+ GuiMessageEntry confirmation = actionMessages.get(0);
+ assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
+ "First action message must be the INFO confirmation");
// Template may have WARNINGs but no ERRORs. The fieldFindings count may be 0.
- assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
+ assertTrue(confirmation.text().startsWith("Aktion Validieren wurde ausgeführt."),
"Action-confirmation message text must start with expected prefix");
});
}
@@ -244,25 +248,27 @@ class GuiValidateActionSmokeTest {
// =========================================================================
/**
- * Smoke test: clicking "Validieren" twice must leave exactly one action-confirmation
- * INFO message in the message list (the second click replaces the first).
+ * Smoke test: clicking "Validieren" twice must leave two action-confirmation
+ * INFO messages in the message list (accumulation semantics — each click appends
+ * a fresh snapshot of findings).
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
- void validateAction_clickedTwice_infoMessageAppearsExactlyOnce() throws Exception {
+ void validateAction_clickedTwice_infoMessageAppearsTwice() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = buildWorkspace();
ws.validateButton.fire();
ws.validateButton.fire();
- long count = ws.pendingMessages.stream()
+ long confirmationCount = ws.pendingMessages.stream()
.filter(m -> m.source().isPresent()
&& ACTION_SOURCE.equals(m.source().get()))
+ .filter(m -> m.text().startsWith("Aktion Validieren wurde ausgeführt."))
.count();
- assertEquals(1, count,
- "After two clicks the action-confirmation INFO message must appear exactly once");
+ assertEquals(2, confirmationCount,
+ "After two clicks two action-confirmation INFO messages must be present");
});
}
@@ -296,12 +302,10 @@ class GuiValidateActionSmokeTest {
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"),
- (family, propertyValue) ->
- de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
- .EffectiveApiKeyDescriptor.absent(),
+ noOpApiKeyResolutionPort(),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
- (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
+ noOpApiKeyResolutionPort()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
@Override public boolean isDirectoryReadable(String p) { return false; }
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
@@ -318,7 +322,7 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
- (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
+ noOpApiKeyResolutionPort())),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -338,6 +342,17 @@ class GuiValidateActionSmokeTest {
// Helpers
// =========================================================================
+ /**
+ * Returns a no-op {@link de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort}
+ * that always reports {@code ABSENT}. Used for tests that do not exercise actual API-key
+ * resolution.
+ */
+ private static de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort
+ noOpApiKeyResolutionPort() {
+ return (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
+ .EffectiveApiKeyDescriptor.absent();
+ }
+
/**
* Builds a workspace with no-op loader/writer and absent API-key resolution,
* suitable for in-memory validation tests.
@@ -354,12 +369,10 @@ class GuiValidateActionSmokeTest {
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"),
- (family, propertyValue) ->
- de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
- .EffectiveApiKeyDescriptor.absent(),
+ noOpApiKeyResolutionPort(),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
- (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
+ noOpApiKeyResolutionPort()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
@Override public boolean isDirectoryReadable(String p) { return false; }
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
@@ -376,7 +389,7 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
- (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
+ noOpApiKeyResolutionPort())),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java
index cfdfcfe..76f6b29 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java
@@ -58,7 +58,7 @@ class GuiConfigurationTemplateFactoryTest {
}
@Test
- void createBlankStartState_startsWithoutLoadedConfigurationAndWithoutTemplateValues() {
+ void createBlankStartState_returnsStandardTemplateValuesWithoutLoadedFile() {
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createBlankStartState();
assertFalse(state.isDirty());
@@ -66,6 +66,22 @@ class GuiConfigurationTemplateFactoryTest {
assertTrue(state.isNewConfiguration());
assertEquals("", state.configurationPathText());
+ GuiConfigurationValues values = state.values();
+ assertEquals("./work/local/source", values.sourceFolder());
+ assertEquals("./work/local/target", values.targetFolder());
+ assertEquals(AiProviderFamily.CLAUDE.getIdentifier(), values.activeProviderFamily());
+ assertFalse(values.providerConfigurations().isEmpty());
+ }
+
+ @Test
+ void createEmptyStartState_startsWithoutLoadedConfigurationAndWithoutTemplateValues() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createEmptyStartState();
+
+ assertFalse(state.isDirty());
+ assertFalse(state.hasLoadedFileSnapshot());
+ assertTrue(state.isNewConfiguration());
+ assertEquals("", state.configurationPathText());
+
GuiConfigurationValues values = state.values();
assertEquals("", values.sourceFolder());
assertEquals("", values.targetFolder());
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/validation/EnvironmentApiKeyResolutionAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/validation/EnvironmentApiKeyResolutionAdapter.java
index e613dab..97f1a8a 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/validation/EnvironmentApiKeyResolutionAdapter.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/validation/EnvironmentApiKeyResolutionAdapter.java
@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
import java.util.Objects;
+import java.util.Optional;
import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
@@ -103,6 +104,50 @@ public class EnvironmentApiKeyResolutionAdapter implements ApiKeyResolutionPort
return EffectiveApiKeyDescriptor.absent();
}
+ /**
+ * Liefert den effektiven API-Schlüssel-Rohwert anhand derselben Vorrangregel wie {@link #resolve}.
+ *
+ * @param family die Provider-Familie; darf nicht {@code null} sein
+ * @param propertyValue aktueller Property-Wert aus dem Editor; darf nicht {@code null} sein
+ * @return der effektive Rohwert, falls verfügbar; sonst leer
+ */
+ @Override
+ public Optional resolveEffectiveApiKeyValue(AiProviderFamily family, String propertyValue) {
+ Objects.requireNonNull(family, "family must not be null");
+ Objects.requireNonNull(propertyValue, "propertyValue must not be null");
+
+ return switch (family) {
+ case CLAUDE -> resolveClaudeValue(propertyValue);
+ case OPENAI_COMPATIBLE -> resolveOpenAiCompatibleValue(propertyValue);
+ };
+ }
+
+ private Optional resolveClaudeValue(String propertyValue) {
+ String envValue = environmentLookup.apply(ENV_CLAUDE_API_KEY);
+ if (isPresent(envValue)) {
+ return Optional.of(envValue);
+ }
+ if (isPresent(propertyValue)) {
+ return Optional.of(propertyValue);
+ }
+ return Optional.empty();
+ }
+
+ private Optional resolveOpenAiCompatibleValue(String propertyValue) {
+ String primaryEnv = environmentLookup.apply(ENV_OPENAI_API_KEY);
+ if (isPresent(primaryEnv)) {
+ return Optional.of(primaryEnv);
+ }
+ String legacyEnv = environmentLookup.apply(ENV_LEGACY_OPENAI_API_KEY);
+ if (isPresent(legacyEnv)) {
+ return Optional.of(legacyEnv);
+ }
+ if (isPresent(propertyValue)) {
+ return Optional.of(propertyValue);
+ }
+ return Optional.empty();
+ }
+
private static boolean isPresent(String value) {
return value != null && !value.isBlank();
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/ApiKeyResolutionPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/ApiKeyResolutionPort.java
index a1522d3..6d2d1c9 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/ApiKeyResolutionPort.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/ApiKeyResolutionPort.java
@@ -1,5 +1,7 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor;
+import java.util.Optional;
+
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
@@ -22,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApi
*
* Implementierungen dieses Ports liegen im Adapter-Out-Modul.
*/
+@FunctionalInterface
public interface ApiKeyResolutionPort {
/**
@@ -37,4 +40,32 @@ public interface ApiKeyResolutionPort {
* @return der Descriptor für die effektive Schlüsselherkunft; nie {@code null}
*/
EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue);
+
+ /**
+ * Liefert den effektiven API-Schlüssel-Rohwert anhand derselben Vorrangregel wie {@link #resolve}.
+ *
+ * Dient technischen Tests wie dem Modellkatalogabruf, die den tatsächlichen Schlüssel im
+ * HTTP-Header benötigen. Die Herkunft selbst wird nicht mit zurückgegeben – dafür ist
+ * {@link #resolve} zuständig.
+ *
+ * Diese Default-Implementierung deckt den Fall ab, in dem ein Adapter ausschließlich die
+ * Property-Datei kennt: liefert {@link #resolve} {@code ABSENT}, wird ein leerer Optional
+ * zurückgegeben; andernfalls wird der nicht-leere Property-Wert geliefert. Adapter, die
+ * Umgebungsvariablen lesen, müssen diese Methode überschreiben, damit der ENV-Wert auch
+ * tatsächlich an HTTP-Aufrufer durchgereicht wird.
+ *
+ * @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 effektive Schlüssel-Rohwert, falls eine der Quellen einen Wert liefert; sonst leer
+ */
+ default Optional resolveEffectiveApiKeyValue(AiProviderFamily family, String propertyValue) {
+ EffectiveApiKeyDescriptor descriptor = resolve(family, propertyValue);
+ if (descriptor.isAbsent()) {
+ return Optional.empty();
+ }
+ if (propertyValue != null && !propertyValue.isBlank()) {
+ return Optional.of(propertyValue);
+ }
+ return Optional.empty();
+ }
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java
index a11a6d8..46c0db1 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java
@@ -28,10 +28,12 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApi
* @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 claudeApiKeyPropertyValue Rohwert des Claude-API-Schlüssels aus der Properties-Datei
* @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}
+ * @param openaiApiKeyPropertyValue Rohwert des OpenAI-API-Schlüssels aus der Properties-Datei
*/
public record EditorValidationInput(
String activeProviderIdentifier,
@@ -46,10 +48,12 @@ public record EditorValidationInput(
String claudeModel,
String claudeTimeoutSeconds,
EffectiveApiKeyDescriptor claudeApiKeyDescriptor,
+ String claudeApiKeyPropertyValue,
String openaiBaseUrl,
String openaiModel,
String openaiTimeoutSeconds,
- EffectiveApiKeyDescriptor openaiApiKeyDescriptor) {
+ EffectiveApiKeyDescriptor openaiApiKeyDescriptor,
+ String openaiApiKeyPropertyValue) {
/**
* Erstellt eine neue Eingabe für den Validator.
@@ -66,10 +70,12 @@ public record EditorValidationInput(
* @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 claudeApiKeyPropertyValue Claude-API-Key-Rohwert aus der Properties-Datei; {@code null} wird zu leerem String
* @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
+ * @param openaiApiKeyPropertyValue OpenAI-API-Key-Rohwert aus der Properties-Datei; {@code null} wird zu leerem String
* @throws NullPointerException wenn {@code claudeApiKeyDescriptor} oder {@code openaiApiKeyDescriptor} {@code null} sind
*/
public EditorValidationInput {
@@ -86,11 +92,13 @@ public record EditorValidationInput(
claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds);
claudeApiKeyDescriptor = Objects.requireNonNull(claudeApiKeyDescriptor,
"claudeApiKeyDescriptor must not be null");
+ claudeApiKeyPropertyValue = normalizeText(claudeApiKeyPropertyValue);
openaiBaseUrl = normalizeText(openaiBaseUrl);
openaiModel = normalizeText(openaiModel);
openaiTimeoutSeconds = normalizeText(openaiTimeoutSeconds);
openaiApiKeyDescriptor = Objects.requireNonNull(openaiApiKeyDescriptor,
"openaiApiKeyDescriptor must not be null");
+ openaiApiKeyPropertyValue = normalizeText(openaiApiKeyPropertyValue);
}
private static String normalizeText(String value) {
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java
index ea0beb2..a00f73c 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java
@@ -79,6 +79,8 @@ public class ProviderTechnicalTestService {
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
private final AiModelCatalogPort modelCatalogPort;
+ private final ApiKeyResolutionPort apiKeyResolutionPort;
+
/**
* Erstellt einen neuen Service mit den erforderlichen Ports.
*
@@ -89,7 +91,7 @@ public class ProviderTechnicalTestService {
public ProviderTechnicalTestService(AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort) {
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort, "modelCatalogPort must not be null");
- Objects.requireNonNull(apiKeyResolutionPort,
+ this.apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
"apiKeyResolutionPort must not be null");
}
@@ -377,13 +379,10 @@ public class ProviderTechnicalTestService {
/**
* Baut den {@link ModelCatalogRequest} aus dem aktuellen Editorzustand auf.
*
- * Da {@link EditorValidationInput} keinen direkten API-Key-String enthält, sondern
- * nur einen bereits aufgelösten {@link EffectiveApiKeyDescriptor}, wird der Descriptor
- * aus dem Editorzustand direkt verwendet. Der Adapter-Out-Seitige Dispatcher erwartet
- * den Key entweder als ENV-Variable (die er selbst liest) oder als optionalen Wert
- * im Request. Da die Auflösung beim Service bereits über {@link ApiKeyResolutionPort}
- * erfolgt ist, wird für den Catalog-Request ein leerer Optional-Wert geliefert –
- * der Adapter verwendet dann intern seine eigene ENV-Variable-Auflösung.
+ * Der effektive API-Key-Rohwert wird über den {@link ApiKeyResolutionPort} ermittelt
+ * (Vorrangregel: providerspezifische ENV → Legacy-ENV → Property-Wert) und in den
+ * Request übernommen. Dadurch ist der Schlüssel bereits beim Adapter verfügbar und
+ * spiegelt exakt die Quelle wider, die zuvor im Deskriptor ausgewiesen wurde.
*
* @param input aktueller Editorzustand
* @param family aktive Provider-Familie
@@ -393,12 +392,9 @@ public class ProviderTechnicalTestService {
private ModelCatalogRequest buildCatalogRequest(EditorValidationInput input,
AiProviderFamily family,
EffectiveApiKeyDescriptor apiKeyDesc) {
- // EditorValidationInput enthält keinen direkten API-Key-String-Wert, nur den Descriptor.
- // Für den ModelCatalogRequest übergeben wir einen leeren Optional für den apiKey,
- // sodass der Adapter seine eigene ENV-Variable-Auflösung durchführt.
- // Der Adapter liefert dann IncompleteConfiguration, wenn auch er keinen Key findet –
- // was aber nicht passiert, da wir oben bereits geprüft haben, dass apiKeyDesc nicht ABSENT ist.
- Optional apiKeyForRequest = Optional.empty();
+ String propertyValue = resolveApiKeyPropertyValue(input, family);
+ Optional apiKeyForRequest = apiKeyResolutionPort
+ .resolveEffectiveApiKeyValue(family, propertyValue);
String rawBaseUrl = resolveBaseUrlValue(input, family);
Optional baseUrl = rawBaseUrl.isBlank() ? Optional.empty() : Optional.of(rawBaseUrl);
@@ -412,6 +408,21 @@ public class ProviderTechnicalTestService {
timeout);
}
+ /**
+ * Liest den Roh-Property-Wert des API-Schlüssels für die angegebene Provider-Familie
+ * aus dem Editorzustand.
+ *
+ * @param input aktueller Editorzustand
+ * @param family aktive Provider-Familie
+ * @return Property-Wert; nie {@code null}, leer wenn nicht gesetzt
+ */
+ private String resolveApiKeyPropertyValue(EditorValidationInput input, AiProviderFamily family) {
+ return switch (family) {
+ case CLAUDE -> input.claudeApiKeyPropertyValue();
+ case OPENAI_COMPATIBLE -> input.openaiApiKeyPropertyValue();
+ };
+ }
+
/**
* Liest den bereits aufgelösten {@link EffectiveApiKeyDescriptor} für die aktive Provider-Familie
* direkt aus dem {@link EditorValidationInput}.
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java
index 6225321..11f3c62 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java
@@ -41,10 +41,12 @@ class EditorConfigurationValidatorTest {
"claude-3-5-sonnet", // claudeModel
"30", // claudeTimeoutSeconds
EffectiveApiKeyDescriptor.fromPropertyFile(), // claudeApiKeyDescriptor
+ "sk-claude", // claudeApiKeyPropertyValue
"https://api.openai.com", // openaiBaseUrl
"gpt-4", // openaiModel
"30", // openaiTimeoutSeconds
- EffectiveApiKeyDescriptor.fromPropertyFile() // openaiApiKeyDescriptor
+ EffectiveApiKeyDescriptor.fromPropertyFile(), // openaiApiKeyDescriptor
+ "sk-openai" // openaiApiKeyPropertyValue
);
}
@@ -57,8 +59,8 @@ class EditorConfigurationValidatorTest {
EditorValidationInput input = new EditorValidationInput(
"", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
- "", "", "30", EffectiveApiKeyDescriptor.absent(),
- "", "", "30", EffectiveApiKeyDescriptor.absent());
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -74,8 +76,8 @@ class EditorConfigurationValidatorTest {
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());
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -94,8 +96,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -109,8 +111,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -124,8 +126,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -139,8 +141,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -158,8 +160,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -173,8 +175,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -188,8 +190,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -204,8 +206,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -223,8 +225,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -238,8 +240,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -253,8 +255,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -275,8 +277,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -290,8 +292,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -305,8 +307,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -320,8 +322,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -340,8 +342,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -359,8 +361,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "", "30",
- EffectiveApiKeyDescriptor.fromPropertyFile(),
- "", "", "30", EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -374,8 +376,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
"", "claude-3-5-sonnet", "30",
- EffectiveApiKeyDescriptor.fromPropertyFile(),
- "", "", "30", EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -389,8 +391,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -408,8 +410,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.absent(), "",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -434,8 +436,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY"), "",
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
@@ -449,9 +451,9 @@ class EditorConfigurationValidatorTest {
EditorValidationInput input = new EditorValidationInput(
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
- "", "", "30", EffectiveApiKeyDescriptor.absent(),
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY"));
+ EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY"), "");
EditorValidationReport report = validator.validate(input);
@@ -478,9 +480,9 @@ class EditorConfigurationValidatorTest {
EditorValidationInput input = new EditorValidationInput(
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500",
- "", "", "30", EffectiveApiKeyDescriptor.absent(),
+ "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.fromPropertyFile());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-openai");
EditorValidationReport report = validator.validate(input);
@@ -498,8 +500,8 @@ class EditorConfigurationValidatorTest {
"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());
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
+ "", "", "", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input);
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java
index 0b32ac0..8a504ea 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java
@@ -29,9 +29,9 @@ class ProviderTechnicalTestServiceTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000",
"https://api.anthropic.com", model, "30",
- apiKeyDescriptor,
+ apiKeyDescriptor, "sk-test",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.absent(), "");
}
private static EditorValidationInput openaiInput(EffectiveApiKeyDescriptor apiKeyDescriptor,
@@ -41,9 +41,9 @@ class ProviderTechnicalTestServiceTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000",
"https://api.anthropic.com", "claude-3-sonnet", "30",
- EffectiveApiKeyDescriptor.absent(),
+ EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", model, "30",
- apiKeyDescriptor);
+ apiKeyDescriptor, "sk-test");
}
private static EffectiveApiKeyDescriptor keyFromEnv() {
@@ -60,11 +60,32 @@ class ProviderTechnicalTestServiceTest {
/**
* Stub-Port der immer den über den Konstruktor übergebenen Descriptor zurückgibt,
- * unabhängig von family und propertyValue.
+ * unabhängig von family und propertyValue. Liefert den Roh-API-Schlüssel aus
+ * {@code propertyValue}, falls gesetzt; sonst einen festen Stub-Wert, sobald der
+ * Descriptor eine Quelle meldet.
*/
private static de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort
apiKeyPort(EffectiveApiKeyDescriptor descriptor) {
- return (family, propertyValue) -> descriptor;
+ return new de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort() {
+ @Override
+ public EffectiveApiKeyDescriptor resolve(
+ de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily family,
+ String propertyValue) {
+ return descriptor;
+ }
+
+ @Override
+ public java.util.Optional resolveEffectiveApiKeyValue(
+ de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily family,
+ String propertyValue) {
+ if (propertyValue != null && !propertyValue.isBlank()) {
+ return java.util.Optional.of(propertyValue);
+ }
+ return descriptor.isAbsent()
+ ? java.util.Optional.empty()
+ : java.util.Optional.of("stub-key");
+ }
+ };
}
/**
@@ -388,9 +409,9 @@ class ProviderTechnicalTestServiceTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000",
"", "model", "30",
- EffectiveApiKeyDescriptor.absent(),
+ EffectiveApiKeyDescriptor.absent(), "",
"", "model", "30",
- EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.absent(), "");
List results = service.runProviderChecks(input);
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java
index 6a6cd76..80aeef9 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java
@@ -31,9 +31,9 @@ class TechnicalTestOrchestratorTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30",
- EffectiveApiKeyDescriptor.fromPropertyFile(),
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.absent(), "");
}
/**
@@ -45,9 +45,9 @@ class TechnicalTestOrchestratorTest {
"", "", "", "",
"", "", "",
"", "", "",
- EffectiveApiKeyDescriptor.absent(),
+ EffectiveApiKeyDescriptor.absent(), "",
"", "", "",
- EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.absent(), "");
}
/** No-op {@link PathCheckPort}: alle Prüfungen liefern {@code false}. */
@@ -372,9 +372,9 @@ class TechnicalTestOrchestratorTest {
"", // kein Prompt-Pfad
"3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30",
- EffectiveApiKeyDescriptor.fromPropertyFile(),
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.absent(), "");
// PathCheckPort: Dateien fehlen, aber Elternordner sind schreibbar
PathCheckPort pathPort = new PathCheckPort() {
@@ -417,9 +417,9 @@ class TechnicalTestOrchestratorTest {
"", // kein Prompt-Pfad
"3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30",
- EffectiveApiKeyDescriptor.fromPropertyFile(),
+ EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test",
"https://api.openai.com", "gpt-4", "30",
- EffectiveApiKeyDescriptor.absent());
+ EffectiveApiKeyDescriptor.absent(), "");
PathCheckPort pathPort = new PathCheckPort() {
@Override public boolean isDirectoryReadable(String p) { return true; }
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java
index 8b5bb0d..1443caf 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java
@@ -16,8 +16,8 @@ class TechnicalTestRequestTest {
private static EditorValidationInput minimalInput() {
return new EditorValidationInput(
"claude", "", "", "", "", "3", "10", "2000",
- "", "model-x", "60", EffectiveApiKeyDescriptor.absent(),
- "", "", "60", EffectiveApiKeyDescriptor.absent());
+ "", "model-x", "60", EffectiveApiKeyDescriptor.absent(), "",
+ "", "", "60", EffectiveApiKeyDescriptor.absent(), "");
}
@Test