diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1c2bff55..8269aea1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -57,6 +57,22 @@ jobs:
tests_failed: ${{ steps.covstats.outputs.tests_failed }}
tests_skipped: ${{ steps.covstats.outputs.tests_skipped }}
steps:
+
+ - name: Trace pkill calls
+ run: |
+ 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
@@ -82,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/.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.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.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);
+ }
+ }
+}
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);
}
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.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/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
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/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
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/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/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;
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 @@
-
-
-
-
-
+
+
+
+
+
-
+
-
+
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..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;
@@ -98,6 +108,12 @@ private void MarkDirty()
UpdateSaveButtonState();
}
+ private void MarkDirty(object? sender)
+ {
+ if (!DirtySaveGuard.Consume(sender)) return;
+ MarkDirty();
+ }
+
private void UpdateSaveButtonState()
{
bool showGlow;
@@ -137,20 +153,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)
@@ -435,7 +465,7 @@ private void ShowEditor(bool show)
private void PopulateEditor(WheelSpinTrigger trigger)
{
- SuppressChanges(() =>
+ SuppressChangesDeferred(() =>
{
TriggerEnabledCheck.IsChecked = trigger.IsEnabled;
@@ -480,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);
@@ -673,7 +703,7 @@ private void AddTrigger_Click(object? sender, RoutedEventArgs e)
RefreshTriggerRowHighlight(null);
EventTypeSourceLabel.Text = "";
- SuppressChanges(() =>
+ SuppressChangesDeferred(() =>
{
TriggerEnabledCheck.IsChecked = true;
_selectedEventTag = null;