@
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:
@@ -39,6 +39,7 @@ $RECYCLE.BIN/
|
||||
|
||||
## Application data (do NOT commit secrets or runtime data)
|
||||
settings.json
|
||||
appsettings.Local.json
|
||||
Backups/
|
||||
Logs/
|
||||
*.log
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,6 +36,12 @@
|
||||
<None Update="settings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Generated
-274
@@ -1,274 +0,0 @@
|
||||
namespace IBKRTrader
|
||||
{
|
||||
partial class LauncherForm
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LauncherForm));
|
||||
menuStrip1 = new MenuStrip();
|
||||
toolStrip1 = new ToolStrip();
|
||||
statusStrip1 = new StatusStrip();
|
||||
tabControl1 = new TabControl();
|
||||
tabPage_dash = new TabPage();
|
||||
tabPage_sett = new TabPage();
|
||||
tabPage_modules = new TabPage();
|
||||
flp_modules = new FlowLayoutPanel();
|
||||
btn_activateTrading = new ToolStripButton();
|
||||
tabPage_history = new TabPage();
|
||||
tabPage_workers = new TabPage();
|
||||
toolStrip2 = new ToolStrip();
|
||||
dgv_workerlist = new DataGridView();
|
||||
pg_settings = new PropertyGrid();
|
||||
tabPage_logs = new TabPage();
|
||||
rtb_logs = new RichTextBox();
|
||||
toolStrip3 = new ToolStrip();
|
||||
toolStrip1.SuspendLayout();
|
||||
tabControl1.SuspendLayout();
|
||||
tabPage_sett.SuspendLayout();
|
||||
tabPage_workers.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)dgv_workerlist).BeginInit();
|
||||
tabPage_logs.SuspendLayout();
|
||||
SuspendLayout();
|
||||
//
|
||||
// menuStrip1
|
||||
//
|
||||
menuStrip1.ImageScalingSize = new Size(24, 24);
|
||||
menuStrip1.Location = new Point(0, 0);
|
||||
menuStrip1.Name = "menuStrip1";
|
||||
menuStrip1.Size = new Size(2441, 24);
|
||||
menuStrip1.TabIndex = 0;
|
||||
menuStrip1.Text = "menuStrip1";
|
||||
//
|
||||
// toolStrip1
|
||||
//
|
||||
toolStrip1.ImageScalingSize = new Size(24, 24);
|
||||
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_activateTrading });
|
||||
toolStrip1.Location = new Point(0, 24);
|
||||
toolStrip1.Name = "toolStrip1";
|
||||
toolStrip1.Size = new Size(2441, 34);
|
||||
toolStrip1.TabIndex = 1;
|
||||
toolStrip1.Text = "toolStrip1";
|
||||
//
|
||||
// statusStrip1
|
||||
//
|
||||
statusStrip1.ImageScalingSize = new Size(24, 24);
|
||||
statusStrip1.Location = new Point(0, 1197);
|
||||
statusStrip1.Name = "statusStrip1";
|
||||
statusStrip1.Size = new Size(2441, 22);
|
||||
statusStrip1.TabIndex = 2;
|
||||
statusStrip1.Text = "statusStrip1";
|
||||
//
|
||||
// tabControl1
|
||||
//
|
||||
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||
tabControl1.Controls.Add(tabPage_dash);
|
||||
tabControl1.Controls.Add(tabPage_history);
|
||||
tabControl1.Controls.Add(tabPage_sett);
|
||||
tabControl1.Controls.Add(tabPage_logs);
|
||||
tabControl1.Controls.Add(tabPage_workers);
|
||||
tabControl1.Controls.Add(tabPage_modules);
|
||||
tabControl1.Location = new Point(12, 52);
|
||||
tabControl1.Name = "tabControl1";
|
||||
tabControl1.SelectedIndex = 0;
|
||||
tabControl1.Size = new Size(2429, 1142);
|
||||
tabControl1.TabIndex = 3;
|
||||
//
|
||||
// tabPage_dash
|
||||
//
|
||||
tabPage_dash.Location = new Point(4, 34);
|
||||
tabPage_dash.Name = "tabPage_dash";
|
||||
tabPage_dash.Padding = new Padding(3);
|
||||
tabPage_dash.Size = new Size(2421, 1104);
|
||||
tabPage_dash.TabIndex = 0;
|
||||
tabPage_dash.Text = "Dashboard";
|
||||
tabPage_dash.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// tabPage_sett
|
||||
//
|
||||
tabPage_sett.Controls.Add(pg_settings);
|
||||
tabPage_sett.Location = new Point(4, 34);
|
||||
tabPage_sett.Name = "tabPage_sett";
|
||||
tabPage_sett.Padding = new Padding(3);
|
||||
tabPage_sett.Size = new Size(2421, 1104);
|
||||
tabPage_sett.TabIndex = 1;
|
||||
tabPage_sett.Text = "Settings";
|
||||
tabPage_sett.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// tabPage_modules
|
||||
//
|
||||
tabPage_modules.Controls.Add(flp_modules);
|
||||
tabPage_modules.Location = new Point(4, 34);
|
||||
tabPage_modules.Name = "tabPage_modules";
|
||||
tabPage_modules.Padding = new Padding(3);
|
||||
tabPage_modules.Size = new Size(2421, 1104);
|
||||
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.Alignment = ToolStripItemAlignment.Right;
|
||||
btn_activateTrading.Image = (Image)resources.GetObject("btn_activateTrading.Image");
|
||||
btn_activateTrading.ImageTransparentColor = Color.Magenta;
|
||||
btn_activateTrading.Name = "btn_activateTrading";
|
||||
btn_activateTrading.Size = new Size(166, 29);
|
||||
btn_activateTrading.Text = "Activate Trading";
|
||||
//
|
||||
// tabPage_history
|
||||
//
|
||||
tabPage_history.Location = new Point(4, 34);
|
||||
tabPage_history.Name = "tabPage_history";
|
||||
tabPage_history.Padding = new Padding(3);
|
||||
tabPage_history.Size = new Size(2421, 1104);
|
||||
tabPage_history.TabIndex = 3;
|
||||
tabPage_history.Text = "Trade History";
|
||||
tabPage_history.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// tabPage_workers
|
||||
//
|
||||
tabPage_workers.Controls.Add(dgv_workerlist);
|
||||
tabPage_workers.Controls.Add(toolStrip2);
|
||||
tabPage_workers.Location = new Point(4, 34);
|
||||
tabPage_workers.Name = "tabPage_workers";
|
||||
tabPage_workers.Padding = new Padding(3);
|
||||
tabPage_workers.Size = new Size(2421, 1104);
|
||||
tabPage_workers.TabIndex = 4;
|
||||
tabPage_workers.Text = "Workers / Services";
|
||||
tabPage_workers.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// toolStrip2
|
||||
//
|
||||
toolStrip2.ImageScalingSize = new Size(24, 24);
|
||||
toolStrip2.Location = new Point(3, 3);
|
||||
toolStrip2.Name = "toolStrip2";
|
||||
toolStrip2.Size = new Size(2415, 25);
|
||||
toolStrip2.TabIndex = 0;
|
||||
toolStrip2.Text = "toolStrip2";
|
||||
//
|
||||
// dgv_workerlist
|
||||
//
|
||||
dgv_workerlist.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||
dgv_workerlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||
dgv_workerlist.Location = new Point(6, 31);
|
||||
dgv_workerlist.Name = "dgv_workerlist";
|
||||
dgv_workerlist.RowHeadersWidth = 62;
|
||||
dgv_workerlist.Size = new Size(2409, 1067);
|
||||
dgv_workerlist.TabIndex = 1;
|
||||
//
|
||||
// pg_settings
|
||||
//
|
||||
pg_settings.BackColor = SystemColors.Control;
|
||||
pg_settings.Location = new Point(3, 6);
|
||||
pg_settings.Name = "pg_settings";
|
||||
pg_settings.Size = new Size(1101, 1075);
|
||||
pg_settings.TabIndex = 0;
|
||||
//
|
||||
// tabPage_logs
|
||||
//
|
||||
tabPage_logs.Controls.Add(toolStrip3);
|
||||
tabPage_logs.Controls.Add(rtb_logs);
|
||||
tabPage_logs.Location = new Point(4, 34);
|
||||
tabPage_logs.Name = "tabPage_logs";
|
||||
tabPage_logs.Padding = new Padding(3);
|
||||
tabPage_logs.Size = new Size(2421, 1104);
|
||||
tabPage_logs.TabIndex = 5;
|
||||
tabPage_logs.Text = "Logs";
|
||||
tabPage_logs.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// rtb_logs
|
||||
//
|
||||
rtb_logs.Location = new Point(6, 31);
|
||||
rtb_logs.Name = "rtb_logs";
|
||||
rtb_logs.Size = new Size(1849, 811);
|
||||
rtb_logs.TabIndex = 0;
|
||||
rtb_logs.Text = "";
|
||||
//
|
||||
// toolStrip3
|
||||
//
|
||||
toolStrip3.ImageScalingSize = new Size(24, 24);
|
||||
toolStrip3.Location = new Point(3, 3);
|
||||
toolStrip3.Name = "toolStrip3";
|
||||
toolStrip3.Size = new Size(2415, 25);
|
||||
toolStrip3.TabIndex = 1;
|
||||
toolStrip3.Text = "toolStrip3";
|
||||
//
|
||||
// Form1
|
||||
//
|
||||
AutoScaleDimensions = new SizeF(10F, 25F);
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(2441, 1219);
|
||||
Controls.Add(tabControl1);
|
||||
Controls.Add(statusStrip1);
|
||||
Controls.Add(toolStrip1);
|
||||
Controls.Add(menuStrip1);
|
||||
MainMenuStrip = menuStrip1;
|
||||
Name = "LauncherForm";
|
||||
Text = "IBKRTrader — Launcher";
|
||||
toolStrip1.ResumeLayout(false);
|
||||
toolStrip1.PerformLayout();
|
||||
tabControl1.ResumeLayout(false);
|
||||
tabPage_sett.ResumeLayout(false);
|
||||
tabPage_workers.ResumeLayout(false);
|
||||
tabPage_workers.PerformLayout();
|
||||
((System.ComponentModel.ISupportInitialize)dgv_workerlist).EndInit();
|
||||
tabPage_logs.ResumeLayout(false);
|
||||
tabPage_logs.PerformLayout();
|
||||
ResumeLayout(false);
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private MenuStrip menuStrip1;
|
||||
private ToolStrip toolStrip1;
|
||||
private ToolStripButton btn_activateTrading;
|
||||
private StatusStrip statusStrip1;
|
||||
private TabControl tabControl1;
|
||||
private TabPage tabPage_dash;
|
||||
private TabPage tabPage_sett;
|
||||
private TabPage tabPage_modules;
|
||||
private FlowLayoutPanel flp_modules;
|
||||
private TabPage tabPage_history;
|
||||
private TabPage tabPage_workers;
|
||||
private DataGridView dgv_workerlist;
|
||||
private ToolStrip toolStrip2;
|
||||
private PropertyGrid pg_settings;
|
||||
private TabPage tabPage_logs;
|
||||
private RichTextBox rtb_logs;
|
||||
private ToolStrip toolStrip3;
|
||||
}
|
||||
}
|
||||
+99
-168
@@ -1,224 +1,155 @@
|
||||
using IBKRTrader.Core.Database.Migrations;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modules;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.UI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader;
|
||||
|
||||
/// <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.
|
||||
/// Launcher – das Basis-Fenster (Shell). Zeigt je registrierter View einen Button, führt beim Start
|
||||
/// Migrationen aus, startet Module und die WorkerEngine, und trägt das gemeinsame Fenster-Menü.
|
||||
/// Die inhaltlichen Ansichten (Logs, Settings, Workers, Module) sind eigenständige Fenster.
|
||||
/// </summary>
|
||||
public partial class LauncherForm : Form
|
||||
public sealed class LauncherForm : Form
|
||||
{
|
||||
private readonly LoggingService _logger;
|
||||
private readonly WorkerEngine _workerEngine;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly CoreMigrations _migrations;
|
||||
private readonly IBKRMigrations _ibkrMigrations;
|
||||
private readonly ModuleRegistry _modules;
|
||||
private readonly WindowManager _windows;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ShellUiHost _uiHost;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly LoggingService _logger;
|
||||
private readonly WorkerEngine _workerEngine;
|
||||
private readonly IReadOnlyList<IModule> _modules;
|
||||
|
||||
private LogPanelController? _logPanel;
|
||||
|
||||
public LauncherForm(
|
||||
LoggingService logger,
|
||||
WorkerEngine workerEngine,
|
||||
SettingsService settings,
|
||||
CoreMigrations migrations,
|
||||
IBKRMigrations ibkrMigrations,
|
||||
ModuleRegistry modules,
|
||||
WindowManager windows,
|
||||
IServiceProvider services)
|
||||
private readonly Dictionary<string, Button> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly FlowLayoutPanel _buttonPanel = new()
|
||||
{
|
||||
InitializeComponent();
|
||||
Dock = DockStyle.Fill, Padding = new Padding(16), AutoScroll = true
|
||||
};
|
||||
private readonly ToolStripStatusLabel _status = new("Start...");
|
||||
|
||||
_logger = logger;
|
||||
_workerEngine = workerEngine;
|
||||
_settings = settings;
|
||||
_migrations = migrations;
|
||||
_ibkrMigrations = ibkrMigrations;
|
||||
_modules = modules;
|
||||
_windows = windows;
|
||||
_services = services;
|
||||
public LauncherForm(ShellUiHost uiHost, IServiceProvider services)
|
||||
{
|
||||
_uiHost = uiHost;
|
||||
_services = services;
|
||||
_logger = services.GetRequiredService<LoggingService>();
|
||||
_workerEngine = services.GetRequiredService<WorkerEngine>();
|
||||
_modules = services.GetServices<IModule>().ToList();
|
||||
|
||||
Text = "IBKRTrader — Launcher";
|
||||
Width = 720;
|
||||
Height = 540;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
_uiHost.SetMainWindow(this);
|
||||
|
||||
BuildUi();
|
||||
_uiHost.OpenStateChanged += UpdateButtonStates;
|
||||
}
|
||||
|
||||
// ─── Form-Events ──────────────────────────────────────────────────────────
|
||||
private void BuildUi()
|
||||
{
|
||||
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
|
||||
WindowMenu.Wire(menu, _uiHost, null);
|
||||
|
||||
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
var id = view.Id;
|
||||
var btn = new Button
|
||||
{
|
||||
Text = view.Title, Width = 210, Height = 48, Margin = new Padding(8),
|
||||
TextAlign = ContentAlignment.MiddleLeft, Image = view.Icon,
|
||||
ImageAlign = ContentAlignment.MiddleLeft, TextImageRelation = TextImageRelation.ImageBeforeText
|
||||
};
|
||||
btn.Click += (_, _) => _uiHost.OpenView(id);
|
||||
_viewButtons[id] = btn;
|
||||
_buttonPanel.Controls.Add(btn);
|
||||
}
|
||||
|
||||
var statusStrip = new StatusStrip();
|
||||
statusStrip.Items.Add(_status);
|
||||
|
||||
// Reihenfolge: Fill-Panel zuerst, dann Bottom, dann Top (WinForms Dock-Stacking).
|
||||
Controls.Add(_buttonPanel);
|
||||
Controls.Add(statusStrip);
|
||||
Controls.Add(menu);
|
||||
MainMenuStrip = menu;
|
||||
}
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
InitializeLogPanel();
|
||||
InitializeWorkerList();
|
||||
InitializeSettingsGrid();
|
||||
InitializeModulePanel();
|
||||
_ = StartupAsync();
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
base.OnFormClosing(e);
|
||||
// Offene Modul-Fenster schließen, dann Worker sauber beenden.
|
||||
_windows.CloseAll();
|
||||
_workerEngine.StopAllAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
// ─── Initialisierung ──────────────────────────────────────────────────────
|
||||
|
||||
private void InitializeLogPanel()
|
||||
{
|
||||
// RichTextBox auf Dock.Fill setzen
|
||||
rtb_logs.Dock = DockStyle.Fill;
|
||||
_logPanel = new LogPanelController(rtb_logs, _logger);
|
||||
|
||||
// LogLevel aus Settings setzen
|
||||
var levelStr = _settings.Settings.Logging.Level;
|
||||
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
|
||||
_logger.SetMinLevel(level);
|
||||
}
|
||||
|
||||
private void InitializeWorkerList()
|
||||
{
|
||||
WorkerListBindingSource.Setup(dgv_workerlist, _workerEngine.WorkerInfos);
|
||||
}
|
||||
|
||||
private void InitializeSettingsGrid()
|
||||
{
|
||||
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
|
||||
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
|
||||
if (!_uiHost.ShutdownConfirmed)
|
||||
{
|
||||
Width = 320,
|
||||
Height = 150,
|
||||
Margin = new Padding(8),
|
||||
BorderStyle = BorderStyle.FixedSingle
|
||||
};
|
||||
e.Cancel = true;
|
||||
BeginInvoke((Action)(() => _uiHost.RequestShutdown()));
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_uiHost.CloseAllViews();
|
||||
try
|
||||
{
|
||||
_windows.OpenOrFocus(module.Key, () => module.CreateWindow(_services));
|
||||
foreach (var module in _modules)
|
||||
module.StopAsync(default).GetAwaiter().GetResult();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
catch { /* Shutdown darf nicht am Modul scheitern */ }
|
||||
_workerEngine.StopAllAsync().GetAwaiter().GetResult();
|
||||
|
||||
// ─── Async Startup ────────────────────────────────────────────────────────
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
private async Task StartupAsync()
|
||||
{
|
||||
_logger.Info("Core", "=== IBKRTrader startet ===");
|
||||
_logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
|
||||
|
||||
// DB-Verbindung testen und Core-Migrationen laufen lassen
|
||||
// Log-Level aus Settings.
|
||||
var levelStr = _services.GetRequiredService<SettingsService>().Settings.Logging.Level;
|
||||
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
|
||||
_logger.SetMinLevel(level);
|
||||
|
||||
SetStatus("Migrationen...");
|
||||
try
|
||||
{
|
||||
_logger.Info("Core", "Verbinde mit Datenbank...");
|
||||
await _migrations.RunAsync();
|
||||
await _ibkrMigrations.RunAsync();
|
||||
await _services.GetRequiredService<CoreMigrations>().RunAsync();
|
||||
await _services.GetRequiredService<IBKRMigrations>().RunAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Core", "Core-Datenbankfehler beim Start.", ex);
|
||||
}
|
||||
|
||||
// Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul).
|
||||
foreach (var module in _modules.Modules)
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
try
|
||||
{
|
||||
await module.InitializeAsync(_services);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(module.Key, $"{module.DisplayName}: Initialisierung fehlgeschlagen.", ex);
|
||||
}
|
||||
try { await module.StartAsync(default); }
|
||||
catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||||
}
|
||||
|
||||
// Worker starten
|
||||
await _workerEngine.StartAllAsync();
|
||||
|
||||
_logger.Info("Core", "IBKRTrader bereit.");
|
||||
UpdateStatusBar("Bereit");
|
||||
SetStatus("Bereit");
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
// ─── StatusBar ────────────────────────────────────────────────────────────
|
||||
|
||||
private void UpdateStatusBar(string text)
|
||||
private void UpdateButtonStates()
|
||||
{
|
||||
if (statusStrip1.InvokeRequired)
|
||||
statusStrip1.BeginInvoke(() => UpdateStatusBar(text));
|
||||
else
|
||||
{
|
||||
// Vorhandenes Label nutzen oder neues anlegen
|
||||
if (statusStrip1.Items.Count == 0)
|
||||
statusStrip1.Items.Add(new ToolStripStatusLabel());
|
||||
statusStrip1.Items[0].Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
if (IsDisposed) return;
|
||||
if (InvokeRequired) { BeginInvoke((Action)UpdateButtonStates); return; }
|
||||
foreach (var (id, btn) in _viewButtons)
|
||||
btn.Font = new Font(btn.Font, _uiHost.IsOpen(id) ? FontStyle.Bold : FontStyle.Regular);
|
||||
}
|
||||
|
||||
private void SetStatus(string text)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; }
|
||||
_status.Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>17, 17</value>
|
||||
</metadata>
|
||||
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>175, 17</value>
|
||||
</metadata>
|
||||
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>320, 17</value>
|
||||
</metadata>
|
||||
<metadata name="toolStrip3.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>625, 17</value>
|
||||
</metadata>
|
||||
<metadata name="toolStrip2.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>480, 17</value>
|
||||
</metadata>
|
||||
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
|
||||
<data name="btn_activateTrading.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>
|
||||
iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
|
||||
YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAEBSURBVEhL3ZIvC8JAGIf3OTTZ/A62+RXs2g2rFsGi2P0C
|
||||
WlwT02wGMQjLhgVRnAMR/5bVV37CHeduf9TdFQfPDt7wPOy9GWEYkk4MvGzbJsuylAInD2BgmqZS4JQC
|
||||
99tDCcoCl+OBPKdH3qxHe3eiPuAOTFq0CxwWURYQ5QBfojfgKA5sl0Mux7pwJx8H/NOdmvPj64yKs8gM
|
||||
QFqdBlQc7V/nt5HUwDq4cTnj20hiYLW7Unnsv8l/icQGKrVGojwrgpk4lwL1VpdKw40kjCMaYfclzqVA
|
||||
3z1LojSYTPwZ2Bxrzh1gsujPALBmrDt3IA2sG2vXFgBw/lFAJwh0dGLofp4xWrfOyeSKuAAAAABJRU5E
|
||||
rkJggg==
|
||||
</value>
|
||||
</data>
|
||||
<metadata name="toolStrip3.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>625, 17</value>
|
||||
</metadata>
|
||||
</root>
|
||||
+132
-56
@@ -4,107 +4,183 @@ using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Database.Migrations;
|
||||
using IBKRTrader.Core.IBKR;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modules;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Core.Workers.BuiltIn;
|
||||
using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.UI;
|
||||
using IBKRTrader.UI.Views;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace IBKRTrader;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
public static IHost? AppHost { get; private set; }
|
||||
|
||||
[STAThread]
|
||||
static void Main()
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// Headless-Smoke-Test der UI (konstruiert jede View + Launcher, ohne Message-Loop).
|
||||
if (args.Length > 0 && string.Equals(args[0], "--smoke-ui", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = RunSmokeUi();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
// ── DI-Container aufbauen ────────────────────────────────────────────
|
||||
var services = new ServiceCollection();
|
||||
var modules = new List<IModule> { new CongressTradingModule() };
|
||||
|
||||
// Core: Settings (zuerst laden)
|
||||
AppHost = Host.CreateDefaultBuilder()
|
||||
.UseContentRoot(AppContext.BaseDirectory)
|
||||
.ConfigureAppConfiguration((_, config) =>
|
||||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
RegisterCoreServices(services);
|
||||
foreach (var module in modules)
|
||||
{
|
||||
services.AddSingleton(module);
|
||||
module.RegisterServices(services, context.Configuration);
|
||||
}
|
||||
services.AddSingleton<ShellUiHost>();
|
||||
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<ShellUiHost>());
|
||||
services.AddSingleton<LauncherForm>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz.
|
||||
AppHost.Services.GetRequiredService<WebApiService>()
|
||||
.SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>());
|
||||
|
||||
// Views registrieren (Core + Module), dann Launcher starten.
|
||||
var uiHost = AppHost.Services.GetRequiredService<ShellUiHost>();
|
||||
RegisterCoreViews(uiHost, AppHost.Services);
|
||||
foreach (var module in modules)
|
||||
module.RegisterUi(uiHost, AppHost.Services);
|
||||
|
||||
Application.Run(AppHost.Services.GetRequiredService<LauncherForm>());
|
||||
AppHost.StopAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>Registriert alle Core-Services im DI-Container.</summary>
|
||||
private static void RegisterCoreServices(IServiceCollection services)
|
||||
{
|
||||
// Settings zuerst laden (eine Quelle, als Singleton weitergereicht).
|
||||
var settingsService = new SettingsService();
|
||||
settingsService.Load();
|
||||
services.AddSingleton(settingsService);
|
||||
|
||||
// Core: Logging
|
||||
services.AddSingleton<LoggingService>();
|
||||
|
||||
// Core: Database
|
||||
services.AddSingleton<DatabaseService>();
|
||||
services.AddSingleton<CoreMigrations>();
|
||||
services.AddSingleton<IBKRMigrations>();
|
||||
|
||||
// Core: IBKR Services
|
||||
services.AddSingleton<IBKRGatewayService>();
|
||||
services.AddSingleton<IBKRMarketDataRepository>();
|
||||
|
||||
// Core: Other Services
|
||||
services.AddSingleton<BudgetService>();
|
||||
services.AddSingleton<TradeHistoryService>();
|
||||
services.AddSingleton<AIModelService>();
|
||||
|
||||
// Core: Trading-Kern
|
||||
// Trading-Kern
|
||||
services.AddSingleton<IRiskService, RiskService>();
|
||||
services.AddSingleton<IPortfolioService, PortfolioService>();
|
||||
services.AddSingleton<IExecutionService, ExecutionService>();
|
||||
// Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter
|
||||
// angebunden und gegen den Paper-Gateway verifiziert ist.
|
||||
// Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter verifiziert ist.
|
||||
services.AddSingleton<IBrokerClient, NullBrokerClient>();
|
||||
|
||||
// Core: Built-In Worker
|
||||
// Core-Worker/Services
|
||||
services.AddSingleton<BackupWorker>();
|
||||
services.AddSingleton<WebserverService>();
|
||||
services.AddSingleton<WebApiService>();
|
||||
services.AddSingleton<IBKRInstrumentSyncWorker>();
|
||||
services.AddSingleton<IBKRPriceHistoryWorker>();
|
||||
|
||||
// ── Module registrieren ──────────────────────────────────────────────
|
||||
// Der Core kennt konkrete Module nicht: Module werden hier instanziiert,
|
||||
// hängen ihre Services über IModule.RegisterServices ein und werden über
|
||||
// die ModuleRegistry zugänglich gemacht.
|
||||
IModule[] modules =
|
||||
[
|
||||
new CongressTradingModule(),
|
||||
];
|
||||
|
||||
foreach (var module in modules)
|
||||
module.RegisterServices(services);
|
||||
|
||||
var moduleRegistry = new ModuleRegistry(modules);
|
||||
services.AddSingleton(moduleRegistry);
|
||||
|
||||
// ── IWorker-Sammlung: Core + Module ──────────────────────────────────
|
||||
services.AddSingleton<IEnumerable<IWorker>>(sp => new IWorker[]
|
||||
{
|
||||
// Core-Worker
|
||||
sp.GetRequiredService<BackupWorker>(),
|
||||
sp.GetRequiredService<WebserverService>(),
|
||||
sp.GetRequiredService<WebApiService>(),
|
||||
// IBKR Market Data Worker
|
||||
sp.GetRequiredService<IBKRInstrumentSyncWorker>(),
|
||||
sp.GetRequiredService<IBKRPriceHistoryWorker>(),
|
||||
}
|
||||
// Modul-Worker dynamisch anhängen
|
||||
.Concat(moduleRegistry.Modules.SelectMany(m => m.GetWorkers(sp)))
|
||||
.ToArray());
|
||||
// Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>.
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
|
||||
|
||||
services.AddSingleton<WorkerEngine>();
|
||||
|
||||
// UI: Launcher + Fenster-Verwaltung
|
||||
services.AddSingleton<WindowManager>();
|
||||
services.AddSingleton<LauncherForm>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// WebApiService bekommt Engine-Referenz (zirkuläre Abhängigkeit auflösen)
|
||||
var webApi = provider.GetRequiredService<WebApiService>();
|
||||
var engine = provider.GetRequiredService<WorkerEngine>();
|
||||
webApi.SetEngine(engine);
|
||||
|
||||
Application.Run(provider.GetRequiredService<LauncherForm>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registriert die Core-Views (Logs, Settings, Workers) bei der Shell.</summary>
|
||||
private static void RegisterCoreViews(IModuleUiHost uiHost, IServiceProvider sp)
|
||||
{
|
||||
uiHost.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.workers", Title = "Workers / Services", Group = "Core", Order = 10,
|
||||
CreateForm = () => new WorkersView(sp.GetRequiredService<WorkerEngine>())
|
||||
});
|
||||
uiHost.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
|
||||
CreateForm = () => new LogsView(sp.GetRequiredService<LoggingService>())
|
||||
});
|
||||
uiHost.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.settings", Title = "Settings", Group = "Core", Order = 30,
|
||||
CreateForm = () => new SettingsView(sp.GetRequiredService<SettingsService>())
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Headless-Smoke-Test: baut den Host, registriert Views + Modul-UI und konstruiert jede
|
||||
/// registrierte View sowie den Launcher – ohne Message-Loop. Gibt die Fehleranzahl zurück.
|
||||
/// </summary>
|
||||
private static int RunSmokeUi()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
var modules = new List<IModule> { new CongressTradingModule() };
|
||||
|
||||
using var host = Host.CreateDefaultBuilder()
|
||||
.UseContentRoot(AppContext.BaseDirectory)
|
||||
.ConfigureAppConfiguration((_, config) =>
|
||||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
RegisterCoreServices(services);
|
||||
foreach (var module in modules)
|
||||
{
|
||||
services.AddSingleton(module);
|
||||
module.RegisterServices(services, context.Configuration);
|
||||
}
|
||||
services.AddSingleton<ShellUiHost>();
|
||||
services.AddSingleton<IModuleUiHost>(s => s.GetRequiredService<ShellUiHost>());
|
||||
services.AddSingleton<LauncherForm>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
host.Services.GetRequiredService<WebApiService>()
|
||||
.SetEngine(host.Services.GetRequiredService<WorkerEngine>());
|
||||
|
||||
var uiHost = host.Services.GetRequiredService<ShellUiHost>();
|
||||
RegisterCoreViews(uiHost, host.Services);
|
||||
foreach (var module in modules)
|
||||
module.RegisterUi(uiHost, host.Services);
|
||||
|
||||
var failures = 0;
|
||||
Console.WriteLine("=== Smoke-UI: View-Konstruktion ===");
|
||||
foreach (var view in uiHost.Views)
|
||||
{
|
||||
try { using var form = view.CreateForm(); Console.WriteLine($"[OK] {view.Id} ({view.Title})"); }
|
||||
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
try { using var launcher = host.Services.GetRequiredService<LauncherForm>(); Console.WriteLine("[OK] LauncherForm konstruiert"); }
|
||||
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] LauncherForm: {ex.GetType().Name}: {ex.Message}"); }
|
||||
|
||||
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using IBKRTrader.Core.Modularity;
|
||||
|
||||
namespace IBKRTrader.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die registrierten Fenster-Views: öffnet sie als eigenständige Forms, hält je View
|
||||
/// höchstens eine Instanz offen und holt ein bereits offenes Fenster wieder in den Vordergrund.
|
||||
/// Meldet Änderungen am Offen-Status (für die Button-Markierung im Launcher).
|
||||
/// Vorbild: PolytraderSharp <c>ShellUiHost</c>.
|
||||
/// </summary>
|
||||
public sealed class ShellUiHost : IModuleUiHost
|
||||
{
|
||||
private readonly List<ModuleView> _views = new();
|
||||
private readonly Dictionary<string, Form> _open = new(StringComparer.OrdinalIgnoreCase);
|
||||
private Form? _mainWindow;
|
||||
|
||||
/// <summary>True, sobald das Beenden bestätigt wurde (der Launcher wertet das in FormClosing aus).</summary>
|
||||
public bool ShutdownConfirmed { get; private set; }
|
||||
|
||||
public event Action? OpenStateChanged;
|
||||
|
||||
public IReadOnlyList<ModuleView> Views => _views;
|
||||
|
||||
public void RegisterView(ModuleView view) => _views.Add(view);
|
||||
|
||||
/// <summary>Setzt das Hauptfenster (Launcher) – Ziel für <see cref="ActivateMain"/>.</summary>
|
||||
public void SetMainWindow(Form main) => _mainWindow = main;
|
||||
|
||||
public bool IsOpen(string viewId) =>
|
||||
_open.TryGetValue(viewId, out var form) && !form.IsDisposed;
|
||||
|
||||
public void ActivateMain()
|
||||
{
|
||||
if (_mainWindow is null || _mainWindow.IsDisposed) return;
|
||||
if (_mainWindow.WindowState == FormWindowState.Minimized)
|
||||
_mainWindow.WindowState = FormWindowState.Normal;
|
||||
_mainWindow.BringToFront();
|
||||
_mainWindow.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Sicherheitsabfrage; bei Bestätigung wird die Message-Loop beendet.</summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
if (ShutdownConfirmed) return;
|
||||
var owner = _mainWindow is { IsDisposed: false } ? _mainWindow : null;
|
||||
var result = MessageBox.Show(owner,
|
||||
"IBKRTrader wirklich beenden? Laufende Worker/Services werden gestoppt.",
|
||||
"Beenden", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning);
|
||||
if (result != DialogResult.OK) return;
|
||||
|
||||
ShutdownConfirmed = true;
|
||||
Application.Exit();
|
||||
}
|
||||
|
||||
public void OpenView(string viewId)
|
||||
{
|
||||
var view = _views.FirstOrDefault(v => v.Id == viewId);
|
||||
if (view is not null) OpenView(view);
|
||||
}
|
||||
|
||||
private void OpenView(ModuleView view)
|
||||
{
|
||||
if (_open.TryGetValue(view.Id, out var existing) && !existing.IsDisposed)
|
||||
{
|
||||
if (existing.WindowState == FormWindowState.Minimized)
|
||||
existing.WindowState = FormWindowState.Normal;
|
||||
existing.BringToFront();
|
||||
existing.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var form = view.CreateForm();
|
||||
if (string.IsNullOrEmpty(form.Text) || form.Text == form.Name)
|
||||
form.Text = view.Title;
|
||||
|
||||
// Gemeinsames „Fenster"-Menü in jedes Fenster injizieren (identische Shell-Chrome).
|
||||
if (form.MainMenuStrip is null)
|
||||
AttachWindowMenu(form, view.Id);
|
||||
|
||||
_open[view.Id] = form;
|
||||
form.FormClosed += (_, _) =>
|
||||
{
|
||||
_open.Remove(view.Id);
|
||||
OpenStateChanged?.Invoke();
|
||||
};
|
||||
|
||||
form.Show();
|
||||
OpenStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void AttachWindowMenu(Form form, string currentViewId)
|
||||
{
|
||||
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
|
||||
WindowMenu.Wire(menu, this, currentViewId);
|
||||
form.Controls.Add(menu);
|
||||
form.MainMenuStrip = menu;
|
||||
}
|
||||
|
||||
/// <summary>Schließt alle offenen View-Fenster (beim Herunterfahren).</summary>
|
||||
public void CloseAllViews()
|
||||
{
|
||||
foreach (var form in _open.Values.ToList())
|
||||
if (!form.IsDisposed)
|
||||
form.Close();
|
||||
_open.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.UI.Views;
|
||||
|
||||
/// <summary>Core-View: Live-Log (RichTextBox), an den LoggingService gebunden.</summary>
|
||||
public sealed class LogsView : Form
|
||||
{
|
||||
public LogsView(LoggingService logger)
|
||||
{
|
||||
Text = "Logs";
|
||||
Width = 1000;
|
||||
Height = 650;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
var rtb = new RichTextBox { Dock = DockStyle.Fill };
|
||||
Controls.Add(rtb);
|
||||
|
||||
// Verbindet den Logger mit der RichTextBox (Theme + AttachRichTextBox).
|
||||
_ = new LogPanelController(rtb, logger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.UI.Views;
|
||||
|
||||
/// <summary>Core-View: Einstellungen (PropertyGrid auf AppSettings) mit Speichern-Button.</summary>
|
||||
public sealed class SettingsView : Form
|
||||
{
|
||||
public SettingsView(SettingsService settings)
|
||||
{
|
||||
Text = "Settings";
|
||||
Width = 820;
|
||||
Height = 720;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
var grid = new PropertyGrid { Dock = DockStyle.Fill, SelectedObject = settings.Settings };
|
||||
|
||||
var save = new Button { Text = "Speichern", Dock = DockStyle.Bottom, Height = 36 };
|
||||
save.Click += (_, _) => settings.Save();
|
||||
|
||||
Controls.Add(grid);
|
||||
Controls.Add(save);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using IBKRTrader.Core.Workers;
|
||||
|
||||
namespace IBKRTrader.UI.Views;
|
||||
|
||||
/// <summary>Core-View: Worker/Services-Übersicht (DataGridView, live an die WorkerEngine gebunden).</summary>
|
||||
public sealed class WorkersView : Form
|
||||
{
|
||||
public WorkersView(WorkerEngine engine)
|
||||
{
|
||||
Text = "Workers / Services";
|
||||
Width = 1200;
|
||||
Height = 700;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
var dgv = new DataGridView { Dock = DockStyle.Fill };
|
||||
Controls.Add(dgv);
|
||||
|
||||
WorkerListBindingSource.Setup(dgv, engine.WorkerInfos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Database": {
|
||||
"MySqlConnectionString": ""
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,12 +79,13 @@ IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + C
|
||||
- [x] `tests/IBKRTrader.Tests` – Referenzen auf Core + Modul; Fixture-Pfad angepasst
|
||||
- [x] Neue `.slnx`; ungenutztes HtmlAgilityPack entfernt; Build + **38/38 Tests grün**; App startet
|
||||
|
||||
### R2 – Core-Contracts + Generic Host + Shell-UI
|
||||
- [ ] `IModule` (PolytraderSharp-Stil) + `ModuleView` + `IModuleUiHost` + `WindowMenu` in Core
|
||||
- [ ] `Program.cs` → `Host.CreateDefaultBuilder`; `IConfiguration` (appsettings.json + appsettings.Local.json)
|
||||
- [ ] `ShellUiHost` + `LauncherForm` (View-Buttons + Fenster-Menü); Core-Views registrieren
|
||||
- [ ] CongressTrading auf neuen `IModule`-Vertrag; Module über `IHostedService`
|
||||
- [ ] `--smoke-ui` Headless-Test
|
||||
### R2 – Core-Contracts + Generic Host + Shell-UI ✅
|
||||
- [x] `IModule` (PolytraderSharp-Stil) + `ModuleView` + `IModuleUiHost` + `WindowMenu` in Core
|
||||
- [x] `Program.cs` → `Host.CreateDefaultBuilder`; `IConfiguration` (appsettings.json + appsettings.Local.json, gitignored)
|
||||
- [x] `ShellUiHost` + `LauncherForm` (View-Buttons + gemeinsames Fenster-Menü); Core-Views (Workers/Logs/Settings) als eigene Fenster
|
||||
- [x] CongressTrading auf neuen `IModule`-Vertrag; Modul-Worker als `IWorker` registriert
|
||||
- [x] `--smoke-ui` Headless-Test (alle Views + Launcher konstruieren) → grün
|
||||
- [ ] **Offen (R4/R5):** Worker von `WorkerEngine`/`IWorker` auf `IHostedService` umstellen (aktuell noch WorkerEngine)
|
||||
|
||||
### R3 – Persistenz auf EF Core
|
||||
- [ ] `AddCorePersistence` + `CoreDbContext` + Entities + EF-Repos (core_)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Erlaubt dem Testprojekt, interne Helfer zu testen. -->
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.Core.Modularity;
|
||||
|
||||
/// <summary>
|
||||
/// Vertrag für ein IBKRTrader-Modul. Module referenzieren nur den Core, niemals einander.
|
||||
/// Ein Modul kapselt einen eigenständigen Funktionsbereich (z. B. eine Trading-Strategie)
|
||||
/// mit eigenen Services, Jobs, DB-Objekten sowie eigenen UI-Ansichten.
|
||||
///
|
||||
/// Vorbild: PolytraderSharp <c>IPolyTraderModule</c>.
|
||||
/// </summary>
|
||||
public interface IModule
|
||||
{
|
||||
/// <summary>Eindeutiger Anzeigename des Moduls, z. B. "CongressTrading".</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>DB-Präfix für die Objekte des Moduls (z. B. "ct_"), damit sich Module nicht überschneiden.</summary>
|
||||
string DbPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registriert Services/Jobs des Moduls im DI-Container. Wird beim App-Start aufgerufen,
|
||||
/// bevor der Host gebaut wird.
|
||||
/// </summary>
|
||||
void RegisterServices(IServiceCollection services, IConfiguration configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Registriert die UI-Ansichten des Moduls bei der Shell (Launcher). Die Shell öffnet jede Ansicht
|
||||
/// auf Wunsch in einem eigenen Fenster; Module liefern eigene Forms, die Shell bleibt modul-agnostisch.
|
||||
/// </summary>
|
||||
void RegisterUi(IModuleUiHost host, IServiceProvider services);
|
||||
|
||||
/// <summary>
|
||||
/// Optionale Vorab-Prüfung, ob das Modul aktiviert werden kann. Gibt <c>null</c> zurück, wenn
|
||||
/// aktivierbar, sonst einen kurzen Grund (z. B. „IBKR-Gateway nicht konfiguriert").
|
||||
/// </summary>
|
||||
string? GetActivationBlocker(IConfiguration configuration) => null;
|
||||
|
||||
/// <summary>Wird nach der Core-Initialisierung gestartet (kein Anlaufen gegen leeren State).</summary>
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task StopAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace IBKRTrader.Core.Modularity;
|
||||
|
||||
/// <summary>
|
||||
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die eigentliche UI ist ein
|
||||
/// <see cref="Form"/>, das über <see cref="CreateForm"/> erzeugt wird (mit DI-Abhängigkeiten).
|
||||
/// Die Shell zeigt je View höchstens eine Instanz und holt ein offenes Fenster wieder nach vorne.
|
||||
/// </summary>
|
||||
public sealed class ModuleView
|
||||
{
|
||||
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>Titel (Fallback-Fenstertitel, falls das Form keinen eigenen setzt).</summary>
|
||||
public string Title { get; init; } = "Fenster";
|
||||
|
||||
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
|
||||
public string? Group { get; init; }
|
||||
|
||||
/// <summary>Optionale Sortierreihenfolge in Menü/Buttons.</summary>
|
||||
public int Order { get; init; } = 0;
|
||||
|
||||
/// <summary>Optionales Icon für Menü/Buttons.</summary>
|
||||
public System.Drawing.Image? Icon { get; set; }
|
||||
|
||||
/// <summary>Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung).</summary>
|
||||
public Func<Form> CreateForm { get; init; } = () => new Form();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
|
||||
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core kennen)
|
||||
/// das gemeinsame „Fenster"-Menü bauen (siehe <see cref="WindowMenu"/>).
|
||||
/// </summary>
|
||||
public interface IModuleUiHost
|
||||
{
|
||||
void RegisterView(ModuleView view);
|
||||
|
||||
/// <summary>Alle registrierten Ansichten (Core + Module).</summary>
|
||||
IReadOnlyList<ModuleView> Views { get; }
|
||||
|
||||
/// <summary>Ist das Fenster dieser View gerade offen?</summary>
|
||||
bool IsOpen(string viewId);
|
||||
|
||||
/// <summary>Öffnet die View bzw. holt ein bereits offenes Fenster in den Vordergrund.</summary>
|
||||
void OpenView(string viewId);
|
||||
|
||||
/// <summary>Holt das Hauptfenster (Launcher) in den Vordergrund.</summary>
|
||||
void ActivateMain();
|
||||
|
||||
/// <summary>Leitet das (bestätigte) Herunterfahren der App ein. Aus jedem Fenster aufrufbar.</summary>
|
||||
void RequestShutdown();
|
||||
|
||||
/// <summary>Feuert, wenn sich der Offen-Status irgendeiner View ändert.</summary>
|
||||
event Action? OpenStateChanged;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace IBKRTrader.Core.Modularity;
|
||||
|
||||
/// <summary>
|
||||
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen allen
|
||||
/// Fenstern (Launcher + Core + Module) erlaubt. Da es nur den Core-Contract <see cref="IModuleUiHost"/>
|
||||
/// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen).
|
||||
/// </summary>
|
||||
public static class WindowMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdrahtet einen <see cref="MenuStrip"/> mit der Fensterliste: füllt ihn sofort und baut ihn bei
|
||||
/// jeder Offen-Status-Änderung neu auf. Die Registrierung wird beim Entsorgen sauber gelöst.
|
||||
/// </summary>
|
||||
public static void Wire(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
void Refresh()
|
||||
{
|
||||
if (menu.IsDisposed) return;
|
||||
if (menu.IsHandleCreated && menu.InvokeRequired)
|
||||
{
|
||||
try { menu.BeginInvoke((Action)(() => Populate(menu, host, currentViewId))); }
|
||||
catch { /* Fenster wird gerade geschlossen */ }
|
||||
return;
|
||||
}
|
||||
Populate(menu, host, currentViewId);
|
||||
}
|
||||
|
||||
Populate(menu, host, currentViewId);
|
||||
host.OpenStateChanged += Refresh;
|
||||
menu.Disposed += (_, _) => host.OpenStateChanged -= Refresh;
|
||||
}
|
||||
|
||||
/// <summary>Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Aktion rechts).</summary>
|
||||
public static void Populate(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
menu.Items.Clear();
|
||||
|
||||
var launcher = new ToolStripMenuItem("Launcher") { Checked = currentViewId == null };
|
||||
if (currentViewId == null) launcher.Font = new Font(launcher.Font, FontStyle.Bold);
|
||||
launcher.Click += (_, _) => host.ActivateMain();
|
||||
menu.Items.Add(launcher);
|
||||
|
||||
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
bool isCurrent = view.Id == currentViewId;
|
||||
var item = new ToolStripMenuItem(view.Title)
|
||||
{
|
||||
Image = view.Icon,
|
||||
ImageScaling = ToolStripItemImageScaling.SizeToFit,
|
||||
DisplayStyle = view.Icon != null
|
||||
? ToolStripItemDisplayStyle.ImageAndText
|
||||
: ToolStripItemDisplayStyle.Text,
|
||||
Checked = isCurrent || host.IsOpen(view.Id)
|
||||
};
|
||||
if (isCurrent) item.Font = new Font(item.Font, FontStyle.Bold);
|
||||
string id = view.Id;
|
||||
item.Click += (_, _) => host.OpenView(id);
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
|
||||
// Kontextabhängige rechte Aktion: nur der Launcher darf die App beenden; jedes andere Fenster
|
||||
// bietet nur „Fenster schließen" (kein App-Shutdown, Module laufen weiter).
|
||||
if (currentViewId == null)
|
||||
{
|
||||
var exit = new ToolStripMenuItem("Beenden") { Alignment = ToolStripItemAlignment.Right };
|
||||
exit.Click += (_, _) => host.RequestShutdown();
|
||||
menu.Items.Add(exit);
|
||||
}
|
||||
else
|
||||
{
|
||||
var close = new ToolStripMenuItem("Fenster schließen") { Alignment = ToolStripItemAlignment.Right };
|
||||
close.Click += (_, _) => menu.FindForm()?.Close();
|
||||
menu.Items.Add(close);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using IBKRTrader.Core.Workers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.Core.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Vertrag für ein eigenständiges Trading-Modul.
|
||||
///
|
||||
/// Architektur-Regel: Der Core kennt konkrete Module NICHT. Module hängen sich
|
||||
/// ausschließlich über diesen Vertrag und den DI-Container ein
|
||||
/// (Abhängigkeit nur Modul → Core, niemals umgekehrt).
|
||||
///
|
||||
/// Lebenszyklus:
|
||||
/// 1. <see cref="RegisterServices"/> – vor dem Bauen des DI-Containers
|
||||
/// 2. <see cref="InitializeAsync"/> – beim App-Start (Migrationen etc.)
|
||||
/// 3. <see cref="GetWorkers"/> – Worker werden der WorkerEngine übergeben
|
||||
/// 4. <see cref="CreateWindow"/> – Launcher öffnet das Modul-Fenster
|
||||
/// </summary>
|
||||
public interface IModule
|
||||
{
|
||||
/// <summary>Eindeutiges Modul-Kürzel, zugleich DB-Tabellen-Präfix (z. B. "CT").</summary>
|
||||
string Key { get; }
|
||||
|
||||
/// <summary>Anzeigename im Launcher (z. B. "Congress Trading").</summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>Kurzbeschreibung der Strategie für den Launcher.</summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>Modul-Version.</summary>
|
||||
string Version { get; }
|
||||
|
||||
/// <summary>Registriert alle Modul-Services im DI-Container (vor dem Build).</summary>
|
||||
void RegisterServices(IServiceCollection services);
|
||||
|
||||
/// <summary>Führt Modul-Migrationen und einmalige Initialisierung aus (beim Start).</summary>
|
||||
Task InitializeAsync(IServiceProvider provider);
|
||||
|
||||
/// <summary>Liefert die Worker/Services des Moduls für die WorkerEngine.</summary>
|
||||
IEnumerable<IWorker> GetWorkers(IServiceProvider provider);
|
||||
|
||||
/// <summary>Erzeugt das eigenständige Fenster des Moduls (vom Launcher geöffnet).</summary>
|
||||
Form CreateWindow(IServiceProvider provider);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace IBKRTrader.Core.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale Registry aller geladenen Module.
|
||||
/// Wird in Program.cs mit den Modul-Instanzen befüllt und als Singleton
|
||||
/// bereitgestellt, damit Launcher und Startup-Code über die Module iterieren
|
||||
/// können, ohne konkrete Modultypen zu kennen.
|
||||
/// </summary>
|
||||
public class ModuleRegistry
|
||||
{
|
||||
private readonly List<IModule> _modules;
|
||||
|
||||
public ModuleRegistry(IEnumerable<IModule> modules)
|
||||
=> _modules = modules?.ToList() ?? throw new ArgumentNullException(nameof(modules));
|
||||
|
||||
/// <summary>Alle registrierten Module in Registrierungsreihenfolge.</summary>
|
||||
public IReadOnlyList<IModule> Modules => _modules;
|
||||
|
||||
/// <summary>Findet ein Modul anhand seines Kürzels (case-insensitive) oder null.</summary>
|
||||
public IModule? Find(string key)
|
||||
=> _modules.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,68 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modules;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||
using IBKRTrader.Modules.CongressTrading.UI;
|
||||
using IBKRTrader.Modules.CongressTrading.Workers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.Modules.CongressTrading;
|
||||
|
||||
/// <summary>
|
||||
/// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern
|
||||
/// (Quelle: capitoltrades.com).
|
||||
///
|
||||
/// Architektur-Regel: Dieses Modul hängt sich ausschließlich über den
|
||||
/// <see cref="IModule"/>-Vertrag ein. Der Core kennt dieses Modul NICHT –
|
||||
/// die Abhängigkeit geht nur von Modul → Core.
|
||||
/// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern (capitoltrades.com).
|
||||
/// Hängt sich ausschließlich über den <see cref="IModule"/>-Vertrag ein (Abhängigkeit nur Modul → Core).
|
||||
/// </summary>
|
||||
public sealed class CongressTradingModule : IModule
|
||||
{
|
||||
public const string ModuleKey = "CT";
|
||||
/// <summary>Kürzel für Logging/DB-Präfix.</summary>
|
||||
public const string LogTag = "CT";
|
||||
|
||||
public string Key => ModuleKey;
|
||||
public string DisplayName => "Congress Trading";
|
||||
public string Description => "Kopiert Aktien-Trades von US-Kongressmitgliedern (capitoltrades.com).";
|
||||
public string Version => "1.0.0";
|
||||
public string Name => "CongressTrading";
|
||||
public string DbPrefix => "ct_";
|
||||
|
||||
/// <summary>Registriert alle Modul-Services im DI-Container.</summary>
|
||||
public void RegisterServices(IServiceCollection services)
|
||||
// In RegisterUi gesetzt (hat den Provider); für Modul-Migrationen in StartAsync genutzt.
|
||||
private IServiceProvider? _services;
|
||||
|
||||
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Datenbank
|
||||
services.AddSingleton<CongressMigrations>();
|
||||
services.AddSingleton<CongressRepository>();
|
||||
|
||||
// Scraper
|
||||
services.AddSingleton<CapitolTradesScraper>();
|
||||
|
||||
// Worker
|
||||
services.AddSingleton<CongressHistoryImportWorker>();
|
||||
services.AddSingleton<CongressScrapeWorker>();
|
||||
|
||||
// Worker als IWorker registrieren → die WorkerEngine erhält sie über IEnumerable<IWorker>.
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressHistoryImportWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressScrapeWorker>());
|
||||
}
|
||||
|
||||
/// <summary>Führt die Modul-Migrationen aus.</summary>
|
||||
public async Task InitializeAsync(IServiceProvider provider)
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
|
||||
{
|
||||
var migrations = provider.GetRequiredService<CongressMigrations>();
|
||||
await migrations.RunAsync();
|
||||
_services = services;
|
||||
|
||||
// Einmaliger Reset (deaktiviert): löscht alten Fortschritt für sauberen Neustart.
|
||||
// var repo = provider.GetRequiredService<CongressRepository>();
|
||||
// await repo.ResetHistoryImportAsync();
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "congresstrading.main",
|
||||
Title = "Congress Trading",
|
||||
Group = Name,
|
||||
Order = 100,
|
||||
CreateForm = () => new CongressTradingForm(
|
||||
services.GetRequiredService<CongressRepository>(),
|
||||
services.GetRequiredService<WorkerEngine>(),
|
||||
services.GetRequiredService<LoggingService>())
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Gibt die Modul-Worker für die WorkerEngine zurück.</summary>
|
||||
public IEnumerable<IWorker> GetWorkers(IServiceProvider provider) =>
|
||||
[
|
||||
provider.GetRequiredService<CongressHistoryImportWorker>(),
|
||||
provider.GetRequiredService<CongressScrapeWorker>()
|
||||
];
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Modul-Migrationen laufen nach der Core-Initialisierung.
|
||||
if (_services is null) return;
|
||||
var migrations = _services.GetRequiredService<CongressMigrations>();
|
||||
await migrations.RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Erzeugt das eigenständige Modul-Fenster.</summary>
|
||||
public Form CreateWindow(IServiceProvider provider)
|
||||
=> new CongressTradingForm(
|
||||
provider.GetRequiredService<CongressRepository>(),
|
||||
provider.GetRequiredService<WorkerEngine>(),
|
||||
provider.GetRequiredService<LoggingService>());
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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.
|
||||
/// Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger.
|
||||
/// Die vollständige Trade-/Positions-Ansicht folgt in einer späteren Phase.
|
||||
/// </summary>
|
||||
public sealed class CongressTradingForm : ModuleFormBase
|
||||
public sealed class CongressTradingForm : Form
|
||||
{
|
||||
private const string ScrapeWorkerName = "CT-ScrapeWorker";
|
||||
|
||||
@@ -25,13 +24,16 @@ public sealed class CongressTradingForm : ModuleFormBase
|
||||
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]";
|
||||
Text = "Congress Trading [CT]";
|
||||
Width = 900;
|
||||
Height = 600;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(600, 400);
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
@@ -86,7 +88,7 @@ public sealed class CongressTradingForm : ModuleFormBase
|
||||
_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}");
|
||||
_logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +98,12 @@ public sealed class CongressTradingForm : ModuleFormBase
|
||||
{
|
||||
_lblStatus.Text = "Scrape angestoßen...";
|
||||
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
|
||||
_logger.Info(CongressTradingModule.ModuleKey, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
|
||||
_logger.Info(CongressTradingModule.LogTag, "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);
|
||||
_logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user