diff --git a/CLAUDE.md b/CLAUDE.md index 3e26216..737476d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,16 +21,16 @@ Die Dokumente haben folgende feste Bedeutung: Bei Konflikten gilt folgende Priorität: -1. **Technik- und Architektur-Dokument** +1. **Technik- und Architektur-Dokument** Verbindliche technische Zielarchitektur. Architekturbrüche sind unzulässig. -2. **Fachliche Anforderungen** +2. **Fachliche Anforderungen** Verbindliche fachliche Regeln und fachliches Zielverhalten. -3. **Meilensteine** +3. **Meilensteine** Begrenzen den zulässigen Funktionsumfang auf den aktuellen Entwicklungsstand. -4. **Arbeitspakete** +4. **Arbeitspakete** Definieren den konkret erlaubten Umsetzungsumfang des aktuellen Schritts. Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und keine stillen Annahmen treffen. @@ -66,11 +66,13 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein - Adapter dürfen nicht direkt voneinander abhängen - Keine Vermischung von Dateisystem, PDF-Auslese, SQLite, KI-HTTP, Konfiguration, Logging, Benennungslogik und Retry-Entscheidungen - Logging ist technische Infrastruktur, kein fachlicher Port +- Port-Verträge enthalten weder `Path`/`File` noch NIO- oder JDBC-Typen ## Globale fachliche Leitplanken - Zielformat: `YYYY-MM-DD - Titel.pdf` - Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ... - Die **20 Zeichen** gelten nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit +- Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt - Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen - Eigennamen bleiben unverändert - Datumsermittlung mit Priorität aus den fachlichen Anforderungen; wenn kein belastbares Datum eindeutig ableitbar ist, ist das **aktuelle Datum** als Fallback erlaubt @@ -81,12 +83,111 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein - Identifikation erfolgt **nicht** über Dateinamen - Quelldateien werden **nie** überschrieben, verändert, verschoben oder gelöscht +## Aktiver Implementierungsstand + +M1 bis M5 sind vollständig abgeschlossen. Der aktive Stand ergänzt den vollständigen +Erfolgspfad: korrekt benannte Zielkopie erzeugen und Enderfolg konsistent persistieren. + +### Baseline aus M5 +- Externer Prompt-Bezug über konfigurierbare Prompt-Datei +- OpenAI-kompatibler HTTP-Adapter vollständig verdrahtet +- Validierte KI-Antwort mit `date`, `title`, `reasoning` +- Persistierter Benennungsvorschlag mit Status `PROPOSAL_READY` +- Versuchshistorie mit KI-Nachvollziehbarkeit (Modell, Prompt-ID, Zeichenzahl, Rohantwort, Reasoning, Datum, Datumsquelle) +- Idempotente Migration M4 → M5 + +### Ziel des aktiven Stands +- Technische Dateinamensbildung im Format `YYYY-MM-DD - Titel.pdf` +- Dublettenbehandlung im Zielordner: `(1)`, `(2)`, … +- Physische Zielkopie via temporäre Datei und finalem Move/Rename +- Schemaevolution auf den aktiven Stand (Zielpfad, Zieldateiname) +- Statustransition `PROPOSAL_READY` → `SUCCESS` +- Zusätzliche Historisierung für Enderfolg und technische Fehler (Proposal-Versuch bleibt erhalten) +- Startvalidierung für Zielordner-Konfiguration (`target.folder`) + +## Statussemantik + +| Status | Bedeutung | +|---|---| +| `READY_FOR_AI` | Verarbeitbar, KI-Pfad noch nicht durchlaufen | +| `FAILED_RETRYABLE` | Verarbeitbar, transient fehlgeschlagen | +| `PROPOSAL_READY` | Eingangszustand für Dateinamensbildung und Zielkopie | +| `SUCCESS` | Terminaler Enderfolg – nur nach Zielkopie und konsistenter Persistenz zulässig | +| `FAILED_FINAL` | Terminal, wird nicht erneut fachlich verarbeitet | +| `SKIPPED_ALREADY_PROCESSED` | Historisierter Skip für SUCCESS-Dokumente | +| `SKIPPED_FINAL_FAILURE` | Historisierter Skip für FAILED_FINAL-Dokumente | + +### SUCCESS-Bedingung (verbindlich) +`SUCCESS` darf erst gesetzt werden, wenn: +1. die Zielkopie erfolgreich geschrieben wurde, +2. der finale Zieldateiname bestimmt ist, +3. die Persistenz konsistent fortgeschrieben wurde. + +### Führende Quelle des Benennungsvorschlags (verbindlich) +- Die führende Quelle für Datum, Datumsquelle, validierten Titel und Reasoning ist der **neueste Versuchshistorieneintrag mit Status `PROPOSAL_READY`**. +- Kein Rekonstruieren aus dem Dokument-Stammsatz. +- Kein neuer KI-Aufruf, wenn bereits ein nutzbarer `PROPOSAL_READY`-Versuch vorliegt. +- Status `PROPOSAL_READY` ohne lesbaren konsistenten Proposal-Versuch = dokumentbezogener technischer Fehler. +- Proposal-Versuch mit fachlich unbrauchbarem Titel oder Datum = inkonsistenter Persistenzzustand = dokumentbezogener technischer Fehler. +- Inkonsistente Proposal-Zustände werden **nicht stillschweigend geheilt**, sondern als technische Dokumentfehler behandelt. + +## Verarbeitungsreihenfolge pro Dokument (aktiver Stand) + +1. Fingerprint berechnen +2. Dokument-Stammsatz laden +3. Terminale Skip-Fälle entscheiden (`SUCCESS` → `SKIPPED_ALREADY_PROCESSED`, `FAILED_FINAL` → `SKIPPED_FINAL_FAILURE`) +4. Falls nötig: M5-Pfad bis `PROPOSAL_READY` durchlaufen +5. Führenden `PROPOSAL_READY`-Versuch laden +6. Finalen Basis-Dateinamen bilden +7. Dubletten-Suffix im Zielordner bestimmen +8. Zielkopie schreiben (temporäre Datei + finaler Move/Rename) +9. Neuen Versuch für Enderfolg oder technischen Fehler historisieren +10. Dokument-Stammsatz konsistent fortschreiben + +## Zielkopie-Semantik +- Kopie zunächst in temporäre Zieldatei im Zielkontext +- Finaler Move/Rename auf den geplanten Zieldateinamen +- Quelldatei bleibt **immer unverändert** +- Kein Sofort-Wiederholversuch im selben Lauf +- Bei Persistenzfehler nach erfolgreicher Zielkopie: kein `SUCCESS` setzen, best-effort Rückbau der Zielkopie vorsehen, Ergebnis bleibt dokumentbezogener technischer Fehler + +## Fehlersemantik (aktiver Stand) +Technische Fehler bei Proposal-Quelllesung, Zielpfadbildung, Dublettenauflösung, +Zielkopie oder aktiver Persistenz nach Fingerprint-Ermittlung: +- → dokumentbezogener technischer Fehler +- → `FAILED_RETRYABLE`, Transientfehlerzähler +1 +- → kein Abbruch des Batch-Laufs für andere Dokumente +- → keine neue finale Fehlerkategorie + +## Persistenzerweiterung (aktiver Stand) + +**Dokument-Stammsatz** erhält zusätzlich: +- letzten Zielpfad +- letzten Zieldateinamen + +**Versuchshistorie** erhält zusätzlich: +- finalen Zieldateinamen + +**Invariante:** Der führende `PROPOSAL_READY`-Versuch wird nicht überschrieben. +Enderfolg und technische Fehler des aktiven Stands werden als **zusätzliche neue Versuche** historisiert. + +## Naming-Regel (verbindlich für alle Arbeitspakete) +In Implementierungen, Kommentaren und JavaDoc dürfen **keine** Meilenstein- oder +Arbeitspaket-Bezeichner erscheinen: + +- Verboten: `M1`, `M2`, `M3`, `M4`, `M5`, `M6`, `M7`, `M8` +- Verboten: `AP-001`, `AP-002`, … `AP-00x` + +Stattdessen werden **zeitlose technische Bezeichnungen** verwendet. +Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen berührt werden, sind zu ersetzen. + ## Arbeitsweise - Arbeite immer nur im **explizit aktiven Meilenstein** und im **explizit aktiven Arbeitspaket** - **Kein Vorgriff** auf spätere Meilensteine oder Arbeitspakete - Änderungen klein, fokussiert und architekturtreu halten - Keine unnötigen Umbenennungen, keine großflächigen Refactorings ohne Not - Vor Änderungen zuerst die betroffenen Dateien und Abhängigkeiten verstehen +- **Keine Annahmen über Dateipfade.** Typen und Klassen werden per Suche nach Typname gefunden, nicht über vermutete Pfade. - Keine Vermutungen: Bei echter Unklarheit oder Dokumentkonflikten knapp nachfragen oder den Konflikt benennen ## Definition of Done pro Arbeitspaket @@ -97,9 +198,25 @@ Ein Arbeitspaket ist erst fertig, wenn: - keine Inhalte späterer Meilensteine vorweggenommen wurden - der Zwischenstand in sich geschlossen und übergabefähig ist +## Pflicht-Output-Format nach jedem Arbeitspaket + +``` +- Scope erfüllt: ja/nein +- Geänderte Dateien: + - + - ... +- Build-Kommando: +- Build-Status: ERFOLGREICH / FEHLGESCHLAGEN +- Offene Punkte: keine / +- Risiken: keine / +``` + ## Qualitäts- und Prüfreihenfolge - Nur den für das aktuelle Arbeitspaket nötigen Scope ändern - Nach Änderungen den kleinsten sinnvollen Build-/Test-Umfang ausführen +- Build-Validierung vom Parent-Root: + `.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-bootstrap --also-make` +- Schlägt der Build fehl: Fehler beheben, erneut bauen, erst dann weiter - Vor Abschluss sicherstellen, dass der relevante Maven-Reactor-Stand fehlerfrei ist - Fehler nicht kaschieren; Ursachen sauber beheben oder offen benennen @@ -109,6 +226,23 @@ Ein Arbeitspaket ist erst fertig, wenn: - Exit-Code `0`: Lauf technisch ordnungsgemäß ausgeführt, auch wenn einzelne Dateien fachlich oder transient fehlgeschlagen sind - Exit-Code `1`: harter Start-/Bootstrap-Fehler - Umgebungsvariable hat Vorrang vor Properties beim API-Key +- Dokumentbezogene Fehler führen **nicht** zu Exit-Code `1` + +## Konfigurationsparameter +Verbindlich zweckmäßige Parameter: +- `source.folder` – Quellordner +- `target.folder` – Zielordner (muss vorhanden oder anlegbar sein, Schreibzugriff erforderlich) +- `sqlite.file` – SQLite-Datenbankdatei +- `api.baseUrl` – KI-Basis-URL +- `api.model` – Modellname +- `api.timeoutSeconds` – Timeout +- `max.retries.transient` – maximale transiente Wiederholversuche +- `max.pages` – Seitenlimit +- `max.text.characters` – maximale Zeichenzahl für KI-Eingabe +- `prompt.template.file` – externe Prompt-Datei +- `runtime.lock.file` – Lock-Datei (optional) +- `log.directory` – Log-Verzeichnis (optional) +- `api.key` – API-Key (Umgebungsvariable hat Vorrang) ## Nicht-Ziele / Verbote - kein Web-UI @@ -120,3 +254,6 @@ Ein Arbeitspaket ist erst fertig, wenn: - keine Architekturbrüche - keine neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung - keine stillen Änderungen an Provider-Bindung oder Architekturprinzipien +- kein Sofort-Wiederholversuch der Zielkopie im selben Lauf +- kein Logging-Feinschliff des Endstands +- keine Reporting- oder Statistikfunktionen diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 0000000..af14cf7 --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,123 @@ +# WORKFLOW – KI-gesteuerte AP-Implementierung + +Dieses Dokument beschreibt verbindlich, wie Claude Code Arbeitspakete eines Meilensteins +sequenziell implementiert. Es ist ein technischer Arbeitsrahmen, kein fachliches Dokument. + +--- + +## Eingabe + +- Eine Arbeitspakete-MD-Datei des aktiven Meilensteins (z. B. `M6 - Arbeitspakete.md`) +- `CLAUDE.md` im Projektroot (Architektur, Konventionen, Nicht-Ziele) +- alle verbindlichen Spezifikationsdokumente im Projektroot + +--- + +## Grundregeln + +- **Immer zuerst lesen, dann implementieren.** Vor jeder Änderung die relevanten + Typen, Ports und Implementierungen im echten Workspace suchen und öffnen. +- **Keine Annahmen über Dateipfade.** Typen und Klassen werden per Suche nach + Typname gefunden, nicht über vermutete Pfade. +- **Kein Git.** Keine git-Befehle, keine Commits, kein Staging, kein Revert. +- **Nur Dateioperationen.** + +--- + +## Sequenzielle AP-Bearbeitung + +Arbeitspakete werden in der Reihenfolge des Dokuments abgearbeitet. +Ein AP ist abgeschlossen, wenn: + +1. der Scope vollständig umgesetzt ist, +2. der Build vom Parent-Root fehlerfrei durchläuft, +3. das vorgeschriebene Output-Format ausgegeben wurde. + +Erst dann beginnt das nächste AP. + +--- + +## Build-Validierung + +Nach jedem AP wird **zwingend** ein Build aus dem Parent-Root ausgeführt: + +``` +.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-bootstrap --also-make +``` + +Schlägt der Build fehl: +- Fehler analysieren +- im selben AP beheben +- Build erneut ausführen +- erst bei grünem Build weiter zum nächsten AP + +--- + +## Pflicht-Output-Format nach jedem AP + +``` +## AP-XXX Abschluss + +- Scope erfüllt: ja/nein +- Geänderte Dateien: + - + - ... +- Build-Kommando: .\mvnw.cmd clean verify ... +- Build-Status: ERFOLGREICH / FEHLGESCHLAGEN +- Offene Punkte: keine / +- Risiken: keine / +``` + +--- + +## Scope-Kontrolle + +Pro AP gilt verbindlich: + +- **Nur** die im AP beschriebenen Änderungen umsetzen. +- **Kein Vorgriff** auf spätere APs oder spätere Meilensteine. +- **Kein Refactoring** außerhalb des AP-Scopes. +- **Keine kosmetischen Cleanups**, die nicht durch den AP-Scope gedeckt sind. +- **Keine Import-Bereinigungen**, die nicht durch konkrete eigene Änderungen erzwungen werden. + +--- + +## Naming-Regel (verbindlich für alle APs) + +In geänderten Dateien – Code, Kommentare, JavaDoc – dürfen **keine** Meilenstein- +oder Arbeitspaket-Bezeichner erscheinen: + +- Verboten: `M1`, `M2`, `M3`, `M4`, `M5`, `M6`, `M7`, `M8` +- Verboten: `AP-001`, `AP-002`, … `AP-00x` + +Stattdessen werden **zeitlose technische Bezeichnungen** verwendet. +Wenn bestehende Kommentare solche Bezeichner enthalten und durch den AP-Scope +berührt werden, sind sie zu ersetzen. + +--- + +## Architekturregeln (Kurzfassung – vollständig in CLAUDE.md) + +- Strenge hexagonale Architektur: Abhängigkeitsrichtung zeigt immer nach innen. +- Domain und Application: keine Infrastruktur, kein `Path`/`File`, kein JDBC, kein HTTP. +- Adapter-Out: alle Infrastrukturdetails (Dateisystem, SQLite, HTTP, PDFBox). +- Adapter dürfen nicht direkt voneinander abhängen. +- Ports sind die einzigen Querschnittspunkte. + +--- + +## Startkommando für einen vollständigen Meilenstein + +``` +Lies CLAUDE.md und M6 - Arbeitspakete.md vollständig. +Implementiere danach alle Arbeitspakete sequenziell gemäß WORKFLOW.md. +Beginne mit AP-001. +``` + +## Startkommando für ein einzelnes AP + +``` +Lies CLAUDE.md und M6 - Arbeitspakete.md vollständig. +AP-001 bis AP-00X sind bereits abgeschlossen und bilden die Baseline. +Implementiere ausschließlich AP-00Y gemäß WORKFLOW.md. +``` diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java index 16291c8..5039167 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java @@ -277,11 +277,14 @@ public class OpenAiHttpAdapter implements AiInvocationPort { * The {@link AiRequestRepresentation#sentCharacterCount()} is recorded as audit metadata * but is not used to truncate content in the adapter. The full * document text is sent to the AI service. + *

+ * Package-private for testing: This method is accessible to tests + * in the same package to verify the actual JSON body structure and content. * * @param request the request with prompt and document text * @return JSON string ready to send in HTTP body */ - private String buildJsonRequestBody(AiRequestRepresentation request) { + String buildJsonRequestBody(AiRequestRepresentation request) { JSONObject body = new JSONObject(); body.put("model", apiModel); body.put("temperature", 0.0); diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java index 9279e6e..375cea8 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java @@ -21,6 +21,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.json.JSONArray; +import org.json.JSONObject; + import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess; @@ -232,15 +235,19 @@ class OpenAiHttpAdapterTest { // Act adapter.invoke(request); - // Assert - verify the timeout was configured in the HttpClient - // The timeout is set when building the HttpRequest, not on the client + // Assert - verify the HttpRequest was sent and adapter was initialized with configured timeout ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); verify(httpClient).send(requestCaptor.capture(), any()); - HttpRequest capturedRequest = requestCaptor.getValue(); - // The timeout is embedded in the request; we verify through configuration - // and by checking that adapter was initialized with correct timeout + // The adapter is initialized with TIMEOUT_SECONDS from the configuration + // and uses it in buildRequest() via: .timeout(Duration.ofSeconds(apiTimeoutSeconds)) + // Verify the configuration was correctly applied to the adapter assertThat(testConfiguration.apiTimeoutSeconds()).isEqualTo(TIMEOUT_SECONDS); + + // Verify the request was sent (proving buildRequest was called with timeout) + HttpRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest).isNotNull(); + assertThat(capturedRequest.uri()).isNotNull(); } @Test @@ -269,21 +276,26 @@ class OpenAiHttpAdapterTest { @DisplayName("should use configured model name in the request body") void testConfiguredModelIsUsedInRequestBody() throws Exception { // Arrange + AiRequestRepresentation request = createTestRequest("Test prompt", "Test document"); + + // Act - directly build the JSON body to verify model is present + String jsonBody = adapter.buildJsonRequestBody(request); + + // Assert - verify the actual JSON body contains the configured model + JSONObject bodyJson = new JSONObject(jsonBody); + assertThat(bodyJson.getString("model")).isEqualTo(API_MODEL); + + // Also verify in actual request HttpResponse httpResponse = mockHttpResponse(200, "{}"); when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); - AiRequestRepresentation request = createTestRequest("Test prompt", "Test document"); - - // Act adapter.invoke(request); - // Assert - verify the model name is in the request ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); verify(httpClient).send(requestCaptor.capture(), any()); - HttpRequest capturedRequest = requestCaptor.getValue(); - // HttpRequest doesn't expose body directly, but the adapter uses apiModel - // which we verified is set from configuration + // HttpRequest body is in a BodyPublisher, but we've verified the model is in the JSON + // and that JSON is what gets sent via BodyPublishers.ofString(requestBody) assertThat(testConfiguration.apiModel()).isEqualTo(API_MODEL); } @@ -318,9 +330,6 @@ class OpenAiHttpAdapterTest { @DisplayName("should send full document text without truncation") void testFullDocumentTextIsSentWithoutTruncation() throws Exception { // Arrange - HttpResponse httpResponse = mockHttpResponse(200, "{}"); - when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); - String fullDocumentText = "This is a long document text that should be sent in full."; int sentCharacterCount = 20; // Less than full length PromptIdentifier promptId = new PromptIdentifier("v1"); @@ -331,17 +340,32 @@ class OpenAiHttpAdapterTest { sentCharacterCount ); - // Act + // Act - directly build the JSON body to verify full text is present + String jsonBody = adapter.buildJsonRequestBody(request); + + // Assert - verify the actual JSON body contains the FULL document text, not truncated + JSONObject bodyJson = new JSONObject(jsonBody); + JSONArray messages = bodyJson.getJSONArray("messages"); + JSONObject userMessage = messages.getJSONObject(1); // User message is second + String contentInBody = userMessage.getString("content"); + + // Prove the full text is sent, not truncated to sentCharacterCount + assertThat(contentInBody).isEqualTo(fullDocumentText); + assertThat(contentInBody.length()).isEqualTo(fullDocumentText.length()); + assertThat(contentInBody).isNotEqualTo(fullDocumentText.substring(0, sentCharacterCount)); + + // Also verify in actual request + HttpResponse httpResponse = mockHttpResponse(200, "{}"); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + adapter.invoke(request); - // Assert - The document text is sent as-is; the adapter does not truncate - // This is verified by the fact that we removed the substring call ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); verify(httpClient).send(requestCaptor.capture(), any()); - // The request is sent with the full document text in the JSON body - // (verification happens through implementation inspection and absence of substring) + // Confirm the full text is in the document, not truncated assertThat(request.documentText()).isEqualTo(fullDocumentText); + assertThat(request.documentText().length()).isGreaterThan(sentCharacterCount); } @Test