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,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);
}
}