Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch

Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig
erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner
Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft.
Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch.

WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b
umbenannten Parameter angeglichen.

Dabei behoben:
- 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue"
  gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day.
- Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine
  CSS-Klasse gab - jetzt "success" wie laufende Instanzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-04 12:45:03 +02:00
co-authored by Claude Opus 5.5
parent da4ceac7d5
commit 837a634a22
311 changed files with 7238 additions and 7252 deletions
@@ -7,16 +7,15 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528).
/// "Autostart by manager" — the switch that keeps an instance running (#528).
///
/// <para><b>Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist:</b> Der Manager
/// startet die Instanz beim Hochkommen, und er startet sie nach einem Absturz wieder. Ohne
/// den Schalter rührt er nichts an — ein Manager, der nach einem Neustart der Maschine
/// alles hochfährt, was er findet, startet auch das, was jemand mit Absicht angehalten
/// hat.</para>
/// <para><b>Two things in one switch, because it is the same promise:</b> the manager
/// starts the instance when coming up, and it starts it again after a crash. Without
/// the switch it touches nothing — a manager that boots everything it finds after a restart of the machine
/// also starts what someone stopped on purpose.</para>
///
/// <para><b>Und der Sicherungsautomat ist der eigentliche Inhalt.</b> Wieder hochzufahren
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.</para>
/// <para><b>And the circuit breaker is the actual content.</b> Booting again
/// is the easy half; the hard one is recognizing when to stop.</para>
/// </summary>
public sealed class AutostartTests : IAsyncDisposable
{
@@ -41,9 +40,9 @@ public sealed class AutostartTests : IAsyncDisposable
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - sonst hiesse eine
// vorab hinterlegte Antwort "laeuft schon", und der Manager wuerde uebernehmen
// statt zu starten.
// The socket only answers if a process is really running - otherwise an
// answer stored in advance would mean "already running", and the manager would take over
// instead of starting.
_kanal.Launcher = _launcher;
}
@@ -51,7 +50,7 @@ public sealed class AutostartTests : IAsyncDisposable
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
private ManagerCore Manager(RestartPolicy? policy = null)
@@ -64,7 +63,7 @@ public sealed class AutostartTests : IAsyncDisposable
restartPolicy: policy);
}
/// <summary>Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei.</summary>
/// <summary>Creates an instance and sets the switch in its instance file.</summary>
private string Instanz(string name, bool autostart)
{
var pfad = _verzeichnisse.CreateInstance(name);
@@ -83,7 +82,7 @@ public sealed class AutostartTests : IAsyncDisposable
InstanceEventKind.Crashed, Path.GetFullPath(pfad), name, 4711, 137,
"unerwartet beendet", DateTimeOffset.UtcNow);
// ─── Beim Hochkommen ───
// ─── When coming up ───
[Fact]
public async Task Eine_Instanz_mit_Autostart_wird_beim_Hochfahren_gestartet()
@@ -110,8 +109,8 @@ public sealed class AutostartTests : IAsyncDisposable
}
/// <summary>
/// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben
/// <c>state.db</c> wäre das Gegenteil von hilfreich.
/// What is already running is not started again: a second process on the same
/// <c>state.db</c> would be the opposite of helpful.
/// </summary>
[Fact]
public async Task Was_schon_laeuft_wird_nicht_noch_einmal_gestartet()
@@ -143,7 +142,7 @@ public sealed class AutostartTests : IAsyncDisposable
manager.Instances.Instances.Single(i => i.Name == "Ohne").AutostartByManager.ShouldBeFalse();
}
// ─── Nach einem Absturz ───
// ─── After a crash ───
[Fact]
public async Task Nach_einem_Absturz_wird_wieder_gestartet()
@@ -155,7 +154,7 @@ public sealed class AutostartTests : IAsyncDisposable
var vorher = _launcher.Starts;
// Der Prozess ist weg; die Aufsicht hat es gemeldet.
// The process is gone; supervision has reported it.
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
@@ -178,14 +177,14 @@ public sealed class AutostartTests : IAsyncDisposable
}
/// <summary>
/// Eine verstummte Instanz wird beendet und neu gestartet — aber erst, nachdem der
/// alte Prozess nachweislich weg ist.
/// A silent instance is ended and restarted — but only after the
/// old process is provably gone.
///
/// <para><b>Der Fall wird hier wirklich hergestellt und nicht behauptet.</b> Der
/// Prozess lebt weiter, nur der Socket schweigt — genau die Lage aus #529. Ein Test,
/// der stattdessen das Ereignis von Hand baut und die Neustartmethode aufruft, prüft
/// die eine Hälfte, die ohnehin funktioniert, und lässt die gefährliche aus: dass vor
/// dem Start niemand mehr auf der Datenbank sitzt.</para>
/// <para><b>The case is really produced here and not claimed.</b> The
/// process lives on, only the socket is silent — exactly the situation from #529. A test
/// that instead builds the event by hand and calls the restart method checks
/// the half that works anyway, and leaves out the dangerous one: that before
/// the start nobody sits on the database any more.</para>
/// </summary>
[Fact]
public async Task Eine_verstummte_Instanz_wird_erst_beendet_und_dann_neu_gestartet()
@@ -198,24 +197,24 @@ public sealed class AutostartTests : IAsyncDisposable
var alt = _launcher.Last.ShouldNotBeNull();
_launcher.Starts.ShouldBe(1);
// Ab jetzt haengt sie: Der Prozess lebt, der Socket antwortet nicht mehr.
// From now on it hangs: the process lives, the socket no longer answers.
_kanal.Verstummen(pfad);
var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
// Drei Durchgaenge bis zum Alarm - vorher wird nichts unternommen.
// Three passes until the alarm - nothing is done before that.
for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
alt.WasKilled.ShouldBeFalse("Vor Ablauf der Frist wird nicht abgeschossen.");
// Die Frist laeuft ab (SilentKillGrace: 300ms).
// The deadline expires (SilentKillGrace: 300ms).
await Task.Delay(400);
await aufsicht.CheckAsync();
alt.WasKilled.ShouldBeTrue("Nach der Frist wird der haengende Prozess beendet.");
// Der Absturzweg startet neu - und der neue Prozess ist ein anderer.
// The crash path restarts - and the new process is a different one.
await Warte(() => _launcher.Starts >= 2);
_launcher.Last.ShouldNotBeNull().Id.ShouldNotBe(alt.Id);
@@ -223,11 +222,11 @@ public sealed class AutostartTests : IAsyncDisposable
}
/// <summary>
/// Eine Instanz, die aufräumt, wird nicht abgeschossen — auch wenn es dauert.
/// An instance that is cleaning up is not killed — even if it takes a while.
///
/// <para>Sie antwortet ja; sie tut genau das, worum gebeten wurde. Der Unterschied
/// zwischen „hängt" und „räumt auf" ist der ganze Grund, warum der Puls mehr sagt als
/// „jemand hat geantwortet".</para>
/// <para>It does answer; it is doing exactly what it was asked to do. The difference
/// between "hangs" and "cleans up" is the whole reason why the pulse says more than
/// "someone answered".</para>
/// </summary>
[Fact]
public async Task Wer_aufraeumt_gilt_nicht_als_verstummt()
@@ -259,11 +258,11 @@ public sealed class AutostartTests : IAsyncDisposable
}
/// <summary>
/// Nach der Grenze setzt der Manager aus.
/// After the limit the manager pauses.
///
/// <para>Was an einer kaputten Konfiguration scheitert, scheitert beim zehnten Versuch
/// genauso — dann hat man statt einer toten Instanz eine tote Instanz, ein volles
/// Protokoll und einen Bugtracker voller Wiederholungen.</para>
/// <para>What fails because of a broken configuration fails the same way on the tenth attempt
/// — then instead of a dead instance you have a dead instance, a full
/// log and a bug tracker full of repetitions.</para>
/// </summary>
[Fact]
public async Task Nach_drei_Neustarts_im_Zeitfenster_setzt_der_Manager_aus()
@@ -285,8 +284,8 @@ public sealed class AutostartTests : IAsyncDisposable
}
/// <summary>
/// Wer von Hand startet, hat entweder etwas geändert oder weiß, was er tut. Ihm die
/// Versuche von vorhin anzurechnen hieße, ihn für den Absturz zu bestrafen.
/// Whoever starts by hand has either changed something or knows what they are doing. Charging them with
/// the earlier attempts would mean punishing them for the crash.
/// </summary>
[Fact]
public async Task Ein_Start_von_Hand_raeumt_den_Zaehler_ab()
@@ -304,7 +303,7 @@ public sealed class AutostartTests : IAsyncDisposable
var erschoepft = _launcher.Starts;
// Ein neuer Manager faengt bei null an - genau wie ein Start von Hand.
// A new manager starts at zero - just like a start by hand.
await using var frisch = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
await frisch.StartAsync();
@@ -315,7 +314,7 @@ public sealed class AutostartTests : IAsyncDisposable
}
}
/// <summary>Der Sicherungsautomat für sich genommen.</summary>
/// <summary>The circuit breaker on its own.</summary>
public sealed class RestartLedgerTests
{
private sealed class Uhr(DateTimeOffset jetzt) : TimeProvider
@@ -349,8 +348,8 @@ public sealed class RestartLedgerTests
}
/// <summary>
/// Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstürzt und wieder
/// hochkommt, läuft nie in die Grenze — eine, die im Minutentakt fällt, sofort.
/// The time window moves: an instance that crashes once a month and comes back
/// up never runs into the limit — one that falls every minute does immediately.
/// </summary>
[Fact]
public void Alte_Versuche_fallen_aus_dem_Fenster()
@@ -383,12 +382,12 @@ public sealed class RestartLedgerTests
}
/// <summary>
/// Ein Start, der auf einen veralteten Merkzettel trifft.
/// A start that meets an outdated note.
///
/// <para><b>Der Defekt, den erst die Autostart-Tests gezeigt haben.</b> Zwischen zwei
/// Aufsichtsdurchgängen liegen Sekunden, und in denen kann eine Instanz wegbrechen. Wer in
/// dieser Lücke „starten" verlangte, bekam „läuft schon" — einen Erfolg, hinter dem nichts
/// stand. Der nächste Durchgang meldete dann einen Absturz, den niemand mehr erwartete.</para>
/// <para><b>The defect that only the autostart tests revealed.</b> Between two
/// supervision passes there are seconds, and in them an instance can break away. Whoever
/// asked to "start" in this gap got "already running" — a success with nothing
/// behind it. The next pass then reported a crash nobody expected any more.</para>
/// </summary>
public sealed class StaleStateTests : IAsyncDisposable
{
@@ -409,7 +408,7 @@ public sealed class StaleStateTests : IAsyncDisposable
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
[Fact]
@@ -421,7 +420,7 @@ public sealed class StaleStateTests : IAsyncDisposable
await using var aufsicht = new InstanceSupervisor(
dir, _launcher, _kanal, NullLogger.Instance,
new SupervisionOptions(
TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen
TimeSpan.FromSeconds(30), // deliberately sluggish: no pass in between
TimeSpan.FromMilliseconds(600),
3,
TimeSpan.FromMilliseconds(300),
@@ -431,8 +430,8 @@ public sealed class StaleStateTests : IAsyncDisposable
(await aufsicht.StartAsync()).ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
// Die Instanz bricht weg. Die Aufsicht hat es noch nicht bemerkt - der naechste
// Durchgang kaeme erst in dreissig Sekunden.
// The instance breaks away. Supervision has not noticed yet - the next
// pass would only come in thirty seconds.
_launcher.Last!.Exit(137);
aufsicht.Condition.ShouldBe(InstanceCondition.Running, "Noch nichts bemerkt.");
@@ -6,7 +6,7 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Tests für Sicherung und Wiederherstellung im Manager (#530, Stufe 6).
/// Tests for backup and restore in the manager (#530, stage 6).
/// </summary>
public sealed class BackupManagementTests : IDisposable
{
@@ -71,13 +71,13 @@ public sealed class BackupManagementTests : IDisposable
File.Exists(zipPath).ShouldBeTrue();
backupResult.SizeBytes.ShouldBeGreaterThan(0);
// Prüfen
// Check
var manifest = await service.InspectAsync(zipPath);
manifest.InstanceId.ShouldBe("alpha-123");
manifest.InstanceName.ShouldBe("Alpha");
manifest.Files.Count.ShouldBeGreaterThan(0);
// Datei im Ziel modifizieren und Restore prüfen
// Modify a file in the target and check the restore
await File.WriteAllTextAsync(testDataPath, "Überschriebene Testdaten");
var restoreResult = await service.RestoreAsync(
@@ -89,7 +89,7 @@ public sealed class BackupManagementTests : IDisposable
var restoredContent = await File.ReadAllTextAsync(testDataPath);
restoredContent.ShouldBe("Wichtige Testdaten");
// Löschen
// Delete
File.Delete(zipPath);
File.Exists(zipPath).ShouldBeFalse();
}
+39 -39
View File
@@ -1,13 +1,13 @@
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Ein Instanzprozess, den der Test steuert.
/// An instance process the test controls.
///
/// <para>Ein echter Prozess ließe die Aufsicht nur oberflächlich prüfen: Ein Absturz zum
/// gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine wiederverwendete
/// Prozesskennung — das alles ist mit einem laufenden Programm entweder nicht oder nur
/// mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die man
/// irgendwann abschaltet.</para>
/// <para>A real process would only allow checking supervision superficially: a crash at the
/// desired moment, a process that lives and stays silent, a reused
/// process ID — all of that can either not be produced with a running program or only
/// with waiting times, and tests with waiting times are tests that
/// eventually get switched off.</para>
/// </summary>
internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceProcess
{
@@ -23,7 +23,7 @@ internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceP
public bool Disposed { get; private set; }
/// <summary>Der Prozess endet — mit oder ohne Zutun.</summary>
/// <summary>The process ends — with or without intervention.</summary>
public void Exit(int code)
{
HasExited = true;
@@ -47,19 +47,19 @@ internal sealed class FakeLauncher : IInstanceLauncher
public int Starts { get; private set; }
/// <summary>Welcher Prozess zu welchem Instanzverzeichnis gehört.</summary>
/// <summary>Which process belongs to which instance directory.</summary>
public Dictionary<string, FakeProcess> ByDirectory { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Ob für dieses Verzeichnis gerade ein Prozess lebt.</summary>
/// <summary>Whether a process is currently alive for this directory.</summary>
public bool IsRunning(string instanceDirectory)
=> ByDirectory.TryGetValue(Path.GetFullPath(instanceDirectory), out var p)
&& !p.HasExited;
/// <summary>Wenn gesetzt, scheitert der Start damit.</summary>
/// <summary>If set, the start fails with this.</summary>
public Exception? FailStartWith { get; set; }
/// <summary>Was <see cref="Attach"/> liefern soll — bereits laufende Prozesse.</summary>
/// <summary>What <see cref="Attach"/> should deliver — already running processes.</summary>
public Dictionary<int, FakeProcess> Running { get; } = [];
public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch;
@@ -77,10 +77,10 @@ internal sealed class FakeLauncher : IInstanceLauncher
}
/// <summary>
/// Ein Prozess, der schon lief, bevor dieser Manager kam (E-M6).
/// A process that was already running before this manager came (E-M6).
///
/// <para>Zählt nicht als Start — genau darin liegt der Unterschied, den mehrere Tests
/// prüfen: Was läuft, wird übernommen und nicht ein zweites Mal hochgefahren.</para>
/// <para>Does not count as a start — exactly therein lies the difference several tests
/// check: what runs is taken over and not booted a second time.</para>
/// </summary>
public FakeProcess Fremdstart(string instanceDirectory, int processId)
{
@@ -95,65 +95,65 @@ internal sealed class FakeLauncher : IInstanceLauncher
if (!Running.TryGetValue(processId, out var process))
return null;
// Dieselbe Pruefung wie im Echtbetrieb: gleiche Kennung, aber andere Startzeit
// heisst anderer Prozess.
// The same check as in real operation: same ID, but a different start time
// means a different process.
return (process.StartedAt - startedAt).Duration() > TimeSpan.FromSeconds(2)
? null
: process;
}
}
/// <summary>Der Socket, den der Test antworten oder schweigen lässt.</summary>
/// <summary>The socket that the test lets answer or stay silent.</summary>
internal sealed class FakeChannel : IInstanceChannel
{
/// <summary>Die Antwort für alle Verzeichnisse, sofern keine eigene hinterlegt ist.</summary>
/// <summary>The answer for all directories, unless one of their own is stored.</summary>
public InstanceIdentity? Answer { get; set; }
/// <summary>
/// Antworten je Instanzverzeichnis.
/// Answers per instance directory.
///
/// <para>Nötig, sobald mehr als eine Instanz im Spiel ist: Eine Auskunft, die für jedes
/// Verzeichnis dieselbe Instanz nennt, lässt Tests grün werden, die im Betrieb zwei
/// verschiedene Instanzen verwechseln würden.</para>
/// <para>Needed as soon as more than one instance is involved: information that names the same
/// instance for every directory lets tests turn green that in operation would mix up two
/// different instances.</para>
/// </summary>
public Dictionary<string, InstanceIdentity?> AnswerFor { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Wenn gesetzt, antwortet der Socket nur für Instanzen, die dieser Starter
/// tatsächlich gestartet hat — so wie im Betrieb.
/// If set, the socket only answers for instances that this launcher
/// actually started — as in operation.
///
/// <para><b>Ohne das ist ein Testaufbau leicht falsch:</b> Wer die Antwort hinterlegt,
/// bevor der Manager hochfährt, sagt damit „diese Instanz läuft bereits". Der Manager
/// übernimmt sie dann (E-M6), der Starter wird nie angefasst — und der Test prüft etwas
/// anderes, als er behauptet. Das ist mir dreimal passiert; mit dieser Kopplung kann es
/// nicht mehr passieren.</para>
/// <para><b>Without this a test setup is easily wrong:</b> whoever stores the answer
/// before the manager comes up says with it "this instance is already running". The manager
/// then takes it over (E-M6), the launcher is never touched — and the test checks something
/// other than it claims. That happened to me three times; with this coupling it can
/// no longer happen.</para>
///
/// <para>Nicht gesetzt heißt: Die hinterlegte Antwort gilt immer. Das braucht, wer eine
/// Instanz beschreiben will, die läuft, ohne dass dieser Manager sie gestartet hat.</para>
/// <para>Not set means: the stored answer always applies. That is needed by whoever wants to describe an
/// instance that runs without this manager having started it.</para>
/// </summary>
public FakeLauncher? Launcher { get; set; }
/// <summary>
/// Verzeichnisse, deren Instanz verstummt ist: Der Prozess lebt, der Socket schweigt.
/// Directories whose instance has fallen silent: the process lives, the socket is silent.
///
/// <para>Der Fall aus #529 — und der einzige, in dem die Aufsicht von sich aus zur
/// Gewalt greift. Ohne diesen Schalter lässt er sich nicht nachstellen: Die Kopplung
/// an <see cref="Launcher"/> bindet die Antwort sonst genau an das, was hier
/// auseinanderfallen soll.</para>
/// <para>The case from #529 — and the only one in which supervision resorts to
/// force on its own initiative. Without this switch it cannot be reproduced: the coupling
/// to <see cref="Launcher"/> would otherwise bind the answer to exactly what is supposed to
/// fall apart here.</para>
/// </summary>
public HashSet<string> Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Die Instanz hört auf zu antworten, ohne dass ihr Prozess endet.</summary>
/// <summary>The instance stops answering without its process ending.</summary>
public void Verstummen(string instanceDirectory)
=> Verstummt.Add(Path.GetFullPath(instanceDirectory));
public int Calls { get; private set; }
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
/// <summary>How often the instance was asked to end itself.</summary>
public int StopRequests { get; private set; }
/// <summary>Ob die Bitte ankommt. Falsch heisst: Die Instanz hoert gar nicht mehr zu.</summary>
/// <summary>Whether the request arrives. False means: the instance no longer listens at all.</summary>
public bool StopRequestArrives { get; set; } = true;
public Task<InstanceIdentity?> AskAsync(string instanceDirectory, CancellationToken ct = default)
@@ -5,12 +5,12 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Das Register aller Instanzen einer Maschine (#528, Stufe 3).
/// The registry of all instances of a machine (#528, stage 3).
///
/// <para>Zwei Zusagen stehen hier über allem, und beide folgen aus E-M6: <b>Beim Start
/// wird übernommen, nicht gestartet</b>, und <b>beim Beenden wird nichts beendet.</b>
/// Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der Einzelpunkt
/// sein, gegen den dieses ganze Vorhaben gebaut wird.</para>
/// <para>Two promises stand above everything here, and both follow from E-M6: <b>at startup
/// things are taken over, not started</b>, and <b>on exit nothing is ended.</b>
/// If the manager fails, the instances keep running — it must not be the single point
/// this whole project is built against.</para>
/// </summary>
public sealed class InstanceRegistryTests : IAsyncDisposable
{
@@ -34,8 +34,8 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
public InstanceRegistryTests()
{
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
// Betrieb (siehe FakeChannel.Launcher).
// The socket only answers if a process is really running - exactly as in
// operation (see FakeChannel.Launcher).
_kanal.Launcher = _launcher;
}
@@ -52,10 +52,10 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
// ─── Was da ist ───
// ─── What is there ───
[Fact]
public async Task Ein_leeres_Instanzenverzeichnis_ergibt_ein_leeres_Register()
@@ -80,8 +80,8 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
}
/// <summary>
/// Ein Manager, der beim Hochkommen alles startet, was er findet, würde nach einem
/// Neustart der Maschine auch das starten, was jemand mit Absicht angehalten hat.
/// A manager that starts everything it finds when coming up would, after a
/// restart of the machine, also start what someone stopped on purpose.
/// </summary>
[Fact]
public async Task Beim_Nachsehen_wird_nichts_von_selbst_gestartet()
@@ -110,7 +110,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
register.Instances.ShouldBeEmpty();
}
// ─── Anlegen, starten, anhalten ───
// ─── Create, start, stop ───
[Fact]
public async Task Eine_neu_angelegte_Instanz_ist_sofort_im_Register_und_laeuft_nicht()
@@ -138,7 +138,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
(await register.StartAsync(pfad)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// Die Instanz endet auf die Bitte hin.
// The instance ends upon the request.
var prozess = _launcher.Last!;
_kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
@@ -162,15 +162,15 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
{
string pfad;
// Erster Manager legt zwei an und startet nur eine.
// The first manager creates two and starts only one.
await using (var erster = Register())
{
pfad = erster.Create("Alpha");
erster.Create("Beta");
// Nur Alpha antwortet. Eine Auskunft, die fuer jedes Verzeichnis dieselbe
// Instanz nennt, wuerde hier gruen werden und im Betrieb zwei Instanzen
// verwechseln.
// Only Alpha answers. Information that names the same instance for every
// directory would turn green here and in operation mix up two
// instances.
_kanal.AnswerFor[Path.GetFullPath(pfad)] =
new InstanceIdentity("i-1", "Alpha", "0.1.2");
@@ -179,7 +179,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
_ereignisse.Clear();
// Zweiter Manager, derselbe Rechner.
// Second manager, the same machine.
await using var zweiter = Register();
await zweiter.RefreshAsync();
@@ -187,17 +187,17 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
alpha.Condition.ShouldBe(InstanceCondition.Running);
alpha.ProcessId.ShouldBe(_launcher.Last!.Id);
// Beta antwortet nicht und bleibt aus - sie wird nicht mit hochgezogen.
// Beta does not answer and stays off - it is not pulled up along with it.
zweiter.Instances.Single(i => i.Name == "Beta").Condition
.ShouldBe(InstanceCondition.Stopped);
// Uebernehmen ist kein Starten.
// Taking over is not starting.
_launcher.Starts.ShouldBe(1);
_ereignisse.ShouldBeEmpty();
}
/// <summary>
/// Wer den Manager schließt, will den Manager schließen — nicht seine Instanzen.
/// Whoever closes the manager wants to close the manager — not its instances.
/// </summary>
[Fact]
public async Task Das_Beenden_des_Managers_beendet_keine_Instanz()
@@ -214,12 +214,12 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
_launcher.Last!.WasKilled.ShouldBeFalse();
_launcher.Last.HasExited.ShouldBeFalse();
// Und die Zustandsdatei bleibt liegen - sie ist der Hinweis fuer den naechsten.
// And the state file stays behind - it is the hint for the next one.
InstanceStateStore.Read(pfad).ShouldNotBeNull();
}
/// <summary>
/// Die eine Ausnahme: Ein Lizenzwiderruf ist kein Ausfall, sondern ein Urteil.
/// The one exception: a license revocation is not an outage, but a verdict.
/// </summary>
[Fact]
public async Task Bei_einem_Grund_der_alle_betrifft_werden_alle_angehalten()
@@ -239,7 +239,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
_kanal.StopRequests.ShouldBe(1);
}
// ─── Vorkommnisse ───
// ─── Occurrences ───
[Fact]
public async Task Vorkommnisse_einzelner_Instanzen_kommen_beim_Register_an()
@@ -5,17 +5,17 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Die Aufsicht über eine Instanz (#528, Stufe 3).
/// The supervision of an instance (#528, stage 3).
///
/// <para><b>Der Anlass.</b> Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von
/// Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen,
/// kein Bugtracker-Eintrag, kein Watchdog-Alarm — weil Meldeweg und Watchdog in genau dem
/// Prozess lebten, über den sie hätten berichten sollen (#529). Die Abnahmebedingung für
/// diese Stufe lautet deshalb: <b>eine Instanz abschießen, und der Manager merkt es und
/// meldet es.</b></para>
/// <para><b>The occasion.</b> On 2026-09-08 an instance hung and was ended by
/// Windows. Not a single line about this event is in the instance logs,
/// no bug tracker entry, no watchdog alarm — because reporting path and watchdog lived in exactly the
/// process they should have reported on (#529). The acceptance condition for
/// this stage is therefore: <b>kill an instance, and the manager notices it and
/// reports it.</b></para>
///
/// <para>Der schwierigere Fall steht darunter: ein Prozess, der <em>lebt</em> und nicht
/// mehr arbeitet. Den sieht keine Prozessliste — nur der Socket.</para>
/// <para>The harder case stands below: a process that is <em>alive</em> and no longer
/// works. No process list sees it — only the socket.</para>
/// </summary>
public sealed class InstanceSupervisorTests : IDisposable
{
@@ -29,11 +29,11 @@ public sealed class InstanceSupervisorTests : IDisposable
private static readonly InstanceIdentity Test = new("i-1", "Test", "0.1.2");
/// <summary>
/// Kurze Fristen: Der Test soll die Aufsicht prüfen, nicht die Geduld.
/// Short deadlines: the test is supposed to check supervision, not patience.
///
/// <para>Knapp über dem Abfragetakt von 250 ms, damit die Warteschleifen je einen
/// Durchgang schaffen — es geht um die Entscheidung am Ende der Frist, nicht darum,
/// wie oft dazwischen gefragt wurde.</para>
/// <para>Just above the probe interval of 250 ms, so the waiting loops manage one
/// pass each — this is about the decision at the end of the deadline, not about
/// how often there was asking in between.</para>
/// </summary>
private static readonly SupervisionOptions Options = new(
ProbeInterval: TimeSpan.FromMilliseconds(50),
@@ -47,16 +47,16 @@ public sealed class InstanceSupervisorTests : IDisposable
{
Directory.CreateDirectory(_dir);
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
// Betrieb. Eine vorab hinterlegte Antwort hiesse sonst "laeuft schon", und die
// Aufsicht wuerde uebernehmen statt zu starten.
// The socket only answers if a process is really running - exactly as in
// operation. An answer stored in advance would otherwise mean "already running", and
// supervision would take over instead of starting.
_kanal.Launcher = _launcher;
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
private InstanceSupervisor Aufsicht(SupervisionOptions? options = null)
@@ -70,7 +70,7 @@ public sealed class InstanceSupervisorTests : IDisposable
private IEnumerable<InstanceEventKind> Arten => _ereignisse.Select(e => e.Kind);
// ─── Starten ───
// ─── Starting ───
[Fact]
public async Task Ein_Start_gilt_erst_wenn_die_Instanz_antwortet()
@@ -91,16 +91,16 @@ public sealed class InstanceSupervisorTests : IDisposable
}
/// <summary>
/// „Der Prozess läuft" ist nicht dasselbe wie „die Instanz läuft".
/// "The process is running" is not the same as "the instance is running".
///
/// <para>Zwischen beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf
/// <c>Process.Start</c> wartet, hält eine Instanz für gestartet, die gleich an einer
/// kaputten Konfiguration scheitert.</para>
/// <para>Between the two lie database, agents and Kestrel. Whoever only waits for
/// <c>Process.Start</c> considers an instance started that is about to fail on a
/// broken configuration.</para>
/// </summary>
[Fact]
public async Task Ein_Prozess_der_sich_nie_meldet_gilt_nicht_als_gestartet()
{
_kanal.Answer = null; // laeuft, sagt aber nichts
_kanal.Answer = null; // runs, but says nothing
await using var aufsicht = Aufsicht();
(await aufsicht.StartAsync()).ShouldBeFalse();
@@ -108,8 +108,8 @@ public sealed class InstanceSupervisorTests : IDisposable
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
Arten.ShouldBe([InstanceEventKind.StartFailed]);
// Nicht stehen lassen: Ein Prozess, der laeuft und schweigt, haelt den Socket
// besetzt und blockiert damit den naechsten Versuch.
// Do not leave it standing: a process that runs and stays silent keeps the socket
// occupied and thus blocks the next attempt.
_launcher.Last!.WasKilled.ShouldBeTrue();
InstanceStateStore.Read(_dir).ShouldBeNull();
}
@@ -126,7 +126,7 @@ public sealed class InstanceSupervisorTests : IDisposable
_ereignisse[0].Message.ShouldContain("Programmdatei fehlt");
}
// ─── Der Fall, um den es geht ───
// ─── The case this is about ───
[Fact]
public async Task Ein_abgeschossener_Prozess_wird_bemerkt_und_gemeldet()
@@ -136,7 +136,7 @@ public sealed class InstanceSupervisorTests : IDisposable
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
// Jemand schiesst die Instanz ab. Niemand hat darum gebeten.
// Someone kills the instance. Nobody asked for it.
_launcher.Last!.Exit(code: 137);
_kanal.Answer = null;
@@ -149,16 +149,16 @@ public sealed class InstanceSupervisorTests : IDisposable
absturz.InstanceName.ShouldBe("Test");
absturz.WorthReporting.ShouldBeTrue("Das ist die Meldung, die es 2026-09-08 nicht gab.");
// Und die Zustandsdatei zeigt nicht mehr auf einen Prozess, den es nicht gibt.
// And the state file no longer points to a process that does not exist.
InstanceStateStore.Read(_dir).ShouldBeNull();
}
/// <summary>
/// Der schwierigere Fall: Der Prozess lebt und arbeitet nicht mehr.
/// The harder case: the process lives and no longer works.
///
/// <para>Genau das war der Zustand am 2026-09-08 — die Anwendung stand einwandfrei in
/// der Prozessliste, bis Windows sie als „reagiert nicht mehr" beendet hat. Eine
/// Aufsicht, die nur Prozesse zählt, sieht davon nichts.</para>
/// <para>Exactly that was the state on 2026-09-08 — the application stood flawlessly in
/// the process list until Windows ended it as "not responding". A
/// supervision that only counts processes sees nothing of it.</para>
/// </summary>
[Fact]
public async Task Ein_lebender_aber_stummer_Prozess_faellt_auf()
@@ -168,10 +168,10 @@ public sealed class InstanceSupervisorTests : IDisposable
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
_kanal.Answer = null; // haengt - der Prozess lebt weiter
_kanal.Answer = null; // hangs - the process lives on
// Nicht beim ersten Aussetzer: Der waere Alltag, und ein Alarm bei jedem
// Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest.
// Not at the first hiccup: that would be everyday life, and an alarm at every
// hiccup is after a week the alarm nobody reads any more.
await aufsicht.CheckAsync();
await aufsicht.CheckAsync();
Arten.ShouldNotContain(InstanceEventKind.Unresponsive);
@@ -220,7 +220,7 @@ public sealed class InstanceSupervisorTests : IDisposable
Arten.ShouldContain(InstanceEventKind.Recovered);
}
// ─── Anhalten ───
// ─── Stopping ───
[Fact]
public async Task Ein_erbetenes_Ende_ist_kein_Absturz()
@@ -230,7 +230,7 @@ public sealed class InstanceSupervisorTests : IDisposable
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
// Die Instanz endet auf die Bitte hin - so wie im Betrieb.
// The instance ends upon the request - as in operation.
_launcher.Last!.Exit(code: 0);
_kanal.Answer = null;
@@ -253,7 +253,7 @@ public sealed class InstanceSupervisorTests : IDisposable
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
// Antwortet weiter und endet nicht - die Bitte verhallt.
// Keeps answering and does not end - the request goes unheard.
_kanal.StopRequestArrives = false;
(await aufsicht.StopAsync()).ShouldBeTrue();
@@ -264,14 +264,14 @@ public sealed class InstanceSupervisorTests : IDisposable
}
/// <summary>
/// Schweigen ist kein Todesnachweis, solange ein Prozesshandle das Gegenteil sagt.
/// Silence is no proof of death as long as a process handle says the opposite.
///
/// <para><b>Hier lag der gefährlichste Fehlschluss.</b> Die Warteschleife brach ab,
/// sobald der Socket nicht mehr antwortete, meldete „beendet" und gab <c>true</c>
/// zurück — ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket
/// aber von vornherein still: Der Aufruf meldete sofort Erfolg, der Prozess lief
/// weiter, und der Neustart setzte einen zweiten auf dieselbe <c>state.db</c>. Genau
/// der Schaden, gegen den dieses Vorhaben gebaut ist.</para>
/// <para><b>Here lay the most dangerous fallacy.</b> The waiting loop broke off
/// as soon as the socket no longer answered, reported "ended" and returned <c>true</c>
/// — without touching the process. With a silent instance, however, the socket
/// is quiet from the start: the call reported success immediately, the process kept
/// running, and the restart put a second one on the same <c>state.db</c>. Exactly
/// the damage this project is built against.</para>
/// </summary>
[Fact]
public async Task Ein_stummer_aber_lebender_Prozess_gilt_nicht_als_beendet()
@@ -283,7 +283,7 @@ public sealed class InstanceSupervisorTests : IDisposable
var prozess = _launcher.Last.ShouldNotBeNull();
// Sie haengt: Der Socket schweigt, der Prozess lebt.
// It hangs: the socket is silent, the process lives.
_kanal.Verstummen(_dir);
(await aufsicht.StopAsync()).ShouldBeTrue();
@@ -296,28 +296,28 @@ public sealed class InstanceSupervisorTests : IDisposable
}
/// <summary>
/// Wer aufräumt, wird nicht abgeschossen — die Frist wird nachgezogen.
/// Whoever is cleaning up is not killed — the deadline is extended.
///
/// <para>Die Instanz gibt sich selbst eine Frist zum Auslaufen. Wäre die des Managers
/// kürzer, schösse er mitten in das Aufräumen hinein, um das er gerade gebeten
/// hat.</para>
/// <para>The instance gives itself a deadline for running out. If the manager's were
/// shorter, it would shoot into the middle of the cleanup it has just asked
/// for.</para>
/// </summary>
[Fact]
public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen()
{
_kanal.Answer = Test;
// #722: Die Aufsicht fragt alle 250 ms und schiebt die Frist nur bei "raeume auf" weiter.
// Mit 300 ms Frist kam unter Parallellast die naechste Frage zu spaet, und der Test sah
// einen Abschuss, wo keiner hingehoert. Eine Sekunde Frist laesst dem Takt Luft; die
// Instanz raeumt trotzdem deutlich laenger auf, als die Frist allein erlaubt.
// #722: Supervision asks every 250 ms and only extends the deadline on "cleaning up".
// With a 300 ms deadline the next question came too late under parallel load, and the test saw
// a kill where none belongs. A one-second deadline leaves the tick room; the
// instance still cleans up considerably longer than the deadline alone allows.
await using var aufsicht = Aufsicht(Options with { StopGrace = TimeSpan.FromSeconds(1), MaxDrainWait = TimeSpan.FromSeconds(20) });
await aufsicht.StartAsync();
var prozess = _launcher.Last.ShouldNotBeNull();
// Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (1 s), aber
// kuerzer als MaxDrainWait.
// It answers and reports: I am cleaning up. Longer than StopGrace (1 s), but
// shorter than MaxDrainWait.
_kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
_kanal.OnStopRequest = _ => Task.Run(async () =>
@@ -334,11 +334,11 @@ public sealed class InstanceSupervisorTests : IDisposable
}
/// <summary>
/// Eine Instanz, die der Manager nicht selbst gestartet hat und die nicht hört, kann
/// er auch nicht beenden — und das muss er sagen, statt Erfolg zu melden.
/// An instance that the manager did not start itself and that does not listen, it
/// cannot end either — and it has to say so instead of reporting success.
///
/// <para>Der Fall entsteht nach einem Managerneustart, wenn die Prozesskennung nicht
/// mehr zu greifen ist (siehe <see cref="Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten"/>).</para>
/// <para>The case arises after a manager restart when the process ID can no longer
/// be grabbed (see <see cref="Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten"/>).</para>
/// </summary>
[Fact]
public async Task Was_nicht_zu_fassen_ist_wird_nicht_als_beendet_gemeldet()
@@ -346,12 +346,12 @@ public sealed class InstanceSupervisorTests : IDisposable
_kanal.Answer = Test;
_kanal.StopRequestArrives = false;
// Sie lief schon, bevor dieser Manager kam - und ohne Zustandsdatei ist ihr
// Prozess fuer ihn nicht zu greifen.
// It was already running before this manager came - and without a state file its
// process cannot be grabbed by it.
_launcher.Fremdstart(_dir, 4711);
await using var aufsicht = Aufsicht();
await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess
await aufsicht.ReattachAsync(); // taken over, but without a process
aufsicht.ProcessId.ShouldBeNull();
(await aufsicht.StopAsync()).ShouldBeFalse();
@@ -360,20 +360,20 @@ public sealed class InstanceSupervisorTests : IDisposable
Arten.ShouldNotContain(InstanceEventKind.StoppedOrderly);
}
// ─── Wiederfinden (E-M6) ───
// ─── Rediscovering (E-M6) ───
[Fact]
public async Task Eine_laufende_Instanz_wird_nach_einem_Managerneustart_uebernommen()
{
_kanal.Answer = Test;
// Erster Manager startet sie.
// The first manager starts it.
await using (var erster = Aufsicht())
await erster.StartAsync();
_ereignisse.Clear();
// Zweiter Manager, derselbe Rechner, dieselbe laufende Instanz.
// Second manager, the same machine, the same running instance.
await using var zweiter = Aufsicht();
(await zweiter.ReattachAsync()).ShouldBeTrue();
@@ -381,18 +381,18 @@ public sealed class InstanceSupervisorTests : IDisposable
zweiter.InstanceName.ShouldBe("Test");
zweiter.ProcessId.ShouldBe(_launcher.Last!.Id);
// Uebernehmen ist kein Starten - es gibt nichts zu melden.
// Taking over is not starting - there is nothing to report.
_ereignisse.ShouldBeEmpty();
_launcher.Starts.ShouldBe(1);
}
/// <summary>
/// Eine wiederverwendete Prozesskennung darf nicht zur Übernahme eines fremden
/// Programms führen.
/// A reused process ID must not lead to taking over a foreign
/// program.
///
/// <para>Nach einem Neustart der Maschine gehört 4711 mit einiger Wahrscheinlichkeit
/// etwas ganz anderem. Es dann zu beaufsichtigen — und beim Anhalten zu beenden —
/// wäre deutlich schlimmer, als die Instanz gar nicht wiederzufinden.</para>
/// <para>After a restart of the machine, 4711 belongs with some probability
/// to something entirely different. Supervising it then — and ending it when stopping —
/// would be considerably worse than not finding the instance at all.</para>
/// </summary>
[Fact]
public async Task Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten()
@@ -404,7 +404,7 @@ public sealed class InstanceSupervisorTests : IDisposable
var alte = _launcher.Last!;
// Dieselbe Kennung, viel spaeter gestartet: ein anderer Prozess.
// The same ID, started much later: a different process.
_launcher.Running[alte.Id] = new FakeProcess(alte.Id, alte.StartedAt.AddHours(3));
await using var zweiter = Aufsicht();
@@ -424,7 +424,7 @@ public sealed class InstanceSupervisorTests : IDisposable
InstanceStateStore.Read(_dir).ShouldNotBeNull();
// Der Manager war weg, die Instanz ist inzwischen auch weg.
// The manager was gone, the instance is gone by now too.
_kanal.Answer = null;
await using var zweiter = Aufsicht();
@@ -438,8 +438,8 @@ public sealed class InstanceSupervisorTests : IDisposable
[Fact]
public async Task Ohne_Zustandsdatei_wird_eine_antwortende_Instanz_trotzdem_uebernommen()
{
// Der Fall nach einem Absturz des Managers, bevor er schreiben konnte - oder
// nachdem jemand die Datei geloescht hat. Der Socket antwortet, also laeuft sie.
// The case after a crash of the manager before it could write - or
// after someone deleted the file. The socket answers, so it is running.
_kanal.Answer = Test;
_launcher.Fremdstart(_dir, 4711);
@@ -451,12 +451,12 @@ public sealed class InstanceSupervisorTests : IDisposable
InstanceStateStore.Read(_dir).ShouldNotBeNull();
}
// ─── Was gemeldet wird und was nicht ───
// ─── What is reported and what is not ───
/// <summary>
/// Der Manager meldet, was aus seiner Sicht relevant ist — und nur das (Konzept,
/// Abschnitt 4). Was die Instanz betrifft, bleibt in der Instanz und geht mit ihrem
/// eigenen Sub-Token raus.
/// The manager reports what is relevant from its point of view — and only that (concept,
/// section 4). What concerns the instance stays in the instance and goes out with its
/// own sub-token.
/// </summary>
[Theory]
[InlineData(InstanceEventKind.Crashed, true)]
@@ -8,8 +8,8 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am
/// Tag, aufgeräumt, auf Wunsch hochgeladen.
/// The backups in the manager (#530): encrypted with the license key, once a
/// day, cleaned up, uploaded on request.
/// </summary>
public sealed class ManagerBackupsTests : IAsyncDisposable
{
@@ -36,7 +36,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
await Task.CompletedTask;
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
private string Instanz(string name, bool automatisch = true, string uhrzeit = "03:00", int behalten = 14)
@@ -71,11 +71,11 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
return Directory.Exists(ordner) ? Directory.GetFiles(ordner) : [];
}
// ─── Namen ───
// ─── Names ───
/// <summary>
/// Ein Präfix ist keine Grenze: Die Archive von <c>Test_2</c> gehören nicht zu
/// <c>Test</c> — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg.
/// A prefix is not a boundary: the archives of <c>Test_2</c> do not belong to
/// <c>Test</c> — otherwise the cleanup of one throws away the backups of the other.
/// </summary>
[Fact]
public void Ineinanderliegende_Instanznamen_werden_nicht_verwechselt()
@@ -89,8 +89,8 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
}
[Theory]
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // taegliche Sicherung der Instanz
[InlineData("backup_Alpha_20260911_030000.zip")] // erste Verwaltungsansicht
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // daily backup of the instance
[InlineData("backup_Alpha_20260911_030000.zip")] // first administration view
[InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager
public void Aeltere_Schreibweisen_werden_erkannt(string name)
{
@@ -106,7 +106,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse();
}
// ─── Sichern ───
// ─── Backing up ───
[Fact]
public async Task Ohne_Lizenzschluessel_wird_keine_Sicherung_angelegt()
@@ -141,8 +141,8 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
}
/// <summary>
/// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für
/// misslungen zu erklären — sie liegt ja lokal.
/// A failed upload is a finding, but no reason to declare the backup
/// failed — it lies locally, after all.
/// </summary>
[Fact]
public async Task Ein_gescheiterter_Upload_laesst_die_Sicherung_stehen()
@@ -183,7 +183,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
namen.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0)));
}
// ─── Der tägliche Takt ───
// ─── The daily timer ───
[Fact]
public async Task Vor_der_Uhrzeit_passiert_nichts()
@@ -213,8 +213,8 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
}
/// <summary>
/// Auch über einen Neustart des Managers hinweg. Nur im Speicher zu zählen hieße: Wer
/// nach der Sicherungszeit neu startet, bekommt eine zweite Sicherung.
/// Even across a restart of the manager. Counting only in memory would mean: whoever
/// restarts after the backup time gets a second backup.
/// </summary>
[Fact]
public async Task Ein_Neustart_des_Managers_erzeugt_keine_zweite_Sicherung()
@@ -245,7 +245,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
Sicherungen(pfad).ShouldBeEmpty();
}
// ─── Hilfen ───
// ─── Helpers ───
private async Task<IReadOnlyList<ManagedInstance>> RegisterAsync()
{
@@ -273,7 +273,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
public Task<string?> TestAsync(CancellationToken ct) => Task.FromResult<string?>(null);
}
/// <summary>Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt.</summary>
/// <summary>A clock in UTC, so "local" means the same everywhere in the test.</summary>
private sealed class FesteUhr(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Jetzt { get; set; } = start;
@@ -4,7 +4,7 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>Zählt mit, wem ein Token besorgt wurde.</summary>
/// <summary>Counts for whom a token was obtained.</summary>
internal sealed class FakeTokenSource : IInstanceTokenSource
{
public List<string> Fuer { get; } = [];
@@ -19,12 +19,12 @@ internal sealed class FakeTokenSource : IInstanceTokenSource
}
/// <summary>
/// Der Instanzmanager als Ganzes (#528, Stufe 3).
/// The instance manager as a whole (#528, stage 3).
///
/// <para><b>Die Lizenz wird hier ohne Lizenzserver geprüft</b>, und das ist Absicht: Die
/// Zusage lautet nicht „der Torwächter funktioniert" — das ist die Aufgabe des SDK —,
/// sondern „ein Urteil führt zum Anhalten aller Instanzen, ein Ausfall nicht". Genau
/// dieser Weg wird hier gegangen, ausgelöst über
/// <para><b>The license is checked here without a license server</b>, and that is intentional: the
/// promise is not "the gatekeeper works" — that is the SDK's job —,
/// but "a verdict leads to stopping all instances, an outage does not". Exactly
/// this path is taken here, triggered via
/// <see cref="ManagerCore.HandleLicenseRevokedAsync"/>.</para>
/// </summary>
public sealed class ManagerCoreTests : IAsyncDisposable
@@ -49,8 +49,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
// Betrieb (siehe FakeChannel.Launcher).
// The socket only answers if a process is really running - exactly as in
// operation (see FakeChannel.Launcher).
_kanal.Launcher = _launcher;
}
@@ -67,18 +67,17 @@ public sealed class ManagerCoreTests : IAsyncDisposable
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
/// <summary>
/// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich
/// gestartet und nicht bloß übernommen wurde.
/// Lets the instance answer from now on — and checks that it was really
/// started afterwards and not merely taken over.
///
/// <para><b>Der Unterschied ist keine Feinheit.</b> Wer die Antwort schon vor dem
/// Hochfahren des Managers hinterlegt, sagt damit „diese Instanz läuft bereits" — der
/// Manager übernimmt sie dann (E-M6), der Starter wird nie angefasst, und der Test
/// prüft etwas anderes, als er behauptet. Genau das ist mir hier zweimal
/// passiert.</para>
/// <para><b>The difference is not a subtlety.</b> Whoever stores the answer before
/// the manager comes up says with it "this instance is already running" — the
/// manager then takes it over (E-M6), the launcher is never touched, and the test
/// checks something other than it claims. Exactly that happened to me here twice.</para>
/// </summary>
private async Task<StartRefusal> StarteUndPruefe(ManagerCore manager, string pfad, string name)
{
@@ -92,13 +91,13 @@ public sealed class ManagerCoreTests : IAsyncDisposable
return ergebnis;
}
// ─── Hochfahren ───
// ─── Starting up ───
[Fact]
public async Task Ohne_Lizenzpruefung_arbeitet_der_Manager()
{
// Kein Torwaechter heisst keine Sperre - sonst gaebe es eine Henne-Ei-Sperre,
// bevor ueberhaupt jemand einen Schluessel eintragen kann.
// No gatekeeper means no lock - otherwise there would be a chicken-and-egg lock
// before anyone can even enter a key.
await using var manager = Manager();
(await manager.StartAsync()).ShouldBeTrue();
@@ -122,8 +121,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
[Fact]
public async Task Eine_neue_Instanz_bekommt_gleich_ihr_eigenes_Token()
{
// Beim Anlegen und nicht beim ersten Start: Dann gehoert die Instanzdatei
// zweifelsfrei niemandem.
// At creation and not at the first start: then the instance file belongs
// to nobody without doubt.
await using var manager = Manager();
var pfad = await manager.CreateInstanceAsync("Frisch");
@@ -134,8 +133,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
[Fact]
public async Task Vor_dem_Start_wird_das_Token_nachgeholt()
{
// Der Fall einer Instanz, die es vor dem Manager schon gab - oder bei der das
// Deploymentcenter beim Anlegen nicht erreichbar war.
// The case of an instance that existed before the manager - or for which the
// Deploymentcenter was not reachable at creation.
var pfad = _verzeichnisse.CreateInstance("Alt");
await using var manager = Manager();
@@ -149,8 +148,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
[Fact]
public async Task Ein_fehlgeschlagener_Tokenbezug_verhindert_den_Start_nicht()
{
// Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still,
// wenn man es braucht.
// Monitoring that only runs with perfect permissions is silent exactly
// when you need it.
_tokens.Erfolg = false;
var pfad = _verzeichnisse.CreateInstance("Alt");
@@ -161,14 +160,14 @@ public sealed class ManagerCoreTests : IAsyncDisposable
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
}
// ─── Das Urteil ───
// ─── The verdict ───
/// <summary>
/// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.
/// The only case in which the manager stops instances on its own initiative.
///
/// <para>Ein Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt Urteil und
/// gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein
/// Netzproblem sämtliche Installationen gleichzeitig mit.</para>
/// <para>A server outage explicitly does not belong here — the SDK separates verdict and
/// failed attempt, and only the verdict arrives here. Otherwise a
/// network problem would take all installations down at the same time.</para>
/// </summary>
[Fact]
public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an()
@@ -181,7 +180,7 @@ public sealed class ManagerCoreTests : IAsyncDisposable
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// Die Instanz endet auf die Bitte hin.
// The instance ends upon the request.
var prozess = _launcher.Last!;
_kanal.OnStopRequest = _ => { _kanal.AnswerFor[Path.GetFullPath(pfad)] = null; prozess.Exit(0); };
@@ -208,7 +207,7 @@ public sealed class ManagerCoreTests : IAsyncDisposable
_tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht.");
}
// ─── Wenn der Start scheitert ───
// ─── When the start fails ───
[Fact]
public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet()
@@ -218,7 +217,7 @@ public sealed class ManagerCoreTests : IAsyncDisposable
await using var manager = Manager();
await manager.StartAsync();
// Der Prozess laeuft, die Instanz meldet sich nie.
// The process runs, the instance never reports in.
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
}
}
@@ -4,8 +4,8 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// <c>manager.json</c> und der Protokollauszug — die beiden Stellen, an denen der Manager
/// mit dem Dateisystem spricht (#528, Stufe 3).
/// <c>manager.json</c> and the log excerpt — the two places where the manager
/// talks to the file system (#528, stage 3).
/// </summary>
public sealed class ManagerSettingsTests : IDisposable
{
@@ -17,7 +17,7 @@ public sealed class ManagerSettingsTests : IDisposable
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
[Fact]
@@ -32,8 +32,8 @@ public sealed class ManagerSettingsTests : IDisposable
}
/// <summary>
/// Master-Token und Lizenzschlüssel liegen in der Datei nicht im Klartext — zur
/// Laufzeit aber schon, sonst könnte niemand damit arbeiten.
/// Master token and license key are not in plain text in the file — but they are at
/// runtime, otherwise nobody could work with them.
/// </summary>
[Fact]
public void Geheimnisse_stehen_nicht_im_Klartext_in_der_Datei()
@@ -54,19 +54,19 @@ public sealed class ManagerSettingsTests : IDisposable
}
/// <summary>
/// Eine von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark,
/// und sie darf trotzdem gelten.
/// A file written by hand or by PowerShell often has a byte order mark,
/// and it must still apply.
///
/// <para>Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete
/// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag.</para>
/// <para>In the acceptance run it looked as if it were ignored — the manager worked
/// with defaults. This test clarifies whether that was due to the file.</para>
/// </summary>
[Fact]
public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch()
{
var pfad = Path.Combine(_dir, "manager.json");
// Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach
// dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad.
// Exactly as PowerShell writes it: with BOM, with double spaces after
// the colon, and with doubled backslashes in the path.
var inhalt = """
{
"instanceExecutable": "J:\\Programme\\ClawdDotNet.exe",
@@ -88,8 +88,8 @@ public sealed class ManagerSettingsTests : IDisposable
}
/// <summary>
/// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar
/// keine Instanz mehr, und das ist der schlechtere Zustand.
/// An unreadable file must not prevent the manager from starting — then
/// no instance would run at all, and that is the worse state.
/// </summary>
[Fact]
public void Eine_kaputte_Datei_verhindert_den_Start_nicht()
@@ -100,15 +100,15 @@ public sealed class ManagerSettingsTests : IDisposable
store.Load().WebUiPort.ShouldBe(7000);
// Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit
// Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist.
// And the reason is recorded - otherwise the manager silently works with
// defaults, and nobody can say why its instance directory is wrong.
store.LoadProblem.ShouldNotBeNullOrWhiteSpace();
}
/// <summary>
/// Unsinnige Zahlen können in einer von Hand bearbeitbaren Datei stehen. Ein
/// Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null Fehlversuchen ein
/// Alarm bei jedem Aussetzer.
/// Nonsensical numbers can be in a hand-editable file. A
/// probe interval of 0 would be an endless loop, an alarm after zero failed attempts an
/// alarm at every hiccup.
/// </summary>
[Fact]
public void Unsinnige_Aufsichtswerte_werden_eingefangen()
@@ -130,7 +130,7 @@ public sealed class ManagerSettingsTests : IDisposable
}
}
/// <summary>Der Protokollauszug, der einer Absturzmeldung erst ihren Wert gibt.</summary>
/// <summary>The log excerpt that only gives a crash report its value.</summary>
public sealed class InstanceLogExcerptTests : IDisposable
{
private readonly string _instanz = Path.Combine(
@@ -141,7 +141,7 @@ public sealed class InstanceLogExcerptTests : IDisposable
public void Dispose()
{
try { Directory.Delete(_instanz, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
catch { /* A leftover test folder does not turn a run red. */ }
}
private void Schreibe(string tag, string modul, params string[] zeilen)
@@ -154,15 +154,15 @@ public sealed class InstanceLogExcerptTests : IDisposable
[Fact]
public void Ohne_Protokoll_kommt_nichts_zurueck()
{
// Kein Fehler, sondern ein Befund: Ein Prozess, der erschlagen wurde, bevor sein
// Schreibpuffer geleert war, hinterlaesst genau nichts (#529).
// Not an error, but a finding: a process that was killed before its
// write buffer was flushed leaves exactly nothing (#529).
InstanceLogExcerpt.Read(_instanz).ShouldBeEmpty();
}
/// <summary>
/// Module beschreiben denselben Zeitraum. Bei einem Absturz will man wissen, was
/// gleichzeitig passiert ist — nicht erst alles aus einer Datei, dann alles aus der
/// nächsten.
/// Modules describe the same period. With a crash you want to know what
/// happened at the same time — not first everything from one file, then everything from the
/// next.
/// </summary>
[Fact]
public void Die_Module_werden_nach_Zeit_zusammengefuehrt()
@@ -181,7 +181,7 @@ public sealed class InstanceLogExcerptTests : IDisposable
zeilen[1].ShouldContain("17:26:49");
zeilen[2].ShouldContain("17:26:50");
// Aus welcher Datei eine Zeile stammt, gehoert dazu.
// Which file a line comes from is part of it.
zeilen[1].ShouldStartWith("Web.log:");
}
@@ -204,8 +204,8 @@ public sealed class InstanceLogExcerptTests : IDisposable
}
/// <summary>
/// Die Datei kann in genau diesem Moment noch beschrieben werden — von einer Instanz,
/// die gerade endet. Wer sie exklusiv öffnen wollte, bekäme im Zweifel gar nichts.
/// The file may still be written to at exactly this moment — by an instance
/// that is just ending. Whoever wanted to open it exclusively might get nothing at all.
/// </summary>
[Fact]
public void Eine_offen_beschriebene_Datei_laesst_sich_lesen()
@@ -6,7 +6,7 @@ using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff.</summary>
/// <summary>Captures the last request and answers with a fixed response, without network access.</summary>
internal sealed class CapturingHandler(
HttpStatusCode status = HttpStatusCode.OK,
string responseBody = """{"status":"success","item_id":1,"is_new":true,"occurrence_count":1}""")
@@ -32,13 +32,12 @@ internal sealed class CapturingHandler(
}
/// <summary>
/// Die stellvertretende Meldung (#528, Konzept Abschnitt 4).
/// The report on the instance's behalf (#528, concept section 4).
///
/// <para>Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von Windows beendet:
/// keine Protokollzeile, keine Fehlermeldung, kein Bugtracker-Eintrag. Bauartbedingt —
/// Meldeweg und Watchdog lebten in dem Prozess, über den sie berichten sollten. Der
/// Manager meldet deshalb an ihrer Stelle, und zwar <b>nur</b> das, was aus seiner Sicht
/// relevant ist.</para>
/// <para>On 2026-09-08 an instance hung and was ended by Windows:
/// no log line, no error message, no bug tracker entry. Inherent in the design —
/// reporting path and watchdog lived in the process they were supposed to report on. The
/// manager therefore reports in their place, and <b>only</b> what is relevant from its point of view.</para>
/// </summary>
public sealed class SupervisionReporterTests
{
@@ -70,13 +69,13 @@ public sealed class SupervisionReporterTests
rumpf.ShouldContain("4711");
rumpf.ShouldContain("137");
rumpf.ShouldContain("instanzmanager");
rumpf.ShouldContain("clawddotnet"); // Projektbindung
rumpf.ShouldContain("clawddotnet"); // project binding
rumpf.ShouldContain("0.1.2"); // build_version
}
/// <summary>
/// Was die Instanz betrifft, bleibt in der Instanz — sie meldet es selbst, mit ihrem
/// eigenen Sub-Token. Der Manager meldet nur, was niemand bestellt hat.
/// What concerns the instance stays in the instance — it reports it itself, with its
/// own sub-token. The manager only reports what nobody ordered.
/// </summary>
[Theory]
[InlineData(InstanceEventKind.Started)]
@@ -89,9 +88,9 @@ public sealed class SupervisionReporterTests
}
/// <summary>
/// Stürzt dieselbe Instanz wiederholt ab, soll der Server hochzählen statt den
/// Bugtracker mit Zwillingen zu füllen — wiederholte Abstürze sind ein Befund und
/// nicht ein Dutzend gleicher.
/// If the same instance crashes repeatedly, the server should count up instead of filling the
/// bug tracker with twins — repeated crashes are one finding and
/// not a dozen identical ones.
/// </summary>
[Fact]
public async Task Dieselbe_Instanz_und_dieselbe_Art_ergeben_denselben_Schluessel()
@@ -104,11 +103,11 @@ public sealed class SupervisionReporterTests
await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed));
SchluesselAus(_handler.LastBody!).ShouldBe(erster);
// Eine andere Art ist ein anderer Befund.
// A different kind is a different finding.
await melder.ReportAsync(Ereignis(InstanceEventKind.Unresponsive));
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
// Und eine andere Instanz erst recht.
// And a different instance all the more.
await melder.ReportAsync(
Ereignis(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta"));
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
@@ -124,9 +123,9 @@ public sealed class SupervisionReporterTests
}
/// <summary>
/// Ein leerer Auszug ist die aussagekräftigste Zeile der ganzen Meldung: Ein Prozess,
/// der erschlagen wurde, bevor sein Schreibpuffer geleert war, hinterlässt nichts.
/// Genau das war der Zustand aus #529.
/// An empty excerpt is the most meaningful line of the whole report: a process
/// that was killed before its write buffer was flushed leaves nothing.
/// Exactly that was the state from #529.
/// </summary>
[Fact]
public async Task Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt()
@@ -137,9 +136,9 @@ public sealed class SupervisionReporterTests
}
/// <summary>
/// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten: Der
/// ursprüngliche Befund ginge dabei verloren, und die Aufsicht stürbe an ihrem
/// eigenen Bericht.
/// A reporting path that throws itself would be the worst of all worlds: the
/// original finding would get lost, and the supervision would die of its
/// own report.
/// </summary>
[Fact]
public async Task Ein_Serverfehler_reisst_die_Aufsicht_nicht_mit()