diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBanner.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBanner.java new file mode 100644 index 0000000..b8961aa --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBanner.java @@ -0,0 +1,202 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus; +import javafx.application.Platform; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; + +/** + * Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs + * die aggregierten Ergebnisse anzeigt. + * + *

Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und + * oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null + * ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf: + * {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im + * Enum {@link DocumentCompletionStatus} nicht enthalten – alle enthaltenen Werte + * werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen + * abgeschlossenen Zustand darstellen. + * + *

Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält + * ein Icon und einen Text. + * + *

Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind + * thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen + * werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden). + * Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig + * unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden. + */ +public final class BatchRunSummaryBanner { + + /** Trennzeichen zwischen den Kategoriesegmenten. */ + private static final String SEGMENT_SEPARATOR = " · "; + + /** Abstand zwischen den Label-Segmenten in Pixeln. */ + private static final int SPACING = 0; + + /** Innerer Abstand des Containers in Pixeln (oben/unten). */ + private static final double PADDING_V = 4.0; + + /** Standardfarbe für den Summentext. */ + private static final String STYLE_DEFAULT = "-fx-font-size: 12;"; + + /** + * Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden, + * in der verbindlichen Anzeigereihenfolge gemäß Spezifikation. + */ + private static final List DISPLAYED_ORDER = List.of( + DocumentCompletionStatus.SUCCESS, + DocumentCompletionStatus.FAILED_RETRYABLE, + DocumentCompletionStatus.FAILED_PERMANENT, + DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, + DocumentCompletionStatus.SKIPPED_FINAL_FAILURE + ); + + /** Wurzel-Container des Banners – wird in das Tab-Layout eingebettet. */ + private final HBox container; + + /** Label, das den kompletten Bannertext als Inline-Segmente trägt. */ + private final Label contentLabel; + + /** + * Erstellt ein neues, initial unsichtbares Summary-Banner. + */ + public BatchRunSummaryBanner() { + contentLabel = new Label(); + contentLabel.setStyle(STYLE_DEFAULT); + contentLabel.setWrapText(false); + + container = new HBox(SPACING, contentLabel); + container.setAlignment(Pos.CENTER_LEFT); + container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;"); + + // Initial unsichtbar, nimmt keinen Platz ein + container.setVisible(false); + container.setManaged(false); + } + + // ------------------------------------------------------------------------- + // Öffentliche API + // ------------------------------------------------------------------------- + + /** + * Versteckt das Banner und leert seinen Inhalt. + * + *

Muss auf dem JavaFX Application Thread aufgerufen werden. + */ + public void clear() { + contentLabel.setText(""); + container.setVisible(false); + container.setManaged(false); + } + + /** + * Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar. + * + *

Zeigt nur Kategorien mit Anzahl > 0. Wenn alle Zähler null sind (leerer Lauf), + * wird das Banner versteckt. + * + *

Muss auf dem JavaFX Application Thread aufgerufen werden. + * + * @param counts Zuordnung von Verarbeitungsstatus zu Anzahl; + * fehlende Status werden als 0 interpretiert; darf nicht null sein + */ + public void update(Map counts) { + Objects.requireNonNull(counts, "counts darf nicht null sein"); + + String text = buildBannerText(counts); + if (text.isEmpty()) { + clear(); + return; + } + + contentLabel.setText(text); + container.setVisible(true); + container.setManaged(true); + } + + /** + * Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout. + * + * @return der Container-Knoten; nie null + */ + public HBox getNode() { + return container; + } + + // ------------------------------------------------------------------------- + // Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX) + // ------------------------------------------------------------------------- + + /** + * Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen + * Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie + * keinen abgeschlossenen Verarbeitungszustand darstellen. + * + *

Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem + * Thread aufgerufen werden. + * + * @param rows die Ergebniszeilen des Laufs; darf nicht null sein; + * null-Elemente werden übersprungen + * @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden + * Status (fehlende haben Wert 0); nie null + */ + public static Map aggregateCounts( + Iterable rows) { + Objects.requireNonNull(rows, "rows darf nicht null sein"); + + Map counts = new EnumMap<>(DocumentCompletionStatus.class); + // Alle anzuzeigenden Status mit 0 vorbelegen + for (DocumentCompletionStatus status : DISPLAYED_ORDER) { + counts.put(status, 0); + } + + for (GuiBatchRunResultRow row : rows) { + if (row == null) { + continue; + } + // Reset-Pending-Zeilen zählen nicht – sie haben noch keinen abgeschlossenen Status + if (row.resetPending()) { + continue; + } + DocumentCompletionStatus status = row.status(); + // Nur anzuzeigende Status zählen (entspricht dem Ausschluss von + // Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING) + if (counts.containsKey(status)) { + counts.merge(status, 1, Integer::sum); + } + } + return counts; + } + + // ------------------------------------------------------------------------- + // Interne Hilfsmethoden + // ------------------------------------------------------------------------- + + /** + * Erzeugt den angezeigten Bannertext aus den Zählern. + * Liefert einen leeren String wenn alle Zähler null sind. + * + * @param counts die Zähler je Status; darf nicht null sein + * @return der fertige Bannertext oder ein leerer String + */ + static String buildBannerText(Map counts) { + List segments = new ArrayList<>(); + for (DocumentCompletionStatus status : DISPLAYED_ORDER) { + int count = counts.getOrDefault(status, 0); + if (count > 0) { + String icon = ProcessingStatusPresentation.iconFor(status); + String category = ProcessingStatusPresentation.summaryCategoryFor(status); + segments.add(icon + " " + count + " " + category); + } + } + return String.join(SEGMENT_SEPARATOR, segments); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java index c2ed63e..dc47c96 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java @@ -199,6 +199,9 @@ public final class GuiBatchRunTab { /** PDF-Vorschau-Komponente im Detailbereich. */ private final PdfPreviewPane pdfPreview = new PdfPreviewPane(); + /** Summary-Banner unterhalb des Fortschrittsbalkens – sichtbar nach Laufabschluss. */ + private final BatchRunSummaryBanner summaryBanner = new BatchRunSummaryBanner(); + private final Supplier configPathSupplier; private final BooleanSupplier savedConfigurationReadyCheck; private final Runnable onRunStateChanged; @@ -501,8 +504,14 @@ public final class GuiBatchRunTab { HBox.setHgrow(progressBar, Priority.ALWAYS); counterLabel.setId("batch-run-counter"); - HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel); - header.setAlignment(Pos.CENTER_LEFT); + HBox progressRow = new HBox(SECONDARY_SPACING, progressBar, counterLabel); + progressRow.setAlignment(Pos.CENTER_LEFT); + + // Summary-Banner unterhalb des Fortschrittsbalkens, oberhalb der Tabelle + HBox bannerNode = summaryBanner.getNode(); + bannerNode.setId("batch-run-summary-banner"); + + VBox header = new VBox(0, progressRow, bannerNode); header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0)); return header; } @@ -1187,6 +1196,7 @@ public final class GuiBatchRunTab { messageArea.setVisible(false); messageArea.setManaged(false); messageArea.setStyle(null); + summaryBanner.clear(); resetMetrics(); updateCounterLabel(); progressBar.setProgress(0); @@ -1580,6 +1590,10 @@ public final class GuiBatchRunTab { miniRunCompletedFingerprints = new HashSet<>(); } selectedRows.clear(); + // Summary-Banner aus der aktuellen Ergebnisliste aggregieren und anzeigen + Map counts = + BatchRunSummaryBanner.aggregateCounts(resultItems); + summaryBanner.update(counts); appendSummary(outcome); updateButtonStates(); notifyRunStateChanged(); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBannerTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBannerTest.java new file mode 100644 index 0000000..777c865 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBannerTest.java @@ -0,0 +1,233 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; + +/** + * Unit-Tests für {@link BatchRunSummaryBanner}. + *

+ * Geprüft werden die Aggregationslogik und die Textgenerierung unabhängig von JavaFX. + * Die GUI-Integrationsmethoden ({@code clear()}, {@code update()}, {@code getNode()}) + * erfordern eine JavaFX-Runtime und werden durch Smoke-Tests abgedeckt. + */ +class BatchRunSummaryBannerTest { + + // ------------------------------------------------------------------------- + // Hilfsmethoden für Testdaten + // ------------------------------------------------------------------------- + + private static GuiBatchRunResultRow row(DocumentCompletionStatus status) { + return new GuiBatchRunResultRow( + "test.pdf", + new DocumentFingerprint("a".repeat(64)), + status, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Duration.ZERO, + false, + Optional.empty()); + } + + private static GuiBatchRunResultRow resetPendingRow() { + GuiBatchRunResultRow base = row(DocumentCompletionStatus.SUCCESS); + return GuiBatchRunResultRow.resetMarker(base); + } + + // ------------------------------------------------------------------------- + // aggregateCounts + // ------------------------------------------------------------------------- + + @Test + void aggregateCounts_leereListe_alleZaehlerNull() { + Map counts = + BatchRunSummaryBanner.aggregateCounts(Collections.emptyList()); + + assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SUCCESS, 0)); + assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_RETRYABLE, 0)); + assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_PERMANENT, 0)); + assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0)); + assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0)); + } + + @Test + void aggregateCounts_nurErfolgreiche_zaehltNurSuccess() { + List rows = List.of( + row(DocumentCompletionStatus.SUCCESS), + row(DocumentCompletionStatus.SUCCESS), + row(DocumentCompletionStatus.SUCCESS)); + + Map counts = + BatchRunSummaryBanner.aggregateCounts(rows); + + assertEquals(3, counts.get(DocumentCompletionStatus.SUCCESS)); + assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE)); + assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT)); + assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED)); + assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE)); + } + + @Test + void aggregateCounts_gemischterLauf_alleKategorienKorrekt() { + List rows = List.of( + row(DocumentCompletionStatus.SUCCESS), + row(DocumentCompletionStatus.SUCCESS), + row(DocumentCompletionStatus.FAILED_RETRYABLE), + row(DocumentCompletionStatus.FAILED_PERMANENT), + row(DocumentCompletionStatus.FAILED_PERMANENT), + row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED), + row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED), + row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED), + row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE)); + + Map counts = + BatchRunSummaryBanner.aggregateCounts(rows); + + assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS)); + assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE)); + assertEquals(2, counts.get(DocumentCompletionStatus.FAILED_PERMANENT)); + assertEquals(3, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED)); + assertEquals(1, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE)); + } + + @Test + void aggregateCounts_resetPendingZeilenWerdenNichtGezaehlt() { + // Reset-Pending-Zeilen haben noch keinen abgeschlossenen Status und + // dürfen nicht ins Summary einfließen + List rows = List.of( + row(DocumentCompletionStatus.SUCCESS), + resetPendingRow(), + resetPendingRow()); + + Map counts = + BatchRunSummaryBanner.aggregateCounts(rows); + + assertEquals(1, counts.get(DocumentCompletionStatus.SUCCESS)); + assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE)); + assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT)); + } + + // ------------------------------------------------------------------------- + // buildBannerText + // ------------------------------------------------------------------------- + + @Test + void buildBannerText_alleZaehlerNull_leerString() { + Map counts = Map.of( + DocumentCompletionStatus.SUCCESS, 0, + DocumentCompletionStatus.FAILED_RETRYABLE, 0, + DocumentCompletionStatus.FAILED_PERMANENT, 0, + DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0, + DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0); + + String text = BatchRunSummaryBanner.buildBannerText(counts); + + assertTrue(text.isEmpty(), "Leere Zähler ergeben leeren Text: '" + text + "'"); + } + + @Test + void buildBannerText_nurErfolgreiche_nurSuccessSegment() { + Map counts = Map.of( + DocumentCompletionStatus.SUCCESS, 17, + DocumentCompletionStatus.FAILED_RETRYABLE, 0, + DocumentCompletionStatus.FAILED_PERMANENT, 0, + DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0, + DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0); + + String text = BatchRunSummaryBanner.buildBannerText(counts); + + assertTrue(text.contains("17"), "Anzahl 17 muss im Text erscheinen: " + text); + assertTrue(text.contains("erfolgreich"), "Kategorie 'erfolgreich' muss erscheinen: " + text); + assertTrue(text.contains("✓"), "Icon ✓ muss erscheinen: " + text); + assertFalse(text.contains("↻"), "Kein ↻ wenn FAILED_RETRYABLE = 0: " + text); + assertFalse(text.contains("×"), "Kein × wenn FAILED_PERMANENT = 0: " + text); + assertFalse(text.contains("≡"), "Kein ≡ wenn SKIPPED_ALREADY_PROCESSED = 0: " + text); + assertFalse(text.contains("⊘"), "Kein ⊘ wenn SKIPPED_FINAL_FAILURE = 0: " + text); + } + + @Test + void buildBannerText_vollerLauf_alleSegmenteEnthalten() { + Map counts = Map.of( + DocumentCompletionStatus.SUCCESS, 14, + DocumentCompletionStatus.FAILED_RETRYABLE, 1, + DocumentCompletionStatus.FAILED_PERMANENT, 2, + DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 3, + DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 1); + + String text = BatchRunSummaryBanner.buildBannerText(counts); + + // Jedes Segment enthält Icon + Anzahl + Kategorie + assertTrue(text.contains("✓ 14 erfolgreich"), "SUCCESS-Segment: " + text); + assertTrue(text.contains("↻ 1 wird wiederholt"), "FAILED_RETRYABLE-Segment: " + text); + assertTrue(text.contains("× 2 fehlgeschlagen"), "FAILED_PERMANENT-Segment: " + text); + assertTrue(text.contains("≡ 3 übersprungen"), "SKIPPED_ALREADY_PROCESSED-Segment: " + text); + assertTrue(text.contains("⊘ 1 endgültig übersprungen"), "SKIPPED_FINAL_FAILURE-Segment: " + text); + } + + @Test + void buildBannerText_nurSkippedFinalFailure_erscheintImBanner() { + // Sicherstellung: ⊘ erscheint auch wenn > 0, obwohl es die seltenste Kategorie ist + Map counts = Map.of( + DocumentCompletionStatus.SUCCESS, 0, + DocumentCompletionStatus.FAILED_RETRYABLE, 0, + DocumentCompletionStatus.FAILED_PERMANENT, 0, + DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0, + DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 2); + + String text = BatchRunSummaryBanner.buildBannerText(counts); + + assertTrue(text.contains("⊘"), "Icon ⊘ muss erscheinen: " + text); + assertTrue(text.contains("2"), "Anzahl 2 muss erscheinen: " + text); + assertTrue(text.contains("endgültig übersprungen"), "Kategorie muss erscheinen: " + text); + } + + @Test + void buildBannerText_nurKategorienMitAnzahlGroesserNull_erscheinen() { + // Nur SUCCESS=5 ist gesetzt; alle anderen 0 → kein anderes Segment + Map counts = Map.of( + DocumentCompletionStatus.SUCCESS, 5, + DocumentCompletionStatus.FAILED_RETRYABLE, 0, + DocumentCompletionStatus.FAILED_PERMANENT, 0, + DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0, + DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0); + + String text = BatchRunSummaryBanner.buildBannerText(counts); + + // Kein Trennzeichen (·) darf erscheinen, wenn nur ein Segment vorhanden ist + assertFalse(text.contains("·"), "Kein Trenner bei einzelnem Segment: " + text); + assertTrue(text.contains("✓ 5 erfolgreich"), "Nur SUCCESS-Segment: " + text); + } + + @Test + void aggregateCounts_kombinationMitResetPending_nurEchtAbgeschlosseneGezaehlt() { + // 2 SUCCESS + 1 FAILED_PERMANENT + 1 resetPending(SUCCESS) → nur 2+1 gezählt + List rows = List.of( + row(DocumentCompletionStatus.SUCCESS), + row(DocumentCompletionStatus.SUCCESS), + row(DocumentCompletionStatus.FAILED_PERMANENT), + resetPendingRow()); + + Map counts = + BatchRunSummaryBanner.aggregateCounts(rows); + + assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS)); + assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_PERMANENT)); + // Summe aller gezählten Einträge = 3, nicht 4 + int total = counts.values().stream().mapToInt(Integer::intValue).sum(); + assertEquals(3, total, "Reset-Pending darf nicht mitgezählt werden"); + } +}