From 6cd9ebd96272e390a620707fa7f2185853ec315b Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:38:42 -0300 Subject: [PATCH 01/12] add enter-to-save, more dirty save detect guards --- .gitignore | 2 + .../EditRouteWindow.Handlers.cs | 57 +++++++------ SubathonManager.UI/UiUtils/DirtySaveGuard.cs | 80 +++++++++++++++++++ SubathonManager.UI/UiUtils/EnterKeyCommit.cs | 57 +++++++++++++ SubathonManager.UI/Views/GoalsEditor.axaml.cs | 20 ++++- .../Views/Prompts/PromptsEditor.axaml.cs | 35 ++++++-- SubathonManager.UI/Views/SettingsControl.cs | 28 +++++-- .../Views/SettingsProperties.cs | 9 +++ .../Views/SettingsView.axaml.cs | 2 + .../Views/WheelSpin/WheelEditor.axaml.cs | 32 +++++--- .../WheelSpin/WheelTriggerEditor.axaml.cs | 48 +++++++---- 11 files changed, 307 insertions(+), 63 deletions(-) create mode 100644 SubathonManager.UI/UiUtils/DirtySaveGuard.cs create mode 100644 SubathonManager.UI/UiUtils/EnterKeyCommit.cs diff --git a/.gitignore b/.gitignore index ace32f34..a700706a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ presets/** !.idea/inspectionProfiles/ !.idea/runConfigurations/ +resources/HONSE/* + ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index 2e758505..2520a0d9 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -1065,13 +1065,16 @@ void Attach() csscp.ColorChanged += Value_OnChanged; break; } + + DirtySaveGuard.Rebase(sender); } SuppressUnsavedChanges(Attach); } private void Value_OnChanged(object? sender, RoutedEventArgs e) { - if (_suppressCount > 0) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; if (sender is TextBox { IsFocused: false }) return; UpdateSaveButtonBorder(SaveButtonBorder, true); if (sender is Control { Tag: JsVariable }) _hasPendingJsChanges = true; @@ -1304,37 +1307,43 @@ private static string JsEventTypeDisplay(string? value) private void JsEventSubTypeSelectBox_Loaded(object? sender, RoutedEventArgs e) { if (sender is not ComboBox { Tag: JsVariable jsVar } cb) return; - var values = Enum.GetValues() - .Where(x => ((SubathonEventSubType?)x).IsTrueEvent()) - .OrderBy(x => x.GetOrderNumber()); - cb.Items.Add(string.Empty); - foreach (var val in values) cb.Items.Add(val.ToString()); - cb.SelectedItem = string.IsNullOrWhiteSpace(jsVar.Value) ? string.Empty : jsVar.Value; - Dispatcher.UIThread.Post(() => - { - cb.SelectionChanged += (_, _) => jsVar.Value = $"{cb.SelectedItem}"; - }, DispatcherPriority.Loaded); + if (cb.ItemCount == 0) + { + var values = Enum.GetValues() + .Where(x => ((SubathonEventSubType?)x).IsTrueEvent()) + .OrderBy(x => x.GetOrderNumber()); + cb.Items.Add(string.Empty); + foreach (var val in values) cb.Items.Add(val.ToString()); + cb.SelectedItem = string.IsNullOrWhiteSpace(jsVar.Value) ? string.Empty : jsVar.Value; + Dispatcher.UIThread.Post(() => + { + cb.SelectionChanged += (_, _) => jsVar.Value = $"{cb.SelectedItem}"; + }, DispatcherPriority.Loaded); + } AttachChangeHandler(sender, e); } private void JsStringSelectBox_Loaded(object? sender, RoutedEventArgs e) { if (sender is not ComboBox { Tag: JsVariable jsVar } cb) return; - var values = jsVar.Value?.Trim().Split(',') ?? []; - foreach (var val in values) cb.Items.Add(val); - cb.SelectedItem = values.Length > 0 ? values[0] : string.Empty; - Dispatcher.UIThread.Post(() => + if (cb.ItemCount == 0) { - cb.SelectionChanged += (_, _) => + var values = jsVar.Value?.Trim().Split(',') ?? []; + foreach (var val in values) cb.Items.Add(val); + cb.SelectedItem = values.Length > 0 ? values[0] : string.Empty; + Dispatcher.UIThread.Post(() => { - if (!jsVar.Value?.Contains(',') ?? true) return; - if (jsVar.Value!.StartsWith($"{cb.SelectedItem},")) return; - var newVal = new List { $"{cb.SelectedItem}" }; - foreach (var v in values) - if (!newVal.Contains(v)) newVal.Add(v); - jsVar.Value = string.Join(',', newVal); - }; - }, DispatcherPriority.Loaded); + cb.SelectionChanged += (_, _) => + { + if (!jsVar.Value?.Contains(',') ?? true) return; + if (jsVar.Value!.StartsWith($"{cb.SelectedItem},")) return; + var newVal = new List { $"{cb.SelectedItem}" }; + foreach (var v in values) + if (!newVal.Contains(v)) newVal.Add(v); + jsVar.Value = string.Join(',', newVal); + }; + }, DispatcherPriority.Loaded); + } AttachChangeHandler(sender, e); } diff --git a/SubathonManager.UI/UiUtils/DirtySaveGuard.cs b/SubathonManager.UI/UiUtils/DirtySaveGuard.cs new file mode 100644 index 00000000..7b58bbbf --- /dev/null +++ b/SubathonManager.UI/UiUtils/DirtySaveGuard.cs @@ -0,0 +1,80 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.VisualTree; +using SubathonManager.UI.Views; + +namespace SubathonManager.UI.UiUtils; + +public static class DirtySaveGuard +{ + private sealed class Baseline + { + public object? Value; + } + + private static readonly ConditionalWeakTable Baselines = new(); + + public static bool TryGetValue(object? control, out object? value) + { + switch (control) + { + case TextBox tb: + value = tb.Text ?? ""; + return true; + case AutoCompleteBox acb: + value = acb.Text ?? ""; + return true; + case ToggleButton toggle: + value = toggle.IsChecked; + return true; + case ComboBox cb: + value = cb.SelectedItem ?? cb.SelectedIndex; + return true; + case NumericUpDown nud: + value = nud.Value; + return true; + case RangeBase range: + value = range.Value; + return true; + case CssColorPicker picker: + value = picker.CssColor ?? ""; + return true; + default: + value = null; + return false; + } + } + + public static void Rebase(object? control) + { + if (control is not AvaloniaObject obj || !TryGetValue(control, out var value)) return; + if (Baselines.TryGetValue(obj, out var baseline)) baseline.Value = value; + else Baselines.Add(obj, new Baseline { Value = value }); + } + + public static void RebaseAll(Visual? root) + { + if (root == null) return; + Rebase(root); + foreach (var child in root.GetVisualChildren()) + RebaseAll(child); + } + + public static bool Consume(object? control) + { + if (control is not AvaloniaObject obj || !TryGetValue(control, out var current)) return true; + + if (!Baselines.TryGetValue(obj, out var baseline)) + { + Baselines.Add(obj, new Baseline { Value = current }); + return true; + } + + if (Equals(baseline.Value, current)) return false; + + baseline.Value = current; + return true; + } +} diff --git a/SubathonManager.UI/UiUtils/EnterKeyCommit.cs b/SubathonManager.UI/UiUtils/EnterKeyCommit.cs new file mode 100644 index 00000000..f9b7d12f --- /dev/null +++ b/SubathonManager.UI/UiUtils/EnterKeyCommit.cs @@ -0,0 +1,57 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SubathonManager.UI.UiUtils; + +public static class EnterKeyCommit +{ + public static void Attach(InputElement root, Action commit) + { + root.AddHandler(InputElement.KeyDownEvent, (_, e) => + { + if (e.Key != Key.Enter) return; + if (!ShouldCommit(e.Source)) return; + + e.Handled = true; + commit(); + }, RoutingStrategies.Bubble); + } + + private static bool ShouldCommit(object? source) + { + switch (source) + { + case TextBox { AcceptsReturn: true }: + case ComboBox { IsDropDownOpen: true }: + case AutoCompleteBox { IsDropDownOpen: true }: + case Button: + case MenuItem: + return false; + case Visual v: + return !HasExcludedAncestor(v); + default: + return false; + } + } + + private static bool HasExcludedAncestor(Visual? v) + { + while (v != null) + { + switch (v) + { + case TextBox { AcceptsReturn: true }: + case ComboBox { IsDropDownOpen: true }: + case AutoCompleteBox { IsDropDownOpen: true }: + case Button: + case MenuItem: + return true; + } + v = v.GetVisualParent(); + } + return false; + } +} diff --git a/SubathonManager.UI/Views/GoalsEditor.axaml.cs b/SubathonManager.UI/Views/GoalsEditor.axaml.cs index c4af85aa..e3601f3c 100644 --- a/SubathonManager.UI/Views/GoalsEditor.axaml.cs +++ b/SubathonManager.UI/Views/GoalsEditor.axaml.cs @@ -26,6 +26,7 @@ public partial class GoalsEditor : UserControl private SubathonGoalSet? _activeGoalSet; private readonly IDbContextFactory _factory; private int _suppressCount; + private bool _initialized; public GoalsEditor() { @@ -34,6 +35,17 @@ public GoalsEditor() GoalSetType.ItemsSource = Enum.GetNames().ToList(); LoadAllSets(); SubathonEvents.SubathonDataUpdate += UpdatePointsCount; + + Loaded += (_, _) => + { + if (_initialized) return; + _initialized = true; + EnterKeyCommit.Attach(this, () => + { + GoalSetNameBox_LostFocus(GoalSetNameBox, new RoutedEventArgs()); + SaveGoals_Click(this, new RoutedEventArgs()); + }); + }; } private void UpdatePointsCount(SubathonData subathon, DateTime time) @@ -140,7 +152,8 @@ private void GoalSetNameBox_LostFocus(object? sender, RoutedEventArgs e) private void GoalSetType_SelectionChanged(object? sender, SelectionChangedEventArgs e) { - if (_suppressCount > 0 || _activeGoalSet == null) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || _activeGoalSet == null || !realChange) return; UpdateSaveButtonBorder(true); } @@ -239,6 +252,7 @@ private async void LoadGoals() }; ToolTip.SetTip(textBox, "Goal Description"); textBox.TextChanged += Value_OnChanged; + DirtySaveGuard.Rebase(textBox); TextBoxAssist.SetClear(textBox, true); var pointsBox = new TextBox @@ -251,6 +265,7 @@ private async void LoadGoals() ToolTip.SetTip(pointsBox, "Points/Money to achieve"); NumericInputBehaviour.SetMode(pointsBox, NumericInputBehaviour.NumericMode.Integer); pointsBox.TextChanged += Value_OnChanged; + DirtySaveGuard.Rebase(pointsBox); var deleteBtn = new Button { @@ -381,7 +396,8 @@ private void Grid_PointerPressed(object? sender, PointerPressedEventArgs e) private void Value_OnChanged(object? sender, TextChangedEventArgs e) { - if (_suppressCount > 0) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, true); } diff --git a/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs index 9247ca6c..9f72b7ec 100644 --- a/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs +++ b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs @@ -28,6 +28,7 @@ public partial class PromptsEditor : UserControl private SubathonPromptSet? _activeSet; private SubathonPrompt? _selectedPrompt; private int _suppressCount; + private bool _initialized; private SubathonEventType? _selectedFilterEventType; private string? _selectedFilterEventMeta; private Guid? _activeRunPromptId; @@ -55,11 +56,19 @@ public PromptsEditor() LoadPromptRows(); }); - Loaded += (_, _) => Dispatcher.UIThread.Post(() => + Loaded += (_, _) => { - AttachChangeHandlers(); - UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, false); - }, DispatcherPriority.Background); + if (!_initialized) + { + _initialized = true; + EnterKeyCommit.Attach(this, () => Save_Click(this, new RoutedEventArgs())); + } + Dispatcher.UIThread.Post(() => + { + AttachChangeHandlers(); + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, false); + }, DispatcherPriority.Background); + }; } private void OnGoAffProStoreDiscovered(GoAffProStore store) @@ -963,17 +972,26 @@ private void AttachChangeHandlers() PromptTierBox.SelectionChanged -= OnTierChanged; PromptTierBox.SelectionChanged += OnTierChanged; + + foreach (var control in new Control[] + { + SetIntervalBox, SetOffsetBox, SetCooldownBox, PromptTextBox, PromptValueBox, + PromptDurationBox, PromptQuantityBox, PromptSubTypeBox, PromptTierBox + }) + DirtySaveGuard.Rebase(control); } private void OnFieldChanged(object? sender, TextChangedEventArgs e) { + if (!DirtySaveGuard.Consume(sender)) return; if (sender is TextBox { IsFocused: false }) return; MarkPendingChanges(); } private void OnSubTypeChanged(object? sender, SelectionChangedEventArgs e) { - if (_suppressCount > 0) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; var type = SelectedType() ?? SubathonPromptType.Points; var subType = SelectedSubType() ?? SubathonPromptSubType.Default; @@ -985,7 +1003,12 @@ private void OnSubTypeChanged(object? sender, SelectionChangedEventArgs e) MarkPendingChanges(); } - private void OnTierChanged(object? sender, SelectionChangedEventArgs e) => MarkPendingChanges(); + private void OnTierChanged(object? sender, SelectionChangedEventArgs e) + { + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; + MarkPendingChanges(); + } private async void ExportPromptSet_Click(object? sender, RoutedEventArgs e) { diff --git a/SubathonManager.UI/Views/SettingsControl.cs b/SubathonManager.UI/Views/SettingsControl.cs index 01e23cb7..2eeecb20 100644 --- a/SubathonManager.UI/Views/SettingsControl.cs +++ b/SubathonManager.UI/Views/SettingsControl.cs @@ -26,7 +26,7 @@ public abstract class SettingsControl : UserControl internal readonly IDbContextFactory _factory = AppServices.Provider.GetRequiredService>(); - private int _suppressCount = 0; + private static int _suppressCount = 0; protected SettingsControl() { @@ -89,25 +89,38 @@ private void WireInputs(Visual parent) private void AttachHandler(Visual element) { + if (SettingsProperties.GetUnsavedHandlerAttached(element)) return; + switch (element) { case TextBox tb: - tb.TextChanged += (_, _) => OnInputChanged(); + tb.TextChanged += (s, _) => OnInputChanged(s); + break; + case AutoCompleteBox acb: + acb.TextChanged += (s, _) => OnInputChanged(s); break; case ComboBox cb: - cb.SelectionChanged += (_, _) => OnInputChanged(); + cb.SelectionChanged += (s, _) => OnInputChanged(s); break; case CheckBox chk: - chk.IsCheckedChanged += (_, _) => OnInputChanged(); + chk.IsCheckedChanged += (s, _) => OnInputChanged(s); break; case Slider sld: - sld.ValueChanged += (_, _) => OnInputChanged(); + sld.ValueChanged += (s, _) => OnInputChanged(s); break; + default: + return; } + + SettingsProperties.SetUnsavedHandlerAttached(element, true); + DirtySaveGuard.Rebase(element); } private void WireExpander(Expander expander) { + if (SettingsProperties.GetUnsavedHandlerAttached(expander)) return; + SettingsProperties.SetUnsavedHandlerAttached(expander, true); + bool firstExpand = true; expander.PropertyChanged += (_, e) => @@ -131,9 +144,10 @@ private void WireExpander(Expander expander) } } - private void OnInputChanged() + private void OnInputChanged(object? sender) { - if (_suppressCount > 0) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; SettingsEvents.RaiseSettingsUnsavedChanges(true); } diff --git a/SubathonManager.UI/Views/SettingsProperties.cs b/SubathonManager.UI/Views/SettingsProperties.cs index 2c46b67c..37101d51 100644 --- a/SubathonManager.UI/Views/SettingsProperties.cs +++ b/SubathonManager.UI/Views/SettingsProperties.cs @@ -12,4 +12,13 @@ public static void SetExcludeFromUnsaved(AvaloniaObject element, bool value) public static bool GetExcludeFromUnsaved(AvaloniaObject element) => element.GetValue(ExcludeFromUnsavedProperty); + + public static readonly AttachedProperty UnsavedHandlerAttachedProperty = + AvaloniaProperty.RegisterAttached("UnsavedHandlerAttached", typeof(SettingsProperties)); + + public static void SetUnsavedHandlerAttached(AvaloniaObject element, bool value) + => element.SetValue(UnsavedHandlerAttachedProperty, value); + + public static bool GetUnsavedHandlerAttached(AvaloniaObject element) + => element.GetValue(UnsavedHandlerAttachedProperty); } diff --git a/SubathonManager.UI/Views/SettingsView.axaml.cs b/SubathonManager.UI/Views/SettingsView.axaml.cs index aea878ae..9a98f9a6 100644 --- a/SubathonManager.UI/Views/SettingsView.axaml.cs +++ b/SubathonManager.UI/Views/SettingsView.axaml.cs @@ -14,6 +14,7 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Objects; +using SubathonManager.UI.UiUtils; using SubathonManager.UI.Views.SettingsViews; using SubathonManager.UI.Services; @@ -43,6 +44,7 @@ public SettingsView() SettingsEvents.SettingsUnsavedChanges += UpdateSaveButtonBorder; RegisterUnsavedChangeHandlers(); InitCurrencySelects(); + EnterKeyCommit.Attach(this, () => SaveAllSubathonValuesButton_Click(this, new RoutedEventArgs())); }; StreamingSettingsControl.Init(this); diff --git a/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs index 370681c3..67137ca4 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs @@ -57,6 +57,7 @@ public WheelEditor() if (!_initialized) { AttachChangeHandlers(); + EnterKeyCommit.Attach(this, () => Save_Click(this, new RoutedEventArgs())); _initialized = true; } SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; @@ -573,7 +574,8 @@ private void RefreshRowHighlights(Grid? clickedRow = null) private void ActionType_SelectionChanged(object? sender, SelectionChangedEventArgs e) { - if (_suppressCount > 0) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; var selected = (ActionTypeBox.SelectedItem as ComboBoxItem)?.Tag as WheelSpinActionType?; bool showParams = selected.HasValue && selected.Value.HasAction(); ActionParameterPanel.IsVisible = showParams; @@ -633,7 +635,8 @@ private string BuildMultiplierParameter() private void Multiplier_Changed(object? sender, RoutedEventArgs e) { - if (_suppressCount > 0) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; MarkPendingChanges(); } @@ -651,7 +654,8 @@ private string BuildRerollParameter() private void ItemInfinite_Changed(object? sender, RoutedEventArgs e) { - if (_suppressCount > 0) return; + bool realChange = DirtySaveGuard.Consume(sender); + if (_suppressCount > 0 || !realChange) return; ItemQuantityBox.IsEnabled = !(ItemInfiniteCheck.IsChecked ?? false); MarkPendingChanges(); } @@ -1039,17 +1043,25 @@ private void SuppressChanges(Action action) private void AttachChangeHandlers() { - ItemTextBox.TextChanged += OnFieldChanged; - ItemWeightBox.TextChanged += OnFieldChanged; - ItemQuantityBox.TextChanged += OnFieldChanged; - ActionParameterBox.TextChanged += OnFieldChanged; - MultiplierAmountBox.TextChanged += OnFieldChanged; - MultiplierDurationBox.TextChanged += OnFieldChanged; - RerollCountBox.TextChanged += OnFieldChanged; + foreach (var box in new[] + { + ItemTextBox, ItemWeightBox, ItemQuantityBox, ActionParameterBox, + MultiplierAmountBox, MultiplierDurationBox, RerollCountBox + }) + { + box.TextChanged += OnFieldChanged; + DirtySaveGuard.Rebase(box); + } + + DirtySaveGuard.Rebase(ActionTypeBox); + DirtySaveGuard.Rebase(MultiplierTimeCheck); + DirtySaveGuard.Rebase(MultiplierPointsCheck); + DirtySaveGuard.Rebase(ItemInfiniteCheck); } private void OnFieldChanged(object? sender, TextChangedEventArgs e) { + if (!DirtySaveGuard.Consume(sender)) return; if (sender is TextBox { IsFocused: false }) return; MarkPendingChanges(); } diff --git a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs index 6eabc8d1..7e1e1096 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs @@ -98,6 +98,12 @@ private void MarkDirty() UpdateSaveButtonState(); } + private void MarkDirty(object? sender) + { + if (!DirtySaveGuard.Consume(sender)) return; + MarkDirty(); + } + private void UpdateSaveButtonState() { bool showGlow; @@ -137,20 +143,34 @@ private void LoadCurrencies() private void WireDirtyHandlers() { - TriggerEnabledCheck.IsCheckedChanged += (_, _) => MarkDirty(); - TierComboBox.SelectionChanged += (_, _) => MarkDirty(); - TierTextBox.TextChanged += (_, _) => MarkDirty(); - GiftCountBox.TextChanged += (_, _) => MarkDirty(); - TokenCountBox.TextChanged += (_, _) => MarkDirty(); - OrderByItemsRadio.IsCheckedChanged += (_, _) => MarkDirty(); - OrderByMoneyRadio.IsCheckedChanged += (_, _) => MarkDirty(); - OrderByOrderRadio.IsCheckedChanged += (_, _) => MarkDirty(); - OrderItemCountBox.TextChanged += (_, _) => MarkDirty(); - OrderMoneyBox.TextChanged += (_, _) => MarkDirty(); - OrderCurrencyBox.SelectionChanged += (_, _) => MarkDirty(); - DonationMoneyBox.TextChanged += (_, _) => MarkDirty(); - DonationCurrencyBox.SelectionChanged += (_, _) => MarkDirty(); - SpinsToAddBox.TextChanged += (_, _) => MarkDirty(); + TriggerEnabledCheck.IsCheckedChanged += (s, _) => MarkDirty(s); + TierComboBox.SelectionChanged += (s, _) => MarkDirty(s); + TierTextBox.TextChanged += (s, _) => MarkDirty(s); + GiftCountBox.TextChanged += (s, _) => MarkDirty(s); + TokenCountBox.TextChanged += (s, _) => MarkDirty(s); + OrderByItemsRadio.IsCheckedChanged += (s, _) => MarkDirty(s); + OrderByMoneyRadio.IsCheckedChanged += (s, _) => MarkDirty(s); + OrderByOrderRadio.IsCheckedChanged += (s, _) => MarkDirty(s); + OrderItemCountBox.TextChanged += (s, _) => MarkDirty(s); + OrderMoneyBox.TextChanged += (s, _) => MarkDirty(s); + OrderCurrencyBox.SelectionChanged += (s, _) => MarkDirty(s); + DonationMoneyBox.TextChanged += (s, _) => MarkDirty(s); + DonationCurrencyBox.SelectionChanged += (s, _) => MarkDirty(s); + SpinsToAddBox.TextChanged += (s, _) => MarkDirty(s); + + foreach (var control in new Control[] + { + TriggerEnabledCheck, TierComboBox, TierTextBox, GiftCountBox, TokenCountBox, + OrderByItemsRadio, OrderByMoneyRadio, OrderByOrderRadio, OrderItemCountBox, + OrderMoneyBox, OrderCurrencyBox, DonationMoneyBox, DonationCurrencyBox, SpinsToAddBox + }) + DirtySaveGuard.Rebase(control); + + EnterKeyCommit.Attach(this, () => + { + if (!SaveTriggerBtn.IsEnabled) return; + SaveTrigger_Click(this, new RoutedEventArgs()); + }); } private void Grid_PointerPressed(object? sender, PointerPressedEventArgs e) From 664e27b5e6f309743c03391a86ee93d9604dd391 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:46:33 -0300 Subject: [PATCH 02/12] fix widget editor enter save --- SubathonManager.UI/EditRouteWindow.axaml | 2 +- SubathonManager.UI/EditRouteWindow.axaml.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/SubathonManager.UI/EditRouteWindow.axaml b/SubathonManager.UI/EditRouteWindow.axaml index 2bf31854..b5746a36 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml +++ b/SubathonManager.UI/EditRouteWindow.axaml @@ -261,7 +261,7 @@ - + diff --git a/SubathonManager.UI/EditRouteWindow.axaml.cs b/SubathonManager.UI/EditRouteWindow.axaml.cs index 92d8e4ed..5818532a 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml.cs +++ b/SubathonManager.UI/EditRouteWindow.axaml.cs @@ -104,6 +104,13 @@ public EditRouteWindow(Guid routeId) ApplyWebViewBackground(_webViewLightBg); }); + UiUtils.EnterKeyCommit.Attach(RouteSettingsPanel, () => SaveRouteButton_Click(this, new RoutedEventArgs())); + UiUtils.EnterKeyCommit.Attach(WidgetEditorColumn, () => + { + if (_selectedWidget == null) return; + SaveWidgetButton_Click(this, new RoutedEventArgs()); + }); + Loaded += EditRouteWindow_Loaded; ObsConnected = ServiceManager.OBS.Connected; IntegrationEvents.ConnectionUpdated += OnObsConnectionUpdated; From 9b5c0f8ac441da3dd8e59f50fd18d4f0fcd187ec Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:07:37 -0300 Subject: [PATCH 03/12] Setup channels for websocket --- .../Interfaces/IWebSocketClient.cs | 6 + SubathonManager.Server/WebServer.WebSocket.cs | 130 ++++++--------- SubathonManager.Server/WebSocketClient.cs | 30 +++- .../WebSocketOutboundQueue.cs | 156 ++++++++++++++++++ 4 files changed, 236 insertions(+), 86 deletions(-) create mode 100644 SubathonManager.Server/WebSocketOutboundQueue.cs diff --git a/SubathonManager.Server/Interfaces/IWebSocketClient.cs b/SubathonManager.Server/Interfaces/IWebSocketClient.cs index 0d1fbebc..950cead3 100644 --- a/SubathonManager.Server/Interfaces/IWebSocketClient.cs +++ b/SubathonManager.Server/Interfaces/IWebSocketClient.cs @@ -26,4 +26,10 @@ Task CloseAsync( WebSocketCloseStatus closeStatus, string statusDescription, CancellationToken cancellationToken); + + void Abort(); + void StartOutbound(); + bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoalesceKey.None); + void CompleteOutbound(); + Task CompleteOutboundAsync(TimeSpan drainTimeout); } \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index 190af7dc..a45f9c96 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -20,7 +20,7 @@ public partial class WebServer { private readonly List _clients = new(); private readonly object _lock = new(); - private readonly SemaphoreSlim _sendLock = new(1,1); + private static readonly TimeSpan OutboundDrainTimeout = TimeSpan.FromSeconds(2); private void SetupWebsocketListeners() { @@ -68,6 +68,16 @@ private void StopWebsocketServer() WheelEvents.WheelSpinResult -= SendWheelSpinResult; WheelEvents.WheelSpinStatusChanged -= SendWheelSpinStatusChanged; WheelEvents.WheelDataChanged -= SendWheelDataChanged; + + List clientsCopy; + lock (_lock) + { + clientsCopy = _clients.ToList(); + _clients.Clear(); + } + + foreach (var client in clientsCopy) + client.CompleteOutbound(); } private void OnPromptStart(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) @@ -98,7 +108,7 @@ internal void SendWidgetReload(Guid widgetId, float x, float y, int width, int h widgetId = widgetId.ToString(), x, y, width, height, scaleX, scaleY }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientMessageType.Overlay)); + BroadcastObject(data, WebsocketClientMessageType.Overlay); } internal void SendPromptData(SubathonPromptRun? run, long progress = 0) @@ -119,7 +129,7 @@ internal void SendPromptData(SubathonPromptRun? run, long progress = 0) prompt_eventtype = $"{run?.LinkedPrompt?.FilterEventType}", prompt_eventtype_metafilter = run?.LinkedPrompt?.FilterMeta }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } internal void SendWidgetVarsUpdate(Guid widgetId, @@ -133,13 +143,13 @@ internal void SendWidgetVarsUpdate(Guid widgetId, jsVars = jsVars.Select(v => new { name = v.Name, value = v.Value, injectLine = v.GetInjectLine() }) }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } internal void SendSubathonValues(string jsonData) { var newData = $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {jsonData} }}"; - Task.Run(() => BroadcastAsync(newData, WebsocketClientTypeHelper.ConfigConsumersList)); + Broadcast(newData, OutboundCoalesceKey.None, WebsocketClientTypeHelper.ConfigConsumersList); } internal void SendGoalsUpdated(List goals, long currentPoints, GoalsType type) @@ -151,7 +161,7 @@ internal void SendGoalsUpdated(List goals, long currentPoints, Goa goals = goals.Select(goal => GoalToObject(goal, currentPoints)).ToArray(), goals_type = $"{type}" }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, OutboundCoalesceKey.GoalsList, WebsocketClientTypeHelper.ConsumersList); } private object GoalToObject(SubathonGoal goal, long currentPoints) @@ -173,7 +183,7 @@ internal void SendGoalCompleted(SubathonGoal goal, long currentPoints) goal_points = goal.Points, points = currentPoints }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } private async Task InitConnection(IWebSocketClient socket) @@ -343,28 +353,28 @@ internal void SendSubathonEventProcessed(SubathonEvent subathonEvent, bool effec { bool showOverride = _config.GetBool("App", "ShowLockedEvents", false); if (!showOverride && !subathonEvent.ProcessedToSubathon) return; - Task.Run(() => BroadcastAsyncObject(SubathonEventToObject(subathonEvent), WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(SubathonEventToObject(subathonEvent), WebsocketClientTypeHelper.ConsumersList); } internal void SendSubathonTotals(SubathonTotals totals) { - Task.Run(() => BroadcastAsyncObject(SubathonTotalsToObject(totals), WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(SubathonTotalsToObject(totals), OutboundCoalesceKey.SubathonTotals, + WebsocketClientTypeHelper.ConsumersList); } internal void SendSubscriptionTotals(SubscriptionTotals totals) { - Task.Run(() => BroadcastAsyncObject(SubscriptionTotalsToObject(totals), WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(SubscriptionTotalsToObject(totals), OutboundCoalesceKey.SubscriptionTotals, + WebsocketClientTypeHelper.ConsumersList); } internal void SendRefreshRequest(Guid id) { - Task.Run(() => - BroadcastAsyncObject(new - { - type = "refresh_request", - id = id.ToString() - }, WebsocketClientMessageType.Overlay) - ); + BroadcastObject(new + { + type = "refresh_request", + id = id.ToString() + }, WebsocketClientMessageType.Overlay); } private object SubathonDataToObject(SubathonData subathon) @@ -408,7 +418,8 @@ private object SubathonDataToObject(SubathonData subathon) internal void SendSubathonDataUpdate(SubathonData subathon, DateTime time) { - Task.Run(() => BroadcastAsyncObject(SubathonDataToObject(subathon), WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(SubathonDataToObject(subathon), OutboundCoalesceKey.SubathonTimer, + WebsocketClientTypeHelper.ConsumersList); } internal void AddSocketClient(IWebSocketClient socket) @@ -418,6 +429,7 @@ internal void AddSocketClient(IWebSocketClient socket) _clients.Add(socket); _logger?.LogDebug("{ClientsCount} websocket clients connected", _clients.Count); } + socket.StartOutbound(); } public async Task HandleWebSocketRequestAsync(IHttpContext ctx) @@ -448,6 +460,8 @@ public async Task HandleWebSocketRequestAsync(IHttpContext ctx) } finally { + await client.CompleteOutboundAsync(OutboundDrainTimeout); + foreach (var clientIntegrationSource in client.IntegrationSources) { WebServerEvents.RaiseWebSocketIntegrationSourceChange(clientIntegrationSource.ToString(), false); @@ -486,16 +500,7 @@ private async Task Listen(IWebSocketClient socket) switch (type.GetString()) { case "ping": - var pong = Encoding.UTF8.GetBytes("{\"ws_type\":\"pong\"}"); - await _sendLock.WaitAsync(); - try - { - await socket.SendAsync(pong, WebSocketMessageType.Text, true, CancellationToken.None); - } - finally - { - _sendLock.Release(); - } + socket.TryEnqueue(Encoding.UTF8.GetBytes("{\"ws_type\":\"pong\"}")); break; case "hello": _logger?.LogDebug($"[WebSocket] [{socket.ClientId}] Hello from {json.RootElement.GetProperty("origin").GetString()}"); @@ -687,7 +692,7 @@ private void SendWheelSpinStarted(WheelSet wheel, int delaySeconds) spin_delay_seconds = delaySeconds, timestamp = DateTime.Now }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int _) @@ -707,7 +712,7 @@ private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHisto }, timestamp = DateTime.Now }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) @@ -721,7 +726,7 @@ private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) updated_at = history.UpdatedAt, wheel_item = itemSnapshot }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) @@ -737,16 +742,16 @@ private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) wheel = new { id = wheelId, name = wheelName, spin_count = spinCount, items }, spins_owed = spinsOwed }; - Task.Run(() => BroadcastAsyncObject(data, WebsocketClientTypeHelper.ConsumersList)); + BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private async Task BroadcastAsyncObject(object data, params WebsocketClientMessageType[] types) - { - string json = JsonSerializer.Serialize(data); - await BroadcastAsync(json, types); - } - - private async Task BroadcastAsync(string json, params WebsocketClientMessageType[] types) + private void BroadcastObject(object data, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) + => Broadcast(JsonSerializer.Serialize(data), key, types); + + private void BroadcastObject(object data, params WebsocketClientMessageType[] types) + => Broadcast(JsonSerializer.Serialize(data), OutboundCoalesceKey.None, types); + + private void Broadcast(string json, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) { byte[] bytes = Encoding.UTF8.GetBytes(json); @@ -757,51 +762,18 @@ private async Task BroadcastAsync(string json, params WebsocketClientMessageType foreach (var ws in clientsCopy.Where(ws => ws.State == WebSocketState.Open && ws.ClientTypes.Any(types.Contains))) { - await _sendLock.WaitAsync(); - try - { - await ws.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); - } - finally - { - _sendLock.Release(); - } + ws.TryEnqueue(bytes, key); } } - internal async Task SelectSendAsync(IWebSocketClient client, object data) - { - string json = JsonSerializer.Serialize(data); - byte[] bytes = Encoding.UTF8.GetBytes(json); - if (client.State == WebSocketState.Open) - { - await _sendLock.WaitAsync(); - try - { - await client.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); - } - finally - { - _sendLock.Release(); - } - } - } - - internal async Task SelectSendStringAsync(IWebSocketClient client, string data) + internal Task SelectSendAsync(IWebSocketClient client, object data) + => SelectSendStringAsync(client, JsonSerializer.Serialize(data)); + + internal Task SelectSendStringAsync(IWebSocketClient client, string data) { - byte[] bytes = Encoding.UTF8.GetBytes(data); if (client.State == WebSocketState.Open) - { - await _sendLock.WaitAsync(); - try - { - await client.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); - } - finally - { - _sendLock.Release(); - } - } + client.TryEnqueue(Encoding.UTF8.GetBytes(data)); + return Task.CompletedTask; } public string GetWebsocketInjectionScript(string? routeId = "") diff --git a/SubathonManager.Server/WebSocketClient.cs b/SubathonManager.Server/WebSocketClient.cs index a0d3fa45..fedefdeb 100644 --- a/SubathonManager.Server/WebSocketClient.cs +++ b/SubathonManager.Server/WebSocketClient.cs @@ -1,4 +1,5 @@ -using System.Net.WebSockets; +using System.Net.WebSockets; +using Microsoft.Extensions.Logging; using SubathonManager.Core.Enums; namespace SubathonManager.Server; @@ -7,16 +8,20 @@ public class WebSocketClient : IWebSocketClient { private readonly WebSocket _socket; private Guid _clientId = Guid.NewGuid(); - - public WebSocketClient(WebSocket socket) + private readonly WebSocketOutboundQueue _outbound; + + public WebSocketClient(WebSocket socket, ILogger? logger = null) { _socket = socket; + _outbound = new WebSocketOutboundQueue(_clientId, + (buffer, ct) => _socket.SendAsync(buffer, WebSocketMessageType.Text, true, ct), + () => _socket.State, Abort, logger); } public List IntegrationSources { get; } = new(); public Guid ClientId => _clientId; - + public List ClientTypes { get; set; } = new(){ WebsocketClientMessageType.Generic }; public WebSocketState State => _socket.State; @@ -24,9 +29,20 @@ public WebSocketClient(WebSocket socket) public Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) => _socket.SendAsync(buffer, messageType, endOfMessage, cancellationToken); - public Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToke) + public Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToke) => _socket.ReceiveAsync(buffer, cancellationToke); - public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription, CancellationToken cancellationToken) + public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription, CancellationToken cancellationToken) => _socket.CloseAsync(closeStatus, statusDescription, cancellationToken); -} \ No newline at end of file + + public void Abort() => _socket.Abort(); + + public void StartOutbound() => _outbound.Start(); + + public bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoalesceKey.None) + => _outbound.TryEnqueue(payload, key); + + public void CompleteOutbound() => _outbound.Complete(); + + public Task CompleteOutboundAsync(TimeSpan drainTimeout) => _outbound.CompleteAsync(drainTimeout); +} diff --git a/SubathonManager.Server/WebSocketOutboundQueue.cs b/SubathonManager.Server/WebSocketOutboundQueue.cs new file mode 100644 index 00000000..4f96dd5b --- /dev/null +++ b/SubathonManager.Server/WebSocketOutboundQueue.cs @@ -0,0 +1,156 @@ +using System.Net.WebSockets; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace SubathonManager.Server; + +public enum OutboundCoalesceKey +{ + None = 0, + SubathonTimer, + SubathonTotals, + SubscriptionTotals, + GoalsList +} + +internal sealed class WebSocketOutboundQueue +{ + private const int Capacity = 256; + private static readonly long StallTimeoutMs = (long)TimeSpan.FromSeconds(30).TotalMilliseconds; + + private readonly record struct Slot(byte[]? Payload, OutboundCoalesceKey Key); + + private static readonly int KeyCount = Enum.GetValues().Length; + + private readonly Channel _queue = Channel.CreateBounded( + new BoundedChannelOptions(Capacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + + private readonly byte[]?[] _latest = new byte[]?[KeyCount]; + private readonly int[] _marked = new int[KeyCount]; + + private readonly Func, CancellationToken, Task> _send; + private readonly Func _state; + private readonly Action _onStalled; + private readonly ILogger? _logger; + private readonly Guid _clientId; + + private long _fullSinceTicks; + private int _stalledFired; + private int _started; + private int _completed; + private Task _pump = Task.CompletedTask; + + internal WebSocketOutboundQueue( + Guid clientId, + Func, CancellationToken, Task> send, + Func state, + Action onStalled, + ILogger? logger) + { + _clientId = clientId; + _send = send; + _state = state; + _onStalled = onStalled; + _logger = logger; + } + + internal void Start() + { + if (Interlocked.Exchange(ref _started, 1) == 1) return; + _pump = Task.Run(PumpAsync); + } + + internal bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoalesceKey.None) + { + if (Volatile.Read(ref _completed) == 1) return false; + + if (key == OutboundCoalesceKey.None) + return _queue.Writer.TryWrite(new Slot(payload, key)) ? NoteWritten() : NoteFull(); + + int i = (int)key; + Interlocked.Exchange(ref _latest[i], payload); + + if (Interlocked.Exchange(ref _marked[i], 1) == 1) return true; + + if (_queue.Writer.TryWrite(new Slot(null, key))) return NoteWritten(); + + Interlocked.Exchange(ref _marked[i], 0); + return NoteFull(); + } + + private bool NoteWritten() + { + Volatile.Write(ref _fullSinceTicks, 0); + return true; + } + + private bool NoteFull() + { + long now = Environment.TickCount64; + long prev = Interlocked.CompareExchange(ref _fullSinceTicks, now, 0); + long fullSince = prev == 0 ? now : prev; + + if (now - fullSince >= StallTimeoutMs && Interlocked.Exchange(ref _stalledFired, 1) == 0) + { + _logger?.LogWarning( + "WebSocket client [{ClientId}] outbound queue has been full for over 30s. dropping connection", + _clientId); + _onStalled(); + } + + return false; + } + + private async Task PumpAsync() + { + try + { + while (await _queue.Reader.WaitToReadAsync().ConfigureAwait(false)) + { + while (_queue.Reader.TryRead(out Slot slot)) + { + byte[]? payload = slot.Payload; + if (payload is null) + { + int i = (int)slot.Key; + Interlocked.Exchange(ref _marked[i], 0); + payload = Interlocked.Exchange(ref _latest[i], null); + if (payload is null) continue; + } + + if (_state() != WebSocketState.Open) return; + await _send(payload, CancellationToken.None).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "WebSocket client [{ClientId}] outbound pump stopped", _clientId); + } + } + + internal void Complete() + { + if (Interlocked.Exchange(ref _completed, 1) == 1) return; + _queue.Writer.TryComplete(); + } + + internal async Task CompleteAsync(TimeSpan drainTimeout) + { + Complete(); + try + { + await _pump.WaitAsync(drainTimeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + _logger?.LogDebug("WebSocket client [{ClientId}] outbound queue did not drain time before timeout", _clientId); + } + } +} From 890fbe09e9941f8e5b79fd2254087fc7603f1d42 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:19:51 -0300 Subject: [PATCH 04/12] Fix currency service conversion for wheelspin triggers of a different currency --- SubathonManager.Services/CurrencyService.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/SubathonManager.Services/CurrencyService.cs b/SubathonManager.Services/CurrencyService.cs index 3bddad4e..ae797b88 100644 --- a/SubathonManager.Services/CurrencyService.cs +++ b/SubathonManager.Services/CurrencyService.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.Text.Json; using SubathonManager.Core.Events; -using SubathonManager.Core.Enums; using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Interfaces; @@ -162,7 +161,9 @@ public async Task ConvertAsync(double amount, string fromCurrency, strin { fromCurrency = fromCurrency.ToUpperInvariant().Trim(); var defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); - toCurrency ??= defaultCurrency; + toCurrency = string.IsNullOrWhiteSpace(toCurrency) + ? defaultCurrency + : toCurrency.ToUpperInvariant().Trim(); if (fromCurrency == toCurrency) return amount; @@ -190,9 +191,20 @@ public async Task ConvertAsync(double amount, string fromCurrency, strin return 0; } + if (!IsValidCurrency(toCurrency)) + { + var message = $"{toCurrency} is not a valid target currency. Cannot convert {amount} {fromCurrency}"; + _logger?.LogError(message); + + ErrorMessageEvents.RaiseErrorEvent("ERROR", "CurrencyService", + message, DateTime.Now); + return 0; + } + try { - if (!Rates.TryGetValue(fromCurrency, out var fromRate)) + double fromRate = 1.0; + if (fromCurrency != defaultCurrency && !Rates.TryGetValue(fromCurrency, out fromRate)) throw new InvalidOperationException($"Rate for {fromCurrency} not found."); double baseAmount = amount / fromRate; @@ -209,7 +221,7 @@ public async Task ConvertAsync(double amount, string fromCurrency, strin { var message = $"Failed to convert {amount} {fromCurrency} to {toCurrency}"; _logger?.LogError(ex, message); - ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), + ErrorMessageEvents.RaiseErrorEvent("ERROR", "CurrencyService", message, DateTime.Now); } From ddeca816147e2d1de61891366aece25a6a257d7c Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:21:33 -0300 Subject: [PATCH 05/12] fixes maybe for build coverage --- .github/workflows/build.yml | 8 ++++++++ SubathonManager.Tests/SubathonManager.Tests.csproj | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c2bff55..397d9f9a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,14 @@ jobs: tests_failed: ${{ steps.covstats.outputs.tests_failed }} tests_skipped: ${{ steps.covstats.outputs.tests_skipped }} steps: + - name: Override pkill behavior for long process names + run: | + mkdir -p $HOME/bin + echo '#!/bin/bash' > $HOME/bin/pkill + echo 'exec /usr/bin/pkill -f "$@"' >> $HOME/bin/pkill + chmod +x $HOME/bin/pkill + echo "$HOME/bin" >> $GITHUB_PATH + - name: Checkout repository uses: actions/checkout@v6 diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index f45ed160..4b693088 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -1,7 +1,7 @@  - net10.0-windows + net10.0 enable enable false From 42f52f6045d28edc97e82842298bc6c52a9e6c54 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:42:53 -0300 Subject: [PATCH 06/12] maybe fix tests in workflow --- .../PromptOrchestratorService.cs | 43 +++++++++++-------- .../DataUnitTests/OverlayPorterTests.cs | 2 +- .../DataUnitTests/WidgetEntityHelperTests.cs | 2 +- .../BlerpChatServiceTests.cs | 2 +- .../DevTunnelsServiceTests.cs | 2 +- .../ExternalEventServiceTests.cs | 2 +- .../FourthWallUnitTests.cs | 3 +- .../GoAffProServiceTests.cs | 2 +- .../JuniperServiceTests.cs | 2 +- .../IntegrationUnitTests/KoFiServiceTests.cs | 2 +- .../MakeShipServiceTests.cs | 2 +- .../IntegrationUnitTests/PallyServiceTests.cs | 2 +- .../PicartoServiceTests.cs | 2 +- .../StreamElementsServiceTests.cs | 2 +- .../StreamLabsServiceTests.cs | 2 +- .../TangiaServiceTests.cs | 2 +- .../ThroneServiceTests.cs | 2 +- .../TipeeeStreamServiceTests.cs | 2 +- .../TreatStreamServiceTests.cs | 2 +- .../TwitchServiceTests.cs | 2 +- .../YouTubeServiceTests.cs | 2 +- .../SequentialCollectionDefinition.cs | 11 ++--- .../ServerUnitTests/WebServerTests.cs | 2 +- .../WebServerWebSocketTests.cs | 20 ++++----- .../ServicesUnitTests/CommandServiceTests.cs | 2 +- .../WheelSpinTriggerServiceTests.cs | 2 +- SubathonManager.Tests/Utility/EventUtil.cs | 30 +++++++++++++ .../WheelSpin/WheelTriggerEditor.axaml.cs | 16 +++++-- 28 files changed, 103 insertions(+), 64 deletions(-) diff --git a/SubathonManager.Services/PromptOrchestratorService.cs b/SubathonManager.Services/PromptOrchestratorService.cs index 59b9d193..f847f4e3 100644 --- a/SubathonManager.Services/PromptOrchestratorService.cs +++ b/SubathonManager.Services/PromptOrchestratorService.cs @@ -59,30 +59,37 @@ private void UnregisterAll() private async void OnPromptSetEnabledChanged(bool enabled) { - if (enabled && _subathonRunning) + try { - await TryStartSchedulerAsync(); - return; - } - - UnregisterAll(); + if (enabled && _subathonRunning) + { + await TryStartSchedulerAsync(); + return; + } - if (!enabled) - { - await using var db = await factory.CreateDbContextAsync(); - var run = await db.SubathonPromptRuns - .Include(r => r.LinkedPrompt) - .FirstOrDefaultAsync(r => r.Status == SubathonPromptRunStatus.Active); + UnregisterAll(); - if (run != null) + if (!enabled) { - run.Status = SubathonPromptRunStatus.Cancelled; - run.EndedAt = DateTime.Now; - db.SubathonPromptRuns.Update(run); - await db.SaveChangesAsync(); - SubathonEvents.RaisePromptRunUpdate(run, run.LinkedPrompt); + await using var db = await factory.CreateDbContextAsync(); + var run = await db.SubathonPromptRuns + .Include(r => r.LinkedPrompt) + .FirstOrDefaultAsync(r => r.Status == SubathonPromptRunStatus.Active); + + if (run != null) + { + run.Status = SubathonPromptRunStatus.Cancelled; + run.EndedAt = DateTime.Now; + db.SubathonPromptRuns.Update(run); + await db.SaveChangesAsync(); + SubathonEvents.RaisePromptRunUpdate(run, run.LinkedPrompt); + } } } + catch (Exception ex) + { + _logger?.LogError(ex, "[PromptOrchestrator] Error handling prompt set enabled change"); + } } private void OnSubathonDataUpdate(SubathonData data, DateTime _) { diff --git a/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs b/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs index 5c03cbd0..b6bd5c09 100644 --- a/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs +++ b/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs @@ -10,7 +10,7 @@ namespace SubathonManager.Tests.DataUnitTests; -[Collection("ProviderOverrideTests")] +[Collection("GlobalState")] public class OverlayPorterTests : IDisposable { private readonly List _tempDirs = new(); diff --git a/SubathonManager.Tests/DataUnitTests/WidgetEntityHelperTests.cs b/SubathonManager.Tests/DataUnitTests/WidgetEntityHelperTests.cs index a049fbaa..3c8431ac 100644 --- a/SubathonManager.Tests/DataUnitTests/WidgetEntityHelperTests.cs +++ b/SubathonManager.Tests/DataUnitTests/WidgetEntityHelperTests.cs @@ -10,7 +10,7 @@ namespace SubathonManager.Tests.DataUnitTests; -[Collection("ProviderOverrideTests")] +[Collection("GlobalState")] public class WidgetEntityHelperTests { private static IConfig MakeMockConfig(Dictionary<(string, string), string>? values = null) diff --git a/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs index 39434867..99536205 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs @@ -5,7 +5,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class BlerpChatServiceTests { private static SubathonEvent? CaptureEvent(Action trigger) => diff --git a/SubathonManager.Tests/IntegrationUnitTests/DevTunnelsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/DevTunnelsServiceTests.cs index ddab1731..16ef512e 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/DevTunnelsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/DevTunnelsServiceTests.cs @@ -15,7 +15,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class DevTunnelsServiceTests { public DevTunnelsServiceTests() diff --git a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs index e712045a..5d290f8b 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs @@ -10,7 +10,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class ExternalEventServiceTests { private static SubathonEvent? CaptureEvent(Action trigger) => diff --git a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs index 12350332..ab7209bb 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs @@ -28,7 +28,7 @@ // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class FourthWallServiceTests { public FourthWallServiceTests() @@ -358,7 +358,6 @@ public void MapToSubathonEvent_Order_RespectsModeConfig( public void MapToSubathonEvent_Order_SecondaryValue_ContainsProfitAndCurrency() { (FourthWallService service, _) = MakeService(); - // price=12.50×2=25, cost=8×2=16 -> profit=9.00; donation=0 -> totalDirect=9.00 var fwEvent = MakeOrderEvent(subtotal: 25.00, quantity: 2, unitPrice: 12.50, unitCost: 8.00, currency: "USD"); var ev = service.MapToSubathonEvent(fwEvent); diff --git a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs index da1be383..9a5f7bea 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs @@ -11,7 +11,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class GoAffProServiceTests { private static readonly GoAffProStore GamerSupps = new() diff --git a/SubathonManager.Tests/IntegrationUnitTests/JuniperServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/JuniperServiceTests.cs index c18ea5ee..3fc1eb06 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/JuniperServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/JuniperServiceTests.cs @@ -13,7 +13,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class JuniperServiceTests { private const string StoreName = "shop.example.com"; diff --git a/SubathonManager.Tests/IntegrationUnitTests/KoFiServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/KoFiServiceTests.cs index 438d1fc6..b109c321 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/KoFiServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/KoFiServiceTests.cs @@ -16,7 +16,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class KoFiServiceTests { public KoFiServiceTests() diff --git a/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs index 42c8fc91..0995b730 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs @@ -11,7 +11,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class MakeShipServiceTests { private const string PetitionUrl = "https://www.makeship.com/petitions/cool-plush"; diff --git a/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs index 0cb98546..e469d985 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs @@ -9,7 +9,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class PallyServiceTests { private static SubathonEvent? CaptureEvent(Action trigger) => diff --git a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs index 1798fc22..7d85b5fc 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs @@ -20,7 +20,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class PicartoServiceTests { public PicartoServiceTests() diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs index b31ba3da..145356f1 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs @@ -13,7 +13,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class StreamElementsServiceTests { diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs index f0cfdcef..ca27a7e2 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs @@ -14,7 +14,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests { - [Collection("SharedEventBusTests")] + [Collection("GlobalState")] public class StreamLabsServiceTests { private static SubathonEvent? CaptureEvent(Action trigger) => diff --git a/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs index b19d1d05..8449bb0b 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs @@ -13,7 +13,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class TangiaServiceTests { public TangiaServiceTests() diff --git a/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs index c9f34f11..81556d14 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs @@ -14,7 +14,7 @@ // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class ThroneServiceTests { public ThroneServiceTests() diff --git a/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs index e63e3285..314082cf 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs @@ -12,7 +12,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class TipeeeStreamServiceTests { public TipeeeStreamServiceTests() diff --git a/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs index 06d1aaaf..1953dab9 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs @@ -10,7 +10,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class TreatStreamServiceTests { private static SubathonEvent? CaptureEvent(Action trigger) => diff --git a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs index cd489db7..6d37365b 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs @@ -26,7 +26,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests { - [Collection("SharedEventBusTests")] + [Collection("GlobalState")] public class TwitchServiceTests { public TwitchServiceTests() diff --git a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs index 134383bb..1665443d 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs @@ -14,7 +14,7 @@ namespace SubathonManager.Tests.IntegrationUnitTests { - [Collection("SharedEventBusTests")] + [Collection("GlobalState")] public class YouTubeServiceTests { private static SubathonEvent? CaptureEvent(Action trigger) => diff --git a/SubathonManager.Tests/SequentialCollectionDefinition.cs b/SubathonManager.Tests/SequentialCollectionDefinition.cs index 2bf65ed7..47fa772d 100644 --- a/SubathonManager.Tests/SequentialCollectionDefinition.cs +++ b/SubathonManager.Tests/SequentialCollectionDefinition.cs @@ -1,4 +1,4 @@ -namespace SubathonManager.Tests; +namespace SubathonManager.Tests; [CollectionDefinition("Sequential", DisableParallelization = true)] public class SequentialCollectionDefinition @@ -11,11 +11,9 @@ public class SequentialParallelCollectionDefinition { } -[CollectionDefinition("SharedEventBusTests", DisableParallelization = true)] // slowdown but might fix the eventbus issue in websocket consumers -public class SharedEventBusTestsCollection { } -[CollectionDefinition("NonParallel", DisableParallelization = true)] -public class NonParallelCollection { } +[CollectionDefinition("GlobalState", DisableParallelization = true)] +public class GlobalStateCollection { } [CollectionDefinition("ServicesTests", DisableParallelization = true)] public class ServicesTestsCollection { } @@ -23,8 +21,5 @@ public class ServicesTestsCollection { } [CollectionDefinition("CurrencyServiceTests", DisableParallelization = true)] public class CurrencyServiceTestsCollection { } -[CollectionDefinition("ProviderOverrideTests", DisableParallelization = true)] -public class ProviderOverrideTestsCollection { } - [CollectionDefinition("WorkingDirectory", DisableParallelization = true)] public class WorkingDirectoryCollection { } diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs index 81730e3e..c919abf0 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs @@ -12,7 +12,7 @@ // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.ServerUnitTests; -[Collection("ProviderOverrideTests")] +[Collection("GlobalState")] public class WebServerTests { diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs index 87b0cac5..19225674 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs @@ -15,7 +15,7 @@ namespace SubathonManager.Tests.ServerUnitTests; -[Collection("NonParallel")] +[Collection("GlobalState")] public class WebServerWebSocketEventBusEnforcedSequentialTests { @@ -45,12 +45,12 @@ public async Task WebSocket_SendRefreshRequest_NoConsumers() } } -[Collection("SharedEventBusTests")] +[Collection("GlobalState")] public class WebServerWebSocketEventBusTests { - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); + private static Task CaptureEventAsync(Func trigger) => + EventUtil.SubathonEventCapture.CaptureAsync(trigger); [Fact] public async Task WebSocket_ReceiveIntegrationSource_AddsSourceAndEvent() @@ -78,8 +78,8 @@ public async Task WebSocket_ReceiveIntegrationSource_AddsSourceAndEvent() ); ctx.Socket.EnqueueClose(); - SubathonEvent? ev = CaptureEvent( async void () => - await server.HandleWebSocketRequestAsync(ctx)); + SubathonEvent? ev = await CaptureEventAsync( + () => server.HandleWebSocketRequestAsync(ctx)); var result = await sourceTcs.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -125,10 +125,8 @@ public async Task WebSocket_ReceiveCommand() ctx.Socket.EnqueueClose(); - SubathonEvent? ev = CaptureEvent( async void () => - { - await server.HandleWebSocketRequestAsync(ctx); - }); + SubathonEvent? ev = await CaptureEventAsync( + () => server.HandleWebSocketRequestAsync(ctx)); Assert.NotNull(ev); Assert.Equal(SubathonEventSource.External, ev.Source); @@ -143,7 +141,7 @@ public async Task WebSocket_ReceiveCommand() } } -[Collection("ProviderOverrideTests")] +[Collection("GlobalState")] public class WebServerWebSocketTests(ITestOutputHelper testOutputHelper) { private readonly ITestOutputHelper _testOutputHelper = testOutputHelper; diff --git a/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs index a00a2082..eb50aec7 100644 --- a/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs @@ -11,7 +11,7 @@ namespace SubathonManager.Tests.ServicesUnitTests; -[Collection("ProviderOverrideTests")] +[Collection("GlobalState")] public class CommandServiceTests { public CommandServiceTests() diff --git a/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs index 655cfdcf..b8143f0a 100644 --- a/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs @@ -14,7 +14,7 @@ namespace SubathonManager.Tests.ServicesUnitTests; -[Collection("NonParallel")] +[Collection("GlobalState")] public class WheelSpinTriggerServiceTests { private static CurrencyService MakeCurrencyService() diff --git a/SubathonManager.Tests/Utility/EventUtil.cs b/SubathonManager.Tests/Utility/EventUtil.cs index d5b87dcc..07000800 100644 --- a/SubathonManager.Tests/Utility/EventUtil.cs +++ b/SubathonManager.Tests/Utility/EventUtil.cs @@ -8,6 +8,7 @@ public class EventUtil public static class SubathonEventCapture { private static readonly Lock Lock = new(); + private static readonly SemaphoreSlim AsyncGate = new(1, 1); public static SubathonEvent? Capture(Action trigger) { @@ -38,5 +39,34 @@ public static class SubathonEventCapture var ev = Capture(trigger); return ev; } + + public static async Task CaptureAsync(Func trigger) + { + await AsyncGate.WaitAsync(); + try + { + typeof(SubathonEvents) + .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + + SubathonEvent? captured = null; + void Handler(SubathonEvent e) => captured = e; + + SubathonEvents.SubathonEventCreated += Handler; + try + { + await trigger(); + return captured; + } + finally + { + SubathonEvents.SubathonEventCreated -= Handler; + } + } + finally + { + AsyncGate.Release(); + } + } } } diff --git a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs index 7e1e1096..e8392102 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs @@ -91,6 +91,16 @@ private void SuppressChanges(Action action) finally { _suppressCount--; } } + private void SuppressChangesDeferred(Action action) + { + _suppressCount++; + try { action(); } + finally + { + Dispatcher.UIThread.Post(() => _suppressCount--, DispatcherPriority.Background); + } + } + private void MarkDirty() { if (_suppressCount > 0) return; @@ -455,7 +465,7 @@ private void ShowEditor(bool show) private void PopulateEditor(WheelSpinTrigger trigger) { - SuppressChanges(() => + SuppressChangesDeferred(() => { TriggerEnabledCheck.IsChecked = trigger.IsEnabled; @@ -500,7 +510,7 @@ private void PopulateEditor(WheelSpinTrigger trigger) }); EventTypeSourceLabel.Text = trigger.EventType.GetSource().ToString(); - SuppressChanges(() => + SuppressChangesDeferred(() => { var subType = trigger.EventType.GetSubType(); bool isTwitchTier = TwitchTierTypes.Contains(trigger.EventType); @@ -693,7 +703,7 @@ private void AddTrigger_Click(object? sender, RoutedEventArgs e) RefreshTriggerRowHighlight(null); EventTypeSourceLabel.Text = ""; - SuppressChanges(() => + SuppressChangesDeferred(() => { TriggerEnabledCheck.IsChecked = true; _selectedEventTag = null; From 966fc73b2ec266c6517fe63522c4397777b84b1a Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:02:55 -0300 Subject: [PATCH 07/12] debug workflow --- .github/workflows/build.yml | 47 ++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 397d9f9a..8269aea1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,13 +57,21 @@ jobs: tests_failed: ${{ steps.covstats.outputs.tests_failed }} tests_skipped: ${{ steps.covstats.outputs.tests_skipped }} steps: - - name: Override pkill behavior for long process names + + - name: Trace pkill calls run: | - mkdir -p $HOME/bin - echo '#!/bin/bash' > $HOME/bin/pkill - echo 'exec /usr/bin/pkill -f "$@"' >> $HOME/bin/pkill - chmod +x $HOME/bin/pkill - echo "$HOME/bin" >> $GITHUB_PATH + mkdir -p "$HOME/bin" + cat > "$HOME/bin/pkill" <<'SHIM' + #!/bin/bash + { + echo "[pkill-trace] args: $*" + echo "[pkill-trace] caller pid=$PPID" + ps -o pid=,ppid=,comm=,args= -p "$PPID" 2>/dev/null + } >&2 + exec /usr/bin/pkill "$@" + SHIM + chmod +x "$HOME/bin/pkill" + echo "$HOME/bin" >> "$GITHUB_PATH" - name: Checkout repository uses: actions/checkout@v6 @@ -90,10 +98,37 @@ jobs: --configuration Release \ --logger "console;verbosity=detailed" \ --logger "trx;LogFileName=test-results.trx" \ + --blame \ + --blame-crash \ + --blame-hang \ + --blame-hang-timeout 5m \ --collect:"XPlat Code Coverage" \ --results-directory ./coverage \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="**/Migrations/**;**/*.g.cs;**/SubathonManager.UI/**" + - name: Show blame / crash diagnostics + if: always() + shell: bash + run: | + echo "--- files produced by --blame ---" + find ./coverage -iname "Sequence*.xml" -o -iname "*.dmp" -o -iname "core*" || true + for f in $(find ./coverage -iname "Sequence*.xml" 2>/dev/null); do + echo "===== $f =====" + cat "$f" + done + + - name: Upload blame / crash diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-blame-diagnostics + path: | + coverage/**/Sequence*.xml + coverage/**/*.dmp + coverage/**/*.trx + if-no-files-found: ignore + retention-days: 7 + - name: List raw coverage files if: always() shell: bash From b18ca42bc5bb4bc27ef9065f704c388a46f87905 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:19:45 -0300 Subject: [PATCH 08/12] fix tests --- SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs b/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs index 247d8af9..cb8bf12b 100644 --- a/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs @@ -43,12 +43,5 @@ public async Task InstallUpdate_ReturnsFalse_WhenAssetIsNull() Assert.False(installed); } - [Fact] - public async Task DownloadAndInstall_ReturnsFalse_WhenDownloadOrInstallFails() - { - var logger = _loggerMock.Object; - bool result = await AppServices.DownloadAndInstall(logger); - Assert.False(result); - } } } \ No newline at end of file From 49c61e011376d5a7a5dc5915430038698f4ac95d Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:07:56 -0300 Subject: [PATCH 09/12] update packages maybe linux webview fix --- SubathonManager.Data/SubathonManager.Data.csproj | 8 ++++---- .../SubathonManager.Integration.csproj | 2 +- .../SubathonManager.Services.csproj | 2 +- SubathonManager.Tests/SubathonManager.Tests.csproj | 10 +++++----- SubathonManager.UI/SubathonManager.UI.csproj | 14 +++++++------- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/SubathonManager.Data/SubathonManager.Data.csproj b/SubathonManager.Data/SubathonManager.Data.csproj index 59e46bf4..344657a8 100644 --- a/SubathonManager.Data/SubathonManager.Data.csproj +++ b/SubathonManager.Data/SubathonManager.Data.csproj @@ -13,14 +13,14 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + diff --git a/SubathonManager.Integration/SubathonManager.Integration.csproj b/SubathonManager.Integration/SubathonManager.Integration.csproj index 916546a4..a29c659a 100644 --- a/SubathonManager.Integration/SubathonManager.Integration.csproj +++ b/SubathonManager.Integration/SubathonManager.Integration.csproj @@ -16,7 +16,7 @@ - + diff --git a/SubathonManager.Services/SubathonManager.Services.csproj b/SubathonManager.Services/SubathonManager.Services.csproj index ffeb3c37..c3ea79fc 100644 --- a/SubathonManager.Services/SubathonManager.Services.csproj +++ b/SubathonManager.Services/SubathonManager.Services.csproj @@ -15,7 +15,7 @@ - + diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index 4b693088..2c566926 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -16,12 +16,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - - + + diff --git a/SubathonManager.UI/SubathonManager.UI.csproj b/SubathonManager.UI/SubathonManager.UI.csproj index 0caad086..d2bf4f4b 100644 --- a/SubathonManager.UI/SubathonManager.UI.csproj +++ b/SubathonManager.UI/SubathonManager.UI.csproj @@ -47,17 +47,17 @@ - - - - - + + + + + - + - + From a1781abf175b5b5e3e8f9f2b0da4a84949ecbc46 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:13:03 -0300 Subject: [PATCH 10/12] remove debug --- .github/workflows/build.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8269aea1..1404dc5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,37 +98,10 @@ jobs: --configuration Release \ --logger "console;verbosity=detailed" \ --logger "trx;LogFileName=test-results.trx" \ - --blame \ - --blame-crash \ - --blame-hang \ - --blame-hang-timeout 5m \ --collect:"XPlat Code Coverage" \ --results-directory ./coverage \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="**/Migrations/**;**/*.g.cs;**/SubathonManager.UI/**" - - name: Show blame / crash diagnostics - if: always() - shell: bash - run: | - echo "--- files produced by --blame ---" - find ./coverage -iname "Sequence*.xml" -o -iname "*.dmp" -o -iname "core*" || true - for f in $(find ./coverage -iname "Sequence*.xml" 2>/dev/null); do - echo "===== $f =====" - cat "$f" - done - - - name: Upload blame / crash diagnostics - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-blame-diagnostics - path: | - coverage/**/Sequence*.xml - coverage/**/*.dmp - coverage/**/*.trx - if-no-files-found: ignore - retention-days: 7 - - name: List raw coverage files if: always() shell: bash From 18a87be3d9c57c784706bee4ceded19449d462d5 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:16:26 -0300 Subject: [PATCH 11/12] revert test ver --- .github/workflows/build.yml | 27 +++++++++++++++++++ .../SubathonManager.Tests.csproj | 6 ++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1404dc5d..8269aea1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,10 +98,37 @@ jobs: --configuration Release \ --logger "console;verbosity=detailed" \ --logger "trx;LogFileName=test-results.trx" \ + --blame \ + --blame-crash \ + --blame-hang \ + --blame-hang-timeout 5m \ --collect:"XPlat Code Coverage" \ --results-directory ./coverage \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="**/Migrations/**;**/*.g.cs;**/SubathonManager.UI/**" + - name: Show blame / crash diagnostics + if: always() + shell: bash + run: | + echo "--- files produced by --blame ---" + find ./coverage -iname "Sequence*.xml" -o -iname "*.dmp" -o -iname "core*" || true + for f in $(find ./coverage -iname "Sequence*.xml" 2>/dev/null); do + echo "===== $f =====" + cat "$f" + done + + - name: Upload blame / crash diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-blame-diagnostics + path: | + coverage/**/Sequence*.xml + coverage/**/*.dmp + coverage/**/*.trx + if-no-files-found: ignore + retention-days: 7 + - name: List raw coverage files if: always() shell: bash diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index 2c566926..c214f071 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -18,10 +18,10 @@ - + - - + + From d15c8c26d210ccf6a6c434882ff477877525244b Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:17:53 -0300 Subject: [PATCH 12/12] revert stuff --- SubathonManager.Tests/SubathonManager.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index c214f071..4b693088 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -16,8 +16,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + +