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>
202 lines
7.4 KiB
C#
202 lines
7.4 KiB
C#
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.Core.Config;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.App.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// The daily backup, since it belongs to the instance (#528, stage 1).
|
|
///
|
|
/// <para>Switch, time, target folder and retention lived in the
|
|
/// application settings and thus applied to every instance. Two consequences: whoever
|
|
/// enabled the backup for one instance enabled it for all — and all
|
|
/// archives lay in one folder in which retention cleans up by the prefix
|
|
/// <c>backup_<Instanzname>_</c>. That a prefix is not a boundary is shown by
|
|
/// <see cref="Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus"/>.</para>
|
|
/// </summary>
|
|
public sealed class BackupSchedulerTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* A leftover test folder does not turn a run red. */ }
|
|
}
|
|
|
|
/// <summary>An instance directory with enough content that a backup is worthwhile.</summary>
|
|
private string NeueInstanz(string name)
|
|
{
|
|
var dir = Path.Combine(_root, "Instance-" + name);
|
|
Directory.CreateDirectory(dir);
|
|
File.WriteAllText(Path.Combine(dir, "InstanceSettings.json"), $$"""{"instanceName":"{{name}}"}""");
|
|
return dir;
|
|
}
|
|
|
|
private static InstanceConfig Config(string name, string? backupDirectory = null, int keep = 14)
|
|
=> new()
|
|
{
|
|
InstanceName = name,
|
|
WorkingDirectory = "",
|
|
AutoBackupEnabled = true,
|
|
AutoBackupTime = "03:00",
|
|
BackupDirectory = backupDirectory ?? "",
|
|
BackupKeepCount = keep
|
|
};
|
|
|
|
/// <summary>
|
|
/// A clock standing at a time — the point in time decides whether
|
|
/// <c>TickAsync</c> does anything.
|
|
/// </summary>
|
|
private sealed class FesteUhr(DateTime jetzt) : TimeProvider
|
|
{
|
|
public override DateTimeOffset GetUtcNow() => new(jetzt, TimeSpan.Zero);
|
|
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
|
}
|
|
|
|
private static BackupScheduler Scheduler(string dir, InstanceConfig config, DateTime jetzt)
|
|
=> new(dir, config, NullLogger.Instance, new FesteUhr(jetzt));
|
|
|
|
private static DateTime Nachts(int tag) => new(2026, 9, tag, 4, 0, 0, DateTimeKind.Utc);
|
|
|
|
// ─── The target folder ───
|
|
|
|
[Fact]
|
|
public async Task Ohne_Angabe_landet_die_Sicherung_im_Instanzverzeichnis()
|
|
{
|
|
var dir = NeueInstanz("Allein");
|
|
|
|
await Scheduler(dir, Config("Allein"), Nachts(1)).TickAsync();
|
|
|
|
var ziel = Path.Combine(dir, "Backups");
|
|
Directory.Exists(ziel).ShouldBeTrue();
|
|
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zwei_Instanzen_sichern_ohne_Angabe_getrennt()
|
|
{
|
|
var a = NeueInstanz("Eins");
|
|
var b = NeueInstanz("Zwei");
|
|
|
|
await Scheduler(a, Config("Eins"), Nachts(1)).TickAsync();
|
|
await Scheduler(b, Config("Zwei"), Nachts(1)).TickAsync();
|
|
|
|
Directory.GetFiles(Path.Combine(a, "Backups"), "*.zip").Length.ShouldBe(1);
|
|
Directory.GetFiles(Path.Combine(b, "Backups"), "*.zip").Length.ShouldBe(1);
|
|
}
|
|
|
|
// ─── The switch ───
|
|
|
|
[Fact]
|
|
public async Task Ausgeschaltet_passiert_nichts()
|
|
{
|
|
var dir = NeueInstanz("Ruhig");
|
|
var config = Config("Ruhig");
|
|
config.AutoBackupEnabled = false;
|
|
|
|
await Scheduler(dir, config, Nachts(1)).TickAsync();
|
|
|
|
Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Vor_der_eingestellten_Uhrzeit_passiert_nichts()
|
|
{
|
|
var dir = NeueInstanz("Frueh");
|
|
|
|
await Scheduler(dir, Config("Frueh"), new DateTime(2026, 9, 1, 2, 0, 0, DateTimeKind.Utc))
|
|
.TickAsync();
|
|
|
|
Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zweimal_am_selben_Tag_sichert_nur_einmal()
|
|
{
|
|
var dir = NeueInstanz("Einmal");
|
|
var scheduler = Scheduler(dir, Config("Einmal"), Nachts(1));
|
|
|
|
await scheduler.TickAsync();
|
|
await scheduler.TickAsync();
|
|
|
|
Directory.GetFiles(Path.Combine(dir, "Backups"), "*.zip").Length.ShouldBe(1);
|
|
}
|
|
|
|
// ─── Retention ───
|
|
|
|
[Fact]
|
|
public async Task Die_Aufbewahrung_raeumt_die_eigenen_Archive_auf()
|
|
{
|
|
var dir = NeueInstanz("Alt");
|
|
var ziel = Path.Combine(dir, "Backups");
|
|
Directory.CreateDirectory(ziel);
|
|
|
|
// Three archives from the past that are older than the one about to be created.
|
|
foreach (var tag in new[] { 1, 2, 3 })
|
|
{
|
|
var datei = Path.Combine(ziel, $"backup_Alt_2026-08-0{tag}_0300.zip");
|
|
File.WriteAllText(datei, "alt");
|
|
File.SetLastWriteTime(datei, new DateTime(2026, 8, tag, 3, 0, 0));
|
|
}
|
|
|
|
await Scheduler(dir, Config("Alt", keep: 2), Nachts(1)).TickAsync();
|
|
|
|
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Aufbewahrung_null_behaelt_alles()
|
|
{
|
|
var dir = NeueInstanz("Sammler");
|
|
var ziel = Path.Combine(dir, "Backups");
|
|
Directory.CreateDirectory(ziel);
|
|
File.WriteAllText(Path.Combine(ziel, "backup_Sammler_2026-08-01_0300.zip"), "alt");
|
|
|
|
await Scheduler(dir, Config("Sammler", keep: 0), Nachts(1)).TickAsync();
|
|
|
|
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Why the target folder has to belong to the instance.
|
|
///
|
|
/// <para>Retention looks for the prefix <c>backup_<Instanzname>_</c>,
|
|
/// and a prefix is not a boundary: <c>backup_Test_2_….zip</c> — the archive of the
|
|
/// instance <c>Test_2</c> — starts with <c>backup_Test_</c>. In the shared folder
|
|
/// <c>Test</c> therefore counts it among its own and throws it away. Separate folders
|
|
/// take away the effect of the naming question: the test here deliberately restores the old state
|
|
/// by entering the same folder for both instances.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus()
|
|
{
|
|
var gemeinsam = Path.Combine(_root, "Backups");
|
|
Directory.CreateDirectory(gemeinsam);
|
|
|
|
var fremd = Path.Combine(gemeinsam, "backup_Test_2_2026-08-01_0300.zip");
|
|
File.WriteAllText(fremd, "gehoert Test_2");
|
|
File.SetLastWriteTime(fremd, new DateTime(2026, 8, 1, 3, 0, 0));
|
|
|
|
var dir = NeueInstanz("Test");
|
|
await Scheduler(dir, Config("Test", gemeinsam, keep: 1), Nachts(1)).TickAsync();
|
|
|
|
File.Exists(fremd).ShouldBeFalse(
|
|
"Der alte Zustand ist damit belegt — im gemeinsamen Ordner räumt eine "
|
|
+ "Instanz über ihre eigenen Archive hinaus auf.");
|
|
|
|
// And the counter-check: with separate folders they do not get in each other's way.
|
|
var eigen = NeueInstanz("Test2");
|
|
var eigenesArchiv = Path.Combine(eigen, "Backups", "backup_Test2_2026-08-01_0300.zip");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(eigenesArchiv)!);
|
|
File.WriteAllText(eigenesArchiv, "gehoert Test2");
|
|
|
|
var dir2 = NeueInstanz("Test3");
|
|
await Scheduler(dir2, Config("Test3", keep: 1), Nachts(2)).TickAsync();
|
|
|
|
File.Exists(eigenesArchiv).ShouldBeTrue();
|
|
}
|
|
}
|