L1a: Core und Module von WinForms entkoppeln - net10.0 statt net10.0-windows

Core, alle drei Module und das Testprojekt tragen keinen UI-Code mehr und
bauen fuer linux-x64. Nur noch IBKRTrader.App ist Windows-gebunden.

UI-Contract toolkit-neutral (Vorbild: PolytraderSharp):
- ModuleView.CreateForm (Func<Form>) -> CreateView (Func<object>)
- ModuleView.Icon (System.Drawing.Image) -> IconKey (string).
  System.Drawing.Common ist seit .NET 7 Windows-only und wirft auf Linux.
- WindowMenu.cs war reine WinForms-Umsetzung -> in die Shell verschoben.

LoggingService haelt keine RichTextBox mehr, sondern meldet Eintraege ueber
event EntryWritten. Einfaerbung und UI-Thread-Wechsel liegen jetzt im
LogPanelController der Shell. Nebenbei: ToUpper() -> ToUpperInvariant()
(tr-TR haette aus "info" ein "İNFO" gemacht) und \r\n -> Environment.NewLine.

Die drei Modul-Fenster liegen jetzt unter UI/Views/Modules/; RegisterUi der
Module ist bewusst leer, die Shell registriert sie zentral ueber
UI/ModuleViews.cs (nur fuer tatsaechlich geladene Module). ViewIcons loest
IconKey gegen die PNG-Ressourcen auf - dieselben Schluessel bekommt spaeter
die Avalonia-Shell.

UiConstructionTests entfernt: die Konstruktionspruefung deckt --smoke-ui ab,
das Testprojekt braucht dafuer keine UI-Referenz mehr. Der Test
RegisterUi_RegistersMainView haelt jetzt das Gegenteil fest - das Modul darf
nichts registrieren, sonst waere es wieder toolkit-gebunden.

Verifiziert: Build 0 Fehler/0 Warnungen, 163 Tests gruen, --smoke-ui
konstruiert alle 7 Fenster, und Core + 3 Module + Tests bauen fuer linux-x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:38:01 +02:00
co-authored by Claude Opus 5
parent 18b1059fa9
commit 123f38ab6f
22 changed files with 302 additions and 259 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ public sealed class LauncherForm : Form
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
var id = view.Id;
var btn = new ToolStripButton(view.Title, view.Icon)
var btn = new ToolStripButton(view.Title, ViewIcons.Resolve(view.IconKey))
{
DisplayStyle = ToolStripItemDisplayStyle.ImageAndText,
ImageScaling = ToolStripItemImageScaling.None,
+9 -28
View File
@@ -84,7 +84,8 @@ internal static class Program
RegisterCoreViews(uiHost, AppHost.Services);
foreach (var module in modules)
module.RegisterUi(uiHost, AppHost.Services);
AssignViewIcons(uiHost);
ModuleViews.Register(uiHost, AppHost.Services);
ViewIcons.AssignDefaults(uiHost);
Application.Run(AppHost.Services.GetRequiredService<LauncherForm>());
AppHost.StopAsync().GetAwaiter().GetResult();
@@ -163,35 +164,13 @@ internal static class Program
services.AddSingleton<WorkerEngine>();
}
/// <summary>
/// Weist den registrierten Views ihr Button-/Menü-Icon aus den App-Ressourcen zu (über die stabile
/// View-ID). Icons stammen aus PolytraderSharp; nicht passende können später ausgetauscht werden.
/// Bereits gesetzte Icons bleiben erhalten.
/// </summary>
private static void AssignViewIcons(IModuleUiHost uiHost)
{
var map = new Dictionary<string, Image>
{
["core.dashboard"] = Properties.Resources.dashboard,
["core.workers"] = Properties.Resources.system_time,
["core.logs"] = Properties.Resources.error_log,
["core.settings"] = Properties.Resources.setting_tools,
["congresstrading.main"] = Properties.Resources.cross_reference,
["accounting.main"] = Properties.Resources.coins_in_hand,
["supervisor.main"] = Properties.Resources.token_quantifier,
};
foreach (var view in uiHost.Views)
if (view.Icon is null && map.TryGetValue(view.Id, out var img))
view.Icon = img;
}
/// <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.dashboard", Title = "Dashboard", Group = "Core", Order = 5,
CreateForm = () => new DashboardView(
CreateView = () => new DashboardView(
sp.GetRequiredService<DashboardService>(),
sp.GetRequiredService<SettingsService>(),
sp.GetServices<IModule>(),
@@ -201,17 +180,17 @@ internal static class Program
uiHost.RegisterView(new ModuleView
{
Id = "core.workers", Title = "Workers / Services", Group = "Core", Order = 10,
CreateForm = () => new WorkersView(sp.GetRequiredService<WorkerEngine>())
CreateView = () => new WorkersView(sp.GetRequiredService<WorkerEngine>())
});
uiHost.RegisterView(new ModuleView
{
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
CreateForm = () => new LogsView(sp.GetRequiredService<LoggingService>())
CreateView = () => new LogsView(sp.GetRequiredService<LoggingService>())
});
uiHost.RegisterView(new ModuleView
{
Id = "core.settings", Title = "Settings", Group = "Core", Order = 30,
CreateForm = () => new SettingsView(sp.GetRequiredService<SettingsService>())
CreateView = () => new SettingsView(sp.GetRequiredService<SettingsService>())
});
}
@@ -303,12 +282,14 @@ internal static class Program
RegisterCoreViews(uiHost, host.Services);
foreach (var module in modules)
module.RegisterUi(uiHost, host.Services);
ModuleViews.Register(uiHost, host.Services);
ViewIcons.AssignDefaults(uiHost);
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})"); }
try { using var form = (Form)view.CreateView(); Console.WriteLine($"[OK] {view.Id} ({view.Title})"); }
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}"); }
}
+53 -7
View File
@@ -3,11 +3,19 @@ using IBKRTrader.Core.Logging;
namespace IBKRTrader.UI;
/// <summary>
/// Steuert das Log-Panel (RichTextBox im Logs-Tab).
/// Bietet Clear- und Filter-Funktionalität.
/// Bindet eine <see cref="RichTextBox"/> an den <see cref="LoggingService"/> und färbt die Zeilen
/// nach Log-Level ein.
///
/// <para>Einfärbung und Wechsel auf den UI-Thread liegen bewusst hier und nicht mehr im
/// <see cref="LoggingService"/>: der Core trägt seit der Linux-Portierung keine UI-Abhängigkeit
/// (weder WinForms noch <c>System.Drawing</c>). Der Dienst meldet nur noch Einträge.</para>
/// </summary>
public class LogPanelController
public sealed class LogPanelController : IDisposable
{
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
private static readonly Color ColorWarn = Color.FromArgb(255, 190, 60);
private static readonly Color ColorError = Color.FromArgb(255, 80, 80);
private readonly RichTextBox _rtb;
private readonly LoggingService _logger;
@@ -16,15 +24,53 @@ public class LogPanelController
_rtb = rtb;
_logger = logger;
// Logging-Service mit RichTextBox verbinden
_logger.AttachRichTextBox(rtb);
// Hintergrund der RTB auf dunkles Theme setzen
// Dunkles Theme fürs Log-Panel.
_rtb.BackColor = Color.FromArgb(20, 20, 30);
_rtb.ForeColor = Color.FromArgb(200, 200, 200);
_rtb.Font = new Font("Consolas", 9f);
_rtb.ReadOnly = true;
_rtb.WordWrap = false;
_logger.EntryWritten += OnEntryWritten;
_rtb.Disposed += (_, _) => Dispose();
}
public void Dispose() => _logger.EntryWritten -= OnEntryWritten;
/// <summary>
/// Wird aus beliebigen Worker-Threads gerufen deshalb der Wechsel auf den UI-Thread. Ein
/// Fehler hier darf den schreibenden Worker niemals mitreißen.
/// </summary>
private void OnEntryWritten(LogEntry e)
{
try
{
if (_rtb.IsDisposed) return;
var color = e.Level switch
{
AppLogLevel.Warn => ColorWarn,
AppLogLevel.Error => ColorError,
_ => ColorInfo
};
var text = LoggingService.Format(e) + Environment.NewLine;
if (_rtb.InvokeRequired) _rtb.BeginInvoke(() => AppendColored(text, color));
else AppendColored(text, color);
}
catch { /* Fenster wird gerade geschlossen */ }
}
private void AppendColored(string text, Color color)
{
if (_rtb.IsDisposed) return;
_rtb.SelectionStart = _rtb.TextLength;
_rtb.SelectionLength = 0;
_rtb.SelectionColor = color;
_rtb.AppendText(text);
_rtb.SelectionColor = _rtb.ForeColor;
if (_rtb.TextLength > 0)
_rtb.ScrollToCaret();
}
public void Clear() => _rtb.Clear();
+71
View File
@@ -0,0 +1,71 @@
using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using IBKRTrader.UI.Views.Modules;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.UI;
/// <summary>
/// Registriert die Fenster der Module bei der Shell.
///
/// <para><b>Warum hier und nicht im Modul?</b> Ein Modul, das sein eigenes Fenster erzeugt, muss das
/// UI-Toolkit referenzieren und wäre damit an WinForms bzw. Avalonia gebunden. Genau das verhindert
/// den kopflosen Linux-Betrieb. Die Modulprojekte bleiben deshalb frei von UI-Code; ihr
/// <c>RegisterUi</c> ist leer, und die Shell verdrahtet die Fenster zentral. Die Modul-Dienste
/// kommen unverändert aus dem DI-Container.</para>
///
/// <para>Registriert wird nur, was auch geladen ist: fehlt ein Modul in dieser Sitzung, entfällt
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para>
/// </summary>
public static class ModuleViews
{
public static void Register(IModuleUiHost host, IServiceProvider sp)
{
RegisterIfLoaded(sp, "CongressTrading", () => host.RegisterView(new ModuleView
{
Id = "congresstrading.main", Title = "Congress Trading", Group = "CongressTrading", Order = 100,
CreateView = () => new CongressTradingForm(
sp.GetRequiredService<CongressRepository>(),
sp.GetRequiredService<WorkerEngine>(),
sp.GetRequiredService<IPortfolioService>(),
sp.GetRequiredService<LoggingService>())
}));
RegisterIfLoaded(sp, "Supervisor", () => host.RegisterView(new ModuleView
{
Id = "supervisor.main", Title = "Supervisor", Group = "Supervisor", Order = 300,
CreateView = () => new SupervisorMainForm(
sp.GetRequiredService<SupervisorAgent>(),
sp.GetRequiredService<DossierService>(),
sp.GetRequiredService<ISupervisorReportRepository>(),
sp.GetRequiredService<LoggingService>())
}));
RegisterIfLoaded(sp, "Accounting", () => host.RegisterView(new ModuleView
{
Id = "accounting.main", Title = "Accounting", Group = "Accounting", Order = 400,
CreateView = () => new AccountingMainForm(
sp.GetRequiredService<ILedgerRepository>(),
sp.GetRequiredService<IIngestRunRepository>(),
sp.GetRequiredService<AccountingReportService>(),
sp.GetRequiredService<AccountingIngestService>(),
sp.GetRequiredService<LoggingService>())
}));
}
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
private static void RegisterIfLoaded(IServiceProvider sp, string moduleName, Action register)
{
if (sp.GetServices<IModule>().Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase)))
register();
}
}
+3 -1
View File
@@ -69,7 +69,9 @@ public sealed class ShellUiHost : IModuleUiHost
return;
}
var form = view.CreateForm();
// Der Core-Contract ist toolkit-neutral (CreateView liefert object). Ein anderer Typ als
// Form ist ein Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster geben.
var form = (Form)view.CreateView();
if (string.IsNullOrEmpty(form.Text) || form.Text == form.Name)
form.Text = view.Title;
+52
View File
@@ -0,0 +1,52 @@
using System.Drawing;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.UI;
/// <summary>
/// Löst die toolkit-neutralen <see cref="ModuleView.IconKey"/>-Schlüssel gegen die Bildressourcen
/// der Shell auf. Der Core kennt seit der Linux-Portierung keine Bilddaten mehr
/// (<c>System.Drawing.Image</c> ist seit .NET 7 Windows-only) er liefert nur noch den Schlüssel,
/// die jeweilige Shell das Bild.
///
/// <para>Die Avalonia-Shell bekommt ein gleichnamiges Gegenstück mit <b>denselben Schlüsseln</b>
/// und denselben PNG-Dateien, sodass Core und Module unverändert bleiben.</para>
/// </summary>
public static class ViewIcons
{
/// <summary>Symbol-Schlüssel → Bildressource aus <c>Resources/</c>.</summary>
private static readonly Dictionary<string, Image> ImageByKey = new(StringComparer.OrdinalIgnoreCase)
{
["dashboard"] = Properties.Resources.dashboard,
["workers"] = Properties.Resources.system_time,
["logs"] = Properties.Resources.error_log,
["settings"] = Properties.Resources.setting_tools,
["congresstrading"] = Properties.Resources.cross_reference,
["accounting"] = Properties.Resources.coins_in_hand,
["supervisor"] = Properties.Resources.token_quantifier,
};
/// <summary>Standard-Symbolschlüssel je View-ID greift für Views, die keinen eigenen setzen.</summary>
private static readonly Dictionary<string, string> DefaultKeyByViewId = new(StringComparer.OrdinalIgnoreCase)
{
["core.dashboard"] = "dashboard",
["core.workers"] = "workers",
["core.logs"] = "logs",
["core.settings"] = "settings",
["congresstrading.main"] = "congresstrading",
["accounting.main"] = "accounting",
["supervisor.main"] = "supervisor",
};
/// <summary>Bild zum Schlüssel, oder <c>null</c> (kein Symbol / unbekannter Schlüssel).</summary>
public static Image? Resolve(string? iconKey) =>
iconKey != null && ImageByKey.TryGetValue(iconKey, out var img) ? img : null;
/// <summary>Setzt bei allen registrierten Views den Standard-Schlüssel, falls noch keiner gesetzt ist.</summary>
public static void AssignDefaults(IModuleUiHost host)
{
foreach (var view in host.Views)
if (view.IconKey is null && DefaultKeyByViewId.TryGetValue(view.Id, out var key))
view.IconKey = key;
}
}
@@ -3,7 +3,7 @@ using IBKRTrader.Modules.Accounting.Logic;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
namespace IBKRTrader.Modules.Accounting.Ui;
namespace IBKRTrader.UI.Views.Modules;
/// <summary>
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export,
@@ -1,9 +1,10 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database;
namespace IBKRTrader.Modules.CongressTrading.UI;
namespace IBKRTrader.UI.Views.Modules;
/// <summary>
/// Eigenständiges Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Trigger
@@ -6,7 +6,7 @@ using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
namespace IBKRTrader.Modules.Supervisor.Ui;
namespace IBKRTrader.UI.Views.Modules;
/// <summary>
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar), Dossier-Browser,
@@ -1,12 +1,17 @@
using System.Drawing;
using System.Windows.Forms;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.Core.Modularity;
namespace IBKRTrader.UI;
/// <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).
/// Fenstern (Launcher + Core + Module) erlaubt. Es nutzt nur den Core-Contract
/// <see cref="IModuleUiHost"/> und funktioniert deshalb aus jedem Fenster.
///
/// <para>Lag früher im Core. Verschoben in die Shell, weil er reine WinForms-Umsetzung ist der
/// Core soll keine UI-Abhängigkeit tragen (Linux-Portierung). Die Avalonia-Shell bekommt ein
/// eigenes Gegenstück gegen denselben Contract.</para>
/// </summary>
public static class WindowMenu
{
@@ -46,11 +51,12 @@ public static class WindowMenu
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
bool isCurrent = view.Id == currentViewId;
var icon = ViewIcons.Resolve(view.IconKey);
var item = new ToolStripMenuItem(view.Title)
{
Image = view.Icon,
Image = icon,
ImageScaling = ToolStripItemImageScaling.SizeToFit,
DisplayStyle = view.Icon != null
DisplayStyle = icon != null
? ToolStripItemDisplayStyle.ImageAndText
: ToolStripItemDisplayStyle.Text,
Checked = isCurrent || host.IsOpen(view.Id)
+5 -4
View File
@@ -1,12 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<!-- Plattformneutral: derselbe Core läuft unter Windows und Linux. Der UI-Contract
(ModuleView/IModuleUiHost) ist toolkit-neutral (Func<object> statt Func<Form>, IconKey
statt System.Drawing.Image) hier hängt weder WinForms noch System.Drawing.Common,
letzteres ist seit .NET 7 Windows-only und wirft auf Linux. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Der Core stellt den UI-Contract (ModuleFormBase/WindowManager, später IModuleUiHost/ModuleView)
bereit, damit Module designbare Forms beitragen können. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
+35 -46
View File
@@ -4,22 +4,32 @@ namespace IBKRTrader.Core.Logging;
/// <summary>
/// Thread-sicherer Logging-Service.
/// Schreibt farbig in die RichTextBox (UI-Thread-safe via BeginInvoke)
/// Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt
/// Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt sowie strukturiert nach Logs\{Datum}.jsonl
/// Meldet jeden Eintrag über <see cref="EntryWritten"/> an interessierte Senken (z. B. die
/// Live-Log-Ansicht der Oberfläche)
///
/// <para><b>Bewusst ohne UI-Bezug:</b> Früher hielt dieser Dienst direkt eine
/// <c>RichTextBox</c> samt <c>System.Drawing.Color</c> und marshallte selbst auf den UI-Thread.
/// Damit hing der Core an WinForms. Jetzt kennt er nur noch das Ereignis; Einfärbung und
/// Thread-Wechsel sind Sache der jeweiligen Oberfläche.</para>
/// </summary>
public class LoggingService
{
private RichTextBox? _rtb;
private AppLogLevel _minLevel = AppLogLevel.Info;
private readonly object _fileLock = new();
private readonly object _jsonlLock = new();
private static readonly string LogBaseDir =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
Path.Combine(AppContext.BaseDirectory, "Logs");
// ─── Konfiguration ────────────────────────────────────────────────────────
public void AttachRichTextBox(RichTextBox rtb) => _rtb = rtb;
/// <summary>
/// Feuert für jeden geschriebenen Eintrag (nach der Mindest-Level-Prüfung). Die Oberfläche
/// hängt sich hier ein; das Marshalling auf den UI-Thread übernimmt sie selbst, weil dieser
/// Dienst aus beliebigen Worker-Threads schreibt.
/// </summary>
public event Action<LogEntry>? EntryWritten;
public void SetMinLevel(AppLogLevel level) => _minLevel = level;
@@ -51,7 +61,13 @@ public class LoggingService
var entry = new LogEntry(DateTime.Now, level, module, message, ex);
WriteToFile(entry);
WriteToJsonl(entry, cid);
WriteToRtb(entry);
NotifySinks(entry);
}
private void NotifySinks(LogEntry e)
{
// Eine hängende Senke darf den schreibenden Worker nicht mitreißen.
try { EntryWritten?.Invoke(e); } catch { /* Logging darf niemals abstürzen */ }
}
// ─── Datei ────────────────────────────────────────────────────────────────
@@ -66,10 +82,10 @@ public class LoggingService
var file = Path.Combine(dir, $"{e.Level}-{e.Timestamp:dd-MM-yy}.txt");
var line = $"[{e.Timestamp:HH:mm:ss}] {e.Message}";
if (e.Exception != null)
line += $"\r\n {e.Exception}";
line += $"{Environment.NewLine} {e.Exception}";
lock (_fileLock)
File.AppendAllText(file, line + "\r\n");
File.AppendAllText(file, line + Environment.NewLine);
}
catch { /* Logging darf niemals abstürzen */ }
}
@@ -95,45 +111,18 @@ public class LoggingService
catch { /* Logging darf niemals abstürzen */ }
}
// ─── RichTextBox ──────────────────────────────────────────────────────────
// ─── Anzeigeformat ────────────────────────────────────────────────────────
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
private static readonly Color ColorWarn = Color.FromArgb(255, 190, 60);
private static readonly Color ColorError = Color.FromArgb(255, 80, 80);
private void WriteToRtb(LogEntry e)
/// <summary>
/// Einzeilige Darstellung für Log-Ansichten. Liegt hier, damit jede Oberfläche dieselbe Zeile
/// zeigt. <c>ToUpperInvariant</c> ist Absicht: <c>ToUpper()</c> würde unter tr-TR aus "info"
/// ein "İNFO" machen.
/// </summary>
public static string Format(LogEntry e)
{
if (_rtb == null) return;
try
{
var color = e.Level switch
{
AppLogLevel.Warn => ColorWarn,
AppLogLevel.Error => ColorError,
_ => ColorInfo
};
var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpper(),-5}] [{e.Module}] {e.Message}";
if (e.Exception != null)
text += $"\r\n {e.Exception.Message}";
text += "\r\n";
if (_rtb.InvokeRequired)
_rtb.BeginInvoke(() => AppendColored(text, color));
else
AppendColored(text, color);
}
catch { }
}
private void AppendColored(string text, Color color)
{
if (_rtb == null) return;
_rtb.SelectionStart = _rtb.TextLength;
_rtb.SelectionLength = 0;
_rtb.SelectionColor = color;
_rtb.AppendText(text);
_rtb.SelectionColor = _rtb.ForeColor;
if (_rtb.TextLength > 0)
_rtb.ScrollToCaret();
var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpperInvariant(),-5}] [{e.Module}] {e.Message}";
if (e.Exception != null)
text += $"{Environment.NewLine} {e.Exception.Message}";
return text;
}
}
+23 -11
View File
@@ -1,18 +1,23 @@
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.
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View höchstens
/// eine Instanz und holt ein offenes Fenster wieder nach vorne.
///
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>,
/// keinen konkreten Fenstertyp, und <see cref="IconKey"/> ist ein Schlüssel statt eines Bildes.
/// Dadurch trägt der Core keine UI-Abhängigkeit und bleibt plattformneutral Voraussetzung für den
/// kopflosen Linux-Betrieb. Insbesondere hängt hier kein <c>System.Drawing.Image</c> mehr:
/// <c>System.Drawing.Common</c> ist seit .NET 7 Windows-only und wirft auf Linux. Die jeweilige
/// Shell kennt ihr Toolkit und castet die WinForms-Shell auf <c>Form</c>, die Avalonia-Shell
/// auf <c>Window</c>.</para>
/// </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>
/// <summary>Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt).</summary>
public string Title { get; init; } = "Fenster";
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
@@ -21,17 +26,24 @@ public sealed class ModuleView
/// <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>
/// Logischer Schlüssel des Symbols für Menü/Buttons (z. B. "dashboard", "logs"). Die Shell löst
/// ihn gegen ihre eigenen Bildressourcen auf. Settable, damit die Shell den von Modulen
/// registrierten Views zentral ein Symbol zuweisen kann Module kennen die Shell-Ressourcen nicht.
/// </summary>
public string? IconKey { get; set; }
/// <summary>Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung).</summary>
public Func<Form> CreateForm { get; init; } = () => new Form();
/// <summary>
/// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist
/// <see cref="object"/> siehe Klassen-Doku zur Toolkit-Neutralität.
/// </summary>
public Func<object> CreateView { get; init; } = () => new object();
}
/// <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"/>).
/// das gemeinsame „Fenster"-Menü bauen.
/// </summary>
public interface IModuleUiHost
{
@@ -4,7 +4,6 @@ using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.Accounting.Ui;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -47,22 +46,12 @@ public sealed class AccountingModule : IModule
services.AddHostedService(sp => sp.GetRequiredService<AccountingIngestService>());
}
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
{
host.RegisterView(new ModuleView
{
Id = "accounting.main",
Title = "Accounting",
Group = Name,
Order = 400,
CreateForm = () => new AccountingMainForm(
services.GetRequiredService<ILedgerRepository>(),
services.GetRequiredService<IIngestRunRepository>(),
services.GetRequiredService<AccountingReportService>(),
services.GetRequiredService<AccountingIngestService>(),
services.GetRequiredService<LoggingService>())
});
}
/// <summary>
/// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
/// (kopfloser Linux-Betrieb). Das Accounting-Fenster registriert die Shell zentral in
/// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
/// </summary>
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
// DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration).
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
@@ -7,7 +7,6 @@ using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Persistence.Ef;
using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.UI;
using IBKRTrader.Modules.CongressTrading.Workers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -48,21 +47,12 @@ public sealed class CongressTradingModule : IModule
services.AddHostedService(sp => sp.GetRequiredService<CongressScrapeWorker>());
}
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
{
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<IPortfolioService>(),
services.GetRequiredService<LoggingService>())
});
}
/// <summary>
/// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
/// (kopfloser Linux-Betrieb). Das Modul-Fenster registriert die Shell zentral in
/// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
/// </summary>
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
// DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration).
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
@@ -8,7 +8,6 @@ using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Counterfactual;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using IBKRTrader.Modules.Supervisor.Ui;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -58,21 +57,12 @@ public sealed class SupervisorModule : IModule
services.AddHostedService<Mcp.McpLightServer>();
}
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
{
host.RegisterView(new ModuleView
{
Id = "supervisor.main",
Title = "Supervisor",
Group = Name,
Order = 300,
CreateForm = () => new SupervisorMainForm(
services.GetRequiredService<SupervisorAgent>(),
services.GetRequiredService<DossierService>(),
services.GetRequiredService<ISupervisorReportRepository>(),
services.GetRequiredService<LoggingService>())
});
}
/// <summary>
/// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
/// (kopfloser Linux-Betrieb). Das Supervisor-Fenster registriert die Shell zentral in
/// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
/// </summary>
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net10.0-windows + WinForms, weil das Testprojekt die WinForms-Hauptassembly referenziert -->
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<!-- Plattformneutral: die Tests decken Core und Module ab, die beide keinen UI-Code mehr
tragen. Die UI-Konstruktionsprüfung liegt jetzt beim Smoke-UI-Lauf der Shell. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -55,17 +55,17 @@ public class CongressTradingModuleTests
}
[Fact]
public void RegisterUi_RegistersMainView()
public void RegisterUi_RegistriertNichts_DamitDasModulPlattformneutralBleibt()
{
// Absicht, kein Versehen: würde das Modul sein Fenster selbst erzeugen, müsste es das
// UI-Toolkit referenzieren und wäre damit nicht mehr kopflos auf Linux lauffähig.
// Das Fenster registriert die Shell zentral (App: UI/ModuleViews.cs).
var host = new CapturingUiHost();
// CreateForm wird hier NICHT aufgerufen daher genügt ein leerer Provider.
var provider = new ServiceCollection().BuildServiceProvider();
new CongressTradingModule().RegisterUi(host, provider);
host.Views.Should().ContainSingle();
host.Views[0].Id.Should().Be("congresstrading.main");
host.Views[0].Title.Should().Be("Congress Trading");
host.Views.Should().BeEmpty();
}
[Fact]
@@ -1,87 +0,0 @@
using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.Accounting.Ui;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using IBKRTrader.Modules.Supervisor.Ui;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Tests;
/// <summary>
/// Konstruiert die neuen Modul-Fenster mit In-Memory-/Stub-Abhängigkeiten gleichwertig zum
/// Headless-Smoke-UI-Check (`--smoke-ui`), aber ohne die laufende App/DB. Forms bauen im Konstruktor
/// nur Controls (DB-Zugriff erst auf Interaktion), daher genügt Instanziierbarkeit der Services.
/// </summary>
[Trait("cat", "unit")]
public class UiConstructionTests
{
private sealed class Factory<T>(DbContextOptions<T> options) : IDbContextFactory<T> where T : DbContext
{
public T CreateDbContext() => (T)Activator.CreateInstance(typeof(T), options)!;
}
private static Factory<T> InMemory<T>() where T : DbContext =>
new(new DbContextOptionsBuilder<T>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
private sealed class NoChat : IChatCompletionClient
{
public Task<ChatResponse> CompleteAsync(string m, IReadOnlyList<ChatMessage> msgs,
IReadOnlyList<SupervisorTool> tools, CancellationToken ct) => Task.FromResult(new ChatResponse());
}
private static Exception? ConstructOnSta(Action action)
{
Exception? captured = null;
var t = new Thread(() => { try { action(); } catch (Exception ex) { captured = ex; } });
t.SetApartmentState(ApartmentState.STA);
t.Start();
t.Join();
return captured;
}
[Fact]
public void AccountingMainForm_Constructs()
{
var ex = ConstructOnSta(() =>
{
var logger = new LoggingService();
var accDbf = InMemory<AccountingDbContext>();
var ledger = new EfLedgerRepository(accDbf);
var runs = new EfIngestRunRepository(accDbf);
var report = new AccountingReportService(ledger, new EfFxRateRepository(accDbf));
var ingest = new AccountingIngestService(
new NullAccountSource(), ledger, runs, new EfRawSnapshotRepository(accDbf),
new NullStatementSource(), new NullBalanceAnchorSource(), logger);
using var form = new AccountingMainForm(ledger, runs, report, ingest, logger);
form.Text.Should().Be("Accounting");
});
ex.Should().BeNull();
}
[Fact]
public void SupervisorMainForm_Constructs()
{
var ex = ConstructOnSta(() =>
{
var logger = new LoggingService();
var coreDbf = InMemory<CoreDbContext>();
var supDbf = InMemory<SupervisorDbContext>();
IDecisionJournal journal = new EfDecisionJournal(coreDbf, logger);
IOrderEventLog orderLog = new EfOrderEventLog(coreDbf, logger);
var dossiers = new DossierService(journal, orderLog, new TradeLogReader(coreDbf));
var agent = new SupervisorAgent(new NoChat(), new SupervisorToolRegistry());
var reports = new EfSupervisorReportRepository(supDbf, logger);
using var form = new SupervisorMainForm(agent, dossiers, reports, logger);
form.Text.Should().Be("Supervisor");
});
ex.Should().BeNull();
}
}