@
Phase 1: Modul-System formalisiert (IModule + ModuleRegistry) - Core/Modules/IModule.cs: Vertrag (Key, DisplayName, Description, Version, RegisterServices, InitializeAsync, GetWorkers, CreateWindow) - Core/Modules/ModuleRegistry.cs - CongressTradingModule auf IModule umgestellt (instanzbasiert, Platzhalter-Fenster) - Program.cs iteriert ueber IModule[] + Registry; Form1 initialisiert Module ueber Registry - Testbarkeit: WorkerBase DB-Log-Seam (virtuell); CapitolTradesScraper.ParseTradesFromHtml extrahiert (offline gegen ct_raw.html testbar) - Tests: ModuleRegistry, CongressTradingModule, WorkerBase, CapitolTradesScraper -> 15/15 gruen Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -23,6 +23,13 @@
|
||||
<ProjectReference Include="..\IBKRTrader.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>
|
||||
|
||||
@@ -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,49 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||
using IBKRTrader.Modules.CongressTrading.Workers;
|
||||
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_ReturnsForm()
|
||||
{
|
||||
using var form = new CongressTradingModule().CreateWindow(new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
form.Should().NotBeNull();
|
||||
form.Text.Should().Contain("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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user