Phase 2: Launcher-UI + eigenstaendige Modul-Fenster

- Form1 -> LauncherForm (Dateien via git mv, Designer/resx angepasst)
- Modul-Tab: Karten aus ModuleRegistry mit "Fenster oeffnen"-Button je Modul
- UI/WindowManager: Fenster-Tracking (Key->Form), Re-Open fokussiert, CloseAll
- UI/ModuleFormBase: Basisklasse fuer eigenstaendige Modul-Fenster
- CongressTradingForm: DB-Kennzahlen + manueller Scrape-Trigger (Phase-4-Ausbau folgt)
- WindowManager in DI; Launcher schliesst Modul-Fenster beim Beenden
- Tests: WindowManager (6) -> 21/21 gruen; Launcher-Start verifiziert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-27 10:39:50 +02:00
parent 5815d3258f
commit d0bc833235
11 changed files with 483 additions and 59 deletions
@@ -1,8 +1,13 @@
using FluentAssertions; 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;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.Workers; using IBKRTrader.Modules.CongressTrading.Workers;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Tests.Modules; namespace IBKRTrader.Tests.Modules;
@@ -39,11 +44,22 @@ public class CongressTradingModuleTests
} }
[Fact] [Fact]
public void CreateWindow_ReturnsForm() public void CreateWindow_ReturnsModuleForm()
{ {
using var form = new CongressTradingModule().CreateWindow(new ServiceCollection().BuildServiceProvider()); // 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);
form.Should().NotBeNull(); using var provider = services.BuildServiceProvider();
form.Text.Should().Contain("CT"); using var form = module.CreateWindow(provider);
form.Should().BeAssignableTo<ModuleFormBase>();
((ModuleFormBase)form).ModuleKey.Should().Be("CT");
} }
} }
+100
View File
@@ -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>();
}
}
+30 -16
View File
@@ -1,6 +1,6 @@
namespace IBKRTrader namespace IBKRTrader
{ {
partial class Form1 partial class LauncherForm
{ {
/// <summary> /// <summary>
/// Required designer variable. /// Required designer variable.
@@ -28,14 +28,15 @@
/// </summary> /// </summary>
private void InitializeComponent() private void InitializeComponent()
{ {
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1)); System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LauncherForm));
menuStrip1 = new MenuStrip(); menuStrip1 = new MenuStrip();
toolStrip1 = new ToolStrip(); toolStrip1 = new ToolStrip();
statusStrip1 = new StatusStrip(); statusStrip1 = new StatusStrip();
tabControl1 = new TabControl(); tabControl1 = new TabControl();
tabPage_dash = new TabPage(); tabPage_dash = new TabPage();
tabPage_sett = new TabPage(); tabPage_sett = new TabPage();
tabPage_mod_congresstrade = new TabPage(); tabPage_modules = new TabPage();
flp_modules = new FlowLayoutPanel();
btn_activateTrading = new ToolStripButton(); btn_activateTrading = new ToolStripButton();
tabPage_history = new TabPage(); tabPage_history = new TabPage();
tabPage_workers = new TabPage(); tabPage_workers = new TabPage();
@@ -89,7 +90,7 @@
tabControl1.Controls.Add(tabPage_sett); tabControl1.Controls.Add(tabPage_sett);
tabControl1.Controls.Add(tabPage_logs); tabControl1.Controls.Add(tabPage_logs);
tabControl1.Controls.Add(tabPage_workers); tabControl1.Controls.Add(tabPage_workers);
tabControl1.Controls.Add(tabPage_mod_congresstrade); tabControl1.Controls.Add(tabPage_modules);
tabControl1.Location = new Point(12, 52); tabControl1.Location = new Point(12, 52);
tabControl1.Name = "tabControl1"; tabControl1.Name = "tabControl1";
tabControl1.SelectedIndex = 0; tabControl1.SelectedIndex = 0;
@@ -116,15 +117,27 @@
tabPage_sett.TabIndex = 1; tabPage_sett.TabIndex = 1;
tabPage_sett.Text = "Settings"; tabPage_sett.Text = "Settings";
tabPage_sett.UseVisualStyleBackColor = true; tabPage_sett.UseVisualStyleBackColor = true;
// //
// tabPage_mod_congresstrade // tabPage_modules
// //
tabPage_mod_congresstrade.Location = new Point(4, 34); tabPage_modules.Controls.Add(flp_modules);
tabPage_mod_congresstrade.Name = "tabPage_mod_congresstrade"; tabPage_modules.Location = new Point(4, 34);
tabPage_mod_congresstrade.Size = new Size(2421, 1104); tabPage_modules.Name = "tabPage_modules";
tabPage_mod_congresstrade.TabIndex = 2; tabPage_modules.Padding = new Padding(3);
tabPage_mod_congresstrade.Text = "Module: CongressTrading"; tabPage_modules.Size = new Size(2421, 1104);
tabPage_mod_congresstrade.UseVisualStyleBackColor = true; tabPage_modules.TabIndex = 2;
tabPage_modules.Text = "Module";
tabPage_modules.UseVisualStyleBackColor = true;
//
// flp_modules
//
flp_modules.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
flp_modules.AutoScroll = true;
flp_modules.Location = new Point(6, 6);
flp_modules.Name = "flp_modules";
flp_modules.Padding = new Padding(6);
flp_modules.Size = new Size(2409, 1092);
flp_modules.TabIndex = 0;
// //
// btn_activateTrading // btn_activateTrading
// //
@@ -223,8 +236,8 @@
Controls.Add(toolStrip1); Controls.Add(toolStrip1);
Controls.Add(menuStrip1); Controls.Add(menuStrip1);
MainMenuStrip = menuStrip1; MainMenuStrip = menuStrip1;
Name = "Form1"; Name = "LauncherForm";
Text = "IBKRTrader"; Text = "IBKRTrader — Launcher";
toolStrip1.ResumeLayout(false); toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout(); toolStrip1.PerformLayout();
tabControl1.ResumeLayout(false); tabControl1.ResumeLayout(false);
@@ -247,7 +260,8 @@
private TabControl tabControl1; private TabControl tabControl1;
private TabPage tabPage_dash; private TabPage tabPage_dash;
private TabPage tabPage_sett; private TabPage tabPage_sett;
private TabPage tabPage_mod_congresstrade; private TabPage tabPage_modules;
private FlowLayoutPanel flp_modules;
private TabPage tabPage_history; private TabPage tabPage_history;
private TabPage tabPage_workers; private TabPage tabPage_workers;
private DataGridView dgv_workerlist; private DataGridView dgv_workerlist;
+99 -11
View File
@@ -4,27 +4,35 @@ using IBKRTrader.Core.Modules;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.UI; using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader; namespace IBKRTrader;
public partial class Form1 : Form /// <summary>
/// Launcher das Basis-Fenster der Anwendung.
/// Enthält die Core-Panels (Workers, Logs, Settings) und eine Modul-Liste,
/// aus der jedes Modul als eigenständiges Fenster geöffnet wird.
/// </summary>
public partial class LauncherForm : Form
{ {
private readonly LoggingService _logger; private readonly LoggingService _logger;
private readonly WorkerEngine _workerEngine; private readonly WorkerEngine _workerEngine;
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly CoreMigrations _migrations; private readonly CoreMigrations _migrations;
private readonly IBKRMigrations _ibkrMigrations; private readonly IBKRMigrations _ibkrMigrations;
private readonly ModuleRegistry _modules;
private readonly WindowManager _windows;
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private LogPanelController? _logPanel; private LogPanelController? _logPanel;
public Form1( public LauncherForm(
LoggingService logger, LoggingService logger,
WorkerEngine workerEngine, WorkerEngine workerEngine,
SettingsService settings, SettingsService settings,
CoreMigrations migrations, CoreMigrations migrations,
IBKRMigrations ibkrMigrations, IBKRMigrations ibkrMigrations,
ModuleRegistry modules,
WindowManager windows,
IServiceProvider services) IServiceProvider services)
{ {
InitializeComponent(); InitializeComponent();
@@ -34,6 +42,8 @@ public partial class Form1 : Form
_settings = settings; _settings = settings;
_migrations = migrations; _migrations = migrations;
_ibkrMigrations = ibkrMigrations; _ibkrMigrations = ibkrMigrations;
_modules = modules;
_windows = windows;
_services = services; _services = services;
} }
@@ -45,13 +55,15 @@ public partial class Form1 : Form
InitializeLogPanel(); InitializeLogPanel();
InitializeWorkerList(); InitializeWorkerList();
InitializeSettingsGrid(); InitializeSettingsGrid();
InitializeModulePanel();
_ = StartupAsync(); _ = StartupAsync();
} }
protected override void OnFormClosing(FormClosingEventArgs e) protected override void OnFormClosing(FormClosingEventArgs e)
{ {
base.OnFormClosing(e); base.OnFormClosing(e);
// Worker sauber beenden (feuert & vergisst Form muss kurz warten) // Offene Modul-Fenster schließen, dann Worker sauber beenden.
_windows.CloseAll();
_workerEngine.StopAllAsync().GetAwaiter().GetResult(); _workerEngine.StopAllAsync().GetAwaiter().GetResult();
} }
@@ -79,6 +91,83 @@ public partial class Form1 : Form
pg_settings.SelectedObject = _settings.Settings; pg_settings.SelectedObject = _settings.Settings;
} }
/// <summary>Baut je Modul eine Karte mit „Fenster öffnen"-Button.</summary>
private void InitializeModulePanel()
{
flp_modules.FlowDirection = FlowDirection.LeftToRight;
flp_modules.WrapContents = true;
flp_modules.Controls.Clear();
foreach (var module in _modules.Modules)
flp_modules.Controls.Add(BuildModuleCard(module));
}
private Control BuildModuleCard(IModule module)
{
var card = new Panel
{
Width = 320,
Height = 150,
Margin = new Padding(8),
BorderStyle = BorderStyle.FixedSingle
};
var title = new Label
{
Text = $"{module.DisplayName} [{module.Key}]",
Font = new Font(Font.FontFamily, 11f, FontStyle.Bold),
Location = new Point(10, 10),
AutoSize = true
};
var desc = new Label
{
Text = module.Description,
Location = new Point(10, 42),
Size = new Size(300, 60),
AutoEllipsis = true
};
var open = new Button
{
Text = "Fenster öffnen",
Location = new Point(10, 108),
Width = 140,
Tag = module
};
open.Click += (_, _) => OpenModuleWindow(module);
var version = new Label
{
Text = $"v{module.Version}",
Location = new Point(240, 113),
AutoSize = true,
ForeColor = SystemColors.GrayText
};
card.Controls.Add(title);
card.Controls.Add(desc);
card.Controls.Add(open);
card.Controls.Add(version);
return card;
}
/// <summary>Öffnet (oder fokussiert) das eigenständige Fenster eines Moduls.</summary>
private void OpenModuleWindow(IModule module)
{
try
{
_windows.OpenOrFocus(module.Key, () => module.CreateWindow(_services));
}
catch (Exception ex)
{
_logger.Error(module.Key, $"{module.DisplayName}: Fenster konnte nicht geöffnet werden.", ex);
MessageBox.Show(this,
$"Fenster für '{module.DisplayName}' konnte nicht geöffnet werden:\n{ex.Message}",
"Modul-Fenster", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// ─── Async Startup ──────────────────────────────────────────────────────── // ─── Async Startup ────────────────────────────────────────────────────────
private async Task StartupAsync() private async Task StartupAsync()
@@ -99,8 +188,7 @@ public partial class Form1 : Form
} }
// Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul). // Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul).
var registry = _services.GetRequiredService<ModuleRegistry>(); foreach (var module in _modules.Modules)
foreach (var module in registry.Modules)
{ {
try try
{ {
View File
@@ -1,8 +1,9 @@
using System.Drawing; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modules; using IBKRTrader.Core.Modules;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.UI;
using IBKRTrader.Modules.CongressTrading.Workers; using IBKRTrader.Modules.CongressTrading.Workers;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -58,24 +59,10 @@ public sealed class CongressTradingModule : IModule
provider.GetRequiredService<CongressScrapeWorker>() provider.GetRequiredService<CongressScrapeWorker>()
]; ];
/// <summary> /// <summary>Erzeugt das eigenständige Modul-Fenster.</summary>
/// Erzeugt das Modul-Fenster. Platzhalter die vollständige UI folgt in Phase 2.
/// </summary>
public Form CreateWindow(IServiceProvider provider) public Form CreateWindow(IServiceProvider provider)
{ => new CongressTradingForm(
var form = new Form provider.GetRequiredService<CongressRepository>(),
{ provider.GetRequiredService<WorkerEngine>(),
Text = $"{DisplayName} ({Key})", provider.GetRequiredService<LoggingService>());
Width = 800,
Height = 500,
StartPosition = FormStartPosition.CenterParent
};
form.Controls.Add(new Label
{
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter,
Text = $"{DisplayName}\n\nModul-UI folgt in Phase 2."
});
return form;
}
} }
@@ -0,0 +1,107 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.UI;
namespace IBKRTrader.Modules.CongressTrading.UI;
/// <summary>
/// Eigenständiges Fenster des CongressTrading-Moduls.
/// Phase 2: Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger.
/// Die vollständige Trade-/Positions-Ansicht folgt in Phase 4.
/// </summary>
public sealed class CongressTradingForm : ModuleFormBase
{
private const string ScrapeWorkerName = "CT-ScrapeWorker";
private readonly CongressRepository _repo;
private readonly WorkerEngine _engine;
private readonly LoggingService _logger;
private readonly Label _lblTrades = new() { AutoSize = true, Location = new Point(20, 70) };
private readonly Label _lblMembers = new() { AutoSize = true, Location = new Point(20, 100) };
private readonly Button _btnRefresh = new() { Text = "Aktualisieren", Location = new Point(20, 140), Width = 140 };
private readonly Button _btnScrape = new() { Text = "Scrape jetzt", Location = new Point(170, 140), Width = 140 };
private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText };
public CongressTradingForm(CongressRepository repo, WorkerEngine engine, LoggingService logger)
: base(CongressTradingModule.ModuleKey)
{
_repo = repo;
_engine = engine;
_logger = logger;
Text = "Congress Trading [CT]";
BuildLayout();
}
private void BuildLayout()
{
var title = new Label
{
Text = "Congress Trading",
Font = new Font(Font.FontFamily, 14f, FontStyle.Bold),
Location = new Point(18, 20),
AutoSize = true
};
var hint = new Label
{
Text = "Vollständige Trade- und Positions-Ansicht folgt in Phase 4.",
Location = new Point(20, 230),
AutoSize = true,
ForeColor = SystemColors.GrayText
};
_btnRefresh.Click += async (_, _) => await RefreshStatsAsync();
_btnScrape.Click += async (_, _) => await TriggerScrapeAsync();
Controls.Add(title);
Controls.Add(_lblTrades);
Controls.Add(_lblMembers);
Controls.Add(_btnRefresh);
Controls.Add(_btnScrape);
Controls.Add(_lblStatus);
Controls.Add(hint);
}
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
await RefreshStatsAsync();
}
private async Task RefreshStatsAsync()
{
try
{
var trades = await _repo.GetTradeCountAsync();
var members = await _repo.GetMemberCountAsync();
_lblTrades.Text = $"Trades in DB: {trades:N0}";
_lblMembers.Text = $"Mitglieder in DB: {members:N0}";
_lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex)
{
_lblTrades.Text = "Trades in DB: n/v";
_lblMembers.Text = "Mitglieder in DB: n/v";
_lblStatus.Text = $"DB nicht erreichbar: {ex.Message}";
_logger.Warn(CongressTradingModule.ModuleKey, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
}
}
private async Task TriggerScrapeAsync()
{
try
{
_lblStatus.Text = "Scrape angestoßen...";
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
_logger.Info(CongressTradingModule.ModuleKey, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
}
catch (Exception ex)
{
_lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}";
_logger.Error(CongressTradingModule.ModuleKey, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
}
}
}
+6 -2
View File
@@ -10,6 +10,7 @@ using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn; using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Modules.CongressTrading; using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader; namespace IBKRTrader;
@@ -84,7 +85,10 @@ internal static class Program
.ToArray()); .ToArray());
services.AddSingleton<WorkerEngine>(); services.AddSingleton<WorkerEngine>();
services.AddSingleton<Form1>();
// UI: Launcher + Fenster-Verwaltung
services.AddSingleton<WindowManager>();
services.AddSingleton<LauncherForm>();
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
@@ -93,6 +97,6 @@ internal static class Program
var engine = provider.GetRequiredService<WorkerEngine>(); var engine = provider.GetRequiredService<WorkerEngine>();
webApi.SetEngine(engine); webApi.SetEngine(engine);
Application.Run(provider.GetRequiredService<Form1>()); Application.Run(provider.GetRequiredService<LauncherForm>());
} }
} }
+25
View File
@@ -0,0 +1,25 @@
namespace IBKRTrader.UI;
/// <summary>
/// Basisklasse für eigenständige Modul-Fenster.
/// Stellt gemeinsame Grundeinstellungen (Größe, Startposition) bereit und
/// hält den Modul-Key, damit der Launcher/WindowManager das Fenster zuordnen kann.
/// </summary>
public class ModuleFormBase : Form
{
/// <summary>Kürzel des zugehörigen Moduls (z. B. "CT").</summary>
public string ModuleKey { get; }
// Parameterloser Ctor nur für den WinForms-Designer.
protected ModuleFormBase() : this("") { }
protected ModuleFormBase(string moduleKey)
{
ModuleKey = moduleKey;
Width = 900;
Height = 600;
StartPosition = FormStartPosition.CenterScreen;
ShowInTaskbar = true;
MinimumSize = new Size(600, 400);
}
}
+82
View File
@@ -0,0 +1,82 @@
namespace IBKRTrader.UI;
/// <summary>
/// Verwaltet die eigenständigen Modul-Fenster des Launchers.
/// Pro Modul-Key wird höchstens ein Fenster gehalten: erneutes Öffnen
/// fokussiert das bestehende Fenster statt ein zweites zu erzeugen.
///
/// Die tatsächlichen UI-Aktionen (Show/Focus) sind in überschreibbare
/// Methoden gekapselt, damit die Tracking-Logik ohne Message-Loop testbar ist.
/// </summary>
public class WindowManager
{
private readonly Dictionary<string, Form> _open = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Öffnet das Fenster zum Key oder fokussiert ein bereits offenes.</summary>
public Form OpenOrFocus(string key, Func<Form> factory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(factory);
if (_open.TryGetValue(key, out var existing))
{
if (!existing.IsDisposed)
{
Focus(existing);
return existing;
}
_open.Remove(key);
}
var form = factory();
_open[key] = form;
form.FormClosed += OnFormClosed;
Present(form);
return form;
}
/// <summary>True, wenn zum Key aktuell ein nicht-disposetes Fenster offen ist.</summary>
public bool IsOpen(string key)
=> _open.TryGetValue(key, out var f) && !f.IsDisposed;
/// <summary>Anzahl aktuell offener Fenster.</summary>
public int OpenCount => _open.Values.Count(f => !f.IsDisposed);
/// <summary>Schließt alle offenen Fenster (z. B. beim Beenden des Launchers).</summary>
public void CloseAll()
{
foreach (var f in _open.Values.ToList())
{
if (!f.IsDisposed)
{
f.FormClosed -= OnFormClosed;
f.Close();
}
}
_open.Clear();
}
// ─── Überschreibbare UI-Aktionen (für Tests) ──────────────────────────────
/// <summary>Zeigt ein neu erzeugtes Fenster an.</summary>
protected virtual void Present(Form form) => form.Show();
/// <summary>Bringt ein bestehendes Fenster in den Vordergrund.</summary>
protected virtual void Focus(Form form)
{
if (form.WindowState == FormWindowState.Minimized)
form.WindowState = FormWindowState.Normal;
form.Activate();
form.BringToFront();
}
private void OnFormClosed(object? sender, FormClosedEventArgs e)
{
if (sender is not Form f) return;
f.FormClosed -= OnFormClosed;
var entry = _open.FirstOrDefault(p => ReferenceEquals(p.Value, f));
if (entry.Key is not null)
_open.Remove(entry.Key);
}
}
+7 -6
View File
@@ -109,12 +109,13 @@ WinForms selbst wird **nicht** unit-getestet Logik in Services/Manager halte
- [x] Testbarkeits-Seams: `WorkerBase.BeginRunLogAsync/EndRunLogAsync`, `CapitolTradesScraper.ParseTradesFromHtml` - [x] Testbarkeits-Seams: `WorkerBase.BeginRunLogAsync/EndRunLogAsync`, `CapitolTradesScraper.ParseTradesFromHtml`
- [x] Tests: `ModuleRegistry`, `CongressTradingModule`, `WorkerBase`, `CapitolTradesScraper` (gegen `ct_raw.html`) → **15/15 grün** - [x] Tests: `ModuleRegistry`, `CongressTradingModule`, `WorkerBase`, `CapitolTradesScraper` (gegen `ct_raw.html`) → **15/15 grün**
### Phase 2 Launcher-UI + Modul-Fenster ### Phase 2 Launcher-UI + Modul-Fenster
- [ ] `Form1``LauncherForm`; Kern-Panels behalten (Workers, Logs, Settings, Core-Status) - [x] `Form1``LauncherForm` (Dateien via `git mv`, Designer/resx angepasst); Kern-Panels behalten
- [ ] Modul-Panel: Liste aus `ModuleRegistry`, je Modul „Fenster öffnen" - [x] Modul-Tab: Karten aus `ModuleRegistry`, je Modul „Fenster öffnen"
- [ ] `UI/ModuleFormBase.cs` + `UI/WindowManager.cs` (Fenster-Tracking) - [x] `UI/ModuleFormBase.cs` + `UI/WindowManager.cs` (Fenster-Tracking, Re-Open fokussiert, `CloseAll`)
- [ ] `Modules/CongressTrading/UI/CongressTradingForm.cs` (erstes Modul-Fenster) - [x] `Modules/CongressTrading/UI/CongressTradingForm.cs` (DB-Kennzahlen + „Scrape jetzt")
- [ ] Tests: `WindowManager` - [x] `WindowManager` in DI; Launcher schließt Modul-Fenster beim Beenden
- [x] Tests: `WindowManager` (6) → **21/21 grün**; Launcher-Start verifiziert
### Phase 3 Trading-Kern (Core) ### Phase 3 Trading-Kern (Core)
- [ ] `Core/Trading/IIbkrClient.cs` (+ Adapter auf `IBKRGatewayService`) - [ ] `Core/Trading/IIbkrClient.cs` (+ Adapter auf `IBKRGatewayService`)