diff --git a/F4SD.Cockpit.Client.Test/Basics/Sevices/TicketOverview/TicketOverviewCountProcessorTest.cs b/F4SD.Cockpit.Client.Test/Basics/Sevices/TicketOverview/TicketOverviewCountProcessorTest.cs new file mode 100644 index 0000000..60bacea --- /dev/null +++ b/F4SD.Cockpit.Client.Test/Basics/Sevices/TicketOverview/TicketOverviewCountProcessorTest.cs @@ -0,0 +1,150 @@ +using FasdDesktopUi.Basics.Services.Models; + +namespace F4SD.Cockpit.Client.Test.Basics.Sevices.TicketOverview; + +public class TicketOverviewCountProcessorTest +{ + private static readonly string[] OverviewKeys = ["TicketsNew", "IncidentNew"]; + + [Fact] + public void Calculate_PersonalScope_UpdatesOnlyPersonalAndReturnsDeltaChanges() + { + // Arrange + Dictionary current = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = new TileCounts(1, 7), + ["IncidentNew"] = new TileCounts(2, 8) + }; + Dictionary incoming = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 5, + ["IncidentNew"] = 2 + }; + + // Act + ScopeCountProcessingResult result = TicketOverviewCountProcessor.Calculate( + current, + OverviewKeys, + TileScope.Personal, + incoming, + hasInitializedScope: true); + + // Assert + Assert.False(result.IsInitialization); + Assert.Equal(5, result.UpdatedCounts["TicketsNew"].Personal); + Assert.Equal(7, result.UpdatedCounts["TicketsNew"].Role); + Assert.Equal(2, result.UpdatedCounts["IncidentNew"].Personal); + Assert.Equal(8, result.UpdatedCounts["IncidentNew"].Role); + + Assert.Single(result.Changes); + TileCountChange change = result.Changes[0]; + Assert.Equal("TicketsNew", change.Key); + Assert.Equal(TileScope.Personal, change.Scope); + Assert.Equal(1, change.OldCount); + Assert.Equal(5, change.NewCount); + Assert.Equal(4, change.Delta); + } + + [Fact] + public void Calculate_RoleScope_UpdatesOnlyRoleAndReturnsDeltaChanges() + { + // Arrange + Dictionary current = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = new TileCounts(10, 3), + ["IncidentNew"] = new TileCounts(4, 6) + }; + Dictionary incoming = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 3, + ["IncidentNew"] = 9 + }; + + // Act + ScopeCountProcessingResult result = TicketOverviewCountProcessor.Calculate( + current, + OverviewKeys, + TileScope.Role, + incoming, + hasInitializedScope: true); + + // Assert + Assert.False(result.IsInitialization); + Assert.Equal(10, result.UpdatedCounts["TicketsNew"].Personal); + Assert.Equal(3, result.UpdatedCounts["TicketsNew"].Role); + Assert.Equal(4, result.UpdatedCounts["IncidentNew"].Personal); + Assert.Equal(9, result.UpdatedCounts["IncidentNew"].Role); + + Assert.Single(result.Changes); + TileCountChange change = result.Changes[0]; + Assert.Equal("IncidentNew", change.Key); + Assert.Equal(TileScope.Role, change.Scope); + Assert.Equal(6, change.OldCount); + Assert.Equal(9, change.NewCount); + Assert.Equal(3, change.Delta); + } + + [Fact] + public void Calculate_FirstInitialization_DoesNotReturnDeltaChanges() + { + // Arrange + Dictionary current = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = new TileCounts(0, 0), + ["IncidentNew"] = new TileCounts(0, 0) + }; + Dictionary incoming = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 4, + ["IncidentNew"] = 1 + }; + + // Act + ScopeCountProcessingResult result = TicketOverviewCountProcessor.Calculate( + current, + OverviewKeys, + TileScope.Personal, + incoming, + hasInitializedScope: false); + + // Assert + Assert.True(result.IsInitialization); + Assert.Empty(result.Changes); + Assert.Equal(4, result.UpdatedCounts["TicketsNew"].Personal); + Assert.Equal(1, result.UpdatedCounts["IncidentNew"].Personal); + } + + [Fact] + public void Calculate_MissingIncomingValues_DefaultsToZero() + { + // Arrange + Dictionary current = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = new TileCounts(5, 1), + ["IncidentNew"] = new TileCounts(2, 4) + }; + Dictionary incoming = new(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 5 + }; + + // Act + ScopeCountProcessingResult result = TicketOverviewCountProcessor.Calculate( + current, + OverviewKeys, + TileScope.Personal, + incoming, + hasInitializedScope: true); + + // Assert + Assert.Equal(5, result.UpdatedCounts["TicketsNew"].Personal); + Assert.Equal(1, result.UpdatedCounts["TicketsNew"].Role); + Assert.Equal(0, result.UpdatedCounts["IncidentNew"].Personal); + Assert.Equal(4, result.UpdatedCounts["IncidentNew"].Role); + + Assert.Single(result.Changes); + Assert.Equal("IncidentNew", result.Changes[0].Key); + Assert.Equal(2, result.Changes[0].OldCount); + Assert.Equal(0, result.Changes[0].NewCount); + } +} diff --git a/F4SD.Cockpit.Client.Test/Basics/Sevices/TicketOverview/TicketOverviewUpdateServiceTest.cs b/F4SD.Cockpit.Client.Test/Basics/Sevices/TicketOverview/TicketOverviewUpdateServiceTest.cs new file mode 100644 index 0000000..30bea7d --- /dev/null +++ b/F4SD.Cockpit.Client.Test/Basics/Sevices/TicketOverview/TicketOverviewUpdateServiceTest.cs @@ -0,0 +1,243 @@ +using FasdDesktopUi.Basics.Services; +using FasdDesktopUi.Basics.Services.Models; + +namespace F4SD.Cockpit.Client.Test.Basics.Sevices.TicketOverview; + +public class TicketOverviewUpdateServiceTest +{ + [Fact] + public async Task UpdateAvailability_FirstFetch_EmitsInitializationEventsForBothScopes() + { + // Arrange + var communication = new FakeCommunication(); + communication.SetCounts(TileScope.Personal, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 2 + }); + communication.SetCounts(TileScope.Role, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 5 + }); + + var service = CreateService(communication, out _); + var events = new List(); + service.OverviewCountsChanged += (_, args) => events.Add(args); + + // Act + service.UpdateAvailability(true); + await WaitUntilAsync(() => service.AreAllScopesInitialized); + + // Assert + Assert.Equal(2, events.Count); + Assert.All(events, e => Assert.Empty(e.Changes)); + Assert.Contains(events, e => e.InitializedScope == TileScope.Personal); + Assert.Contains(events, e => e.InitializedScope == TileScope.Role); + Assert.Equal(2, service.CurrentCounts["TicketsNew"].Personal); + Assert.Equal(5, service.CurrentCounts["TicketsNew"].Role); + } + + [Fact] + public async Task FetchAsync_ChangedAndUnchangedCounts_EmitsOnlyRealDeltaEvent() + { + // Arrange + var communication = new FakeCommunication(); + communication.SetCounts(TileScope.Personal, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 1 + }); + communication.SetCounts(TileScope.Role, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 3 + }); + + var service = CreateService(communication, out _); + var events = new List(); + service.OverviewCountsChanged += (_, args) => events.Add(args); + + service.UpdateAvailability(true); + await WaitUntilAsync(() => service.AreAllScopesInitialized); + events.Clear(); + + // Act + Assert (changed) + communication.SetCounts(TileScope.Personal, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 4 + }); + await service.FetchAsync(TileScope.Personal); + + Assert.Single(events); + Assert.Single(events[0].Changes); + Assert.Equal("TicketsNew", events[0].Changes[0].Key); + Assert.Equal(1, events[0].Changes[0].OldCount); + Assert.Equal(4, events[0].Changes[0].NewCount); + Assert.Equal(3, events[0].Changes[0].Delta); + + // Act + Assert (unchanged) + events.Clear(); + await service.FetchAsync(TileScope.Personal); + Assert.Empty(events); + } + + [Fact] + public async Task Stop_ResetsInitializationAndCounts() + { + // Arrange + var communication = new FakeCommunication(); + communication.SetCounts(TileScope.Personal, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TicketsNew"] = 7 + }); + communication.SetCounts(TileScope.Role, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["IncidentNew"] = 9 + }); + + var service = CreateService(communication, out _); + + service.UpdateAvailability(true); + await WaitUntilAsync(() => service.AreAllScopesInitialized); + + // Act + service.Stop(); + + // Assert + Assert.False(service.IsScopeInitialized(TileScope.Personal)); + Assert.False(service.IsScopeInitialized(TileScope.Role)); + Assert.All(service.CurrentCounts.Values, value => + { + Assert.Equal(0, value.Personal); + Assert.Equal(0, value.Role); + }); + } + + private static TicketOverviewUpdateService CreateService(FakeCommunication communication, out FakeDispatcher dispatcher) + { + dispatcher = new FakeDispatcher(); + var source = new FakeCommunicationSource(communication); + var settings = new FakeSettingsProvider(); + return new TicketOverviewUpdateService(source, dispatcher, settings); + } + + private static async Task WaitUntilAsync(Func condition, int timeoutMs = 2000) + { + var startedAt = DateTime.UtcNow; + while (!condition()) + { + if ((DateTime.UtcNow - startedAt).TotalMilliseconds > timeoutMs) + { + throw new TimeoutException("Condition was not reached within timeout."); + } + + await Task.Delay(10); + } + } + + private sealed class FakeCommunicationSource : ITicketOverviewCommunicationSource + { + private readonly ITicketOverviewCommunication _communication; + + public FakeCommunicationSource(ITicketOverviewCommunication communication) + { + _communication = communication; + } + + public ITicketOverviewCommunication Resolve() + { + return _communication; + } + } + + private sealed class FakeCommunication : ITicketOverviewCommunication + { + private Dictionary _personalCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + private Dictionary _roleCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public bool IsDemo() + { + return false; + } + + public Task> GetTicketOverviewCounts(string[] overviewKeys, bool useRoleScope) + { + var source = useRoleScope ? _roleCounts : _personalCounts; + return Task.FromResult(new Dictionary(source, StringComparer.OrdinalIgnoreCase)); + } + + public void RegisterGeneratedTicket(FasdCockpitCommunicationDemo.DemoTicketRecord record) + { + } + + public void SetCounts(TileScope scope, Dictionary counts) + { + var copy = counts == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(counts, StringComparer.OrdinalIgnoreCase); + + if (scope == TileScope.Role) + { + _roleCounts = copy; + } + else + { + _personalCounts = copy; + } + } + } + + private sealed class FakeDispatcher : ITicketOverviewDispatcher + { + public Task InvokeAsync(Action action) + { + action(); + return Task.CompletedTask; + } + + public Task InvokeAsync(Func action) + { + return action(); + } + + public ITicketOverviewTimer CreateTimer(TimeSpan interval, Action tick) + { + return new FakeTimer(interval, tick); + } + } + + private sealed class FakeSettingsProvider : ITicketOverviewSettingsProvider + { + public int GetPollingMinutes(TileScope scope) + { + return 1; + } + } + + private sealed class FakeTimer : ITicketOverviewTimer + { + private readonly Action _tick; + + public FakeTimer(TimeSpan interval, Action tick) + { + Interval = interval; + _tick = tick; + } + + public TimeSpan Interval { get; set; } + + public bool IsEnabled { get; private set; } + + public void Start() + { + IsEnabled = true; + } + + public void Stop() + { + IsEnabled = false; + } + + public void Fire() + { + _tick?.Invoke(); + } + } +} diff --git a/FasdDesktopUi/Basics/Services/Models/TicketOverviewCommunicationSource.cs b/FasdDesktopUi/Basics/Services/Models/TicketOverviewCommunicationSource.cs new file mode 100644 index 0000000..7bb7bc4 --- /dev/null +++ b/FasdDesktopUi/Basics/Services/Models/TicketOverviewCommunicationSource.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using C4IT.FASD.Cockpit.Communication; +#if isDemo +using FasdCockpitCommunicationDemo; +#endif + +namespace FasdDesktopUi.Basics.Services.Models +{ + internal interface ITicketOverviewCommunication + { + bool IsDemo(); + + Task> GetTicketOverviewCounts(string[] overviewKeys, bool useRoleScope); + +#if isDemo + void RegisterGeneratedTicket(DemoTicketRecord record); +#endif + } + + internal interface ITicketOverviewCommunicationSource + { + ITicketOverviewCommunication Resolve(); + } + + internal sealed class TicketOverviewCommunicationSource : ITicketOverviewCommunicationSource + { + public ITicketOverviewCommunication Resolve() + { + var communication = cFasdCockpitCommunicationBase.Instance; + return communication == null ? null : new TicketOverviewCommunicationAdapter(communication); + } + } + + internal sealed class TicketOverviewCommunicationAdapter : ITicketOverviewCommunication + { + private readonly cFasdCockpitCommunicationBase _communication; + + internal TicketOverviewCommunicationAdapter(cFasdCockpitCommunicationBase communication) + { + _communication = communication ?? throw new ArgumentNullException(nameof(communication)); + } + + public bool IsDemo() + { + return _communication.IsDemo(); + } + + public async Task> GetTicketOverviewCounts(string[] overviewKeys, bool useRoleScope) + { + var rawCounts = await _communication.GetTicketOverviewCounts(overviewKeys, useRoleScope).ConfigureAwait(false); + return rawCounts == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(rawCounts, StringComparer.OrdinalIgnoreCase); + } + +#if isDemo + public void RegisterGeneratedTicket(DemoTicketRecord record) + { + var demoCommunication = _communication as cFasdCockpitCommunicationDemo; + demoCommunication?.RegisterGeneratedTicket(record); + } +#endif + } +} diff --git a/FasdDesktopUi/Basics/Services/Models/TicketOverviewCountProcessor.cs b/FasdDesktopUi/Basics/Services/Models/TicketOverviewCountProcessor.cs new file mode 100644 index 0000000..dced656 --- /dev/null +++ b/FasdDesktopUi/Basics/Services/Models/TicketOverviewCountProcessor.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; + +namespace FasdDesktopUi.Basics.Services.Models +{ + internal static class TicketOverviewCountProcessor + { + internal static ScopeCountProcessingResult Calculate( + IReadOnlyDictionary currentCounts, + IEnumerable overviewKeys, + TileScope scope, + IDictionary incomingCounts, + bool hasInitializedScope) + { + if (overviewKeys == null) + throw new ArgumentNullException(nameof(overviewKeys)); + + var updatedCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (currentCounts != null) + { + foreach (var kvp in currentCounts) + { + updatedCounts[kvp.Key] = kvp.Value; + } + } + + var changes = new List(); + foreach (var key in overviewKeys) + { + var previous = currentCounts != null && currentCounts.TryGetValue(key, out var counts) + ? counts + : TileCounts.Empty; + var incoming = incomingCounts != null && incomingCounts.TryGetValue(key, out var value) + ? value + : 0; + + TileCounts updated; + int oldValue; + + if (scope == TileScope.Role) + { + updated = new TileCounts(previous.Personal, incoming); + oldValue = previous.Role; + } + else + { + updated = new TileCounts(incoming, previous.Role); + oldValue = previous.Personal; + } + + updatedCounts[key] = updated; + + if (hasInitializedScope && oldValue != incoming) + changes.Add(new TileCountChange(key, scope, oldValue, incoming)); + } + + return new ScopeCountProcessingResult(updatedCounts, changes, !hasInitializedScope); + } + } + + internal sealed class ScopeCountProcessingResult + { + internal ScopeCountProcessingResult( + IReadOnlyDictionary updatedCounts, + IReadOnlyList changes, + bool isInitialization) + { + UpdatedCounts = updatedCounts ?? throw new ArgumentNullException(nameof(updatedCounts)); + Changes = changes ?? Array.Empty(); + IsInitialization = isInitialization; + } + + internal IReadOnlyDictionary UpdatedCounts { get; } + + internal IReadOnlyList Changes { get; } + + internal bool IsInitialization { get; } + } +} diff --git a/FasdDesktopUi/Basics/Services/Models/TicketOverviewDispatcher.cs b/FasdDesktopUi/Basics/Services/Models/TicketOverviewDispatcher.cs new file mode 100644 index 0000000..c864118 --- /dev/null +++ b/FasdDesktopUi/Basics/Services/Models/TicketOverviewDispatcher.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace FasdDesktopUi.Basics.Services.Models +{ + internal interface ITicketOverviewTimer + { + TimeSpan Interval { get; set; } + + bool IsEnabled { get; } + + void Start(); + + void Stop(); + } + + internal interface ITicketOverviewDispatcher + { + Task InvokeAsync(Action action); + + Task InvokeAsync(Func action); + + ITicketOverviewTimer CreateTimer(TimeSpan interval, Action tick); + } + + internal sealed class TicketOverviewDispatcher : ITicketOverviewDispatcher + { + private readonly Dispatcher _dispatcher; + + internal TicketOverviewDispatcher(Dispatcher dispatcher) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + public Task InvokeAsync(Action action) + { + return _dispatcher.InvokeAsync(action, DispatcherPriority.Background).Task; + } + + public Task InvokeAsync(Func action) + { + return _dispatcher.InvokeAsync(action, DispatcherPriority.Background).Task.Unwrap(); + } + + public ITicketOverviewTimer CreateTimer(TimeSpan interval, Action tick) + { + var timer = new DispatcherTimer(interval, DispatcherPriority.Background, (sender, args) => tick?.Invoke(), _dispatcher) + { + IsEnabled = false + }; + return new TicketOverviewTimer(timer); + } + } + + internal sealed class TicketOverviewTimer : ITicketOverviewTimer + { + private readonly DispatcherTimer _timer; + + internal TicketOverviewTimer(DispatcherTimer timer) + { + _timer = timer ?? throw new ArgumentNullException(nameof(timer)); + } + + public TimeSpan Interval + { + get => _timer.Interval; + set => _timer.Interval = value; + } + + public bool IsEnabled => _timer.IsEnabled; + + public void Start() + { + _timer.Start(); + } + + public void Stop() + { + _timer.Stop(); + } + } +} diff --git a/FasdDesktopUi/Basics/Services/Models/TicketOverviewSettingsProvider.cs b/FasdDesktopUi/Basics/Services/Models/TicketOverviewSettingsProvider.cs new file mode 100644 index 0000000..0026f8e --- /dev/null +++ b/FasdDesktopUi/Basics/Services/Models/TicketOverviewSettingsProvider.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics; +using C4IT.FASD.Base; + +namespace FasdDesktopUi.Basics.Services.Models +{ + internal interface ITicketOverviewSettingsProvider + { + int GetPollingMinutes(TileScope scope); + } + + internal sealed class TicketOverviewSettingsProvider : ITicketOverviewSettingsProvider + { + public int GetPollingMinutes(TileScope scope) + { + int minutes = scope == TileScope.Role + ? cF4sdTicketConfig.DefaultOverviewPollingRole + : cF4sdTicketConfig.DefaultOverviewPollingPersonal; + + try + { + var ticketConfig = cFasdCockpitConfig.Instance?.Global?.TicketConfiguration; + if (ticketConfig != null) + { + minutes = scope == TileScope.Role + ? ticketConfig.OverviewPollingRole + : ticketConfig.OverviewPollingPersonal; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[TicketOverview] Settings fallback to defaults: {ex.Message}"); + } + + if (minutes < 1) + { + minutes = 1; + } + + return minutes; + } + } +} diff --git a/FasdDesktopUi/Basics/Services/TicketOverviewUpdateService.cs b/FasdDesktopUi/Basics/Services/TicketOverviewUpdateService.cs index 6cc90a9..16636b0 100644 --- a/FasdDesktopUi/Basics/Services/TicketOverviewUpdateService.cs +++ b/FasdDesktopUi/Basics/Services/TicketOverviewUpdateService.cs @@ -1,13 +1,12 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Threading; - -using C4IT.FASD.Base; -using C4IT.FASD.Cockpit.Communication; -using FasdDesktopUi; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using C4IT.FASD.Base; +using C4IT.FASD.Cockpit.Communication; using FasdDesktopUi.Basics.Models; using FasdDesktopUi.Basics.Services.Models; #if isDemo @@ -18,8 +17,11 @@ using System.Text.RegularExpressions; namespace FasdDesktopUi.Basics.Services { - public sealed class TicketOverviewUpdateService - { + public sealed class TicketOverviewUpdateService + { + #region Fields + + private readonly ITicketOverviewCommunicationSource _communicationSource; private static readonly string[] OverviewKeys = new[] { "TicketsNew", @@ -33,54 +35,78 @@ namespace FasdDesktopUi.Basics.Services "UnassignedTickets", "UnassignedTicketsCritical" }; - private readonly Dispatcher _dispatcher; + private readonly ITicketOverviewDispatcher _dispatcher; + private readonly ITicketOverviewSettingsProvider _settingsProvider; private readonly Dictionary _currentCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<(string Key, bool UseRoleScope), List> _demoRelations = new Dictionary<(string, bool), List>(); private readonly HashSet _pendingScopes = new HashSet(); private readonly HashSet _initializedScopes = new HashSet(); private readonly object _fetchLock = new object(); private readonly HashSet _retryScopes = new HashSet(); - private DispatcherTimer _personalTimer; - private DispatcherTimer _roleTimer; + private ITicketOverviewTimer _personalTimer; + private ITicketOverviewTimer _roleTimer; private Task _fetchWorker; private bool _retryScheduled; private bool _isDemo; private bool _initialized; private bool _isEnabled; private readonly Random _random = new Random(); -#if isDemo - private readonly List _persistedDemoTickets = new List(); - private readonly List _demoTemplates = new List(); - private readonly HashSet _usedSummaries = new HashSet(StringComparer.OrdinalIgnoreCase); - private const int SimulationHotkeyDelayMs = 400; - private int _pendingSimulations; - private DispatcherTimer _simulationFlushTimer; -#endif - - private TicketOverviewUpdateService() - { - _dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; - foreach (var key in OverviewKeys) - { - _currentCounts[key] = TileCounts.Empty; - } -#if isDemo - _simulationFlushTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(SimulationHotkeyDelayMs), DispatcherPriority.Background, SimulationFlushTimer_Tick, _dispatcher) - { - IsEnabled = false - }; -#endif - } - - static TicketOverviewUpdateService() +#if isDemo + private readonly List _persistedDemoTickets = new List(); + private readonly List _demoTemplates = new List(); + private readonly HashSet _usedSummaries = new HashSet(StringComparer.OrdinalIgnoreCase); + private const int SimulationHotkeyDelayMs = 400; + private int _pendingSimulations; + private ITicketOverviewTimer _simulationFlushTimer; +#endif + + #endregion + + #region Construction and Singleton + + private TicketOverviewUpdateService() + : this( + new TicketOverviewCommunicationSource(), + new TicketOverviewDispatcher(Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher), + new TicketOverviewSettingsProvider()) { - Instance = new TicketOverviewUpdateService(); } - public static TicketOverviewUpdateService Instance { get; } - - public event EventHandler OverviewCountsChanged; - + internal TicketOverviewUpdateService( + ITicketOverviewCommunicationSource communicationSource, + ITicketOverviewDispatcher dispatcher) + : this(communicationSource, dispatcher, new TicketOverviewSettingsProvider()) + { + } + + internal TicketOverviewUpdateService( + ITicketOverviewCommunicationSource communicationSource, + ITicketOverviewDispatcher dispatcher, + ITicketOverviewSettingsProvider settingsProvider) + { + _communicationSource = communicationSource ?? throw new ArgumentNullException(nameof(communicationSource)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider)); + foreach (var key in OverviewKeys) + { + _currentCounts[key] = TileCounts.Empty; + } +#if isDemo + _simulationFlushTimer = _dispatcher.CreateTimer(TimeSpan.FromMilliseconds(SimulationHotkeyDelayMs), SimulationFlushTimer_Tick); +#endif + } + + private static readonly Lazy _instance = + new Lazy(() => new TicketOverviewUpdateService()); + + public static TicketOverviewUpdateService Instance => _instance.Value; + + #endregion + + #region Public API + + public event EventHandler OverviewCountsChanged; + public IReadOnlyDictionary CurrentCounts => _currentCounts; public bool IsScopeInitialized(TileScope scope) @@ -113,6 +139,10 @@ namespace FasdDesktopUi.Basics.Services UpdateAvailability(false); } + #endregion + + #region Lifecycle + public void UpdateAvailability(bool isEnabled) { if (isEnabled) @@ -146,7 +176,7 @@ namespace FasdDesktopUi.Basics.Services _isDemo = true; LoadPersistedDemoTickets(); #else - _isDemo = cFasdCockpitCommunicationBase.Instance?.IsDemo() == true; + _isDemo = _communicationSource.Resolve()?.IsDemo() == true; #endif InitializeTimers(); @@ -171,7 +201,7 @@ namespace FasdDesktopUi.Basics.Services _retryScopes.Clear(); } - _dispatcher.InvokeAsync(() => + _ = _dispatcher.InvokeAsync(() => { _personalTimer?.Stop(); _roleTimer?.Stop(); @@ -185,6 +215,10 @@ namespace FasdDesktopUi.Basics.Services }); } + #endregion + + #region Polling and Fetch Pipeline + private void InitializeTimers() { _personalTimer = CreateScopeTimer(TileScope.Personal); @@ -194,33 +228,15 @@ namespace FasdDesktopUi.Basics.Services _roleTimer?.Start(); } - private DispatcherTimer CreateScopeTimer(TileScope scope) + private ITicketOverviewTimer CreateScopeTimer(TileScope scope) { var interval = GetPollingInterval(scope); - var timer = new DispatcherTimer(interval, DispatcherPriority.Background, async (s, e) => await FetchAsync(scope).ConfigureAwait(false), _dispatcher) - { - IsEnabled = false - }; - return timer; + return _dispatcher.CreateTimer(interval, () => _ = FetchAsync(scope)); } private TimeSpan GetPollingInterval(TileScope scope) { - var ticketConfig = cFasdCockpitConfig.Instance?.Global?.TicketConfiguration; - int minutes = scope == TileScope.Role - ? cF4sdTicketConfig.DefaultOverviewPollingRole - : cF4sdTicketConfig.DefaultOverviewPollingPersonal; - - if (ticketConfig != null) - { - minutes = scope == TileScope.Role - ? ticketConfig.OverviewPollingRole - : ticketConfig.OverviewPollingPersonal; - } - - if (minutes < 1) - minutes = 1; - + int minutes = _settingsProvider.GetPollingMinutes(scope); return TimeSpan.FromMinutes(minutes); } @@ -291,7 +307,7 @@ namespace FasdDesktopUi.Basics.Services if (!_isEnabled) return; - var communication = cFasdCockpitCommunicationBase.Instance; + var communication = _communicationSource.Resolve(); if (communication == null) { ScheduleFetchRetry(scope); @@ -328,9 +344,13 @@ namespace FasdDesktopUi.Basics.Services } } + #endregion + + #region Count Processing + private void RefreshTimerIntervals() { - _dispatcher.InvokeAsync(() => + _ = _dispatcher.InvokeAsync(() => { if (_personalTimer != null) _personalTimer.Interval = GetPollingInterval(TileScope.Personal); @@ -345,39 +365,26 @@ namespace FasdDesktopUi.Basics.Services if (newCounts == null) return; - var hasInitializedScope = _initializedScopes.Contains(scope); - var changes = new List(); - - foreach (var key in OverviewKeys) + bool hasInitializedScope; + lock (_fetchLock) { - var previous = _currentCounts.TryGetValue(key, out var counts) ? counts : TileCounts.Empty; - var incoming = newCounts.TryGetValue(key, out var value) ? value : 0; - - TileCounts updated; - int oldValue; - - if (scope == TileScope.Role) - { - updated = new TileCounts(previous.Personal, incoming); - oldValue = previous.Role; - } - else - { - updated = new TileCounts(incoming, previous.Role); - oldValue = previous.Personal; - } - - _currentCounts[key] = updated; - - if (hasInitializedScope && oldValue != incoming) - { - changes.Add(new TileCountChange(key, scope, oldValue, incoming)); - } + hasInitializedScope = _initializedScopes.Contains(scope); } - if (!hasInitializedScope) + var result = TicketOverviewCountProcessor.Calculate(_currentCounts, OverviewKeys, scope, newCounts, hasInitializedScope); + _currentCounts.Clear(); + foreach (var kvp in result.UpdatedCounts) { - _initializedScopes.Add(scope); + _currentCounts[kvp.Key] = kvp.Value; + } + + if (result.IsInitialization) + { + lock (_fetchLock) + { + _initializedScopes.Add(scope); + } + var initArgs = new TicketOverviewCountsChangedEventArgs( Array.Empty(), new Dictionary(_currentCounts, StringComparer.OrdinalIgnoreCase), @@ -386,18 +393,22 @@ namespace FasdDesktopUi.Basics.Services return; } - if (changes.Count == 0) + if (result.Changes.Count == 0) return; - var args = new TicketOverviewCountsChangedEventArgs(changes, new Dictionary(_currentCounts, StringComparer.OrdinalIgnoreCase)); + var args = new TicketOverviewCountsChangedEventArgs(result.Changes, new Dictionary(_currentCounts, StringComparer.OrdinalIgnoreCase)); OverviewCountsChanged?.Invoke(this, args); } + + #endregion + + #region Demo - Public - public void SimulateDemoTicket() - { - _isDemo = cFasdCockpitCommunicationBase.Instance?.IsDemo() == true; - if (!_isDemo) - return; + public void SimulateDemoTicket() + { + _isDemo = _communicationSource.Resolve()?.IsDemo() == true; + if (!_isDemo) + return; #if isDemo if (_demoTemplates.Count == 0) @@ -407,40 +418,44 @@ namespace FasdDesktopUi.Basics.Services return; } - _pendingSimulations++; - - if (_simulationFlushTimer != null) - { + Interlocked.Increment(ref _pendingSimulations); + + if (_simulationFlushTimer != null) + { _simulationFlushTimer.Stop(); _simulationFlushTimer.Interval = TimeSpan.FromMilliseconds(SimulationHotkeyDelayMs); _simulationFlushTimer.Start(); - } - else - { - ProcessDemoSimulations(_pendingSimulations); - _pendingSimulations = 0; - } -#endif - } + } + else + { + var count = Interlocked.Exchange(ref _pendingSimulations, 0); + ProcessDemoSimulations(count); + } +#endif + } - public IEnumerable GetDemoRelations(string key, bool useRoleScope) - { - if (!_isDemo) - return Enumerable.Empty(); + public IEnumerable GetDemoRelations(string key, bool useRoleScope) + { + if (!_isDemo) + return Enumerable.Empty(); lock (_demoRelations) { if (_demoRelations.TryGetValue((key, useRoleScope), out var list)) return list.ToList(); } - - return Enumerable.Empty(); - } - -#if isDemo - private void LoadPersistedDemoTickets() - { - var data = TicketOverviewDataStore.LoadData(); + + return Enumerable.Empty(); + } + + #endregion + +#if isDemo + #region Demo - Internals + + private void LoadPersistedDemoTickets() + { + var data = TicketOverviewDataStore.LoadData(); _demoTemplates.Clear(); if (data.Templates != null) @@ -471,13 +486,12 @@ namespace FasdDesktopUi.Basics.Services _demoTemplates.AddRange(templates); } - private void SimulationFlushTimer_Tick(object sender, EventArgs e) - { - _simulationFlushTimer.Stop(); - var count = _pendingSimulations; - _pendingSimulations = 0; - ProcessDemoSimulations(count); - } + private void SimulationFlushTimer_Tick() + { + _simulationFlushTimer.Stop(); + var count = Interlocked.Exchange(ref _pendingSimulations, 0); + ProcessDemoSimulations(count); + } private void ProcessDemoSimulations(int count) { @@ -511,7 +525,9 @@ namespace FasdDesktopUi.Basics.Services if (appliedChanges.Count == 0) return; - var args = new TicketOverviewCountsChangedEventArgs(appliedChanges, new Dictionary(_currentCounts)); + var args = new TicketOverviewCountsChangedEventArgs( + appliedChanges, + new Dictionary(_currentCounts, StringComparer.OrdinalIgnoreCase)); OverviewCountsChanged?.Invoke(this, args); } @@ -611,10 +627,7 @@ namespace FasdDesktopUi.Basics.Services if (!string.IsNullOrWhiteSpace(record.Summary)) _usedSummaries.Add(record.Summary); - if (cFasdCockpitCommunicationBase.Instance is cFasdCockpitCommunicationDemo demoCommunication) - { - demoCommunication.RegisterGeneratedTicket(record); - } + _communicationSource.Resolve()?.RegisterGeneratedTicket(record); if (!_currentCounts.TryGetValue(record.TileKey, out var previousCounts)) previousCounts = TileCounts.Empty; @@ -759,25 +772,22 @@ namespace FasdDesktopUi.Basics.Services CreatedAt = createdAt, Detail = detail }; - } - -#endif - - public Dictionary GetCountsForScope(bool useRoleScope) - { - return _currentCounts.ToDictionary(kvp => kvp.Key, kvp => useRoleScope ? kvp.Value.Role : kvp.Value.Personal, StringComparer.OrdinalIgnoreCase); - } - - private int GetDemoRelationCount(string key, bool useRoleScope) - { - lock (_demoRelations) - { - if (_demoRelations.TryGetValue((key, useRoleScope), out var list)) - return list.Count; - } - return 0; - } - + } + +#endregion +#endif + + #region Utilities + + public Dictionary GetCountsForScope(bool useRoleScope) + { + return _currentCounts.ToDictionary(kvp => kvp.Key, kvp => useRoleScope ? kvp.Value.Role : kvp.Value.Personal, StringComparer.OrdinalIgnoreCase); + } + + #endregion + + #region Retry Handling + private void ScheduleFetchRetry(TileScope scope) { if (!_isEnabled) @@ -814,7 +824,9 @@ namespace FasdDesktopUi.Basics.Services { System.Diagnostics.Debug.WriteLine($"[TicketOverview] Retry scheduling failed: {ex}"); } - }, DispatcherPriority.Background); + }); } - } + + #endregion + } } diff --git a/FasdDesktopUi/F4SD-Cockpit-Client.csproj b/FasdDesktopUi/F4SD-Cockpit-Client.csproj index 676996b..b9cb4c1 100644 --- a/FasdDesktopUi/F4SD-Cockpit-Client.csproj +++ b/FasdDesktopUi/F4SD-Cockpit-Client.csproj @@ -221,10 +221,14 @@ - - - - + + + + + + + +