feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
@@ -0,0 +1,90 @@
using ClawdDotNet.Core.Scheduling;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Scheduling;
/// <summary>
/// Cron-Parsing und Terminberechnung (deckt Teile von B6/B7 ab). Der bisher ungetestete
/// Basis-Scheduler bekommt hier sein Netz: gültige Ausdrücke rechnen richtig, ungültige
/// werfen eine saubere <see cref="FormatException"/> statt einer rohen Parser-Exception
/// oder einer Endlosschleife.
/// </summary>
public sealed class CronExpressionTests
{
private static DateTime Local(int y, int mo, int d, int h, int mi)
=> new(y, mo, d, h, mi, 0, DateTimeKind.Local);
// ─── R1: korrekte nächste Zeitpunkte ───
[Fact]
public void Alle_30_Minuten()
{
var cron = CronExpression.Parse("*/30 * * * *");
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 5)).ShouldBe(Local(2026, 7, 31, 10, 30));
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 45)).ShouldBe(Local(2026, 7, 31, 11, 0));
}
[Fact]
public void Werktags_um_drei_Uhr_frueh()
{
var cron = CronExpression.Parse("0 3 * * 1-5"); // MoFr
// 2026-08-01 ist ein Samstag → nächster Lauf Montag, 2026-08-03 03:00
cron.GetNextOccurrence(Local(2026, 8, 1, 4, 0)).ShouldBe(Local(2026, 8, 3, 3, 0));
}
[Fact]
public void Listen_von_Minuten()
{
var cron = CronExpression.Parse("15,45 * * * *");
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 20)).ShouldBe(Local(2026, 7, 31, 10, 45));
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 50)).ShouldBe(Local(2026, 7, 31, 11, 15));
}
[Fact]
public void Schaltjahr_29_Februar()
{
var cron = CronExpression.Parse("0 0 29 2 *"); // nur am 29.02.
// 2028 ist das nächste Schaltjahr
cron.GetNextOccurrence(Local(2026, 3, 1, 0, 0)).ShouldBe(Local(2028, 2, 29, 0, 0));
}
// ─── R2: Invariante ───
[Theory]
[InlineData("*/5 * * * *")]
[InlineData("0 3 * * 1-5")]
[InlineData("15,45 9-17 * * *")]
[InlineData("0 0 1 * *")]
public void Der_naechste_Termin_liegt_echt_in_der_Zukunft_und_passt(string expr)
{
var cron = CronExpression.Parse(expr);
var now = Local(2026, 7, 31, 10, 23);
var next = cron.GetNextOccurrence(now);
next.ShouldNotBeNull();
next!.Value.ShouldBeGreaterThan(now);
cron.Matches(next.Value).ShouldBeTrue();
}
// ─── R3: ungültige Ausdrücke ───
[Theory]
[InlineData("* * *")] // zu wenige Felder
[InlineData("* * * * * *")] // zu viele Felder
[InlineData("99 * * * *")] // Minute außerhalb 0-59
[InlineData("* 25 * * *")] // Stunde außerhalb 0-23
[InlineData("a b c d e")] // keine Zahlen
[InlineData("*/0 * * * *")] // Schritt 0 (wäre Endlosschleife)
[InlineData("5-2 * * * *")] // rückwärtiger Bereich
[InlineData("* * * * ")] // ein leeres Feld nach Trim → 4 Felder
public void Ungueltige_Ausdruecke_werfen_eine_saubere_FormatException(string expr)
=> Should.Throw<FormatException>(() => CronExpression.Parse(expr));
[Fact]
public void Ein_gueltiger_Ausdruck_mit_Schritt_bleibt_endlich()
{
// Regressionswächter: */0 hätte früher eine Endlosschleife erzeugt.
Should.CompleteIn(() => CronExpression.Parse("*/15 * * * *"), TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,88 @@
using ClawdDotNet.Core.Scheduling;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Scheduling;
/// <summary>
/// Zeitzonen heißen auf Windows und Linux verschieden. Task-Dateien wandern zwischen
/// beiden — also muss jede Seite die Schreibweise der anderen deuten können.
///
/// Die Tests laufen bewusst auf beiden Plattformen mit denselben Erwartungen: Genau
/// das ist die Eigenschaft, die wir brauchen.
/// </summary>
public sealed class TimeZonesTests
{
[Theory]
[InlineData("Europe/Berlin")]
[InlineData("W. Europe Standard Time")]
[InlineData("America/New_York")]
[InlineData("Eastern Standard Time")]
[InlineData("UTC")]
public void Beide_Schreibweisen_sind_aufloesbar(string id)
{
TimeZones.TryResolve(id).ShouldNotBeNull();
TimeZones.IsKnown(id).ShouldBeTrue();
}
[Fact]
public void Beide_Schreibweisen_meinen_dieselbe_Zone()
{
var iana = TimeZones.TryResolve("Europe/Berlin");
var windows = TimeZones.TryResolve("W. Europe Standard Time");
var sommer = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
iana!.GetUtcOffset(sommer).ShouldBe(windows!.GetUtcOffset(sommer));
iana.GetUtcOffset(sommer).ShouldBe(TimeSpan.FromHours(2)); // MESZ
}
[Theory]
[InlineData("Phantasie/Ort")]
[InlineData("Nicht Existierende Standard Time")]
[InlineData("völliger Unsinn")]
public void Unbekannte_Kennungen_werden_gemeldet_statt_geraten(string id)
{
TimeZones.TryResolve(id).ShouldBeNull();
TimeZones.IsKnown(id).ShouldBeFalse();
}
[Fact]
public void Leer_gilt_als_gueltig_denn_es_bedeutet_keine_Angabe()
{
TimeZones.IsKnown("").ShouldBeTrue();
TimeZones.IsKnown(null).ShouldBeTrue();
TimeZones.IsKnown(" ").ShouldBeTrue();
}
[Theory]
[InlineData("W. Europe Standard Time", "Europe/Berlin")]
[InlineData("Europe/Berlin", "Europe/Berlin")]
[InlineData("", "")]
public void Normalisieren_ergibt_die_IANA_Schreibweise(string input, string expected)
{
TimeZones.ToIana(input).ShouldBe(expected);
}
[Fact]
public void Eine_nicht_umsetzbare_Kennung_bleibt_unveraendert()
{
// Verfälschen wäre schlimmer als durchreichen — so bleibt im Zweifel sichtbar,
// was ursprünglich dastand.
TimeZones.ToIana("Phantasie Standard Time").ShouldBe("Phantasie Standard Time");
}
[Fact]
public void Die_lokale_Zone_wird_in_IANA_Schreibweise_geliefert()
{
var local = TimeZones.LocalIanaId;
local.ShouldNotBeNullOrWhiteSpace();
TimeZones.IsKnown(local).ShouldBeTrue();
// Der eigentliche Punkt: Was hier herauskommt, wird in Task-Dateien geschrieben
// und muss auf der anderen Plattform lesbar sein. IANA-Kennungen enthalten einen
// Schrägstrich, Windows-Kennungen nie — UTC ist die Ausnahme ohne beides.
if (local != "UTC")
local.ShouldContain("/");
}
}