From 123f38ab6f2441bb0fd54368b19608d085ff4d3a Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 6 Aug 2026 22:38:01 +0200 Subject: [PATCH] L1a: Core und Module von WinForms entkoppeln - net10.0 statt net10.0-windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
) -> CreateView (Func) - 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 --- LauncherForm.cs | 2 +- Program.cs | 37 ++------ UI/LogPanelController.cs | 60 +++++++++++-- UI/ModuleViews.cs | 71 +++++++++++++++ UI/ShellUiHost.cs | 4 +- UI/ViewIcons.cs | 52 +++++++++++ .../Views/Modules}/AccountingMainForm.cs | 2 +- .../Views/Modules}/CongressTradingForm.cs | 3 +- .../Views/Modules}/SupervisorMainForm.cs | 2 +- .../Modularity => UI}/WindowMenu.cs | 16 ++-- src/IBKRTrader.Core/IBKRTrader.Core.csproj | 9 +- src/IBKRTrader.Core/Logging/LoggingService.cs | 81 ++++++++--------- src/IBKRTrader.Core/Modularity/ModuleView.cs | 34 +++++--- .../AccountingModule.cs | 23 ++--- .../IBKRTrader.Modules.Accounting.csproj | 6 +- .../CongressTradingModule.cs | 22 ++--- .../IBKRTrader.Modules.CongressTrading.csproj | 6 +- .../IBKRTrader.Modules.Supervisor.csproj | 6 +- .../SupervisorModule.cs | 22 ++--- .../IBKRTrader.Tests/IBKRTrader.Tests.csproj | 6 +- .../Modules/CongressTradingModuleTests.cs | 10 +-- tests/IBKRTrader.Tests/UiConstructionTests.cs | 87 ------------------- 22 files changed, 302 insertions(+), 259 deletions(-) create mode 100644 UI/ModuleViews.cs create mode 100644 UI/ViewIcons.cs rename {src/IBKRTrader.Modules.Accounting/Ui => UI/Views/Modules}/AccountingMainForm.cs (99%) rename {src/IBKRTrader.Modules.CongressTrading/UI => UI/Views/Modules}/CongressTradingForm.cs (98%) rename {src/IBKRTrader.Modules.Supervisor/Ui => UI/Views/Modules}/SupervisorMainForm.cs (99%) rename {src/IBKRTrader.Core/Modularity => UI}/WindowMenu.cs (83%) delete mode 100644 tests/IBKRTrader.Tests/UiConstructionTests.cs diff --git a/LauncherForm.cs b/LauncherForm.cs index 42e70d9..0354360 100644 --- a/LauncherForm.cs +++ b/LauncherForm.cs @@ -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, diff --git a/Program.cs b/Program.cs index 7b2f9b8..d223fae 100644 --- a/Program.cs +++ b/Program.cs @@ -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()); AppHost.StopAsync().GetAwaiter().GetResult(); @@ -163,35 +164,13 @@ internal static class Program services.AddSingleton(); } - /// - /// 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. - /// - private static void AssignViewIcons(IModuleUiHost uiHost) - { - var map = new Dictionary - { - ["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; - } - /// Registriert die Core-Views (Logs, Settings, Workers) bei der Shell. 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(), sp.GetRequiredService(), sp.GetServices(), @@ -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()) + CreateView = () => new WorkersView(sp.GetRequiredService()) }); uiHost.RegisterView(new ModuleView { Id = "core.logs", Title = "Logs", Group = "Core", Order = 20, - CreateForm = () => new LogsView(sp.GetRequiredService()) + CreateView = () => new LogsView(sp.GetRequiredService()) }); uiHost.RegisterView(new ModuleView { Id = "core.settings", Title = "Settings", Group = "Core", Order = 30, - CreateForm = () => new SettingsView(sp.GetRequiredService()) + CreateView = () => new SettingsView(sp.GetRequiredService()) }); } @@ -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}"); } } diff --git a/UI/LogPanelController.cs b/UI/LogPanelController.cs index 4019b27..4de92bb 100644 --- a/UI/LogPanelController.cs +++ b/UI/LogPanelController.cs @@ -3,11 +3,19 @@ using IBKRTrader.Core.Logging; namespace IBKRTrader.UI; /// -/// Steuert das Log-Panel (RichTextBox im Logs-Tab). -/// Bietet Clear- und Filter-Funktionalität. +/// Bindet eine an den und färbt die Zeilen +/// nach Log-Level ein. +/// +/// Einfärbung und Wechsel auf den UI-Thread liegen bewusst hier und nicht mehr im +/// : der Core trägt seit der Linux-Portierung keine UI-Abhängigkeit +/// (weder WinForms noch System.Drawing). Der Dienst meldet nur noch Einträge. /// -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; + + /// + /// Wird aus beliebigen Worker-Threads gerufen – deshalb der Wechsel auf den UI-Thread. Ein + /// Fehler hier darf den schreibenden Worker niemals mitreißen. + /// + 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(); diff --git a/UI/ModuleViews.cs b/UI/ModuleViews.cs new file mode 100644 index 0000000..e14ad88 --- /dev/null +++ b/UI/ModuleViews.cs @@ -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; + +/// +/// Registriert die Fenster der Module bei der Shell. +/// +/// Warum hier und nicht im Modul? 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 +/// RegisterUi ist leer, und die Shell verdrahtet die Fenster zentral. Die Modul-Dienste +/// kommen unverändert aus dem DI-Container. +/// +/// 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. +/// +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(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()) + })); + + RegisterIfLoaded(sp, "Supervisor", () => host.RegisterView(new ModuleView + { + Id = "supervisor.main", Title = "Supervisor", Group = "Supervisor", Order = 300, + CreateView = () => new SupervisorMainForm( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()) + })); + + RegisterIfLoaded(sp, "Accounting", () => host.RegisterView(new ModuleView + { + Id = "accounting.main", Title = "Accounting", Group = "Accounting", Order = 400, + CreateView = () => new AccountingMainForm( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()) + })); + } + + /// Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist. + private static void RegisterIfLoaded(IServiceProvider sp, string moduleName, Action register) + { + if (sp.GetServices().Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase))) + register(); + } +} diff --git a/UI/ShellUiHost.cs b/UI/ShellUiHost.cs index d436f31..6a7d92b 100644 --- a/UI/ShellUiHost.cs +++ b/UI/ShellUiHost.cs @@ -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; diff --git a/UI/ViewIcons.cs b/UI/ViewIcons.cs new file mode 100644 index 0000000..9bc8373 --- /dev/null +++ b/UI/ViewIcons.cs @@ -0,0 +1,52 @@ +using System.Drawing; +using IBKRTrader.Core.Modularity; + +namespace IBKRTrader.UI; + +/// +/// Löst die toolkit-neutralen -Schlüssel gegen die Bildressourcen +/// der Shell auf. Der Core kennt seit der Linux-Portierung keine Bilddaten mehr +/// (System.Drawing.Image ist seit .NET 7 Windows-only) – er liefert nur noch den Schlüssel, +/// die jeweilige Shell das Bild. +/// +/// Die Avalonia-Shell bekommt ein gleichnamiges Gegenstück mit denselben Schlüsseln +/// und denselben PNG-Dateien, sodass Core und Module unverändert bleiben. +/// +public static class ViewIcons +{ + /// Symbol-Schlüssel → Bildressource aus Resources/. + private static readonly Dictionary 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, + }; + + /// Standard-Symbolschlüssel je View-ID – greift für Views, die keinen eigenen setzen. + private static readonly Dictionary 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", + }; + + /// Bild zum Schlüssel, oder null (kein Symbol / unbekannter Schlüssel). + public static Image? Resolve(string? iconKey) => + iconKey != null && ImageByKey.TryGetValue(iconKey, out var img) ? img : null; + + /// Setzt bei allen registrierten Views den Standard-Schlüssel, falls noch keiner gesetzt ist. + 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; + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Ui/AccountingMainForm.cs b/UI/Views/Modules/AccountingMainForm.cs similarity index 99% rename from src/IBKRTrader.Modules.Accounting/Ui/AccountingMainForm.cs rename to UI/Views/Modules/AccountingMainForm.cs index 316a7e5..f983753 100644 --- a/src/IBKRTrader.Modules.Accounting/Ui/AccountingMainForm.cs +++ b/UI/Views/Modules/AccountingMainForm.cs @@ -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; /// /// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export, diff --git a/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs b/UI/Views/Modules/CongressTradingForm.cs similarity index 98% rename from src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs rename to UI/Views/Modules/CongressTradingForm.cs index 3835ee8..18a96aa 100644 --- a/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs +++ b/UI/Views/Modules/CongressTradingForm.cs @@ -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; /// /// Eigenständiges Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Trigger diff --git a/src/IBKRTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs b/UI/Views/Modules/SupervisorMainForm.cs similarity index 99% rename from src/IBKRTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs rename to UI/Views/Modules/SupervisorMainForm.cs index 39f4e0d..36a95fc 100644 --- a/src/IBKRTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs +++ b/UI/Views/Modules/SupervisorMainForm.cs @@ -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; /// /// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar), Dossier-Browser, diff --git a/src/IBKRTrader.Core/Modularity/WindowMenu.cs b/UI/WindowMenu.cs similarity index 83% rename from src/IBKRTrader.Core/Modularity/WindowMenu.cs rename to UI/WindowMenu.cs index 628d4d8..c06115d 100644 --- a/src/IBKRTrader.Core/Modularity/WindowMenu.cs +++ b/UI/WindowMenu.cs @@ -1,12 +1,17 @@ using System.Drawing; using System.Windows.Forms; +using IBKRTrader.Core.Modularity; -namespace IBKRTrader.Core.Modularity; +namespace IBKRTrader.UI; /// /// 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 -/// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen). +/// Fenstern (Launcher + Core + Module) erlaubt. Es nutzt nur den Core-Contract +/// und funktioniert deshalb aus jedem Fenster. +/// +/// 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. /// 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) diff --git a/src/IBKRTrader.Core/IBKRTrader.Core.csproj b/src/IBKRTrader.Core/IBKRTrader.Core.csproj index 2fa5a20..98dfd80 100644 --- a/src/IBKRTrader.Core/IBKRTrader.Core.csproj +++ b/src/IBKRTrader.Core/IBKRTrader.Core.csproj @@ -1,12 +1,13 @@ - net10.0-windows + + net10.0 enable enable - - true en diff --git a/src/IBKRTrader.Core/Logging/LoggingService.cs b/src/IBKRTrader.Core/Logging/LoggingService.cs index 778bda5..4c6f004 100644 --- a/src/IBKRTrader.Core/Logging/LoggingService.cs +++ b/src/IBKRTrader.Core/Logging/LoggingService.cs @@ -4,22 +4,32 @@ namespace IBKRTrader.Core.Logging; /// /// 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 an interessierte Senken (z. B. die +/// Live-Log-Ansicht der Oberfläche) +/// +/// Bewusst ohne UI-Bezug: Früher hielt dieser Dienst direkt eine +/// RichTextBox samt System.Drawing.Color 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. /// 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; + /// + /// 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. + /// + public event Action? 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) + /// + /// Einzeilige Darstellung für Log-Ansichten. Liegt hier, damit jede Oberfläche dieselbe Zeile + /// zeigt. ToUpperInvariant ist Absicht: ToUpper() würde unter tr-TR aus "info" + /// ein "İNFO" machen. + /// + 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; } } diff --git a/src/IBKRTrader.Core/Modularity/ModuleView.cs b/src/IBKRTrader.Core/Modularity/ModuleView.cs index 5340ab8..dbba32b 100644 --- a/src/IBKRTrader.Core/Modularity/ModuleView.cs +++ b/src/IBKRTrader.Core/Modularity/ModuleView.cs @@ -1,18 +1,23 @@ -using System.Windows.Forms; - namespace IBKRTrader.Core.Modularity; /// -/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die eigentliche UI ist ein -/// , das über 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. +/// +/// Bewusst toolkit-neutral: liefert ein , +/// keinen konkreten Fenstertyp, und 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 System.Drawing.Image mehr: +/// System.Drawing.Common ist seit .NET 7 Windows-only und wirft auf Linux. Die jeweilige +/// Shell kennt ihr Toolkit und castet – die WinForms-Shell auf Form, die Avalonia-Shell +/// auf Window. /// public sealed class ModuleView { /// Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View). public string Id { get; init; } = Guid.NewGuid().ToString(); - /// Titel (Fallback-Fenstertitel, falls das Form keinen eigenen setzt). + /// Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt). public string Title { get; init; } = "Fenster"; /// Optionale Gruppierung (z. B. "Core", "CongressTrading"). @@ -21,17 +26,24 @@ public sealed class ModuleView /// Optionale Sortierreihenfolge in Menü/Buttons. public int Order { get; init; } = 0; - /// Optionales Icon für Menü/Buttons. - public System.Drawing.Image? Icon { get; set; } + /// + /// 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. + /// + public string? IconKey { get; set; } - /// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). - public Func CreateForm { get; init; } = () => new Form(); + /// + /// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist + /// – siehe Klassen-Doku zur Toolkit-Neutralität. + /// + public Func CreateView { get; init; } = () => new object(); } /// /// 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 ). +/// das gemeinsame „Fenster"-Menü bauen. /// public interface IModuleUiHost { diff --git a/src/IBKRTrader.Modules.Accounting/AccountingModule.cs b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs index 34caf1a..568bece 100644 --- a/src/IBKRTrader.Modules.Accounting/AccountingModule.cs +++ b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs @@ -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()); } - 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(), - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService()) - }); - } + /// + /// 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 + /// UI/ModuleViews.cs; die Dienste dafür kommen aus dem DI-Container. + /// + 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; diff --git a/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj b/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj index 4c26bd9..229cd03 100644 --- a/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj +++ b/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj @@ -1,11 +1,11 @@ - net10.0-windows + + net10.0 enable enable - - true en diff --git a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs index 2b44ce9..269355d 100644 --- a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs +++ b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs @@ -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()); } - 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(), - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService()) - }); - } + /// + /// 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 + /// UI/ModuleViews.cs; die Dienste dafür kommen aus dem DI-Container. + /// + 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; diff --git a/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj b/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj index a4e1177..a308871 100644 --- a/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj +++ b/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj @@ -1,11 +1,11 @@ - net10.0-windows + + net10.0 enable enable - - true en diff --git a/src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj b/src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj index a4e1177..a308871 100644 --- a/src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj +++ b/src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj @@ -1,11 +1,11 @@ - net10.0-windows + + net10.0 enable enable - - true en diff --git a/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs b/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs index ccaf6e4..7fb0952 100644 --- a/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs +++ b/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs @@ -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(); } - 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(), - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService()) - }); - } + /// + /// 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 + /// UI/ModuleViews.cs; die Dienste dafür kommen aus dem DI-Container. + /// + public void RegisterUi(IModuleUiHost host, IServiceProvider services) { } public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj index 0cac877..e520230 100644 --- a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj +++ b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj @@ -1,9 +1,9 @@ - - net10.0-windows - true + + net10.0 enable enable false diff --git a/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs b/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs index 58db752..96d8184 100644 --- a/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs +++ b/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs @@ -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] diff --git a/tests/IBKRTrader.Tests/UiConstructionTests.cs b/tests/IBKRTrader.Tests/UiConstructionTests.cs deleted file mode 100644 index 16593d8..0000000 --- a/tests/IBKRTrader.Tests/UiConstructionTests.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -[Trait("cat", "unit")] -public class UiConstructionTests -{ - private sealed class Factory(DbContextOptions options) : IDbContextFactory where T : DbContext - { - public T CreateDbContext() => (T)Activator.CreateInstance(typeof(T), options)!; - } - - private static Factory InMemory() where T : DbContext => - new(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); - - private sealed class NoChat : IChatCompletionClient - { - public Task CompleteAsync(string m, IReadOnlyList msgs, - IReadOnlyList 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(); - 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(); - var supDbf = InMemory(); - 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(); - } -}