R1: Umbau auf Multi-Projekt-Struktur (Vorbild PolytraderSharp)

Kurskorrektur: Vorbild ist PolytraderSharp (C#), nicht die veraltete Python-Version.
Reiner Strukturumbau, Verhalten unveraendert.

- src/IBKRTrader.Core (classlib): Core-Code + UI-Contract (ModuleFormBase, WindowManager)
- src/IBKRTrader.Modules.CongressTrading (classlib, referenziert nur Core)
- Root: IBKRTrader.App (WinExe) referenziert Core + Modul
- tests/IBKRTrader.Tests: Referenzen auf Core + Modul
- Neue .slnx; ungenutztes HtmlAgilityPack entfernt
- docs/ARCHITECTURE.md: korrigierter Ziel-Stand + R-Phasenplan
- Build + 38/38 Tests gruen; App startet

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-27 20:26:25 +02:00
parent 2ad4b55db1
commit c20538c8d8
61 changed files with 171 additions and 129 deletions
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net10.0-windows + WinForms, weil das Testprojekt die WinForms-Hauptassembly referenziert -->
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
<ProjectReference Include="..\..\src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Test-Fixture: gespeicherte capitoltrades.com-Seite (kein Netzzugriff im Test) -->
<Content Include="..\..\ct_raw.html" Link="Fixtures\ct_raw.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,57 @@
using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.CongressTrading.Scraper;
namespace IBKRTrader.Tests.Modules;
[Trait("cat", "unit")]
public class CapitolTradesScraperTests
{
private static string LoadFixture()
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "ct_raw.html");
File.Exists(path).Should().BeTrue($"Fixture fehlt: {path}");
return File.ReadAllText(path);
}
[Fact]
public void ParseTradesFromHtml_ExtractsTradesFromRscStream()
{
var scraper = new CapitolTradesScraper(new LoggingService());
var (trades, memberIds) = scraper.ParseTradesFromHtml(LoadFixture());
trades.Should().NotBeEmpty("die Fixture enthält einen RSC-Datenstrom mit Trades");
memberIds.Should().NotBeEmpty();
var first = trades[0];
first.TradeId.Should().NotBeNullOrWhiteSpace();
first.TradeType.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public void ParseTradesFromHtml_MapsMemberSnapshotConsistently()
{
var scraper = new CapitolTradesScraper(new LoggingService());
var (trades, memberIds) = scraper.ParseTradesFromHtml(LoadFixture());
// Jeder Member-Snapshot muss eine BioId haben, die auch im MemberBioIds-Set liegt.
foreach (var trade in trades.Where(t => t.MemberSnapshot is not null))
{
trade.MemberSnapshot!.BioId.Should().NotBeNullOrWhiteSpace();
memberIds.Should().Contain(trade.MemberBioId);
}
}
[Fact]
public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades()
{
var scraper = new CapitolTradesScraper(new LoggingService());
var (trades, memberIds) = scraper.ParseTradesFromHtml("<html><body>nichts</body></html>");
trades.Should().BeEmpty();
memberIds.Should().BeEmpty();
}
}
@@ -0,0 +1,65 @@
using FluentAssertions;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.Workers;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Tests.Modules;
[Trait("cat", "unit")]
public class CongressTradingModuleTests
{
[Fact]
public void Metadata_IsAsExpected()
{
var module = new CongressTradingModule();
module.Key.Should().Be("CT");
module.DisplayName.Should().NotBeNullOrWhiteSpace();
module.Version.Should().Be("1.0.0");
}
[Fact]
public void RegisterServices_RegistersAllModuleServices()
{
var services = new ServiceCollection();
new CongressTradingModule().RegisterServices(services);
var registered = services.Select(d => d.ServiceType).ToList();
registered.Should().Contain(new[]
{
typeof(CongressMigrations),
typeof(CongressRepository),
typeof(CapitolTradesScraper),
typeof(CongressHistoryImportWorker),
typeof(CongressScrapeWorker),
});
}
[Fact]
public void CreateWindow_ReturnsModuleForm()
{
// Minimaler DI-Container mit allen von CreateWindow benötigten Abhängigkeiten.
var services = new ServiceCollection();
services.AddSingleton<SettingsService>();
services.AddSingleton<LoggingService>();
services.AddSingleton<DatabaseService>();
services.AddSingleton<IEnumerable<IWorker>>(_ => Array.Empty<IWorker>());
services.AddSingleton<WorkerEngine>();
var module = new CongressTradingModule();
module.RegisterServices(services);
using var provider = services.BuildServiceProvider();
using var form = module.CreateWindow(provider);
form.Should().BeAssignableTo<ModuleFormBase>();
((ModuleFormBase)form).ModuleKey.Should().Be("CT");
}
}
@@ -0,0 +1,49 @@
using FluentAssertions;
using IBKRTrader.Core.Modules;
using IBKRTrader.Core.Workers;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Tests.Modules;
[Trait("cat", "unit")]
public class ModuleRegistryTests
{
private sealed class FakeModule(string key) : IModule
{
public string Key => key;
public string DisplayName => $"Fake {key}";
public string Description => "test";
public string Version => "0.0.1";
public void RegisterServices(IServiceCollection services) { }
public Task InitializeAsync(IServiceProvider provider) => Task.CompletedTask;
public IEnumerable<IWorker> GetWorkers(IServiceProvider provider) => [];
public Form CreateWindow(IServiceProvider provider) => new();
}
[Fact]
public void Modules_PreservesRegistrationOrder()
{
var registry = new ModuleRegistry([new FakeModule("A"), new FakeModule("B")]);
registry.Modules.Select(m => m.Key).Should().ContainInOrder("A", "B");
}
[Fact]
public void Find_IsCaseInsensitive()
{
var registry = new ModuleRegistry([new FakeModule("CT")]);
registry.Find("ct").Should().NotBeNull();
registry.Find("CT").Should().NotBeNull();
registry.Find("XX").Should().BeNull();
}
[Fact]
public void Ctor_NullModules_Throws()
{
var act = () => new ModuleRegistry(null!);
act.Should().Throw<ArgumentNullException>();
}
}
+29
View File
@@ -0,0 +1,29 @@
using FluentAssertions;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
namespace IBKRTrader.Tests;
/// <summary>
/// Phase-0-Smoke-Test: bestätigt, dass die Test-Infrastruktur steht und die
/// Referenz auf die Hauptassembly (IBKRTrader) kompiliert und lädt.
/// </summary>
[Trait("cat", "unit")]
public class SmokeTests
{
[Fact]
public void MainAssembly_IsReferenced_AndDefaultsLoad()
{
var settings = new AppSettings();
settings.Should().NotBeNull();
settings.IBKR.Should().NotBeNull();
settings.Logging.Level.Should().Be("Info");
}
[Fact]
public void WorkerType_HasWorkerAndService()
{
Enum.GetNames<WorkerType>().Should().Contain(["Worker", "Service"]);
}
}
@@ -0,0 +1,125 @@
using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using NSubstitute;
namespace IBKRTrader.Tests.Trading;
[Trait("cat", "unit")]
public class ExecutionServiceTests
{
private readonly IBrokerClient _broker = Substitute.For<IBrokerClient>();
private readonly IRiskService _risk = Substitute.For<IRiskService>();
private readonly IPortfolioService _portfolio = Substitute.For<IPortfolioService>();
private readonly SettingsService _settings = new();
private ExecutionService CreateSut() =>
new(_broker, _risk, _portfolio, _settings, new LoggingService());
private static readonly TradeSignal BuySignal = new()
{
Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT"
};
private void EnableTrading() => _settings.Settings.Trading.TradingEnabled = true;
private void ArrangeHappyPath()
{
EnableTrading();
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
_portfolio.GetModuleExposureAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(0m));
_portfolio.GetPositionQuantityAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(0));
_risk.Evaluate(Arg.Any<TradeSignal>(), Arg.Any<RiskContext>(), Arg.Any<RiskParameters>())
.Returns(RiskDecision.Approve(5));
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(OrderResult.Filled("O1", 5, 100m)));
}
[Fact]
public async Task TradingDisabled_Skips_WithoutTouchingBroker()
{
// TradingEnabled ist standardmäßig false
var result = await CreateSut().ExecuteAsync(BuySignal);
result.Action.Should().Be("SKIP");
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task NoQuote_Skips()
{
EnableTrading();
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Quote?>(null));
var result = await CreateSut().ExecuteAsync(BuySignal);
result.Action.Should().Be("SKIP");
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task RiskRejects_Skips_WithReason()
{
EnableTrading();
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
_risk.Evaluate(Arg.Any<TradeSignal>(), Arg.Any<RiskContext>(), Arg.Any<RiskParameters>())
.Returns(RiskDecision.Reject("Modul-Limit überschritten."));
var result = await CreateSut().ExecuteAsync(BuySignal);
result.Action.Should().Be("SKIP");
result.Reason.Should().Contain("Modul-Limit");
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task HappyPath_PlacesOrder_AndBooksFill()
{
ArrangeHappyPath();
var result = await CreateSut().ExecuteAsync(BuySignal);
result.Executed.Should().BeTrue();
result.Order!.OrderId.Should().Be("O1");
await _portfolio.Received(1).RecordFillAsync(
"CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", Arg.Any<CancellationToken>());
}
[Fact]
public async Task HappyPath_MarketOrder_WhenNoLimitPrice()
{
ArrangeHappyPath();
await CreateSut().ExecuteAsync(BuySignal);
await _broker.Received(1).PlaceOrderAsync(
Arg.Is<OrderRequest>(o => o.Type == OrderType.Market && o.Quantity == 5),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task OrderFails_ReturnsError_AndDoesNotBook()
{
ArrangeHappyPath();
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(OrderResult.Fail("Broker abgelehnt")));
var result = await CreateSut().ExecuteAsync(BuySignal);
result.Action.Should().Be("ERROR");
result.Reason.Should().Contain("Broker abgelehnt");
await _portfolio.DidNotReceive().RecordFillAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TradeSide>(),
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,142 @@
using FluentAssertions;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Tests.Trading;
[Trait("cat", "unit")]
public class RiskServiceTests
{
private readonly RiskService _risk = new();
// MaxTrade 5 %, MaxModul 20 %, MaxSlippage 5 %
private static readonly RiskParameters DefaultParams = new(5m, 20m, 5m);
private static TradeSignal Buy(decimal? limit = null, decimal? notional = null) => new()
{
Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT",
LimitPrice = limit, SuggestedNotional = notional
};
private static TradeSignal Sell() => new()
{
Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT"
};
[Fact]
public void Buy_SizesByMaxTradePercent()
{
// NetLiq 10.000 × 5 % = 500 max Nominal; Kurs 100 → 5 Stück
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
var d = _risk.Evaluate(Buy(), ctx, DefaultParams);
d.Approved.Should().BeTrue();
d.Quantity.Should().Be(5);
}
[Fact]
public void Buy_UsesSuggestedNotional_WhenSmallerThanMax()
{
// Wunsch 200 < Max 500; Kurs 100 → 2 Stück
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
var d = _risk.Evaluate(Buy(notional: 200m), ctx, DefaultParams);
d.Quantity.Should().Be(2);
}
[Fact]
public void Buy_InvalidPrice_Rejected()
{
var ctx = new RiskContext { Price = 0m, NetLiquidation = 10_000m };
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
}
[Fact]
public void Buy_ZeroAccount_Rejected()
{
var ctx = new RiskContext { Price = 100m, NetLiquidation = 0m };
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
}
[Fact]
public void Buy_QuantityBelowOne_Rejected()
{
// NetLiq 100 × 5 % = 5 max Nominal; Kurs 100 → 0 Stück
var ctx = new RiskContext { Price = 100m, NetLiquidation = 100m };
var d = _risk.Evaluate(Buy(), ctx, DefaultParams);
d.Approved.Should().BeFalse();
d.Reason.Should().Contain("< 1");
}
[Fact]
public void Buy_ExceedsModuleLimit_Rejected()
{
// MaxTrade 50 % → 5.000 Nominal, Kurs 100 → 50 Stück = 5.000
// Modul-Limit 20 % × 10.000 = 2.000 → abgelehnt
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
var pars = new RiskParameters(50m, 20m, 5m);
var d = _risk.Evaluate(Buy(), ctx, pars);
d.Approved.Should().BeFalse();
d.Reason.Should().Contain("Modul-Limit");
}
[Fact]
public void Buy_ExistingExposureCountsTowardModuleLimit()
{
// Kurs 100, Max 5 % → 5 Stück (500). Bereits 1.700 Exposure.
// Projektiert 2.200 > Limit 2.000 → abgelehnt.
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ModuleExposure = 1_700m };
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
}
[Fact]
public void Buy_LimitOrder_SlippageTooHigh_Rejected()
{
// Limit 100, Kurs 110 → 10 % > 5 %
var ctx = new RiskContext { Price = 110m, NetLiquidation = 10_000m };
var d = _risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams);
d.Approved.Should().BeFalse();
d.Reason.Should().Contain("Slippage");
}
[Fact]
public void Buy_LimitOrder_SlippageWithinTolerance_Approved()
{
// Limit 100, Kurs 104 → 4 % < 5 %
var ctx = new RiskContext { Price = 104m, NetLiquidation = 10_000m };
_risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams).Approved.Should().BeTrue();
}
[Fact]
public void Sell_WithPosition_ClosesQuantity()
{
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 12 };
var d = _risk.Evaluate(Sell(), ctx, DefaultParams);
d.Approved.Should().BeTrue();
d.Quantity.Should().Be(12);
}
[Fact]
public void Sell_WithoutPosition_Rejected()
{
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 0 };
var d = _risk.Evaluate(Sell(), ctx, DefaultParams);
d.Approved.Should().BeFalse();
d.Reason.Should().Contain("Keine Position");
}
}
@@ -0,0 +1,100 @@
using FluentAssertions;
using IBKRTrader.UI;
namespace IBKRTrader.Tests.UI;
[Trait("cat", "unit")]
public class WindowManagerTests
{
/// <summary>
/// Testbare Variante: unterdrückt echte Show-/Focus-Aufrufe (kein Message-Loop),
/// zählt aber die Focus-Aufrufe.
/// </summary>
private sealed class TestWindowManager : WindowManager
{
public int FocusCount;
protected override void Present(Form form) { /* kein Show im Test */ }
protected override void Focus(Form form) => FocusCount++;
}
[Fact]
public void OpenOrFocus_NewKey_InvokesFactory_AndTracks()
{
var wm = new TestWindowManager();
var created = 0;
using var form = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(1);
wm.IsOpen("CT").Should().BeTrue();
wm.OpenCount.Should().Be(1);
}
[Fact]
public void OpenOrFocus_SameKey_FocusesExisting_DoesNotRecreate()
{
var wm = new TestWindowManager();
var created = 0;
using var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(1);
wm.FocusCount.Should().Be(1);
second.Should().BeSameAs(first);
wm.OpenCount.Should().Be(1);
}
[Fact]
public void OpenOrFocus_DifferentKeys_TracksSeparately()
{
var wm = new TestWindowManager();
using var a = wm.OpenOrFocus("A", () => new Form());
using var b = wm.OpenOrFocus("B", () => new Form());
wm.OpenCount.Should().Be(2);
wm.IsOpen("A").Should().BeTrue();
wm.IsOpen("B").Should().BeTrue();
}
[Fact]
public void OpenOrFocus_AfterDisposed_RecreatesWindow()
{
var wm = new TestWindowManager();
var created = 0;
var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
first.Dispose();
wm.IsOpen("CT").Should().BeFalse();
using var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(2);
second.Should().NotBeSameAs(first);
}
[Fact]
public void CloseAll_RemovesAllWindows()
{
var wm = new TestWindowManager();
wm.OpenOrFocus("A", () => new Form());
wm.OpenOrFocus("B", () => new Form());
wm.CloseAll();
wm.OpenCount.Should().Be(0);
wm.IsOpen("A").Should().BeFalse();
}
[Fact]
public void OpenOrFocus_BlankKey_Throws()
{
var wm = new TestWindowManager();
var act = () => wm.OpenOrFocus(" ", () => new Form());
act.Should().Throw<ArgumentException>();
}
}
@@ -0,0 +1,121 @@
using System.Diagnostics;
using FluentAssertions;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
namespace IBKRTrader.Tests.Workers;
[Trait("cat", "unit")]
public class WorkerBaseTests
{
/// <summary>
/// Testbarer Worker: überschreibt den DB-Log-Seam (kein MySQL-Zugriff) und
/// zählt seine Läufe. Zustände werden per Polling geprüft, weil WorkerBase
/// Info.Status intern (und nach dem Log-Abschluss) setzt.
/// </summary>
private sealed class TestWorker : WorkerBase
{
private readonly TimeSpan? _interval;
private readonly Func<CancellationToken, Task> _body;
public int Runs;
public override string Name => "TestWorker";
public override string Module => "TEST";
protected override TimeSpan? Interval => _interval;
public TestWorker(TimeSpan? interval, Func<CancellationToken, Task> body)
: base(new LoggingService(),
new DatabaseService(new SettingsService(), new LoggingService()))
{
_interval = interval;
_body = body;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
Interlocked.Increment(ref Runs);
await _body(ct);
}
// Seam überschreiben → kein DB-Zugriff. Positive Id, damit die End-Log-Aufrufe
// (in WorkerBase mit `logId > 0` geschützt) auch im Fehlerpfad laufen.
protected override Task<long> BeginRunLogAsync() => Task.FromResult(1L);
protected override Task EndRunLogAsync(long logId, bool success, string? message = null)
=> Task.CompletedTask;
}
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private static async Task WaitUntilAsync(Func<bool> condition, string because)
{
var sw = Stopwatch.StartNew();
while (!condition())
{
if (sw.Elapsed > Timeout)
throw new TimeoutException($"Bedingung nicht innerhalb {Timeout.TotalSeconds}s erfüllt: {because}");
await Task.Delay(15);
}
}
[Fact]
public async Task RunsOnce_WhenIntervalIsNull_AndReportsIdle()
{
var worker = new TestWorker(interval: null, _ => Task.CompletedTask);
await worker.StartAsync(CancellationToken.None);
// Hinweis: WorkerBase startet bereits im Zustand Idle deshalb auf den
// abgeschlossenen Lauf warten (Runs == 1 UND wieder Idle).
await WaitUntilAsync(
() => worker.Runs == 1 && worker.Info.Status == WorkerStatus.Idle,
"ein Lauf ist abgeschlossen und Status zurück auf Idle");
worker.Runs.Should().Be(1);
await worker.StopAsync();
}
[Fact]
public async Task Trigger_ForcesImmediateRun_BeforeIntervalElapses()
{
// Langes Intervall → ein zweiter Lauf kann nur durch Trigger entstehen.
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
await worker.TriggerAsync();
await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus");
await worker.StopAsync();
}
[Fact]
public async Task Exception_SetsStatusError_AndCapturesMessage()
{
var worker = new TestWorker(interval: null,
_ => throw new InvalidOperationException("boom"));
await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Info.Status == WorkerStatus.Error, "Status wird Error");
worker.Runs.Should().Be(1);
worker.Info.Info.Should().Contain("boom");
await worker.StopAsync();
}
[Fact]
public async Task Stop_SetsStatusStopped()
{
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
await worker.StopAsync();
worker.Info.Status.Should().Be(WorkerStatus.Stopped);
}
}