diff --git a/src/EventLogExpert.Components/Modals/DebugLogModal.razor b/src/EventLogExpert.Components/Modals/DebugLogModal.razor index 6b64e033..5aed1f69 100644 --- a/src/EventLogExpert.Components/Modals/DebugLogModal.razor +++ b/src/EventLogExpert.Components/Modals/DebugLogModal.razor @@ -1,4 +1,3 @@ -@using EventLogExpert.UI @using Microsoft.Extensions.Logging @inherits ModalBase @@ -19,9 +18,9 @@ + ValueChanged="HandleLevelOperatorChanged"> @@ -33,10 +32,10 @@ EmptyText="All" IsMultiSelect T="LogLevel" + ToStringFunc="@(x => x.ToString())" Values="_multiLevels" - ValuesChanged="HandleMultiLevelsChanged" - ToStringFunc="@(x => x.ToString())"> - All + ValuesChanged="HandleMultiLevelsChanged"> + All @foreach (var level in s_logLevels) { @@ -48,10 +47,10 @@ - All + ValueChanged="HandleSingleLevelChanged"> + All @foreach (var level in s_logLevels) { @@ -59,16 +58,16 @@ }
- + -
@line
+
@line
@if (_hasLoaded) diff --git a/src/EventLogExpert.Components/Modals/DebugLogModal.razor.css b/src/EventLogExpert.Components/Modals/DebugLogModal.razor.css index 9ab12c70..818b000b 100644 --- a/src/EventLogExpert.Components/Modals/DebugLogModal.razor.css +++ b/src/EventLogExpert.Components/Modals/DebugLogModal.razor.css @@ -4,14 +4,17 @@ min-width: 0; flex: 1 1 auto; min-height: 0; - overflow-x: auto; + overflow-x: hidden; overflow-y: auto; } .debug-log-row { box-sizing: border-box; + width: 100%; height: 18px; padding: 0 0.25rem; + overflow: hidden; + text-overflow: ellipsis; font-family: monospace; line-height: 18px; diff --git a/src/EventLogExpert.UI/EventLog/Effects.cs b/src/EventLogExpert.UI/EventLog/Effects.cs index f20af790..ed7fd837 100644 --- a/src/EventLogExpert.UI/EventLog/Effects.cs +++ b/src/EventLogExpert.UI/EventLog/Effects.cs @@ -52,7 +52,6 @@ public sealed class Effects( private readonly IFilterService _filterService = filterService; private readonly Lock _globalCtsLock = new(); private readonly ConcurrentDictionary _logCloseCompletions = new(); - /// /// Serializes 's XML-reload path with /// . Both write into with raw @@ -63,9 +62,8 @@ public sealed class Effects( /// it. /// private readonly SemaphoreSlim _logCloseCoordinatorLock = new(1, 1); - private readonly ConcurrentDictionary _logCts = new(); - + private readonly ITraceLogger _logger = logger; /// /// Tracks per-log load completion so can wait for the in-flight /// service scope to dispose before draining the watcher. Without this, cts.Cancel() merely @@ -73,16 +71,13 @@ public sealed class Effects( /// disposes once HandleOpenLog's outer using/finally runs. /// private readonly ConcurrentDictionary _logLoadCompletions = new(); - private readonly ILogWatcherService _logWatcherService = logWatcherService; - private readonly ITraceLogger _logger = logger; - /// /// Tracks which currently-open logs (by ) were loaded with renderXml=true. A /// reload-on-transition only re-opens logs that lack XML; logs that already have it are left alone. Removing or /// disabling an XML filter never triggers a reload because the XML data is already in memory and harmless to keep. /// private readonly ConcurrentDictionary _logsLoadedWithXml = new(); - + private readonly ILogWatcherService _logWatcherService = logWatcherService; /// /// Pending selection restore per log name, populated when a filter transition forces a reload. Consumed by /// when the reloaded log finishes loading. Carries both the selected record-ids and @@ -146,8 +141,7 @@ public async Task HandleCloseAll(IDispatcher dispatcher) { CancelAllLoads(); - await _logWatcherService.RemoveAllAsync(); - + // Sync prefix: subsequent OpenLogAction must see cleared LogTable. dispatcher.Dispatch(new LogTable.CloseAllAction()); dispatcher.Dispatch(new StatusBar.CloseAllAction()); @@ -155,6 +149,8 @@ public async Task HandleCloseAll(IDispatcher dispatcher) _xmlResolver.ClearAll(); _logsLoadedWithXml.Clear(); _pendingSelectionRestore.Clear(); + + await _logWatcherService.RemoveAllAsync(); } [EffectMethod] diff --git a/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs b/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs index 909d4082..cb846240 100644 --- a/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs +++ b/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs @@ -67,6 +67,26 @@ await component.WaitForAssertionAsync(() => Assert.Equal("true", counter.GetAttribute("aria-atomic")); } + [Fact] + public async Task DebugLogModal_AfterLoad_RowsCarryTitleAttributeMirroringText() + { + var firstHeader = DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage); + var secondHeader = DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage); + + _fileLogger.LoadAsync(Arg.Any()).Returns( + DebugLogUtils.ToAsyncEnumerable([firstHeader, secondHeader])); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + var rows = component.FindAll(".debug-log-row"); + Assert.Equal( + rows.Select(row => row.TextContent).ToArray(), + rows.Select(row => row.GetAttribute("title")).ToArray()); + } + [Fact] public async Task DebugLogModal_AfterLoad_ViewportHasRegionRoleWithoutAriaLiveAndIsKeyboardFocusable() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs b/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs index 50cf2003..c034298f 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs @@ -138,6 +138,28 @@ public async Task HandleAddEvent_WhenLogNotActive_ShouldNotDispatchActions() mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); } + [Fact] + public async Task HandleCloseAll_DispatchesStateClearsBeforeWatcherDrain() + { + var (effects, mockDispatcher, mockLogWatcher, mockResolverCache, _) = CreateEffectsWithServices(); + + var watcherTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + mockLogWatcher.RemoveAllAsync().Returns(watcherTcs.Task); + + var closeTask = effects.HandleCloseAll(mockDispatcher); + + Assert.False(closeTask.IsCompleted, "HandleCloseAll must still be awaiting RemoveAllAsync."); + mockDispatcher.Received(1).Dispatch(Arg.Any()); + mockDispatcher.Received(1).Dispatch(Arg.Any()); + mockResolverCache.Received(1).ClearAll(); + + watcherTcs.SetResult(); + await closeTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(closeTask.IsCompletedSuccessfully); + await mockLogWatcher.Received(1).RemoveAllAsync(); + } + [Fact] public async Task HandleCloseAll_ShouldClearAllResolvedXml() { @@ -688,7 +710,7 @@ public async Task HandleSetFilters_FilterBranch_ShouldDispatchSetIsLoadingTrueTh var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); - var filter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true); + var filter = FilterUtils.CreateTestFilter(isEnabled: true); var action = new SetFiltersAction(new EventFilter(null, [filter])); await effects.HandleSetFilters(action, mockDispatcher); @@ -714,7 +736,7 @@ public async Task HandleSetFilters_FilterBranch_WhenFilterServiceThrows_ShouldSt .When(x => x.FilterActiveLogs(Arg.Any>(), Arg.Any())) .Do(_ => throw new InvalidOperationException("boom")); - var filter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true); + var filter = FilterUtils.CreateTestFilter(isEnabled: true); var action = new SetFiltersAction(new EventFilter(null, [filter])); await Assert.ThrowsAsync( @@ -763,7 +785,7 @@ public async Task HandleSetFilters_FilterBranch_WhenLogClosedDuringFilter_Should return filterResult; }); - var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true); + var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true); var action = new SetFiltersAction(new EventFilter(null, [nonXmlFilter])); // Act @@ -827,7 +849,7 @@ public async Task HandleSetFilters_FilterBranch_WhenLogEventsChangeDuringFilter_ }, _ => pass2Result); - var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true); + var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true); var action = new SetFiltersAction(new EventFilter(null, [nonXmlFilter])); // Act @@ -914,7 +936,7 @@ public async Task HandleSetFilters_FilterBranch_WhenLogStillStaleAfterRetry_Shou return pass2Result; }); - var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true); + var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true); var action = new SetFiltersAction(new EventFilter(null, [nonXmlFilter])); // Act @@ -952,7 +974,7 @@ public async Task HandleSetFilters_FilterBranch_WhenSupersededByNewerFilter_Shou var staleStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var staleFilterModel = FilterUtils.CreateTestFilter(Constants.FilterIdEquals999, isEnabled: true); - var freshFilterModel = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true); + var freshFilterModel = FilterUtils.CreateTestFilter(isEnabled: true); var staleFilter = new EventFilter(null, [staleFilterModel]); var freshFilter = new EventFilter(null, [freshFilterModel]); @@ -1053,7 +1075,7 @@ public async Task HandleSetFilters_WhenFilterDoesNotRequireXml_ShouldNotReloadLo var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); - var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true); + var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true); var eventFilter = new EventFilter(null, [nonXmlFilter]); var action = new SetFiltersAction(eventFilter); @@ -1067,50 +1089,6 @@ public async Task HandleSetFilters_WhenFilterDoesNotRequireXml_ShouldNotReloadLo mockDispatcher.Received(1).Dispatch(Arg.Any()); } - [Fact] - public async Task HandleSetFilters_WhenFilterRequiresXmlAndLogLacksXml_ShouldCloseAndReopenLog() - { - // Arrange — active log has not been loaded with XML, so it must be re-read. - var logData = new EventLogData(Constants.LogNameTestLog, LogPathType.Channel, []); - var activeLogs = ImmutableDictionary.Empty.Add(Constants.LogNameTestLog, logData); - - var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); - - // Route CloseLog → HandleCloseLog so HandleSetFilters' await on the close-completion - // TCS resolves quickly (otherwise it hits LogCloseTimeout, 30s). Capture the routed - // tasks so any fault in HandleCloseLog surfaces at the end of the test instead of - // being swallowed by the discard. - var closeTasks = new List(); - mockDispatcher - .When(d => d.Dispatch(Arg.Any())) - .Do(callInfo => - { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); - }); - - var xmlFilter = FilterUtils.CreateTestFilter(Constants.FilterXmlContainsData, isEnabled: true); - var eventFilter = new EventFilter(null, [xmlFilter]); - var action = new SetFiltersAction(eventFilter); - - // Act - await effects.HandleSetFilters(action, mockDispatcher); - - // Assert - Assert.True(eventFilter.RequiresXml); - - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LogName == Constants.LogNameTestLog && a.LogId == logData.Id)); - - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LogName == Constants.LogNameTestLog && a.LogPathType == LogPathType.Channel)); - - // Reload path returns early — no UpdateDisplayedEvents until LoadEvents fires. - mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); - - // Surface any HandleCloseLog faults before exiting the test. - await Task.WhenAll(closeTasks); - } - [Fact] public async Task HandleSetFilters_WhenFilterRequiresXml_AwaitsCloseCompletionBeforeReturning() { @@ -1245,6 +1223,50 @@ public async Task HandleSetFilters_WhenFilterRequiresXml_ShouldRestoreSelectionA await Task.WhenAll(closeTasks); } + [Fact] + public async Task HandleSetFilters_WhenFilterRequiresXmlAndLogLacksXml_ShouldCloseAndReopenLog() + { + // Arrange — active log has not been loaded with XML, so it must be re-read. + var logData = new EventLogData(Constants.LogNameTestLog, LogPathType.Channel, []); + var activeLogs = ImmutableDictionary.Empty.Add(Constants.LogNameTestLog, logData); + + var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); + + // Route CloseLog → HandleCloseLog so HandleSetFilters' await on the close-completion + // TCS resolves quickly (otherwise it hits LogCloseTimeout, 30s). Capture the routed + // tasks so any fault in HandleCloseLog surfaces at the end of the test instead of + // being swallowed by the discard. + var closeTasks = new List(); + mockDispatcher + .When(d => d.Dispatch(Arg.Any())) + .Do(callInfo => + { + closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + }); + + var xmlFilter = FilterUtils.CreateTestFilter(Constants.FilterXmlContainsData, isEnabled: true); + var eventFilter = new EventFilter(null, [xmlFilter]); + var action = new SetFiltersAction(eventFilter); + + // Act + await effects.HandleSetFilters(action, mockDispatcher); + + // Assert + Assert.True(eventFilter.RequiresXml); + + mockDispatcher.Received(1).Dispatch(Arg.Is(a => + a.LogName == Constants.LogNameTestLog && a.LogId == logData.Id)); + + mockDispatcher.Received(1).Dispatch(Arg.Is(a => + a.LogName == Constants.LogNameTestLog && a.LogPathType == LogPathType.Channel)); + + // Reload path returns early — no UpdateDisplayedEvents until LoadEvents fires. + mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); + + // Surface any HandleCloseLog faults before exiting the test. + await Task.WhenAll(closeTasks); + } + [Fact] public void ReopenAfterDatabaseRemoval_DispatchesOpenLogPerSnapshotEntry() {