GUI-Bugfixes: Defaults beim Start, kopierbare Meldungen mit Zeitstempel, Befundauflistung, Modell-ComboBox links, effektiver API-Key für Modellabruf

- Blank-Startzustand zeigt jetzt die Standardvorlage (wie nach "Neu"), neue Factory createEmptyStartState für Tests
- Meldungsbereich ist per Kontextmenü bzw. Strg+C kopierbar
- Jede Meldung trägt ein führendes [HH:mm:ss]-Präfix
- Validieren- und Tests-Aktionen akkumulieren Meldungen, automatische Validierung ersetzt still ihre Einträge
- Validieren-Meldung listet alle konkreten Befunde einzeln auf
- Modell-ComboBox und manuelles Modellfeld sind linksbündig
- ApiKeyResolutionPort liefert jetzt den effektiven API-Schlüsselwert (Default + Env-Adapter-Override), so dass der Modellliste-Test in den technischen Tests nicht mehr "API-Schlüssel fehlt" meldet, obwohl er gesetzt ist
This commit is contained in:
2026-04-21 16:04:15 +02:00
parent 6babdd226e
commit ada7e203e3
18 changed files with 471 additions and 204 deletions
@@ -48,12 +48,16 @@ import javafx.scene.control.Button;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox; import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane; import javafx.scene.control.ScrollPane;
import javafx.scene.control.Separator; import javafx.scene.control.Separator;
import javafx.scene.control.Tab; import javafx.scene.control.Tab;
import javafx.scene.control.TabPane; import javafx.scene.control.TabPane;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
@@ -328,7 +332,7 @@ public final class GuiConfigurationEditorWorkspace {
this.configurationFileLoader = effectiveContext.configurationFileLoader(); this.configurationFileLoader = effectiveContext.configurationFileLoader();
this.configurationFileWriter = effectiveContext.configurationFileWriter(); this.configurationFileWriter = effectiveContext.configurationFileWriter();
this.editorState = effectiveContext.initialState(); this.editorState = effectiveContext.initialState();
this.welcomeGuidanceVisible = editorState.isNewConfiguration(); this.welcomeGuidanceVisible = false;
this.apiKeyResolutionPort = effectiveContext.apiKeyResolutionPort(); this.apiKeyResolutionPort = effectiveContext.apiKeyResolutionPort();
this.modelCatalogCoordinator = new GuiModelCatalogCoordinator( this.modelCatalogCoordinator = new GuiModelCatalogCoordinator(
@@ -1587,10 +1591,12 @@ public final class GuiConfigurationEditorWorkspace {
claudeState.model(), claudeState.model(),
claudeState.timeoutSeconds(), claudeState.timeoutSeconds(),
claudeKeyDescriptor, claudeKeyDescriptor,
claudeState.apiKey().propertyValue(),
openaiState.baseUrl(), openaiState.baseUrl(),
openaiState.model(), openaiState.model(),
openaiState.timeoutSeconds(), 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 * 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. * safe to call on the FX Application Thread at any time.
* <p> * <p>
* In addition to updating the findings shown by the automatic validation, this action * In addition to updating the field-level findings shown by the automatic validation,
* appends a dedicated INFO message to the central message area to confirm to the user * this action appends a dedicated INFO confirmation message plus one message per concrete
* that the action was explicitly executed and to report the number of findings found. * finding to the central message area. These entries accumulate across repeated clicks
* The message uses a distinct source tag so that it can be replaced on subsequent * so the user can compare successive runs.
* executions without removing messages from other sources.
* <p> * <p>
* Differences from the automatic background validation: * Differences from the automatic background validation:
* <ul> * <ul>
@@ -1663,24 +1668,35 @@ public final class GuiConfigurationEditorWorkspace {
private void runValidationAction() { private void runValidationAction() {
LOG.info("Aktion Validieren ausgeführt."); LOG.info("Aktion Validieren ausgeführt.");
// Re-run in-memory validation; this updates pendingMessages, pendingFieldFindings EditorValidationInput input = buildValidationInput();
// and lastValidationResult identically to the automatic background check. EditorValidationReport report = editorValidator.validate(input);
runEditorValidation();
// Replace any previous action-confirmation message; preserve all other messages. // Update field-level findings (drives red labels under problematic inputs).
pendingMessages.removeIf(m -> m.source().isPresent() pendingFieldFindings.clear();
&& "Validierung-Aktion".equals(m.source().get())); for (EditorValidationFinding finding : report.findings()) {
if (finding.hasFieldKey()) {
int findingCount = lastValidationResult.fieldFindings().size(); pendingFieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(),
String confirmationText; toGuiSeverity(finding.severity()), finding.message()));
if (findingCount == 0) {
confirmationText = "Aktion Validieren wurde ausgeführt. Keine Befunde.";
} else {
confirmationText = "Aktion Validieren wurde ausgeführt. "
+ findingCount + " Befund" + (findingCount == 1 ? "" : "e") + " gefunden.";
} }
}
// 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( pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.INFO, confirmationText, "Validierung-Aktion")); GuiMessageSeverity.INFO, confirmationText, "Validierung-Aktion"));
for (EditorValidationFinding finding : report.findings()) {
pendingMessages.add(GuiMessageEntry.of(
toGuiSeverity(finding.severity()), finding.message(), "Validierung-Aktion"));
}
lastValidationResult = new GuiEditorValidationResult( lastValidationResult = new GuiEditorValidationResult(
List.copyOf(pendingMessages), List.copyOf(pendingMessages),
@@ -1727,13 +1743,19 @@ public final class GuiConfigurationEditorWorkspace {
* list. * list.
* <p> * <p>
* Each message is rendered as one {@link TextFlow} row. The severity prefix is coloured using * 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 * the CSS colour from {@link GuiMessageSeverity#getPrefixCssColour()} and carries the message
* message text is always black. The placeholder text is shown when the list is empty. * 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.
* <p>
* 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.
* <p> * <p>
* Must be called on the JavaFX Application Thread. * Must be called on the JavaFX Application Thread.
*/ */
void refreshMessagesArea() { void refreshMessagesArea() {
messagesAreaBox.getChildren().clear(); messagesAreaBox.getChildren().clear();
attachAllCopyContextMenu();
if (pendingMessages.isEmpty()) { if (pendingMessages.isEmpty()) {
Text placeholder = new Text("Keine Meldungen vorhanden."); Text placeholder = new Text("Keine Meldungen vorhanden.");
placeholder.setStyle("-fx-fill: #888888;"); placeholder.setStyle("-fx-fill: #888888;");
@@ -1741,17 +1763,100 @@ public final class GuiConfigurationEditorWorkspace {
return; return;
} }
for (GuiMessageEntry entry : pendingMessages) { 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() + ";" prefix.setStyle("-fx-fill: " + entry.severity().getPrefixCssColour() + ";"
+ " -fx-font-weight: bold;"); + " -fx-font-weight: bold;");
Text body = new Text(entry.text()); Text body = new Text(entry.text());
body.setStyle("-fx-fill: black;"); body.setStyle("-fx-fill: black;");
TextFlow row = new TextFlow(prefix, body); TextFlow row = new TextFlow(prefix, body);
row.setStyle("-fx-padding: 1px 4px;"); row.setStyle("-fx-padding: 1px 4px;");
String fullLine = timestampLabel + " " + severityLabel + " " + entry.text();
attachRowCopyContextMenu(row, fullLine);
messagesAreaBox.getChildren().add(row); 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 // Field-level error label rendering
// ========================================================================= // =========================================================================
@@ -145,17 +145,16 @@ public final class GuiTechnicalTestCoordinator {
/** /**
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an. * Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
* <p> * <p>
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jeden * Fügt für jedes Checkpoint-Ergebnis einen neuen Eintrag zur geteilten Nachrichtenliste
* Checkpoint-Ergebnis einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung * hinzu; vorhandene Einträge bleiben erhalten, sodass die Meldungen über mehrere
* angehängt. * Testläufe hinweg akkumulieren. Zusätzlich wird eine Zusammenfassung angehängt.
* <p> * <p>
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}). * Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
* *
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein * @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
*/ */
private void applyResult(TechnicalTestReport report) { private void applyResult(TechnicalTestReport report) {
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik) // Akkumulieren: Vorherige Einträge anderer Läufe bleiben erhalten.
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
long successCount = 0; long successCount = 0;
long failureErrorCount = 0; long failureErrorCount = 0;
@@ -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.
* <p> * <p>
* This start state intentionally does not show the standard template yet. The template * The start state contains the standard configuration template so the GUI shows the
* is reserved for the explicit {@code Neu} action so the GUI starts without an implicit * default values immediately, equivalent to the explicit {@code Neu} action having been
* draft and only shows the welcome guidance until the user requests a new configuration. * 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() { public static GuiConfigurationEditorState createBlankStartState() {
return createStandardTemplate();
}
/**
* Creates a truly empty editor state without any template values.
* <p>
* 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( GuiConfigurationValues blankValues = new GuiConfigurationValues(
"", "",
"", "",
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.function.Consumer; import java.util.function.Consumer;
import javafx.geometry.Pos;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
import javafx.scene.control.TextField; 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) // Initial state: show text field (NOT_YET_LOADED → manual input)
applyVisibility(false); applyVisibility(false);
setAlignment(Pos.CENTER_LEFT);
getChildren().addAll(comboBox, textField); getChildren().addAll(comboBox, textField);
} }
@@ -206,14 +206,14 @@ class GuiAdapterSmokeTest {
// ========================================================================= // =========================================================================
/** /**
* Verifies that the editor workspace starts without a loaded configuration, shows the * Verifies that the editor workspace starts without a loaded configuration, immediately
* welcome guidance, and exposes the fixed GUI structure of the current shell. * 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 * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
@Order(5) @Order(5)
void editorWorkspace_startStateShowsEmptyHeaderWelcomeGuidanceAndOneTab() throws Exception { void editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab() throws Exception {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>(); AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<GuiConfigurationEditorWorkspace> workspaceReference = new AtomicReference<>(); AtomicReference<GuiConfigurationEditorWorkspace> workspaceReference = new AtomicReference<>();
@@ -225,10 +225,8 @@ class GuiAdapterSmokeTest {
assertEquals("", workspace.configurationPathText(), assertEquals("", workspace.configurationPathText(),
"The header path must stay empty before any configuration is loaded"); "The header path must stay empty before any configuration is loaded");
assertTrue(workspace.isWelcomeGuidanceVisible(), assertFalse(workspace.isWelcomeGuidanceVisible(),
"The welcome guidance must be visible in the unloaded start state"); "The welcome guidance must stay hidden because the standard template is shown immediately");
assertTrue(workspace.welcomeText().contains("Willkommen"),
"The welcome text must be shown in German");
assertNotNull(workspace.root(), assertNotNull(workspace.root(),
"The workspace root must be available"); "The workspace root must be available");
assertEquals("Neu", workspace.newButton().getText(), assertEquals("Neu", workspace.newButton().getText(),
@@ -196,14 +196,14 @@ class GuiEditorIntegrationTest {
// ========================================================================= // =========================================================================
/** /**
* Verifies that starting the GUI without a {@code --config} argument produces the defined * Verifies that starting the GUI without a {@code --config} argument shows the standard
* blank welcome state: header path is empty, welcome guidance is visible, and the editor is * template defaults immediately: header path is empty, welcome guidance is hidden, the
* not in dirty state. * editor is not in dirty state, and the standard default values are populated.
* *
* @throws Exception if the FX thread task fails or times out * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
void guiStartup_withoutConfigPath_showsBlankWelcomeState() throws Exception { void guiStartup_withoutConfigPath_showsStandardTemplateDefaults() throws Exception {
GuiStartupContext blankContext = GuiStartupContext.blank(Optional.empty()); GuiStartupContext blankContext = GuiStartupContext.blank(Optional.empty());
AtomicReference<Throwable> error = new AtomicReference<>(); AtomicReference<Throwable> error = new AtomicReference<>();
@@ -215,14 +215,14 @@ class GuiEditorIntegrationTest {
assertEquals("", workspace.configurationPathText(), assertEquals("", workspace.configurationPathText(),
"Header path must be empty when no configuration is loaded"); "Header path must be empty when no configuration is loaded");
assertTrue(workspace.isWelcomeGuidanceVisible(), assertFalse(workspace.isWelcomeGuidanceVisible(),
"Welcome guidance must be visible when no configuration is loaded"); "Welcome guidance must stay hidden because the standard template is shown immediately");
assertFalse(workspace.editorState().hasLoadedFileSnapshot(), 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(), assertFalse(workspace.editorState().isDirty(),
"Blank start state must not be dirty"); "Default start state must not be dirty");
assertTrue(workspace.welcomeText().contains("Willkommen"), assertEquals("./work/local/source", workspace.editorState().values().sourceFolder(),
"Welcome text must be shown in German"); "Default start state must populate the standard source folder");
} catch (Throwable t) { } catch (Throwable t) {
error.set(t); error.set(t);
@@ -302,8 +302,8 @@ class GuiEditorIntegrationTest {
try { try {
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context); GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
assertTrue(workspace.isWelcomeGuidanceVisible(), assertFalse(workspace.isWelcomeGuidanceVisible(),
"Welcome guidance must be visible when config path does not exist"); "Welcome guidance must stay hidden because the standard template is shown immediately");
assertEquals("", workspace.configurationPathText(), assertEquals("", workspace.configurationPathText(),
"Header path must be empty when config file was not found"); "Header path must be empty when config file was not found");
assertFalse(workspace.editorState().hasLoadedFileSnapshot(), assertFalse(workspace.editorState().hasLoadedFileSnapshot(),
@@ -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.
* <p> * <p>
* The workspace must display the welcome guidance, the header path must be empty, and * The workspace must keep the welcome guidance hidden because the standard template values
* the editor state must not have a file snapshot. "Neu" and "Öffnen" must be present. * 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 * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
@Order(1) @Order(1)
void guiStart_withoutConfig_showsBlankWelcomeStateAndExposesNeuAndOeffnenButtons() void guiStart_withoutConfig_showsStandardTemplateDefaultsAndExposesNeuAndOeffnenButtons()
throws Exception { throws Exception {
runOnFx(() -> { runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty()); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
assertTrue(ws.isWelcomeGuidanceVisible(), assertFalse(ws.isWelcomeGuidanceVisible(),
"Welcome guidance must be visible on blank start"); "Welcome guidance must stay hidden because the standard template is shown immediately");
assertEquals("", ws.configurationPathText(), assertEquals("", ws.configurationPathText(),
"Header path must be empty on blank start"); "Header path must be empty on default start");
assertFalse(ws.editorState().hasLoadedFileSnapshot(), assertFalse(ws.editorState().hasLoadedFileSnapshot(),
"No file snapshot must exist on blank start"); "No file snapshot must exist on default start");
assertFalse(ws.editorState().isDirty(), assertFalse(ws.editorState().isDirty(),
"Blank start state must not be dirty"); "Default start state must not be dirty");
assertEquals("Neu", ws.newButton().getText(), assertEquals("Neu", ws.newButton().getText(),
"'Neu' button must be present"); "'Neu' button must be present");
assertEquals("Öffnen", ws.openButton().getText(), assertEquals("Öffnen", ws.openButton().getText(),
@@ -131,23 +132,24 @@ class GuiEditorRegressionSmokeTest {
// ========================================================================= // =========================================================================
/** /**
* Regression: "Neu" switches the workspace to the standard template, hides the welcome * Regression: "Neu" reloads the standard template values, keeps the welcome guidance
* guidance, and leaves the state clean with all template fields populated. * hidden, and leaves the state clean with all template fields populated.
* *
* @throws Exception if the FX thread task fails or times out * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
@Order(2) @Order(2)
void neu_withStandardTemplate_populatesFieldsAndHidesWelcomeGuidance() throws Exception { void neu_withStandardTemplate_populatesFieldsAndKeepsWelcomeHidden() throws Exception {
runOnFx(() -> { runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty()); 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(); ws.requestNewConfiguration();
assertFalse(ws.isWelcomeGuidanceVisible(), assertFalse(ws.isWelcomeGuidanceVisible(),
"Welcome guidance must be hidden after 'Neu'"); "Welcome guidance must remain hidden after 'Neu'");
assertEquals("", ws.editorState().configurationPathText(), assertEquals("", ws.editorState().configurationPathText(),
"Path must remain empty after 'Neu' (no file saved yet)"); "Path must remain empty after 'Neu' (no file saved yet)");
assertFalse(ws.editorState().isDirty(), assertFalse(ws.editorState().isDirty(),
@@ -39,7 +39,7 @@ import javafx.scene.control.Button;
* {@code technical-tests-button}.</li> * {@code technical-tests-button}.</li>
* <li>Triggering the coordinator synchronously populates {@code pendingMessages} * <li>Triggering the coordinator synchronously populates {@code pendingMessages}
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li> * with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
* <li>A second trigger replaces the previous test entries (replace semantics).</li> * <li>A second trigger appends a fresh batch of test entries (accumulation semantics).</li>
* <li>The post-result callback is invoked after the result is applied.</li> * <li>The post-result callback is invoked after the result is applied.</li>
* </ul> * </ul>
* <p> * <p>
@@ -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 * Smoke test: triggering the coordinator twice accumulates both runs; the
* entries; the count remains the same as after a single trigger. * 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 * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
void trigger_twice_replacesPreviousTestEntries() throws Exception { void trigger_twice_accumulatesTestEntries() throws Exception {
runOnFx(() -> { runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>(); List<GuiMessageEntry> messages = new ArrayList<>();
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { }); GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
@@ -189,8 +190,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get())) && GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
.count(); .count();
assertEquals(countAfterFirst, countAfterSecond, assertEquals(countAfterFirst * 2, countAfterSecond,
"Second trigger must replace (not append) the previous test entries"); "Second trigger must append a fresh batch, doubling the SOURCE_TAG entries");
}); });
} }
@@ -247,9 +248,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt", "/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30", "https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.absent())); EffectiveApiKeyDescriptor.absent(), ""));
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
new EditorConfigurationValidator(), new EditorConfigurationValidator(),
@@ -283,9 +284,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt", "/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30", "https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.absent())); EffectiveApiKeyDescriptor.absent(), ""));
// Second trigger with the updated (unsaved) input. // Second trigger with the updated (unsaved) input.
coordinator.triggerTechnicalTests(); coordinator.triggerTechnicalTests();
@@ -370,9 +371,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt", "/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000", "3", "10", "2000",
"https://api.anthropic.com", "claude-3-sonnet", "30", "https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.absent()); EffectiveApiKeyDescriptor.absent(), "");
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator( GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
orchestrator, orchestrator,
@@ -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.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory; 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.GuiEditorValidationResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
@@ -34,11 +35,11 @@ import javafx.scene.control.Button;
* <h2>Covered scenarios</h2> * <h2>Covered scenarios</h2>
* <ul> * <ul>
* <li>Clicking "Validieren" with an incomplete configuration produces ERROR findings and * <li>Clicking "Validieren" with an incomplete configuration produces ERROR findings and
* an INFO message reporting the finding count.</li> * an INFO message reporting the finding count plus one entry per concrete finding.</li>
* <li>Clicking "Validieren" with a valid template configuration produces no ERRORs and * <li>Clicking "Validieren" with a valid template configuration produces no ERRORs and
* an INFO message reporting "Keine Befunde." or a zero count.</li> * an INFO message reporting "Keine Befunde." or a zero count.</li>
* <li>Clicking "Validieren" twice replaces the previous action-confirmation INFO message * <li>Clicking "Validieren" twice appends a second action-confirmation INFO message
* (replace semantics; the message appears exactly once).</li> * (accumulation semantics; each click adds a fresh snapshot).</li>
* <li>Clicking "Validieren" does not trigger any file write (the writer stub records no * <li>Clicking "Validieren" does not trigger any file write (the writer stub records no
* calls).</li> * calls).</li>
* <li>The button is findable by its CSS ID {@code validate-button}.</li> * <li>The button is findable by its CSS ID {@code validate-button}.</li>
@@ -112,8 +113,8 @@ class GuiValidateActionSmokeTest {
/** /**
* Smoke test: after clicking "Validieren" on a workspace whose editor state has * 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 * 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 * ERROR and the central message area contains one INFO confirmation with source
* "Validierung-Aktion" that reports the number of findings. * "Validierung-Aktion" plus one entry per concrete finding with the same source.
* *
* @throws Exception if the FX thread task fails or times out * @throws Exception if the FX thread task fails or times out
*/ */
@@ -122,8 +123,9 @@ class GuiValidateActionSmokeTest {
runOnFx(() -> { runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = buildWorkspace(); GuiConfigurationEditorWorkspace ws = buildWorkspace();
// Force an incomplete state: start with blank (no active provider). // Force an incomplete state: replace the editor state with a truly empty one
// The blank start state already has an empty active provider → errors expected. // (no active provider, no template values) so validation produces errors.
ws.editorState = GuiConfigurationTemplateFactory.createEmptyStartState();
ws.validateButton.fire(); ws.validateButton.fire();
@@ -136,15 +138,17 @@ class GuiValidateActionSmokeTest {
&& ACTION_SOURCE.equals(m.source().get())) && ACTION_SOURCE.equals(m.source().get()))
.toList(); .toList();
assertEquals(1, actionMessages.size(), assertFalse(actionMessages.isEmpty(),
"Exactly one action-confirmation INFO message must be present"); "At least one action message must be present");
GuiMessageEntry msg = actionMessages.get(0); GuiMessageEntry confirmation = actionMessages.get(0);
assertEquals(GuiMessageSeverity.INFO, msg.severity(), assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
"Action-confirmation message must have INFO severity"); "First action message must be the INFO confirmation");
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"); "Action-confirmation message text must start with expected prefix");
assertFalse(msg.text().contains("Keine Befunde"), assertFalse(confirmation.text().contains("Keine Befunde"),
"With errors present the message must NOT say '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())) && ACTION_SOURCE.equals(m.source().get()))
.toList(); .toList();
assertEquals(1, actionMessages.size(), assertFalse(actionMessages.isEmpty(),
"Exactly one action-confirmation INFO message must be present"); "At least one action message must be present");
GuiMessageEntry msg = actionMessages.get(0); GuiMessageEntry confirmation = actionMessages.get(0);
assertEquals(GuiMessageSeverity.INFO, msg.severity(), assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
"Action-confirmation message must have INFO severity"); "First action message must be the INFO confirmation");
// Template may have WARNINGs but no ERRORs. The fieldFindings count may be 0. // 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"); "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 * Smoke test: clicking "Validieren" twice must leave two action-confirmation
* INFO message in the message list (the second click replaces the first). * 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 * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
void validateAction_clickedTwice_infoMessageAppearsExactlyOnce() throws Exception { void validateAction_clickedTwice_infoMessageAppearsTwice() throws Exception {
runOnFx(() -> { runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = buildWorkspace(); GuiConfigurationEditorWorkspace ws = buildWorkspace();
ws.validateButton.fire(); ws.validateButton.fire();
ws.validateButton.fire(); ws.validateButton.fire();
long count = ws.pendingMessages.stream() long confirmationCount = ws.pendingMessages.stream()
.filter(m -> m.source().isPresent() .filter(m -> m.source().isPresent()
&& ACTION_SOURCE.equals(m.source().get())) && ACTION_SOURCE.equals(m.source().get()))
.filter(m -> m.text().startsWith("Aktion Validieren wurde ausgeführt."))
.count(); .count();
assertEquals(1, count, assertEquals(2, confirmationCount,
"After two clicks the action-confirmation INFO message must appear exactly once"); "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 req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration( .ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"), req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> noOpApiKeyResolutionPort(),
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.EffectiveApiKeyDescriptor.absent(),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( 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"), 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() { new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
@Override public boolean isDirectoryReadable(String p) { return false; } @Override public boolean isDirectoryReadable(String p) { return false; }
@Override public boolean isDirectoryWritableOrCreatable(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( 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"), 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.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { 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"); } @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 // 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, * Builds a workspace with no-op loader/writer and absent API-key resolution,
* suitable for in-memory validation tests. * suitable for in-memory validation tests.
@@ -354,12 +369,10 @@ class GuiValidateActionSmokeTest {
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration( .ModelCatalogResult.IncompleteConfiguration(
req.providerIdentifier(), "kein Port im Test"), req.providerIdentifier(), "kein Port im Test"),
(family, propertyValue) -> noOpApiKeyResolutionPort(),
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.EffectiveApiKeyDescriptor.absent(),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( 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"), 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() { new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
@Override public boolean isDirectoryReadable(String p) { return false; } @Override public boolean isDirectoryReadable(String p) { return false; }
@Override public boolean isDirectoryWritableOrCreatable(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( 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"), 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.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { 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"); } @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"); }
@@ -58,7 +58,7 @@ class GuiConfigurationTemplateFactoryTest {
} }
@Test @Test
void createBlankStartState_startsWithoutLoadedConfigurationAndWithoutTemplateValues() { void createBlankStartState_returnsStandardTemplateValuesWithoutLoadedFile() {
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createBlankStartState(); GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createBlankStartState();
assertFalse(state.isDirty()); assertFalse(state.isDirty());
@@ -66,6 +66,22 @@ class GuiConfigurationTemplateFactoryTest {
assertTrue(state.isNewConfiguration()); assertTrue(state.isNewConfiguration());
assertEquals("", state.configurationPathText()); 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(); GuiConfigurationValues values = state.values();
assertEquals("", values.sourceFolder()); assertEquals("", values.sourceFolder());
assertEquals("", values.targetFolder()); assertEquals("", values.targetFolder());
@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.adapter.out.validation; package de.gecheckt.pdf.umbenenner.adapter.out.validation;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.function.Function; import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
@@ -103,6 +104,50 @@ public class EnvironmentApiKeyResolutionAdapter implements ApiKeyResolutionPort
return EffectiveApiKeyDescriptor.absent(); 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<String> 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<String> 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<String> 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) { private static boolean isPresent(String value) {
return value != null && !value.isBlank(); return value != null && !value.isBlank();
} }
@@ -1,5 +1,7 @@
package de.gecheckt.pdf.umbenenner.application.validation.editor; 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.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
@@ -22,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApi
* <p> * <p>
* Implementierungen dieses Ports liegen im Adapter-Out-Modul. * Implementierungen dieses Ports liegen im Adapter-Out-Modul.
*/ */
@FunctionalInterface
public interface ApiKeyResolutionPort { public interface ApiKeyResolutionPort {
/** /**
@@ -37,4 +40,32 @@ public interface ApiKeyResolutionPort {
* @return der Descriptor für die effektive Schlüsselherkunft; nie {@code null} * @return der Descriptor für die effektive Schlüsselherkunft; nie {@code null}
*/ */
EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue); EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue);
/**
* Liefert den effektiven API-Schlüssel-Rohwert anhand derselben Vorrangregel wie {@link #resolve}.
* <p>
* 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.
* <p>
* 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<String> 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();
}
} }
@@ -28,10 +28,12 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApi
* @param claudeModel Rohtextwert des Claude-Modellnamens * @param claudeModel Rohtextwert des Claude-Modellnamens
* @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts * @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts
* @param claudeApiKeyDescriptor API-Key-Herkunft für den Claude-Provider; nie {@code null} * @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 openaiBaseUrl Rohtextwert der OpenAI-kompatiblen Basis-URL
* @param openaiModel Rohtextwert des OpenAI-kompatiblen Modellnamens * @param openaiModel Rohtextwert des OpenAI-kompatiblen Modellnamens
* @param openaiTimeoutSeconds Rohtextwert des OpenAI-kompatiblen Timeouts * @param openaiTimeoutSeconds Rohtextwert des OpenAI-kompatiblen Timeouts
* @param openaiApiKeyDescriptor API-Key-Herkunft für den OpenAI-kompatiblen Provider; nie {@code null} * @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( public record EditorValidationInput(
String activeProviderIdentifier, String activeProviderIdentifier,
@@ -46,10 +48,12 @@ public record EditorValidationInput(
String claudeModel, String claudeModel,
String claudeTimeoutSeconds, String claudeTimeoutSeconds,
EffectiveApiKeyDescriptor claudeApiKeyDescriptor, EffectiveApiKeyDescriptor claudeApiKeyDescriptor,
String claudeApiKeyPropertyValue,
String openaiBaseUrl, String openaiBaseUrl,
String openaiModel, String openaiModel,
String openaiTimeoutSeconds, String openaiTimeoutSeconds,
EffectiveApiKeyDescriptor openaiApiKeyDescriptor) { EffectiveApiKeyDescriptor openaiApiKeyDescriptor,
String openaiApiKeyPropertyValue) {
/** /**
* Erstellt eine neue Eingabe für den Validator. * 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 claudeModel Claude-Modellname; {@code null} wird zu leerem String
* @param claudeTimeoutSeconds Claude-Timeout; {@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 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 openaiBaseUrl OpenAI-Basis-URL; {@code null} wird zu leerem String
* @param openaiModel OpenAI-Modellname; {@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 openaiTimeoutSeconds OpenAI-Timeout; {@code null} wird zu leerem String
* @param openaiApiKeyDescriptor OpenAI-API-Key-Herkunft; darf nicht {@code null} sein * @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 * @throws NullPointerException wenn {@code claudeApiKeyDescriptor} oder {@code openaiApiKeyDescriptor} {@code null} sind
*/ */
public EditorValidationInput { public EditorValidationInput {
@@ -86,11 +92,13 @@ public record EditorValidationInput(
claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds); claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds);
claudeApiKeyDescriptor = Objects.requireNonNull(claudeApiKeyDescriptor, claudeApiKeyDescriptor = Objects.requireNonNull(claudeApiKeyDescriptor,
"claudeApiKeyDescriptor must not be null"); "claudeApiKeyDescriptor must not be null");
claudeApiKeyPropertyValue = normalizeText(claudeApiKeyPropertyValue);
openaiBaseUrl = normalizeText(openaiBaseUrl); openaiBaseUrl = normalizeText(openaiBaseUrl);
openaiModel = normalizeText(openaiModel); openaiModel = normalizeText(openaiModel);
openaiTimeoutSeconds = normalizeText(openaiTimeoutSeconds); openaiTimeoutSeconds = normalizeText(openaiTimeoutSeconds);
openaiApiKeyDescriptor = Objects.requireNonNull(openaiApiKeyDescriptor, openaiApiKeyDescriptor = Objects.requireNonNull(openaiApiKeyDescriptor,
"openaiApiKeyDescriptor must not be null"); "openaiApiKeyDescriptor must not be null");
openaiApiKeyPropertyValue = normalizeText(openaiApiKeyPropertyValue);
} }
private static String normalizeText(String value) { private static String normalizeText(String value) {
@@ -79,6 +79,8 @@ public class ProviderTechnicalTestService {
private static final int DEFAULT_TIMEOUT_SECONDS = 30; private static final int DEFAULT_TIMEOUT_SECONDS = 30;
private final AiModelCatalogPort modelCatalogPort; private final AiModelCatalogPort modelCatalogPort;
private final ApiKeyResolutionPort apiKeyResolutionPort;
/** /**
* Erstellt einen neuen Service mit den erforderlichen Ports. * Erstellt einen neuen Service mit den erforderlichen Ports.
* *
@@ -89,7 +91,7 @@ public class ProviderTechnicalTestService {
public ProviderTechnicalTestService(AiModelCatalogPort modelCatalogPort, public ProviderTechnicalTestService(AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort) { ApiKeyResolutionPort apiKeyResolutionPort) {
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort, "modelCatalogPort must not be null"); this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort, "modelCatalogPort must not be null");
Objects.requireNonNull(apiKeyResolutionPort, this.apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
"apiKeyResolutionPort must not be null"); "apiKeyResolutionPort must not be null");
} }
@@ -377,13 +379,10 @@ public class ProviderTechnicalTestService {
/** /**
* Baut den {@link ModelCatalogRequest} aus dem aktuellen Editorzustand auf. * Baut den {@link ModelCatalogRequest} aus dem aktuellen Editorzustand auf.
* <p> * <p>
* Da {@link EditorValidationInput} keinen direkten API-Key-String enthält, sondern * Der effektive API-Key-Rohwert wird über den {@link ApiKeyResolutionPort} ermittelt
* nur einen bereits aufgelösten {@link EffectiveApiKeyDescriptor}, wird der Descriptor * (Vorrangregel: providerspezifische ENV → Legacy-ENV → Property-Wert) und in den
* aus dem Editorzustand direkt verwendet. Der Adapter-Out-Seitige Dispatcher erwartet * Request übernommen. Dadurch ist der Schlüssel bereits beim Adapter verfügbar und
* den Key entweder als ENV-Variable (die er selbst liest) oder als optionalen Wert * spiegelt exakt die Quelle wider, die zuvor im Deskriptor ausgewiesen wurde.
* 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.
* *
* @param input aktueller Editorzustand * @param input aktueller Editorzustand
* @param family aktive Provider-Familie * @param family aktive Provider-Familie
@@ -393,12 +392,9 @@ public class ProviderTechnicalTestService {
private ModelCatalogRequest buildCatalogRequest(EditorValidationInput input, private ModelCatalogRequest buildCatalogRequest(EditorValidationInput input,
AiProviderFamily family, AiProviderFamily family,
EffectiveApiKeyDescriptor apiKeyDesc) { EffectiveApiKeyDescriptor apiKeyDesc) {
// EditorValidationInput enthält keinen direkten API-Key-String-Wert, nur den Descriptor. String propertyValue = resolveApiKeyPropertyValue(input, family);
// Für den ModelCatalogRequest übergeben wir einen leeren Optional für den apiKey, Optional<String> apiKeyForRequest = apiKeyResolutionPort
// sodass der Adapter seine eigene ENV-Variable-Auflösung durchführt. .resolveEffectiveApiKeyValue(family, propertyValue);
// 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<String> apiKeyForRequest = Optional.empty();
String rawBaseUrl = resolveBaseUrlValue(input, family); String rawBaseUrl = resolveBaseUrlValue(input, family);
Optional<String> baseUrl = rawBaseUrl.isBlank() ? Optional.empty() : Optional.of(rawBaseUrl); Optional<String> baseUrl = rawBaseUrl.isBlank() ? Optional.empty() : Optional.of(rawBaseUrl);
@@ -412,6 +408,21 @@ public class ProviderTechnicalTestService {
timeout); 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 * Liest den bereits aufgelösten {@link EffectiveApiKeyDescriptor} für die aktive Provider-Familie
* direkt aus dem {@link EditorValidationInput}. * direkt aus dem {@link EditorValidationInput}.
@@ -41,10 +41,12 @@ class EditorConfigurationValidatorTest {
"claude-3-5-sonnet", // claudeModel "claude-3-5-sonnet", // claudeModel
"30", // claudeTimeoutSeconds "30", // claudeTimeoutSeconds
EffectiveApiKeyDescriptor.fromPropertyFile(), // claudeApiKeyDescriptor EffectiveApiKeyDescriptor.fromPropertyFile(), // claudeApiKeyDescriptor
"sk-claude", // claudeApiKeyPropertyValue
"https://api.openai.com", // openaiBaseUrl "https://api.openai.com", // openaiBaseUrl
"gpt-4", // openaiModel "gpt-4", // openaiModel
"30", // openaiTimeoutSeconds "30", // openaiTimeoutSeconds
EffectiveApiKeyDescriptor.fromPropertyFile() // openaiApiKeyDescriptor EffectiveApiKeyDescriptor.fromPropertyFile(), // openaiApiKeyDescriptor
"sk-openai" // openaiApiKeyPropertyValue
); );
} }
@@ -57,8 +59,8 @@ class EditorConfigurationValidatorTest {
EditorValidationInput input = new EditorValidationInput( EditorValidationInput input = new EditorValidationInput(
"", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(), "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -74,8 +76,8 @@ class EditorConfigurationValidatorTest {
EditorValidationInput input = new EditorValidationInput( EditorValidationInput input = new EditorValidationInput(
"unknown-provider", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "unknown-provider", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(), "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -94,8 +96,8 @@ class EditorConfigurationValidatorTest {
"claude", "", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -109,8 +111,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -124,8 +126,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "", "C:/prompt.txt", "claude", "C:/source", "C:/target", "", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -139,8 +141,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "", "claude", "C:/source", "C:/target", "C:/db.sqlite", "",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -158,8 +160,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"0", "10", "500", "0", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -173,8 +175,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"-1", "10", "500", "-1", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -188,8 +190,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"1", "10", "500", "1", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -204,8 +206,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"abc", "10", "500", "abc", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -223,8 +225,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "0", "500", "3", "0", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -238,8 +240,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "101", "500", "3", "101", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -253,8 +255,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "100", "500", "3", "100", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -275,8 +277,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "1000", "3", "10", "1000",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -290,8 +292,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "1001", "3", "10", "1001",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -305,8 +307,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "3000", "3", "10", "3000",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -320,8 +322,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "3001", "3", "10", "3001",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -340,8 +342,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "0", "3", "10", "0",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -359,8 +361,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "", "30", "https://api.anthropic.com", "", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -374,8 +376,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"", "claude-3-5-sonnet", "30", "", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -389,8 +391,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "-5", "https://api.anthropic.com", "claude-3-5-sonnet", "-5",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -408,8 +410,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), EffectiveApiKeyDescriptor.absent(), "",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -434,8 +436,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY"), EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY"), "",
"", "", "30", EffectiveApiKeyDescriptor.absent()); "", "", "30", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -449,9 +451,9 @@ class EditorConfigurationValidatorTest {
EditorValidationInput input = new EditorValidationInput( EditorValidationInput input = new EditorValidationInput(
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(), "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY")); EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY"), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -478,9 +480,9 @@ class EditorConfigurationValidatorTest {
EditorValidationInput input = new EditorValidationInput( EditorValidationInput input = new EditorValidationInput(
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"", "", "30", EffectiveApiKeyDescriptor.absent(), "", "", "30", EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.fromPropertyFile()); EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-openai");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -498,8 +500,8 @@ class EditorConfigurationValidatorTest {
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-5-sonnet", "30", "https://api.anthropic.com", "claude-3-5-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude",
"", "", "", EffectiveApiKeyDescriptor.absent()); "", "", "", EffectiveApiKeyDescriptor.absent(), "");
EditorValidationReport report = validator.validate(input); EditorValidationReport report = validator.validate(input);
@@ -29,9 +29,9 @@ class ProviderTechnicalTestServiceTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt", "/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000", "3", "10", "2000",
"https://api.anthropic.com", model, "30", "https://api.anthropic.com", model, "30",
apiKeyDescriptor, apiKeyDescriptor, "sk-test",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.absent()); EffectiveApiKeyDescriptor.absent(), "");
} }
private static EditorValidationInput openaiInput(EffectiveApiKeyDescriptor apiKeyDescriptor, private static EditorValidationInput openaiInput(EffectiveApiKeyDescriptor apiKeyDescriptor,
@@ -41,9 +41,9 @@ class ProviderTechnicalTestServiceTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt", "/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000", "3", "10", "2000",
"https://api.anthropic.com", "claude-3-sonnet", "30", "https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", model, "30", "https://api.openai.com", model, "30",
apiKeyDescriptor); apiKeyDescriptor, "sk-test");
} }
private static EffectiveApiKeyDescriptor keyFromEnv() { private static EffectiveApiKeyDescriptor keyFromEnv() {
@@ -60,11 +60,32 @@ class ProviderTechnicalTestServiceTest {
/** /**
* Stub-Port der immer den über den Konstruktor übergebenen Descriptor zurückgibt, * 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 private static de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort
apiKeyPort(EffectiveApiKeyDescriptor descriptor) { 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<String> 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", "/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000", "3", "10", "2000",
"", "model", "30", "", "model", "30",
EffectiveApiKeyDescriptor.absent(), EffectiveApiKeyDescriptor.absent(), "",
"", "model", "30", "", "model", "30",
EffectiveApiKeyDescriptor.absent()); EffectiveApiKeyDescriptor.absent(), "");
List<CheckpointResult> results = service.runProviderChecks(input); List<CheckpointResult> results = service.runProviderChecks(input);
@@ -31,9 +31,9 @@ class TechnicalTestOrchestratorTest {
"/src", "/tgt", "/db.sqlite", "/prompt.txt", "/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30", "https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test",
"https://api.openai.com", "gpt-4", "30", "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}. */ /** No-op {@link PathCheckPort}: alle Prüfungen liefern {@code false}. */
@@ -372,9 +372,9 @@ class TechnicalTestOrchestratorTest {
"", // kein Prompt-Pfad "", // kein Prompt-Pfad
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30", "https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.absent()); EffectiveApiKeyDescriptor.absent(), "");
// PathCheckPort: Dateien fehlen, aber Elternordner sind schreibbar // PathCheckPort: Dateien fehlen, aber Elternordner sind schreibbar
PathCheckPort pathPort = new PathCheckPort() { PathCheckPort pathPort = new PathCheckPort() {
@@ -417,9 +417,9 @@ class TechnicalTestOrchestratorTest {
"", // kein Prompt-Pfad "", // kein Prompt-Pfad
"3", "10", "500", "3", "10", "500",
"https://api.anthropic.com", "claude-3-sonnet", "30", "https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.fromPropertyFile(), EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test",
"https://api.openai.com", "gpt-4", "30", "https://api.openai.com", "gpt-4", "30",
EffectiveApiKeyDescriptor.absent()); EffectiveApiKeyDescriptor.absent(), "");
PathCheckPort pathPort = new PathCheckPort() { PathCheckPort pathPort = new PathCheckPort() {
@Override public boolean isDirectoryReadable(String p) { return true; } @Override public boolean isDirectoryReadable(String p) { return true; }
@@ -16,8 +16,8 @@ class TechnicalTestRequestTest {
private static EditorValidationInput minimalInput() { private static EditorValidationInput minimalInput() {
return new EditorValidationInput( return new EditorValidationInput(
"claude", "", "", "", "", "3", "10", "2000", "claude", "", "", "", "", "3", "10", "2000",
"", "model-x", "60", EffectiveApiKeyDescriptor.absent(), "", "model-x", "60", EffectiveApiKeyDescriptor.absent(), "",
"", "", "60", EffectiveApiKeyDescriptor.absent()); "", "", "60", EffectiveApiKeyDescriptor.absent(), "");
} }
@Test @Test