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:
Richard
2026-07-27 10:07:47 +02:00
parent 9abad277c2
commit af398353f0
13 changed files with 449 additions and 35 deletions
@@ -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>();
}
}