R2: Generic Host + Modul-Vertrag + Shell-UI (PolytraderSharp-Konzept)

- Core/Modularity: neuer IModule (Name, DbPrefix, RegisterServices(services,config),
  RegisterUi(host,sp), StartAsync/StopAsync, GetActivationBlocker) + ModuleView
  + IModuleUiHost + WindowMenu. Alte IModule/ModuleRegistry/WindowManager/ModuleFormBase entfernt.
- Program.cs: Host.CreateDefaultBuilder + IConfiguration (appsettings.json/.Local.json);
  Core-Services registriert, Module via RegisterServices, Views via RegisterUi.
- UI: ShellUiHost (Einzelinstanz-Fenster + Fenster-Menue), LauncherForm als Shell
  (Buttons je View), Core-Views Logs/Settings/Workers als eigene Fenster.
- CongressTrading auf neuen Vertrag; Worker als IWorker registriert.
- --smoke-ui Headless-Test (konstruiert jede View + Launcher).
- appsettings.Local.json gitignored.
- Tests angepasst -> 30/30 gruen; Build + Smoke-UI + App-Start verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-28 00:24:00 +02:00
parent c20538c8d8
commit 8f44c0fd82
25 changed files with 695 additions and 1047 deletions
@@ -1,39 +1,49 @@
using FluentAssertions;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Modularity;
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.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace IBKRTrader.Tests.Modules;
[Trait("cat", "unit")]
public class CongressTradingModuleTests
{
private sealed class CapturingUiHost : IModuleUiHost
{
private readonly List<ModuleView> _views = new();
public IReadOnlyList<ModuleView> Views => _views;
public void RegisterView(ModuleView view) => _views.Add(view);
public bool IsOpen(string viewId) => false;
public void OpenView(string viewId) { }
public void ActivateMain() { }
public void RequestShutdown() { }
public event Action? OpenStateChanged { add { } remove { } }
}
[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");
module.Name.Should().Be("CongressTrading");
module.DbPrefix.Should().Be("ct_");
}
[Fact]
public void RegisterServices_RegistersAllModuleServices()
public void RegisterServices_RegistersServicesAndWorkers()
{
var services = new ServiceCollection();
new CongressTradingModule().RegisterServices(services);
new CongressTradingModule().RegisterServices(services, Substitute.For<IConfiguration>());
var registered = services.Select(d => d.ServiceType).ToList();
registered.Should().Contain(new[]
var types = services.Select(d => d.ServiceType).ToList();
types.Should().Contain(new[]
{
typeof(CongressMigrations),
typeof(CongressRepository),
@@ -41,25 +51,29 @@ public class CongressTradingModuleTests
typeof(CongressHistoryImportWorker),
typeof(CongressScrapeWorker),
});
// Worker werden zusätzlich als IWorker registriert (für die WorkerEngine).
services.Count(d => d.ServiceType == typeof(IWorker)).Should().Be(2);
}
[Fact]
public void CreateWindow_ReturnsModuleForm()
public void RegisterUi_RegistersMainView()
{
// 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);
var host = new CapturingUiHost();
// CreateForm wird hier NICHT aufgerufen daher genügt ein leerer Provider.
var provider = new ServiceCollection().BuildServiceProvider();
using var provider = services.BuildServiceProvider();
using var form = module.CreateWindow(provider);
new CongressTradingModule().RegisterUi(host, provider);
form.Should().BeAssignableTo<ModuleFormBase>();
((ModuleFormBase)form).ModuleKey.Should().Be("CT");
host.Views.Should().ContainSingle();
host.Views[0].Id.Should().Be("congresstrading.main");
host.Views[0].Title.Should().Be("Congress Trading");
}
[Fact]
public void GetActivationBlocker_DefaultsToNull()
{
// Default-Interface-Methode → über die Interface-Referenz aufrufen.
IModule module = new CongressTradingModule();
module.GetActivationBlocker(Substitute.For<IConfiguration>()).Should().BeNull();
}
}
@@ -1,49 +0,0 @@
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>();
}
}