# ClawdDotNet — Teststrategie

Ergänzung zur [Bestandsaufnahme](archiv/Bestandsaufnahme-2026-07.md).
Ziel: Fehlerklassen wie B1 (Compaction zerstört tool_call-Paarung) und B2 (Race Condition)
strukturell unmöglich machen, statt sie im Betrieb zu entdecken.

**Leitgedanke:** Nicht Coverage maximieren, sondern die *teuren* Fehler abfangen. In diesem
Projekt sind das drei Kategorien:
1. **Kontext-Invarianten** — ungültige Nachrichtensequenzen kosten API-Fehler mitten im Lauf
2. **Sicherheitsgrenzen** — Pfad-, SQL-, URL- und Empfänger-Prüfungen
3. **Nebenläufigkeit & Zeit** — Scheduler, paralleler Chat-Zugriff, Cron

UI-Layout, Designer-Code und Fremdbibliotheken werden bewusst *nicht* getestet.

---

## 1. Voraussetzung: Testbarkeit herstellen

Vier kleine Refactorings, ohne die die wichtigsten Tests nicht schreibbar sind.

### R1 — `IChatCompletionClient` extrahieren ⚠️ blockiert alles Weitere
[`AgentEngine.cs:15`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L15) und
[`ContextCompactor.cs:10`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L10) hängen an der
konkreten, `sealed`-Klasse `OpenRouterClient`. Ohne Interface ist kein einziger Engine-Test möglich.

```csharp
public interface IChatCompletionClient
{
    Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct);
}

public sealed class OpenRouterClient : IChatCompletionClient, IDisposable { … }
```

Dazu ein `FakeChatClient` im Testprojekt, der eine vorprogrammierte Antwortfolge liefert und
alle empfangenen Requests mitschreibt:

```csharp
internal sealed class FakeChatClient : IChatCompletionClient
{
    private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
    public List<ChatRequest> ReceivedRequests { get; } = new();

    public FakeChatClient RespondsWithText(string text) { … }
    public FakeChatClient RespondsWithToolCall(string tool, string argsJson) { … }
    public FakeChatClient Throws(Exception ex) { … }

    public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
    {
        // Tiefe Kopie ablegen — die Engine mutiert die Nachrichtenliste weiter!
        ReceivedRequests.Add(DeepClone(request));
        …
    }
}
```

> Die tiefe Kopie ist wesentlich: `AgentEngine` reicht dieselbe `List<ChatMessage>`-Instanz
> weiter und verändert sie danach. Ohne Kopie prüfen die Tests den Endzustand statt des
> tatsächlich gesendeten Requests.

### R2 — `TimeProvider` statt `DateTime.Now`
.NET 10 bringt `TimeProvider` mit; `FakeTimeProvider` steckt in
`Microsoft.Extensions.TimeProvider.Testing`. Betrifft
[`CronExpression.cs:53`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs#L53) und den
[`TaskScanner`](../src/ClawdDotNet.Core/Tasks/TaskScanner.cs). (Die ursprünglich hier
genannten `AgentScheduler` und `ToolJobScheduler` sind gelöscht — der Scanner hat sie
abgelöst.)

Damit werden Zeitumstellungs- und Langzeit-Tests deterministisch und laufen in Millisekunden
statt in Echtzeit.

### R3 — `IProcessRunner` für externe Prozesse
[`SocialMediaManagerTool.cs:442`](../src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs#L442)
startet yt-dlp/ffmpeg direkt. Für den Injection-Test (S2) muss die **Argumentbildung** ohne
Prozessstart prüfbar sein:

```csharp
internal static string[] BuildYtDlpArgs(string url, …);   // rein, testbar
public interface IProcessRunner { Task<ProcessResult> RunAsync(string exe, string[] args, CancellationToken ct); }
```

### R4 — Dateisystem **nicht** abstrahieren
Bewusste Entscheidung: Die Path-Traversal-Tests sollen echte Windows-Pfadsemantik prüfen
(`..`, UNC, Alternate Data Streams, Trailing Dots). Eine `IFileSystem`-Abstraktion würde genau
die Fehlerklasse verstecken, um die es geht. Stattdessen ein Temp-Verzeichnis-Fixture:

```csharp
internal sealed class TempWorkspace : IDisposable
{
    public string Personal { get; }
    public string Shared { get; }
    public TempWorkspace() { /* unter Path.GetTempPath()/clawd-tests/{guid} anlegen */ }
    public void Dispose() { /* rekursiv löschen, Fehler schlucken */ }
}
```

---

## 2. Projektstruktur & Stack

```
tests/
  ClawdDotNet.Core.Tests/          ← Engine, Compaction, LoopGuard, Cron, Config, State
  ClawdDotNet.Tools.Tests/         ← alle Tool-Projekte, v.a. Sicherheitsgrenzen
  ClawdDotNet.Integration.Tests/   ← echte Endpunkte, standardmäßig übersprungen
```

| Paket | Zweck |
|---|---|
| `xunit.v3` | Test-Framework |
| `Shouldly` | Assertions — lesbar, frei nutzbar. *(FluentAssertions ist ab v8 für kommerzielle Nutzung kostenpflichtig; falls gewünscht ist `AwesomeAssertions` ein freier Fork von v7.)* |
| `NSubstitute` | Mocks für `ILogger`, `IStateStore`, `IAgentTool` |
| `Microsoft.Extensions.TimeProvider.Testing` | `FakeTimeProvider` |
| `FsCheck.Xunit` | Property-Based Tests für die Kontext-Invarianten |
| `Verify.Xunit` | Snapshot-Tests des Request-JSON |

Die drei Projekte in `ClawdDotNet.slnx` unter einem Ordner `/tests/` eintragen.

> Nebenbefund: In `ClawdDotNet.slnx` fehlen vier Tool-Projekte (AgentComm, AgentSpawn,
> AgentEditor, SocialMediaManager), obwohl sie in `src/ClawdDotNet.App/ClawdDotNet.App.csproj` referenziert sind.
> Sie sollten mit aufgenommen werden, sonst laufen sie in der IDE-Solution nicht mit.

---

## 3. Die Tests

### Prio 1 — Regressionstests für die gefundenen Bugs

Vorgehen: **erst den roten Test, dann den Fix.** Diese Tests sind der eigentliche Beleg, dass
die Bugs behoben sind — und sie bleiben als Wächter erhalten.

#### 3.1 ContextCompactor — Kontext-Invarianten (deckt B1 ab)

Das Herzstück. Ich würde eine wiederverwendbare Prüffunktion bauen und sie überall einsetzen:

```csharp
internal static class ContextInvariants
{
    /// Wirft, wenn die Nachrichtenfolge von der API abgelehnt würde.
    public static void AssertValid(IReadOnlyList<ChatMessage> messages)
    {
        // 1. Höchstens eine system-Nachricht, und zwar an Position 0
        // 2. Jede tool-Nachricht hat eine unmittelbar vorausgehende Gruppe,
        //    deren assistant-Nachricht ein tool_call mit passender Id enthält
        // 3. Jede assistant-Nachricht mit N tool_calls hat genau N tool-Antworten
        // 4. Keine tool-Nachricht ohne ToolCallId
    }
}
```

| # | Test | Erwartung |
|---|---|---|
| C1 | Compaction bei Tail-Grenze mitten in einer Tool-Sequenz | `AssertValid` besteht — **schlägt heute fehl** |
| C2 | Compaction bei `assistant(2 tool_calls) → tool → tool` an der Grenze | Beide tool-Antworten bleiben zusammen mit ihrem Aufruf |
| C3 | System-Prompt vorhanden | Bleibt an Position 0 erhalten |
| C4 | Kein System-Prompt vorhanden | Kein leerer/erfundener System-Eintrag |
| C5 | LLM-Zusammenfassung wirft Exception | Nachrichtenliste bleibt **unverändert und gültig** (kein halb geleerter Zustand) |
| C6 | LLM liefert leeren String zurück | dito |
| C7 | `PruneToolResults` | Kürzt nur außerhalb der letzten 6 Nachrichten |
| C8 | `PruneToolResults` bei Inhalt ≤ 2000 Zeichen | Bleibt unverändert (kein Marker angehängt) |
| C9 | `EstimateTokens` | Monoton: mehr Inhalt ⇒ nie weniger Tokens |
| C10 | Compaction unterhalb der Schwelle | Kein API-Call an den Fake (spart Geld!) |

**Property-Based (FsCheck) — der wertvollste Test des ganzen Projekts:**

```csharp
[Property(MaxTest = 500)]
public Property Compaction_erhaelt_immer_eine_gueltige_Sequenz()
{
    return Prop.ForAll(ValidConversationGenerator(),   // 1..200 gültige Nachrichten
        async conversation =>
        {
            await _compactor.CompactIfNeededAsync(conversation, 999_999, _guard, "m", default);
            ContextInvariants.AssertValid(conversation);
        });
}
```

Ein Generator für gültige Konversationen (System, dann abwechselnd User/Assistant, gelegentlich
Assistant-mit-N-Tool-Calls gefolgt von genau N Tool-Antworten) findet Grenzfälle, die von Hand
niemand schreibt — genau die Klasse, in die B1 fällt.

#### 3.2 LoopGuard (deckt B3 ab)

| # | Test | Erwartung |
|---|---|---|
| L1 | `MaxSteps` erreicht | Wirft bei Schritt N+1, nicht bei N |
| L2 | **Konstanter 20k-Kontext über 10 Schritte** | Bricht **nicht** ab — definiert die Zielsemantik nach dem Fix |
| L3 | Kostenbudget überschritten | Wirft mit klarer Meldung, welches Limit griff |
| L4 | 100 parallele `RecordStep` | Zähler exakt 100 (Interlocked-Korrektheit) |

L2 ist der Test, der den heutigen Fehlentwurf festnagelt: Er schlägt mit den aktuellen Defaults
fehl und geht nach der Trennung von `maxCumulativeTokens` und `maxContextTokens` durch.

#### 3.3 AgentEngine — Nebenläufigkeit (deckt B2 ab)

| # | Test | Erwartung |
|---|---|---|
| E1 | Zwei parallele `ChatAsync` für **denselben** Agenten | Kontext danach `AssertValid`; **schlägt heute fehl** |
| E2 | 50 parallele `ChatAsync`, je 2 Tool-Schritte | Keine Exception, Nachrichtenzahl deterministisch |
| E3 | `AbortChat` bei zwei laufenden Läufen | **Beide** werden abgebrochen |
| E4 | Parallele Läufe für **verschiedene** Agenten | Laufen wirklich parallel (keine globale Serialisierung) |

E4 ist wichtig als Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
gegeneinander sperren.

#### 3.4 CronExpression & Scheduler (deckt B6, B7 ab)

| # | Test | Erwartung |
|---|---|---|
| R1 | Tabelle: `*/30 * * * *`, `0 3 * * 1-5`, `0 0 29 2 *`, `15,45 * * * *` | Korrekte nächste Zeitpunkte |
| R2 | Invariante über viele Ausdrücke | `GetNextOccurrence(t) > t` **und** `Matches(ergebnis)` |
| R3 | Ungültig: `"* * *"`, `"99 * * * *"`, `"a b c d e"`, `"*/0 * * * *"` | Saubere `FormatException` — heute wirft teils `int.Parse`/`DivideByZero` |
| R4 | Zeitumstellung Oktober, `0 2 * * *` | Feuert genau **einmal** |
| R5 | Zeitumstellung März, `0 2 * * *` | Feuert trotzdem (wird nicht übersprungen) |
| R6 | `0 3 1 1 *` (jährlich) | Scheduler überlebt Wartezeit > 24 Tage — **schlägt heute fehl** |
| R7 | Lauf dauert länger als das Intervall | Keine Überlappung, keine Aufstauung |
| R8 | Exception in einem Job-Tick | Scheduler-Schleife läuft weiter, Fehler wird geloggt |

#### 3.5 Kostenerfassung (deckt B4 ab)

| # | Test | Erwartung |
|---|---|---|
| K1 | Modell nicht in der Preistabelle | Sichtbar markiert (nicht stillschweigend 0) |
| K2 | `anthropic/claude-sonnet-4-5` (der Default!) | Liefert einen Preis ≠ 0 |
| K3 | 95k Prompt / 5k Completion | Exakter Betrag gegen handgerechneten Wert — kein 50/50-Split |

---

### Prio 2 — Sicherheitstests

Diese Tests sind gleichzeitig die dauerhafte Dokumentation der Bedrohungsmodelle. Jeder
Angriffsvektor aus der Bestandsaufnahme wird ein `[Theory]`-Fall.

#### 3.6 FileRW — Pfadsicherheit (deckt S6 ab)

```csharp
[Theory]
[InlineData("../../../Windows/System32/drivers/etc/hosts")]
[InlineData(@"..\..\evil.txt")]
[InlineData(@"C:\Windows\System32\config\SAM")]
[InlineData(@"\\server\share\evil.txt")]
[InlineData("subdir/../../../outside.txt")]
[InlineData("file.txt:hidden")]          // Alternate Data Stream
[InlineData("file.txt.")]                // Windows entfernt den Punkt
[InlineData("  ..  /../evil.txt")]
public async Task Read_lehnt_Pfade_ausserhalb_des_Workspace_ab(string path) { … }
```

Plus der Präfix-Angriff, der heute durchgeht:

| # | Test | Erwartung |
|---|---|---|
| F1 | Root `…\Workspace`, Ziel `…\Workspace-Backup\x.txt` | Abgelehnt — **schlägt heute fehl** |
| F2 | Endungs-Whitelist: `.exe`, `.ps1`, `.dll` | Abgelehnt |
| F3 | `.TXT` bei Whitelist `.txt` | Erlaubt (Groß/Kleinschreibung) |
| F4 | `report.txt.exe` | Abgelehnt |
| F5 | `copy` mit `destinationPath` außerhalb des Ziel-Workspace | Abgelehnt (eigener Pfad, eigener Test!) |
| F6 | `sharedAccessLevel: Denied` | Jede shared-Aktion abgelehnt |
| F7 | `ReadWrite` + `delete` in shared | Abgelehnt (nur Admin) |
| F8 | protectedPath `stocks/`, Schreiben auf existierende `stocks/x.json` | Abgelehnt |
| F9 | protectedPath `stocks/`, Schreiben auf **neue** `stocks/y.json` | Erlaubt |
| F10 | protectedPath `stocks/`, Pfad `stocks-alt/x.json` | **Nicht** geschützt (Präfix-Verwechslung) |
| F11 | Admin-Level + protectedPath | Umgeht den Schutz |
| F12 | 20 parallele `stock_add` auf denselben Ticker | `_index.json` enthält exakt 20 Einträge, gültiges JSON |

F12 deckt B9 ab und ist ein echter Nebenläufigkeitstest gegen das Dateisystem.

#### 3.7 DatabaseTool — SQL-Whitelist (deckt S1 ab)

Die Bypass-Tests. **Alle schlagen heute fehl** und definieren die Zielsemantik:

| # | SQL | AccessLevel | Erwartung |
|---|---|---|---|
| D1 | `DELETE FROM users -- prices` | ReadWrite | Abgelehnt |
| D2 | `SELECT * FROM users WHERE note='prices'` | ReadOnly | Abgelehnt |
| D3 | `SELECT * FROM prices; DROP TABLE users` | Admin | Abgelehnt (Mehrfach-Statement) |
| D4 | `SELECT * FROM prices` | ReadOnly | **Erlaubt** |
| D5 | `SELECT * FROM prices WHERE note='update'` | ReadOnly | **Erlaubt** (heutiger False Positive) |
| D6 | `UPDATE prices SET x=1` | ReadOnly | Abgelehnt |
| D7 | `UPDATE prices SET x=1` | ReadWrite | Erlaubt |
| D8 | `DROP TABLE prices` | ReadWrite | Abgelehnt |
| D9 | `allowedTables` fehlt komplett | egal | Abgelehnt (fail closed) |
| D10 | `SELECT` mit 10.000 Zeilen Ergebnis | ReadOnly | Ergebnis wird gekappt |

D4/D5/D7 sind die Gegenproben — sie verhindern, dass der Fix zu restriktiv wird und legitime
Abfragen blockiert.

#### 3.8 WebFetch — SSRF (deckt S5 ab)

Mit einem `FakeHttpMessageHandler`, der Redirects simuliert:

| # | Test | Erwartung |
|---|---|---|
| W1 | Erlaubte Domain → Redirect auf nicht erlaubte | Abgelehnt — **schlägt heute fehl** |
| W2 | `http://169.254.169.254/latest/meta-data/` | Abgelehnt |
| W3 | `http://localhost:8418/` | Abgelehnt (privates Netz) |
| W4 | `http://192.168.178.10/` | Abgelehnt |
| W5 | `file:///C:/Windows/win.ini` | Abgelehnt |
| W6 | Whitelist `example.com`, URL `example.com.attacker.net` | Abgelehnt |
| W7 | Whitelist `example.com`, URL `sub.example.com` | Erlaubt |
| W8 | Host `mywww.example.com` | Wird nicht zu `myexample.com` verstümmelt |
| W9 | Antwort größer als `maxResponseKb` | Sauber gekappt, kein OOM |

#### 3.9 Mail — Empfängerprüfung

| # | Test | Erwartung |
|---|---|---|
| M1 | Nicht gelistete Adresse | Abgelehnt |
| M2 | `ALLOWED@X.DE` bei Whitelist `allowed@x.de` | Erlaubt |
| M3 | `allowed@x.de, evil@y.de` (zwei in einem Feld) | Abgelehnt |
| M4 | `"allowed@x.de" <evil@y.de>` (Display-Name-Trick) | Abgelehnt |
| M5 | `allowedRecipients` fehlt | Abgelehnt (fail closed) |
| M6 | `read_inbox` mit 500 Mails | Ergebnis gekappt |

#### 3.10 SocialMediaManager — Argument-Injection (deckt S2 ab)

| # | Test | Erwartung |
|---|---|---|
| Y1 | `BuildYtDlpArgs("--exec calc.exe https://youtube.com/x")` | Wirft oder quotet — nie als eigene Option |
| Y2 | URL `https://evil.com/x` | Abgelehnt (nur YouTube-Domains) |
| Y3 | URL mit Leerzeichen/Anführungszeichen | Erscheint als **ein** Argument in `ArgumentList` |
| Y4 | Legitime URL/Handle (`@kanal`, volle URL) | Wird korrekt aufgelöst |

#### 3.11 DirectAPI — Key-Leak (deckt S3 ab)

Ein einzelner, sehr einfacher Test mit hohem Wert:

```csharp
[Fact]
public async Task Ergebnis_enthaelt_niemals_den_ApiKey()
{
    const string key = "SECRET-KEY-12345";
    var result = await _tool.ExecuteAsync(QuoteFor("NVDA"), ContextWithKey(key), default);
    result.Content.ShouldNotContain(key);          // schlägt heute fehl
}
```

Analog für alle Provider (`twelvedata`, `alphavantage`, `massive`) und alle Aktionen — als
`[Theory]`. Dieselbe Prüfung sollte es zusätzlich für die Log-Ausgabe geben.

---

### Prio 3 — Engine-Verhalten (mit `FakeChatClient`)

| # | Test | Erwartung |
|---|---|---|
| A1 | Antwort ohne tool_calls | Genau 1 API-Call, `FinalMessage` korrekt, Status `Completed` |
| A2 | Antwort mit 2 tool_calls, dann Text | 2 Tools ausgeführt, 2 tool-Nachrichten mit passenden Ids, dann 2. Call |
| A3 | Tool wirft Exception | Lauf läuft weiter, Fehler kommt als tool-Antwort ins Modell |
| A4 | Modell ruft nicht zugewiesenes Tool auf | `PermissionGate` greift, Fehlermeldung als tool-Antwort, kein Absturz |
| A5 | Modell ruft unbekanntes Tool auf | Saubere Fehlermeldung |
| A6 | Timeout (`FakeTimeProvider`) | Status `Cancelled` |
| A7 | Externe Cancellation | Status `Cancelled` |
| A8 | Loop-Limit erreicht | Status `LoopLimitExceeded` |
| A9 | **`OnRunCompleted` feuert in jedem Endzustand genau einmal** | Parametrisiert über alle 5 Status |
| A10 | System-Prompt | Im Request **genau einmal** enthalten |
| A11 | `FullSystemPrompt` leer | Kein leerer system-Eintrag im Request |
| A12 | Tool-Ergebnis über Limit | Gekappt (nach T2-Fix) |
| A13 | `send_message` A→B→A | Nach `maxDelegationDepth` abgebrochen (nach B8-Fix) |
| A14 | `spawn` auf bereits laufenden Agenten | Abgelehnt mit Hinweis |
| A15 | Snapshot des Request-JSON (Verify) | Wire-Format bleibt stabil |

A9 und A15 sind Invarianten-Tests mit überproportionalem Nutzen: A9 verhindert, dass beim
Erweitern der Fehlerbehandlung die Token-Erfassung stillschweigend ausfällt; A15 schützt beim
Caching-Umbau (T1) davor, das Wire-Format versehentlich zu verändern.

---

### Prio 4 — Konfiguration & Persistenz

| # | Bereich | Test |
|---|---|---|
| P1 | `ConfigLoader` | Fehlender `openRouterApiKey` → aussagekräftige Exception |
| P2 | `ConfigLoader` | Doppelte `agentId` → Exception mit beiden Namen |
| P3 | `ConfigLoader` | JSON mit Kommentaren und trailing commas wird akzeptiert |
| P4 | `ConfigLoader` | Datei fehlt → `FileNotFoundException` mit Pfad |
| P5 | Chat-Persistenz | Round-Trip History + Context, inkl. tool_calls |
| P6 | Chat-Persistenz | Beide Alt-Formate lesbar (Array **und** `{"messages":[…]}`) |
| P7 | Chat-Persistenz | Korrupte JSON-Datei → Warnung, Agent startet trotzdem |
| P8 | Chat-Persistenz | Umlaute/Emoji überleben den Round-Trip (UTF-8) |
| P9 | `SqliteStateStore` | Set/Get/Delete Round-Trip; Überschreiben; unbekannter Key → `null` |
| P10 | `SqliteStateStore` | 50 parallele Schreibzugriffe → kein `database is locked` |
| P11 | `AgentConfig.FullSystemPrompt` | Reihenfolge Identity → Soul → SystemPrompt |
| P12 | `AgentConfig.FullSystemPrompt` | Leere Teile werden ausgelassen, kein doppelter Leerraum |
| P13 | `ToolRegistry` | `GetForAgent` liefert nur zugewiesene Tools |

P10 ist relevant, weil alle Tool-Jobs denselben Store teilen.

---

### Prio 5 — Token & Caching (nach dem Umbau aus T1)

Diese Tests sichern ab, dass die Optimierung tatsächlich greift — ein stillschweigend
*nicht* funktionierender Cache wäre sonst nicht bemerkbar.

| # | Test | Erwartung |
|---|---|---|
| T1 | Cache-Breakpoint | Sitzt am Ende von System-Prompt und Tool-Definitionen |
| T2 | **Präfix-Stabilität über 10 Schritte** | Serialisierter Präfix ist byte-identisch — der entscheidende Test |
| T3 | `cached_tokens` | Wird aus der Antwort gelesen und in `AgentRunResult` weitergereicht |
| T4 | Compaction | Nutzt `summaryModel`, nicht `agentConfig.Model` |
| T5 | Tool-Ergebnis-Kappung | Greift bei allen Tools zentral, nicht nur bei WebFetch |
| T6 | `max_tokens` | Ist im Request gesetzt |

T2 ist der Test, der den ganzen Caching-Aufwand absichert: Ein einziger Zeitstempel im
System-Prompt würde den Cache bei jedem Schritt invalidieren — die Kosten blieben unverändert,
ohne dass es irgendwo auffällt.

---

## 4. Arbeitsregeln

**Test-first bei Bugfixes.** Für jeden Punkt aus der Bestandsaufnahme zuerst den roten Test,
dann den Fix. Das beweist, dass der Test den Fehler wirklich fängt — ein nachträglich
geschriebener Test bestätigt oft nur die Implementierung.

**Keine echten Netzwerkzugriffe in Unit-Tests.** Sie sind langsam, flaky und kosten bei
OpenRouter echtes Geld. Integrationstests kommen ins separate Projekt und werden markiert:

```csharp
[Trait("Category", "Integration")]
```

Standardlauf:
```bash
dotnet test --filter "Category!=Integration"
```

**Zeit immer über `TimeProvider`.** Kein `Thread.Sleep`, kein `Task.Delay` in Tests — sonst
dauert die Suite Minuten und wird instabil.

**Nebenläufigkeitstests wiederholen.** Race Conditions treten sporadisch auf; einmaliges
Bestehen sagt wenig:
```csharp
[Theory]
[Repeat(50)]
```

**Benennung** durchgängig deutsch, im Stil `Methode_Bedingung_Erwartung`:
`Compaction_bei_Toolsequenz_an_der_Grenze_erhaelt_gueltige_Paarung`.

---

## 5. Startpaket

Für die erste Sitzung schlage ich ~35 Tests vor, die exakt die fünf Sofort-Punkte abdecken:

| Reihenfolge | Umfang | Deckt ab |
|---|---|---|
| 1. R1 (`IChatCompletionClient`) + Testprojekte anlegen | Refactoring | Voraussetzung |
| 2. `ContextInvariants.AssertValid` + C1–C10 + Property-Test | ~12 Tests | **B1** |
| 3. L1–L4 | 4 Tests | **B3** |
| 4. E1–E4 | 4 Tests | **B2** |
| 5. Y1–Y4 | 4 Tests | **S2** |
| 6. DirectAPI Key-Leak (alle Provider) | ~6 Tests | **S3** |
| 7. A9 + A15 (Invarianten) | 6 Tests | Absicherung für den Umbau |

Danach ist die Basis da, um die Fixes aus der Bestandsaufnahme mit Netz umzusetzen — und die
Prio-2-Sicherheitstests lassen sich schrittweise ergänzen, wenn die jeweiligen Tools
angefasst werden.

**Langfristig sinnvoll:** ein Gitea-Actions-Workflow, der bei jedem Push
`dotnet test --filter "Category!=Integration"` ausführt.
