diff --git a/src/EventLogExpert.Components.Tests/BannerHostTests.cs b/src/EventLogExpert.Components.Tests/BannerHostTests.cs index 81bd2c2f..9df1a10d 100644 --- a/src/EventLogExpert.Components.Tests/BannerHostTests.cs +++ b/src/EventLogExpert.Components.Tests/BannerHostTests.cs @@ -3,6 +3,7 @@ using Bunit; using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI; using EventLogExpert.UI.Interfaces; using EventLogExpert.UI.Models; using Microsoft.Extensions.DependencyInjection; @@ -16,50 +17,375 @@ public sealed class BannerHostTests : BunitContext Substitute.For(); private readonly IBannerService _bannerService = Substitute.For(); private readonly IClipboardService _clipboardService = Substitute.For(); + private readonly IMenuActionService _menuActionService = Substitute.For(); private readonly ITraceLogger _traceLogger = Substitute.For(); public BannerHostTests() { - _bannerService.UnhandledError.Returns((Exception?)null); - _bannerService.CriticalAlerts.Returns([]); + _bannerService.CurrentCritical.Returns((Exception?)null); + _bannerService.ErrorBanners.Returns([]); _bannerService.InfoBanners.Returns([]); + _bannerService.AttentionEntries.Returns([]); + _bannerService.AttentionDismissed.Returns(false); + _bannerService.BackgroundProgress.Returns((BannerProgressEntry?)null); Services.AddSingleton(_bannerService); Services.AddSingleton(_applicationRestartService); Services.AddSingleton(_clipboardService); + Services.AddSingleton(_menuActionService); Services.AddSingleton(_traceLogger); JSInterop.Mode = JSRuntimeMode.Loose; } + [Fact] + public async Task BannerHost_AttentionDismissClicked_CallsDismissAttention() + { + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + + var component = Render(); + await component.Find("aside.banner-attention button.banner-dismiss").ClickAsync(new()); + + _bannerService.Received(1).DismissAttention(); + } + + [Fact] + public void BannerHost_AttentionDismissed_DoesNotRenderAttentionBanner() + { + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + _bannerService.AttentionDismissed.Returns(true); + + var component = Render(); + + Assert.Empty(component.FindAll("aside.banner-attention")); + } + + [Fact] + public void BannerHost_AttentionEntries_RendersAttentionBannerWithOpenSettingsAndDismiss() + { + _bannerService.AttentionEntries.Returns( + [BuildDatabaseEntry("a.db"), BuildDatabaseEntry("b.db")]); + + var component = Render(); + + var banner = component.Find("aside.banner-attention"); + Assert.Contains("2 databases need attention", banner.TextContent); + Assert.Equal("Open Settings", component.Find("aside.banner-attention button.banner-action").TextContent.Trim()); + Assert.Single(component.FindAll("aside.banner-attention button.banner-dismiss")); + } + + [Fact] + public void BannerHost_AttentionEntriesSingleEntry_UsesSingularDatabaseLabel() + { + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + + var component = Render(); + + var banner = component.Find("aside.banner-attention"); + Assert.Contains("1 database need", banner.TextContent); + Assert.DoesNotContain("databases need", banner.TextContent); + } + + [Fact] + public void BannerHost_BackgroundProgressWithEmptyEntryName_RendersPreparingMessage() + { + // Pre-first-tick rendering: BannerService creates the entry with Position=0/EntryName="" before the + // first per-entry progress event arrives. The "Preparing..." text avoids the misleading + // "Upgrading database 0 of N: " string in that gap. + _bannerService.BackgroundProgress.Returns( + new BannerProgressEntry( + Guid.NewGuid(), + UpgradeProgressScope.Background, + CurrentBatchPosition: 0, + CurrentBatchSize: 3, + CurrentEntryName: string.Empty, + CurrentPhase: UpgradePhase.BackingUp, + QueuedBatchesAfter: 0, + Cancel: () => { })); + + var component = Render(); + + var banner = component.Find("aside.banner-upgrade-progress"); + Assert.Contains("Preparing upgrade of 3 databases", banner.TextContent); + Assert.DoesNotContain("Upgrading database 0", banner.TextContent); + } + + [Fact] + public void BannerHost_BackgroundProgressWithEntryName_RendersUpgradeProgressBannerWithCancelButton() + { + _bannerService.BackgroundProgress.Returns( + new BannerProgressEntry( + Guid.NewGuid(), + UpgradeProgressScope.Background, + CurrentBatchPosition: 2, + CurrentBatchSize: 5, + CurrentEntryName: "MyDb.evtx", + CurrentPhase: UpgradePhase.MigratingSchema, + QueuedBatchesAfter: 0, + Cancel: () => { })); + + var component = Render(); + + var banner = component.Find("aside.banner-upgrade-progress"); + Assert.Contains("Upgrading database 2 of 5", banner.TextContent); + Assert.Contains("MyDb.evtx", banner.TextContent); + Assert.Contains("MigratingSchema", banner.TextContent); + Assert.Equal("Cancel", component.Find("aside.banner-upgrade-progress button.banner-action").TextContent.Trim()); + Assert.Single(component.FindAll("aside.banner-upgrade-progress .banner-spinner")); + } + + [Fact] + public void BannerHost_BackgroundProgressWithQueuedBatches_RendersQueuedBatchesSubtitle() + { + _bannerService.BackgroundProgress.Returns( + new BannerProgressEntry( + Guid.NewGuid(), + UpgradeProgressScope.Background, + CurrentBatchPosition: 1, + CurrentBatchSize: 2, + CurrentEntryName: "x.evtx", + CurrentPhase: UpgradePhase.Verifying, + QueuedBatchesAfter: 3, + Cancel: () => { })); + + var component = Render(); + + var subtitle = component.Find("aside.banner-upgrade-progress .banner-subtitle"); + Assert.Contains("+3 batches queued", subtitle.TextContent); + } + + [Fact] + public async Task BannerHost_CancelUpgradeClicked_InvokesCancelDelegate() + { + int cancelInvocationCount = 0; + _bannerService.BackgroundProgress.Returns( + new BannerProgressEntry( + Guid.NewGuid(), + UpgradeProgressScope.Background, + CurrentBatchPosition: 1, + CurrentBatchSize: 1, + CurrentEntryName: "x.evtx", + CurrentPhase: UpgradePhase.MigratingSchema, + QueuedBatchesAfter: 0, + Cancel: () => cancelInvocationCount++)); + + var component = Render(); + await component.Find("aside.banner-upgrade-progress button.banner-action").ClickAsync(new()); + + Assert.Equal(1, cancelInvocationCount); + } + + [Fact] + public async Task BannerHost_CancelUpgradeThrows_LogsViaTraceLogger_DoesNotPropagate() + { + _bannerService.BackgroundProgress.Returns( + new BannerProgressEntry( + Guid.NewGuid(), + UpgradeProgressScope.Background, + CurrentBatchPosition: 1, + CurrentBatchSize: 1, + CurrentEntryName: "x.evtx", + CurrentPhase: UpgradePhase.MigratingSchema, + QueuedBatchesAfter: 0, + Cancel: () => throw new InvalidOperationException("cts disposed"))); + + var component = Render(); + await component.Find("aside.banner-upgrade-progress button.banner-action").ClickAsync(new()); + + Assert.Single(component.FindAll("aside.banner-upgrade-progress")); + _bannerService.DidNotReceive().ReportCritical(Arg.Any()); + _traceLogger.Received(1).Error(Arg.Is(h => + h.ToString().Contains(nameof(BannerHost)) && h.ToString().Contains("cts disposed"))); + } + [Fact] public async Task BannerHost_CopyDetailsClicked_CopiesExceptionAndShowsCopiedChip() { - var error = new InvalidOperationException("kaboom"); - _bannerService.UnhandledError.Returns(error); + var critical = new InvalidOperationException("kaboom"); + _bannerService.CurrentCritical.Returns(critical); var component = Render(); // Sync Click() returns at the handler's first real async point (the 2s Task.Delay) with // the chip rendered; ClickAsync would block for the full delay until the chip clears. - component.Find("aside.banner-error .banner-actions button:nth-child(3)").Click(); + component.Find("aside.banner-critical .banner-actions button:nth-child(3)").Click(); await _clipboardService.Received(1) .CopyTextAsync(Arg.Is(s => s.Contains("InvalidOperationException") && s.Contains("kaboom"))); - Assert.Single(component.FindAll("aside.banner-error .banner-feedback .banner-chip")); + Assert.Single(component.FindAll("aside.banner-critical .banner-feedback .banner-chip")); + } + + [Fact] + public void BannerHost_CriticalActive_DoesNotRenderCycleNav_EvenWithOtherSlices() + { + // Critical pre-empts the entire cycle — no Prev/Next chevrons should appear. + _bannerService.CurrentCritical.Returns(new InvalidOperationException("kaboom")); + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(Guid.NewGuid(), "E", "m", null, null, DateTime.UtcNow)]); + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + + var component = Render(); + + Assert.Single(component.FindAll("aside.banner-critical")); + Assert.Empty(component.FindAll("button.banner-cycle-prev")); + Assert.Empty(component.FindAll("button.banner-cycle-next")); + Assert.Empty(component.FindAll(".banner-pagination")); + } + + [Fact] + public void BannerHost_CriticalAndErrorAndInfoAllPresent_RendersOnlyCritical() + { + _bannerService.CurrentCritical.Returns(new InvalidOperationException("kaboom")); + + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(Guid.NewGuid(), "Error", "E", null, null, DateTime.UtcNow)]); + + _bannerService.InfoBanners.Returns([ + new BannerInfoEntry(Guid.NewGuid(), "Info", "I", BannerSeverity.Info, DateTime.UtcNow) + ]); + + var component = Render(); + + Assert.Single(component.FindAll("aside.banner-critical")); + Assert.Empty(component.FindAll("aside.banner-error")); + Assert.Empty(component.FindAll("aside.banner-info")); + } + + [Fact] + public void BannerHost_CurrentCritical_RendersCriticalBannerWithThreeButtons() + { + var critical = new InvalidOperationException("kaboom"); + _bannerService.CurrentCritical.Returns(critical); + + var component = Render(); + + var banner = component.Find("aside.banner-critical"); + Assert.Contains("InvalidOperationException", banner.TextContent); + Assert.Contains("kaboom", banner.TextContent); + + var buttons = component.FindAll("aside.banner-critical .banner-actions button"); + Assert.Equal(3, buttons.Count); + Assert.Contains("Reload", buttons[0].TextContent); + Assert.Contains("Relaunch", buttons[1].TextContent); + Assert.Contains("Copy details", buttons[2].TextContent); + } + + [Fact] + public void BannerHost_CycleErrorAndAttention_RendersFirstErrorWithCyclePagination_TwoOfTwo() + { + var error = new ErrorBannerEntry(Guid.NewGuid(), "Err", "msg", null, null, DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([error]); + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + + var component = Render(); + + var banner = component.Find("aside.banner-error"); + var pagination = component.Find("aside.banner-error .banner-pagination"); + Assert.Equal("1 of 2", pagination.TextContent.Trim()); + Assert.Contains("Err: msg", banner.TextContent); + } + + [Fact] + public async Task BannerHost_CycleNextAtLast_DisabledAndDoesNotAdvance() + { + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(Guid.NewGuid(), "E", "m", null, null, DateTime.UtcNow)]); + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + + var component = Render(); + // Advance to last item (index 1). + await component.Find("button.banner-cycle-next").ClickAsync(new()); + + var next = component.Find("button.banner-cycle-next"); + Assert.True(next.HasAttribute("disabled")); + + await next.ClickAsync(new()); + + // Index stays at 1. + Assert.Equal("2 of 2", component.Find(".banner-pagination").TextContent.Trim()); + Assert.Single(component.FindAll("aside.banner-attention")); + } + + [Fact] + public async Task BannerHost_CycleNextClicked_AdvancesToAttentionItem() + { + var error = new ErrorBannerEntry(Guid.NewGuid(), "Err", "msg", null, null, DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([error]); + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + + var component = Render(); + await component.Find("button.banner-cycle-next").ClickAsync(new()); + + var pagination = component.Find(".banner-pagination"); + Assert.Equal("2 of 2", pagination.TextContent.Trim()); + Assert.Single(component.FindAll("aside.banner-attention")); + Assert.Empty(component.FindAll("aside.banner-error")); + } + + [Fact] + public async Task BannerHost_CyclePrevAtFirst_DisabledAndDoesNotAdvance() + { + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(Guid.NewGuid(), "E", "m", null, null, DateTime.UtcNow)]); + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + + var component = Render(); + var prev = component.Find("button.banner-cycle-prev"); + Assert.True(prev.HasAttribute("disabled")); + + await prev.ClickAsync(new()); + + // Index stays at 0 — first error still rendered. + Assert.Equal("1 of 2", component.Find(".banner-pagination").TextContent.Trim()); + Assert.Single(component.FindAll("aside.banner-error")); } [Fact] - public async Task BannerHost_DismissCriticalClicked_CallsDismissCriticalWithEntryId() + public async Task BannerHost_CycleStableSelection_DismissingPrecedingError_StaysOnSameLogicalError() { - var alert = new CriticalAlertEntry(Guid.NewGuid(), "Database", "Schema invalid", DateTime.UtcNow); - _bannerService.CriticalAlerts.Returns([alert]); + // Regression: an earlier design matched selection by (View, IndexWithinSlice) record equality, which + // silently jumped the user to a different error whenever a preceding error was dismissed (e.g., user on + // E1 = index 1, then E0 dismissed → new (Error, 1) refers to E2 → user is now reading E2 without any + // intent to navigate). BannerCycleItem.EntryId provides stable identity so the user stays on E1. + var e0 = new ErrorBannerEntry(Guid.NewGuid(), "First", "first message", null, null, DateTime.UtcNow); + var e1 = new ErrorBannerEntry(Guid.NewGuid(), "Second", "second message", null, null, DateTime.UtcNow); + var e2 = new ErrorBannerEntry(Guid.NewGuid(), "Third", "third message", null, null, DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([e0, e1, e2]); var component = Render(); - await component.Find("aside.banner-critical button.banner-dismiss").ClickAsync(new()); + // Advance to e1. + await component.Find("button.banner-cycle-next").ClickAsync(new()); + Assert.Contains("Second: second message", component.Find("aside.banner-error").TextContent); + Assert.Equal("2 of 3", component.Find(".banner-pagination").TextContent.Trim()); + + // Simulate e0 being dismissed externally — IndexWithinSlice for e1/e2 shifts down by one, but EntryId + // stays stable so selection-by-EntryId still resolves to e1. + _bannerService.ErrorBanners.Returns([e1, e2]); + _bannerService.StateChanged += Raise.Event(); + + component.WaitForState(() => + { + var pages = component.FindAll(".banner-pagination"); + return pages.Count > 0 && pages[0].TextContent.Trim() == "1 of 2"; + }); + + // After rebuild, the user must still see e1 (now at index 0). The bug being prevented: jumping to e2 + // because the old (Error, 1) tuple matched e2's new (Error, 1) position. + Assert.Contains("Second: second message", component.Find("aside.banner-error").TextContent); + Assert.DoesNotContain("Third", component.Find("aside.banner-error").TextContent); + } + + [Fact] + public async Task BannerHost_DismissErrorClicked_CallsDismissErrorWithEntryId() + { + var entry = new ErrorBannerEntry(Guid.NewGuid(), "Database", "Schema invalid", null, null, DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); - _bannerService.Received(1).DismissCritical(alert.Id); + var component = Render(); + await component.Find("aside.banner-error button.banner-dismiss").ClickAsync(new()); + + _bannerService.Received(1).DismissError(entry.Id); } [Fact] @@ -75,22 +401,79 @@ public async Task BannerHost_DismissInfoClicked_CallsDismissInfoBannerWithEntryI } [Fact] - public void BannerHost_ErrorAndCriticalAndInfoAllPresent_RendersOnlyError() + public async Task BannerHost_ErrorBannerActionClicked_InvokesSuppliedCallback() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + int actionInvocationCount = 0; + var entry = new ErrorBannerEntry( + Guid.NewGuid(), + "Database", + "Recovery required", + "Resolve", + () => { actionInvocationCount++; return Task.CompletedTask; }, + DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); - _bannerService.CriticalAlerts.Returns( - [new CriticalAlertEntry(Guid.NewGuid(), "Critical", "C", DateTime.UtcNow)]); + var component = Render(); + await component.Find("aside.banner-error button.banner-action").ClickAsync(new()); - _bannerService.InfoBanners.Returns([ - new BannerInfoEntry(Guid.NewGuid(), "Info", "I", BannerSeverity.Info, DateTime.UtcNow) - ]); + Assert.Equal(1, actionInvocationCount); + } + + [Fact] + public async Task BannerHost_ErrorBannerActionThrows_LogsViaTraceLogger_DoesNotPropagate() + { + // Arrange — action exceptions must be swallowed by BannerHost so they do not bubble up to ErrorBoundary + // and escalate the visible banner from Error to Critical (which would replace the user's actionable error + // with a Reload-tier critical banner). + var actionException = new InvalidOperationException("action boom"); + var entry = new ErrorBannerEntry( + Guid.NewGuid(), + "Database", + "Recovery required", + "Resolve", + () => throw actionException, + DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); var component = Render(); + // Act + await component.Find("aside.banner-error button.banner-action").ClickAsync(new()); + + // Assert — banner stays visible, the critical slot was not populated, and the exception was logged. Assert.Single(component.FindAll("aside.banner-error")); - Assert.Empty(component.FindAll("aside.banner-critical")); - Assert.Empty(component.FindAll("aside.banner-info")); + _bannerService.DidNotReceive().ReportCritical(Arg.Any()); + _traceLogger.Received(1).Error(Arg.Is(h => + h.ToString().Contains(nameof(BannerHost)) && h.ToString().Contains("action boom"))); + } + + [Fact] + public void BannerHost_ErrorBannerWithAction_RendersActionButtonWithLabel() + { + var entry = new ErrorBannerEntry( + Guid.NewGuid(), + "Database", + "Recovery required", + "Resolve", + () => Task.CompletedTask, + DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); + + var component = Render(); + + var actionButton = component.Find("aside.banner-error button.banner-action"); + Assert.Equal("Resolve", actionButton.TextContent.Trim()); + } + + [Fact] + public void BannerHost_ErrorBannerWithoutAction_DoesNotRenderActionButton() + { + var entry = new ErrorBannerEntry(Guid.NewGuid(), "Database", "Schema invalid", null, null, DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); + + var component = Render(); + + Assert.Empty(component.FindAll("aside.banner-error button.banner-action")); } [Fact] @@ -107,19 +490,19 @@ public void BannerHost_InfoSeverity_RendersInfoStyledBanner() } [Fact] - public void BannerHost_MultipleCriticalAlerts_RendersFirstWithPagination() + public void BannerHost_MultipleErrorBanners_RendersFirstWithPagination() { - var first = new CriticalAlertEntry(Guid.NewGuid(), "First", "First message", DateTime.UtcNow); - var second = new CriticalAlertEntry(Guid.NewGuid(), "Second", "Second message", DateTime.UtcNow); - _bannerService.CriticalAlerts.Returns([first, second]); + var first = new ErrorBannerEntry(Guid.NewGuid(), "First", "First message", null, null, DateTime.UtcNow); + var second = new ErrorBannerEntry(Guid.NewGuid(), "Second", "Second message", null, null, DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([first, second]); var component = Render(); - var banner = component.Find("aside.banner-critical"); + var banner = component.Find("aside.banner-error"); Assert.Contains("First: First message", banner.TextContent); Assert.DoesNotContain("Second", banner.TextContent); - var pagination = component.Find("aside.banner-critical .banner-pagination"); + var pagination = component.Find("aside.banner-error .banner-pagination"); Assert.Equal("1 of 2", pagination.TextContent.Trim()); } @@ -131,16 +514,137 @@ public void BannerHost_NoState_RendersNothing() Assert.Equal(string.Empty, component.Markup.Trim()); } + [Fact] + public async Task BannerHost_OpenSettingsClicked_DismissesAttention_BeforeAwaitingOpenSettings() + { + // Clicking the action button is itself the user-acknowledgement that they're acting on the items; + // dismissing immediately means the banner doesn't linger while the modal opens (which can take a + // perceptible beat). On failure the error banner replaces the attention banner as the visible signal. + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + _menuActionService.OpenSettingsAsync().Returns(Task.FromResult(true)); + + var component = Render(); + await component.Find("aside.banner-attention button.banner-action").ClickAsync(new()); + + Received.InOrder( + () => + { + _bannerService.DismissAttention(); + _ = _menuActionService.OpenSettingsAsync(); + }); + _bannerService.DidNotReceive().ReportError(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task BannerHost_OpenSettingsReturnsFalse_DismissesAttentionImmediately_AndReportsRecoverableError() + { + // Dismiss-immediately semantics: the attention banner is gone the instant the user clicked, regardless + // of outcome. When OpenSettingsAsync returns false (caught internally), surface a recoverable Error so + // the user knows the click was received but the modal failed to open. + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + _menuActionService.OpenSettingsAsync().Returns(Task.FromResult(false)); + + var component = Render(); + await component.Find("aside.banner-attention button.banner-action").ClickAsync(new()); + + _bannerService.Received(1).DismissAttention(); + _bannerService.Received(1) + .ReportError("Settings", Arg.Is(s => s.Contains("Failed to open settings"))); + _bannerService.DidNotReceive().ReportCritical(Arg.Any()); + } + + [Fact] + public async Task BannerHost_OpenSettingsReturnsFalse_RendersNewErrorBanner_NotStaleAttention() + { + // Round-2 regression: without explicitly steering _selectedItem to the new error after ReportError, + // ItemMatches preserves the stale Attention selection by (View=Attention, EntryId=null) and the user + // never sees the failure message they need — the error banner would be one page back in the cycle. + var attention = BuildDatabaseEntry("a.db"); + var newErrorId = Guid.NewGuid(); + var newError = new ErrorBannerEntry( + newErrorId, + "Settings", + "Failed to open settings; try again from the menu.", + null, + null, + DateTime.UtcNow); + + _bannerService.AttentionEntries.Returns([attention]); + _menuActionService.OpenSettingsAsync().Returns(Task.FromResult(false)); + _bannerService.ReportError("Settings", Arg.Any()) + .Returns(_ => + { + // Simulate the real BannerService side effect: the new error joins the ErrorBanners list so + // the next rebuild has both [Error, Attention] in the cycle. + _bannerService.ErrorBanners.Returns([newError]); + return newErrorId; + }); + + var component = Render(); + Assert.Single(component.FindAll("aside.banner-attention")); + + await component.Find("aside.banner-attention button.banner-action").ClickAsync(new()); + // Real BannerService raises StateChanged from inside ReportError; the mock does not, so raise it here + // to drive the re-render that proves _selectedItem was steered to the new error. + _bannerService.StateChanged += Raise.Event(); + + component.WaitForState(() => component.FindAll("aside.banner-error").Count > 0); + + var errorBanner = component.Find("aside.banner-error"); + Assert.Contains("Failed to open settings; try again from the menu.", errorBanner.TextContent); + // Bug being prevented: stale Attention selection would render Attention here instead of Error. + Assert.Empty(component.FindAll("aside.banner-attention")); + } + + [Fact] + public async Task BannerHost_OpenSettingsThrowsJSDisconnected_DismissesAttention_NoErrorReport() + { + // Per rule 3.9, JSDisconnectedException is expected during teardown and must be caught silently — it + // does not warrant ReportError surface (the user closed the circuit themselves). The dismiss happened + // before the await so the attention banner is already gone by the time the throw lands. + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + _menuActionService.OpenSettingsAsync() + .Returns(Task.FromException(new Microsoft.JSInterop.JSDisconnectedException("circuit gone"))); + + var component = Render(); + await component.Find("aside.banner-attention button.banner-action").ClickAsync(new()); + + _bannerService.Received(1).DismissAttention(); + _bannerService.DidNotReceive().ReportError(Arg.Any(), Arg.Any()); + _bannerService.DidNotReceive().ReportCritical(Arg.Any()); + } + + [Fact] + public async Task BannerHost_OpenSettingsThrowsUnexpectedly_DismissesAttention_LogsAndReportsRecoverableError() + { + // Defensive path: contract says OpenSettingsAsync catches internally, but a synchronous throw before the + // first await would still bubble. Must not propagate to ErrorBoundary (which would escalate the visible + // banner from Attention to Critical). Surface as Error; attention was already dismissed on click. + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + var openException = new InvalidOperationException("modal boom"); + _menuActionService.OpenSettingsAsync().Returns(Task.FromException(openException)); + + var component = Render(); + await component.Find("aside.banner-attention button.banner-action").ClickAsync(new()); + + _bannerService.Received(1).DismissAttention(); + _bannerService.Received(1) + .ReportError("Settings", Arg.Is(s => s.Contains("modal boom"))); + _bannerService.DidNotReceive().ReportCritical(Arg.Any()); + _traceLogger.Received(1).Error(Arg.Is(h => + h.ToString().Contains(nameof(BannerHost)) && h.ToString().Contains("modal boom"))); + } + [Fact] public async Task BannerHost_RecoveryThrows_ShowsRecoveryFailureSubtitle() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.Returns(new InvalidOperationException("kaboom")); _bannerService.TryRecoverAsync().Returns(Task.FromException(new InvalidOperationException("recovery failed"))); var component = Render(); - await component.Find("aside.banner-error .banner-actions button:nth-child(1)").ClickAsync(new()); + await component.Find("aside.banner-critical .banner-actions button:nth-child(1)").ClickAsync(new()); - var subtitle = component.Find("aside.banner-error .banner-feedback .banner-subtitle"); + var subtitle = component.Find("aside.banner-critical .banner-feedback .banner-subtitle"); Assert.Contains("Recovery failed", subtitle.TextContent); Assert.Contains("recovery failed", subtitle.TextContent); } @@ -148,11 +652,11 @@ public async Task BannerHost_RecoveryThrows_ShowsRecoveryFailureSubtitle() [Fact] public async Task BannerHost_RelaunchClicked_InvokesTryRestartAsync() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.Returns(new InvalidOperationException("kaboom")); _applicationRestartService.TryRestartAsync().Returns(true); var component = Render(); - await component.Find("aside.banner-error .banner-actions button:nth-child(2)").ClickAsync(new()); + await component.Find("aside.banner-critical .banner-actions button:nth-child(2)").ClickAsync(new()); await _applicationRestartService.Received(1).TryRestartAsync(); } @@ -160,59 +664,40 @@ public async Task BannerHost_RelaunchClicked_InvokesTryRestartAsync() [Fact] public async Task BannerHost_RelaunchFails_ShowsRestartFailureSubtitle() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.Returns(new InvalidOperationException("kaboom")); _applicationRestartService.TryRestartAsync().Returns(false); var component = Render(); - await component.Find("aside.banner-error .banner-actions button:nth-child(2)").ClickAsync(new()); + await component.Find("aside.banner-critical .banner-actions button:nth-child(2)").ClickAsync(new()); - var subtitle = component.Find("aside.banner-error .banner-feedback .banner-subtitle"); + var subtitle = component.Find("aside.banner-critical .banner-feedback .banner-subtitle"); Assert.Contains("Restart failed", subtitle.TextContent); } [Fact] public async Task BannerHost_ReloadClicked_InvokesTryRecoverAsync() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.Returns(new InvalidOperationException("kaboom")); _bannerService.TryRecoverAsync().Returns(Task.CompletedTask); var component = Render(); - await component.Find("aside.banner-error .banner-actions button:nth-child(1)").ClickAsync(new()); + await component.Find("aside.banner-critical .banner-actions button:nth-child(1)").ClickAsync(new()); await _bannerService.Received(1).TryRecoverAsync(); } [Fact] - public void BannerHost_SingleCriticalAlert_RendersWithoutPagination() + public void BannerHost_SingleErrorBanner_RendersWithoutPagination() { - var alert = new CriticalAlertEntry(Guid.NewGuid(), "Database", "Schema invalid", DateTime.UtcNow); - _bannerService.CriticalAlerts.Returns([alert]); - - var component = Render(); - - var banner = component.Find("aside.banner-critical"); - Assert.Contains("Database: Schema invalid", banner.TextContent); - Assert.Empty(component.FindAll("aside.banner-critical .banner-pagination")); - Assert.Single(component.FindAll("aside.banner-critical button.banner-dismiss")); - } - - [Fact] - public void BannerHost_UnhandledError_RendersErrorBannerWithThreeButtons() - { - var error = new InvalidOperationException("kaboom"); - _bannerService.UnhandledError.Returns(error); + var entry = new ErrorBannerEntry(Guid.NewGuid(), "Database", "Schema invalid", null, null, DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); var component = Render(); var banner = component.Find("aside.banner-error"); - Assert.Contains("InvalidOperationException", banner.TextContent); - Assert.Contains("kaboom", banner.TextContent); - - var buttons = component.FindAll("aside.banner-error .banner-actions button"); - Assert.Equal(3, buttons.Count); - Assert.Contains("Reload", buttons[0].TextContent); - Assert.Contains("Relaunch", buttons[1].TextContent); - Assert.Contains("Copy details", buttons[2].TextContent); + Assert.Contains("Database: Schema invalid", banner.TextContent); + Assert.Empty(component.FindAll("aside.banner-error .banner-pagination")); + Assert.Single(component.FindAll("aside.banner-error button.banner-dismiss")); } [Fact] @@ -231,4 +716,7 @@ public void BannerHost_WarningSeverity_RendersWarningStyledBanner() Assert.Single(component.FindAll("aside.banner.banner-warning")); Assert.Empty(component.FindAll("aside.banner.banner-info")); } + + private static DatabaseEntry BuildDatabaseEntry(string fileName) => + new(fileName, $@"C:\dbs\{fileName}", IsEnabled: false, DatabaseStatus.UpgradeRequired); } diff --git a/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs b/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs new file mode 100644 index 00000000..b38abd62 --- /dev/null +++ b/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs @@ -0,0 +1,430 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.UI; +using EventLogExpert.UI.Models; + +namespace EventLogExpert.Components.Tests; + +public sealed class DatabaseEntryRowTests : BunitContext +{ + public DatabaseEntryRowTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public async Task RemoveButtonClick_InvokesOnRemove() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnRemove, () => invocationCount++)); + + // Act + await component.Find(".db-entry-remove-btn").ClickAsync(new()); + + // Assert + Assert.Equal(1, invocationCount); + } + + [Fact] + public void Render_BackupExistsAndIsUpgrading_StillShowsRecoveryBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, backupExists: true); + + // Act + var component = RenderRow(entry, isUpgrading: true); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Recovery required", badge.TextContent); + Assert.Equal("Recovery", badge.GetAttribute("data-badge")); + + Assert.Empty(component.FindAll(".db-entry-upgrading")); + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".toggle")); + + // Trash is unconditionally rendered now; visibility is governed by the CSS + // hover/focus reveal animation, not by markup. + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_BackupExistsEntry_OverridesReadyStatus() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, backupExists: true); + + // Act + var component = RenderRow(entry); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Recovery required", badge.TextContent); + Assert.Empty(component.FindAll(".toggle")); + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_BackupExistsEntry_ShowsRecoveryRequiredBadge_AndShowsTrash() + { + // Arrange — BackupExists routes the user to the recovery dialog as the primary + // action, but the trash is still rendered (revealed by hover/focus) so a user + // who wants to abandon the entry entirely can do so. + var entry = MakeEntry(DatabaseStatus.UpgradeRequired, backupExists: true); + + // Act + var component = RenderRow(entry); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Recovery required", badge.TextContent); + Assert.Equal("Recovery", badge.GetAttribute("data-badge")); + + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".toggle")); + Assert.Empty(component.FindAll(".db-entry-upgrading")); + + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_DisabledEntries_ShowTrashButton() + { + foreach (var status in Enum.GetValues()) + { + // Arrange + var entry = MakeEntry(status); + + // Act + var component = RenderRow(entry); + + // Assert — every status renders the trash; visibility is governed by the + // CSS hover/focus reveal animation, not by markup. + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + } + + [Fact] + public void Render_FileName_AppearsInRow() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, fileName: "MyProvider.db"); + + // Act + var component = RenderRow(entry); + + // Assert + Assert.Equal("MyProvider.db", component.Find(".db-entry-name").TextContent); + } + + [Fact] + public void Render_IsUpgradeBlocked_DisablesRetryButton() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeFailed); + + // Act + var component = RenderRow(entry, isUpgradeBlocked: true); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.True(button.HasAttribute("disabled")); + } + + [Fact] + public void Render_IsUpgradeBlocked_DisablesUpgradeButton() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + // Act + var component = RenderRow(entry, isUpgradeBlocked: true); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.True(button.HasAttribute("disabled")); + } + + [Fact] + public void Render_IsUpgrading_ShowsSpinner_AndHidesBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + // Act + var component = RenderRow(entry, isUpgrading: true); + + // Assert + var upgrading = component.Find(".db-entry-upgrading"); + Assert.Contains("Upgrading", upgrading.TextContent); + Assert.Single(component.FindAll(".db-entry-upgrading .db-entry-spinner")); + + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".db-entry-badge")); + + // Trash is rendered even during an in-flight upgrade; RemoveAsync coordinates + // with the per-file reservation so the user-initiated delete is safe to expose. + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_NonReadyEnabledEntry_ShowsTrashButton() + { + // Arrange — non-Ready entries are not loaded by the resolver regardless of IsEnabled, + // so the file is not locked and removal is safe. + var entry = MakeEntry(DatabaseStatus.UpgradeFailed, isEnabled: true); + + // Act + var component = RenderRow(entry); + + // Assert + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_NotClassifiedEntry_ShowsDisabledToggle_AndClassifyingBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.NotClassified); + + // Act + var component = RenderRow(entry); + + // Assert + var radios = component.FindAll(".toggle input[type='radio']"); + Assert.NotEmpty(radios); + Assert.All(radios, r => Assert.True(r.HasAttribute("disabled"))); + + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Classifying\u2026", badge.TextContent); + Assert.Equal("NotClassified", badge.GetAttribute("data-badge")); + } + + [Fact] + public void Render_ReadyEnabledEntry_OptimisticToggleOff_StillRendersTrashButton() + { + // Arrange — toggle change is optimistic and not yet committed; trash is in the DOM + // either way under the new contract. + var entry = MakeEntry(DatabaseStatus.Ready, isEnabled: true); + + // Act + var component = RenderRow(entry, effectiveEnabled: false); + + // Assert + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_ReadyEnabledEntry_RendersTrashButton() + { + // Arrange — Ready+Enabled is now safe to delete because RemoveAsync coordinates + // closing and reopening any open log views around the file delete. The trash is + // rendered (just visually faded by CSS until the row is hovered or focused). + var entry = MakeEntry(DatabaseStatus.Ready, isEnabled: true); + + // Act + var component = RenderRow(entry, effectiveEnabled: true); + + // Assert + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_ReadyEntry_ShowsToggle_AndNoBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + + // Act + var component = RenderRow(entry); + + // Assert + Assert.Single(component.FindAll(".toggle")); + Assert.Empty(component.FindAll(".db-entry-badge")); + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".db-entry-upgrading")); + } + + [Fact] + public void Render_ReadyEntryWithClassificationPending_ShowsDisabledToggle() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + + // Act + var component = RenderRow(entry, isClassificationPending: true); + + // Assert + var radios = component.FindAll(".toggle input[type='radio']"); + Assert.NotEmpty(radios); + Assert.All(radios, r => Assert.True(r.HasAttribute("disabled"))); + } + + [Fact] + public void Render_RemoveButton_HasAriaLabel() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, fileName: "MyProvider.db"); + + // Act + var component = RenderRow(entry); + + // Assert + var button = component.Find(".db-entry-remove-btn"); + Assert.Equal("Remove database MyProvider.db", button.GetAttribute("aria-label")); + } + + [Theory] + [InlineData(DatabaseStatus.UnrecognizedSchema, "Unrecognized")] + [InlineData(DatabaseStatus.ObsoleteSchema, "Obsolete")] + [InlineData(DatabaseStatus.ClassificationFailed, "Classification failed")] + public void Render_TerminalStatus_ShowsBadge_AndOnlyTrash(DatabaseStatus status, string expectedLabel) + { + // Arrange + var entry = MakeEntry(status); + + // Act + var component = RenderRow(entry); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal(expectedLabel, badge.TextContent); + Assert.Equal(status.ToString(), badge.GetAttribute("data-badge")); + + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".toggle")); + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_UpgradeFailedEntry_ShowsRetryButton_AndRedBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeFailed); + + // Act + var component = RenderRow(entry); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.Equal("Retry Upgrade", button.TextContent.Trim()); + Assert.Contains("button-red", button.GetAttribute("class") ?? string.Empty); + + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Upgrade failed", badge.TextContent); + Assert.Equal("UpgradeFailed", badge.GetAttribute("data-badge")); + } + + [Fact] + public void Render_UpgradeRequiredEntry_ShowsTrashButton() + { + // Arrange — UpgradeRequired now renders the trash like every other status; the + // user is no longer forced to either Upgrade or transition through UpgradeFailed + // before being able to remove the entry. + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + // Act + var component = RenderRow(entry); + + // Assert + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_UpgradeRequiredEntry_ShowsUpgradeButton_AndNoBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + // Act + var component = RenderRow(entry); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.Equal("Upgrade", button.TextContent.Trim()); + Assert.False(button.HasAttribute("disabled")); + Assert.DoesNotContain("button-red", button.GetAttribute("class") ?? string.Empty); + + Assert.Empty(component.FindAll(".db-entry-badge")); + Assert.Empty(component.FindAll(".toggle")); + } + + [Fact] + public async Task RetryButtonClick_InvokesOnUpgrade() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeFailed); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnUpgrade, () => invocationCount++)); + + // Act + await component.Find(".db-entry-upgrade-btn").ClickAsync(new()); + + // Assert + Assert.Equal(1, invocationCount); + } + + [Fact] + public async Task TogglingTrueRadio_InvokesOnToggle_OnReadyEntry() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.EffectiveEnabled, false) + .Add(p => p.OnToggle, () => invocationCount++)); + + // Act + var enableRadio = component.FindAll(".toggle input[type='radio']")[1]; + await enableRadio.ChangeAsync(new() { Value = "true" }); + + // Assert + Assert.Equal(1, invocationCount); + } + + [Fact] + public async Task UpgradeButtonClick_InvokesOnUpgrade() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnUpgrade, () => invocationCount++)); + + // Act + await component.Find(".db-entry-upgrade-btn").ClickAsync(new()); + + // Assert + Assert.Equal(1, invocationCount); + } + + private static DatabaseEntry MakeEntry( + DatabaseStatus status, + string fileName = "a.db", + bool isEnabled = false, + bool backupExists = false) => + new(fileName, $@"C:\dbs\{fileName}", isEnabled, status, backupExists); + + private IRenderedComponent RenderRow( + DatabaseEntry entry, + bool isClassificationPending = false, + bool isUpgrading = false, + bool isUpgradeBlocked = false, + bool effectiveEnabled = false) => + Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.IsClassificationPending, isClassificationPending) + .Add(p => p.IsUpgrading, isUpgrading) + .Add(p => p.IsUpgradeBlocked, isUpgradeBlocked) + .Add(p => p.EffectiveEnabled, effectiveEnabled)); +} diff --git a/src/EventLogExpert.Components.Tests/DatabaseRecoveryDialogTests.cs b/src/EventLogExpert.Components.Tests/DatabaseRecoveryDialogTests.cs new file mode 100644 index 00000000..5ce63f28 --- /dev/null +++ b/src/EventLogExpert.Components.Tests/DatabaseRecoveryDialogTests.cs @@ -0,0 +1,580 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace EventLogExpert.Components.Tests; + +public sealed class DatabaseRecoveryDialogTests : BunitContext +{ + private readonly IBannerService _bannerService = Substitute.For(); + private readonly IDatabaseService _databaseService = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + private int _onDismissedCallCount; + + public DatabaseRecoveryDialogTests() + { + _databaseService.Entries.Returns([]); + + Services.AddSingleton(_bannerService); + Services.AddSingleton(_databaseService); + Services.AddSingleton(_traceLogger); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public async Task DatabaseRecoveryDialog_AllRowsSucceed_AutoDismisses() + { + // Arrange + var entriesBefore = new DatabaseEntry[] + { + BuildEntry("a.db", backupExists: true), + BuildEntry("b.db", backupExists: true) + }; + + _databaseService.Entries.Returns(entriesBefore); + + _databaseService.RestoreFromBackupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(true)); + + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + // Act + await component.Find("button:contains('Apply')").ClickAsync(new()); + + _databaseService.Entries.Returns([]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + await component.WaitForAssertionAsync(() => Assert.Equal(1, _onDismissedCallCount)); + await _databaseService.Received(1).RestoreFromBackupAsync("a.db", Arg.Any()); + await _databaseService.Received(1).RestoreFromBackupAsync("b.db", Arg.Any()); + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyDeleteReturnsFalse_SurfacesErrorBannerAndMarksRowFailed() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _databaseService.DeleteEntryWithBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(false)); + + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + // Act + await component.Find("button.button:contains('Delete all')").ClickAsync(new()); + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + _bannerService.Received(1).ReportError( + "Database recovery failed", + "Failed to delete 'a.db'."); + + var rowClass = component.Find("li.recovery-row").GetAttribute("class") ?? string.Empty; + Assert.Contains("recovery-row-failed", rowClass); + Assert.Equal(0, _onDismissedCallCount); + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyDisablesAllControls_WhilePending() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + var pendingRestore = new TaskCompletionSource(); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(pendingRestore.Task); + + var component = Render(); + + // Act + var applyClick = component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + Assert.True(((AngleSharp.Html.Dom.IHtmlButtonElement)component.Find("button:contains('Apply')")).IsDisabled); + Assert.True(((AngleSharp.Html.Dom.IHtmlButtonElement)component.Find("button:contains('Cancel')")).IsDisabled); + Assert.True(((AngleSharp.Html.Dom.IHtmlButtonElement)component.Find("button.button:contains('Restore all')")).IsDisabled); + Assert.True(((AngleSharp.Html.Dom.IHtmlButtonElement)component.Find("button.button:contains('Delete all')")).IsDisabled); + + foreach (var radio in component.FindAll("li.recovery-row input[type=radio]")) + { + Assert.True(((AngleSharp.Html.Dom.IHtmlInputElement)radio).IsDisabled); + } + + pendingRestore.SetResult(true); + await applyClick; + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyMixed_CallsBothMethodsForRespectiveRows() + { + // Arrange + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(true)); + _databaseService.DeleteEntryWithBackupAsync("b.db", Arg.Any()) + .Returns(Task.FromResult(true)); + + var component = Render(); + + // Act + var bRow = component.FindAll("li.recovery-row")[1]; + var bDeleteRadio = bRow.QuerySelectorAll("input[type=radio]")[1]; + await bDeleteRadio.ChangeAsync(new() { Value = "on" }); + + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + await _databaseService.Received(1).RestoreFromBackupAsync("a.db", Arg.Any()); + await _databaseService.Received(1).DeleteEntryWithBackupAsync("b.db", Arg.Any()); + await _databaseService.DidNotReceive().RestoreFromBackupAsync("b.db", Arg.Any()); + await _databaseService.DidNotReceive().DeleteEntryWithBackupAsync("a.db", Arg.Any()); + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyRestoreReturnsFalse_SurfacesErrorBannerAndMarksRowFailed() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(false)); + + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + // Act + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + _bannerService.Received(1).ReportError( + "Database recovery failed", + "Failed to restore 'a.db' from backup."); + + var rowClass = component.Find("li.recovery-row").GetAttribute("class") ?? string.Empty; + Assert.Contains("recovery-row-failed", rowClass); + Assert.Equal(0, _onDismissedCallCount); + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyThrowsInvalidOperation_TreatsAsBenignSkipNoErrorBanner() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("entry not found"))); + + var component = Render(); + + // Act + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + + var rowClass = component.Find("li.recovery-row").GetAttribute("class") ?? string.Empty; + Assert.DoesNotContain("recovery-row-failed", rowClass); + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyThrowsUnexpected_SurfacesErrorBannerAndMarksRowFailed() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromException(new IOException("disk gone"))); + + var component = Render(); + + // Act + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + _bannerService.Received(1).ReportError( + "Database recovery failed", + "Failed to restore 'a.db' from backup."); + + var rowClass = component.Find("li.recovery-row").GetAttribute("class") ?? string.Empty; + Assert.Contains("recovery-row-failed", rowClass); + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyWithDelete_CallsDeleteEntryWithBackupAsyncWithFileName() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _databaseService.DeleteEntryWithBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(true)); + + var component = Render(); + + // Act + await component.Find("button.button:contains('Delete all')").ClickAsync(new()); + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + await _databaseService.Received(1).DeleteEntryWithBackupAsync( + Arg.Is(name => name == "a.db"), + Arg.Any()); + await _databaseService.DidNotReceive().RestoreFromBackupAsync( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task DatabaseRecoveryDialog_ApplyWithRestore_CallsRestoreFromBackupAsyncWithFileName() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(true)); + + var component = Render(); + + // Act + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + await _databaseService.Received(1).RestoreFromBackupAsync( + Arg.Is(name => name == "a.db"), + Arg.Any()); + await _databaseService.DidNotReceive().DeleteEntryWithBackupAsync( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task DatabaseRecoveryDialog_CancelClicked_RaisesOnDismissedDoesNotCallDatabaseService() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + // Act + await component.Find("button:contains('Cancel')").ClickAsync(new()); + + // Assert + Assert.Equal(1, _onDismissedCallCount); + await _databaseService.DidNotReceive().RestoreFromBackupAsync( + Arg.Any(), + Arg.Any()); + await _databaseService.DidNotReceive().DeleteEntryWithBackupAsync( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void DatabaseRecoveryDialog_DefaultsEachRowToRestore() + { + // Arrange + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + // Act + var component = Render(); + + // Assert + foreach (var row in component.FindAll("li.recovery-row")) + { + var radios = row.QuerySelectorAll("input[type=radio]"); + Assert.Equal(2, radios.Length); + Assert.True(((AngleSharp.Html.Dom.IHtmlInputElement)radios[0]).IsChecked); + Assert.False(((AngleSharp.Html.Dom.IHtmlInputElement)radios[1]).IsChecked); + } + } + + [Fact] + public async Task DatabaseRecoveryDialog_DeleteAllClicked_SetsAllRowsToDelete() + { + // Arrange + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + var component = Render(); + + // Act + await component.Find("button.button:contains('Delete all')").ClickAsync(new()); + + // Assert + foreach (var row in component.FindAll("li.recovery-row")) + { + var radios = row.QuerySelectorAll("input[type=radio]"); + Assert.False(((AngleSharp.Html.Dom.IHtmlInputElement)radios[0]).IsChecked); + Assert.True(((AngleSharp.Html.Dom.IHtmlInputElement)radios[1]).IsChecked); + } + } + + [Fact] + public async Task DatabaseRecoveryDialog_EntriesChangedAllResolved_AutoDismisses() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + // Act + _databaseService.Entries.Returns([]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + await component.WaitForAssertionAsync(() => Assert.Equal(1, _onDismissedCallCount)); + } + + [Fact] + public async Task DatabaseRecoveryDialog_EntriesChangedNewBackupExistsEntry_AddsRowWithRestoreDefault() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + // Act + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + component.WaitForState(() => component.FindAll("li.recovery-row").Count == 2); + + // Assert + var newRow = component.FindAll("li.recovery-row")[1]; + Assert.Contains("b.db", newRow.TextContent); + + var radios = newRow.QuerySelectorAll("input[type=radio]"); + Assert.True(((AngleSharp.Html.Dom.IHtmlInputElement)radios[0]).IsChecked); + Assert.False(((AngleSharp.Html.Dom.IHtmlInputElement)radios[1]).IsChecked); + + await Task.CompletedTask; + } + + [Fact] + public async Task DatabaseRecoveryDialog_EntriesChangedSubsetResolved_RemovesResolvedRowsKeepsDialogOpen() + { + // Arrange + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + var bRow = component.FindAll("li.recovery-row")[1]; + var bDeleteRadio = bRow.QuerySelectorAll("input[type=radio]")[1]; + await bDeleteRadio.ChangeAsync(new() { Value = "on" }); + + // Act + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + component.WaitForState(() => component.FindAll("li.recovery-row").Count == 1); + + // Assert + var remainingRow = component.Find("li.recovery-row"); + Assert.Contains("a.db", remainingRow.TextContent); + + var radios = remainingRow.QuerySelectorAll("input[type=radio]"); + Assert.True(((AngleSharp.Html.Dom.IHtmlInputElement)radios[0]).IsChecked); + Assert.False(((AngleSharp.Html.Dom.IHtmlInputElement)radios[1]).IsChecked); + Assert.Equal(0, _onDismissedCallCount); + } + + [Fact] + public async Task DatabaseRecoveryDialog_EscDuringApply_DoesNotDismiss() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + var pendingRestore = new TaskCompletionSource(); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(pendingRestore.Task); + + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + // Act + var applyTask = component.Find("button:contains('Apply')").ClickAsync(new()); + + await component.Find("dialog").TriggerEventAsync("oncancel", EventArgs.Empty); + + // Assert + Assert.Equal(0, _onDismissedCallCount); + + pendingRestore.SetResult(true); + await applyTask; + } + + [Fact] + public async Task DatabaseRecoveryDialog_FailedRow_LosesFailureMarkOnNextApplySuccessWhileOthersStayFailed() + { + // Arrange + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(false)); + _databaseService.RestoreFromBackupAsync("b.db", Arg.Any()) + .Returns(Task.FromResult(false)); + + var component = Render(); + + await component.Find("button:contains('Apply')").ClickAsync(new()); + + Assert.Contains( + "recovery-row-failed", + FindRowByFileName(component, "a.db").GetAttribute("class") ?? string.Empty); + Assert.Contains( + "recovery-row-failed", + FindRowByFileName(component, "b.db").GetAttribute("class") ?? string.Empty); + + // Act + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(true)); + + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + Assert.DoesNotContain( + "recovery-row-failed", + FindRowByFileName(component, "a.db").GetAttribute("class") ?? string.Empty); + Assert.Contains( + "recovery-row-failed", + FindRowByFileName(component, "b.db").GetAttribute("class") ?? string.Empty); + } + + [Fact] + public async Task DatabaseRecoveryDialog_InitialEmptySet_AutoDismissesWithoutShowingModal() + { + // Arrange + _databaseService.Entries.Returns([]); + + // Act + var component = Render(parameters => parameters + .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + + // Assert + await component.WaitForAssertionAsync(() => Assert.Equal(1, _onDismissedCallCount)); + } + + [Fact] + public void DatabaseRecoveryDialog_RendersOneRowPerBackupExistsEntry() + { + // Arrange + _databaseService.Entries.Returns( + [ + BuildEntry("a.db", backupExists: true), + BuildEntry("b.db", backupExists: true), + BuildEntry("c.db", backupExists: false) + ]); + + // Act + var component = Render(); + + // Assert + var rows = component.FindAll("li.recovery-row"); + Assert.Equal(2, rows.Count); + Assert.Contains("a.db", rows[0].TextContent); + Assert.Contains("b.db", rows[1].TextContent); + } + + [Fact] + public async Task DatabaseRecoveryDialog_RestoreAllClicked_SetsAllRowsToRestore() + { + // Arrange + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + var component = Render(); + + var rowCount = component.FindAll("li.recovery-row").Count; + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) + { + var deleteRadio = component.FindAll("li.recovery-row")[rowIndex] + .QuerySelectorAll("input[type=radio]")[1]; + await deleteRadio.ChangeAsync(new() { Value = "on" }); + } + + // Act + await component.Find("button.button:contains('Restore all')").ClickAsync(new()); + + // Assert + foreach (var row in component.FindAll("li.recovery-row")) + { + var radios = row.QuerySelectorAll("input[type=radio]"); + Assert.True(((AngleSharp.Html.Dom.IHtmlInputElement)radios[0]).IsChecked); + Assert.False(((AngleSharp.Html.Dom.IHtmlInputElement)radios[1]).IsChecked); + } + } + + [Fact] + public async Task DatabaseRecoveryDialog_RowResolvedExternallyMidLoop_DoesNotCallServiceForResolvedRow() + { + // Arrange + var entriesBefore = new DatabaseEntry[] + { + BuildEntry("a.db", backupExists: true), + BuildEntry("b.db", backupExists: true) + }; + + var entriesAfter = new DatabaseEntry[] + { + BuildEntry("a.db", backupExists: true), + BuildEntry("b.db", backupExists: false) + }; + + _databaseService.Entries.Returns(entriesBefore); + _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) + .Returns(Task.FromResult(true)); + + var component = Render(); + + _databaseService.Entries.Returns(entriesAfter); + + // Act + await component.Find("button:contains('Apply')").ClickAsync(new()); + + // Assert + await _databaseService.Received(1).RestoreFromBackupAsync("a.db", Arg.Any()); + await _databaseService.DidNotReceive().RestoreFromBackupAsync("b.db", Arg.Any()); + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + private static DatabaseEntry BuildEntry(string fileName, bool backupExists) => + new( + fileName, + $@"C:\dbs\{fileName}", + IsEnabled: false, + DatabaseStatus.UpgradeRequired, + BackupExists: backupExists); + + private static AngleSharp.Dom.IElement FindRowByFileName( + IRenderedComponent component, + string fileName) + { + foreach (var row in component.FindAll("li.recovery-row")) + { + if (row.TextContent.Contains(fileName, StringComparison.Ordinal)) + { + return row; + } + } + + throw new InvalidOperationException( + $"No recovery row found whose text contains '{fileName}'."); + } +} diff --git a/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs b/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs new file mode 100644 index 00000000..32821edd --- /dev/null +++ b/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs @@ -0,0 +1,405 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace EventLogExpert.Components.Tests; + +public sealed class DatabaseRecoveryHostTests : BunitContext +{ + private readonly IBannerService _bannerService = Substitute.For(); + private readonly IDatabaseService _databaseService = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + private Func? _capturedRecoveryAction; + private Guid _nextBannerId = Guid.NewGuid(); + + public DatabaseRecoveryHostTests() + { + _databaseService.Entries.Returns([]); + _bannerService.ErrorBanners.Returns([]); + + _bannerService + .ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do?>(action => _capturedRecoveryAction = action)) + .Returns(_ => _nextBannerId); + + Services.AddSingleton(_bannerService); + Services.AddSingleton(_databaseService); + Services.AddSingleton(_traceLogger); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public void DatabaseRecoveryHost_BannerDismissedExternally_DoesNotRepromptForSameSet() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", null, + new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc))]); + + Render(); + _bannerService.ClearReceivedCalls(); + + // Act + _bannerService.ErrorBanners.Returns([]); + _bannerService.StateChanged += Raise.Event(); + + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_BannerDismissedExternally_NewBackupEntryAppears_RepromptsWithNewCount() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", null, + new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc))]); + + Render(); + _bannerService.ClearReceivedCalls(); + + _bannerService.ErrorBanners.Returns([]); + _bannerService.StateChanged += Raise.Event(); + + // Act + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public async Task DatabaseRecoveryHost_DialogDismissed_BannerStillVisible_DoesNotDismissBanner() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + _bannerService.ClearReceivedCalls(); + + await component.InvokeAsync(() => _capturedRecoveryAction!()); + + // Act + await component.Find("button:contains('Cancel')").ClickAsync(new()); + + // Assert + Assert.Empty(component.FindAll("dialog")); + + _bannerService.DidNotReceive().DismissError(Arg.Any()); + } + + [Fact] + public async Task DatabaseRecoveryHost_DialogOpen_NewBackupEntryAppears_RefreshesBannerKeepsDialogOpen() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + await component.InvokeAsync(() => _capturedRecoveryAction!()); + + Assert.Single(component.FindAll("dialog")); + + // Act + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.Received(1).DismissError(initialId); + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + + Assert.Single(component.FindAll("dialog")); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_DismissesOwnedBanner() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + // Act + component.Instance.Dispose(); + + // Assert + _bannerService.Received(1).DismissError(initialId); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_NoLongerRespondsToEntriesChanged() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + component.Instance.Dispose(); + _bannerService.ClearReceivedCalls(); + + // Act + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.DidNotReceive().DismissError(Arg.Any()); + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_TwiceIsIdempotent() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + // Act + component.Instance.Dispose(); + component.Instance.Dispose(); + + // Assert + _bannerService.Received(1).DismissError(initialId); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_WithNoOwnedBanner_DoesNotCallDismiss() + { + // Arrange + _databaseService.Entries.Returns([]); + + var component = Render(); + + // Act + component.Instance.Dispose(); + + // Assert + _bannerService.DidNotReceive().DismissError(Arg.Any()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_AllRecovered_DismissesBannerAndDoesNotReprompt() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + Render(); + _bannerService.ClearReceivedCalls(); + + // Act + _databaseService.Entries.Returns([]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.Received(1).DismissError(initialId); + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_NewBackupExistsEntry_DismissesOldBannerAndRaisesNewWithUpdatedCount() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + Render(); + + // Act + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.Received(1).DismissError(initialId); + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_SameBackupSet_DoesNotDismissOrReprompt() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + Render(); + _bannerService.ClearReceivedCalls(); + + // Act + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.DidNotReceive().DismissError(Arg.Any()); + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_ShrinkButStillNonEmpty_DismissesOldBannerAndRaisesNewWithUpdatedCount() + { + // Arrange + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + Render(); + + // Act + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns([BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + // Assert + _bannerService.Received(1).DismissError(initialId); + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "1 database needs recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_OnInit_MultipleEntries_UsesPluralLabel() + { + // Arrange + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + // Act + Render(); + + // Assert + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_OnInit_WithBackupExistsEntries_RaisesErrorBanner() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + // Act + Render(); + + // Assert + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "1 database needs recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_OnInit_WithNoBackupExistsEntries_DoesNotRaiseBanner() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: false)]); + + // Act + Render(); + + // Assert + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public async Task DatabaseRecoveryHost_ResolveActionClicked_OpensDialog() + { + // Arrange + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + Assert.NotNull(_capturedRecoveryAction); + Assert.Empty(component.FindAll("dialog")); + + // Act + await component.InvokeAsync(() => _capturedRecoveryAction!()); + + // Assert + Assert.Single(component.FindAll("dialog")); + } + + private static DatabaseEntry BuildEntry(string fileName, bool backupExists) => + new( + fileName, + $@"C:\dbs\{fileName}", + IsEnabled: false, + DatabaseStatus.UpgradeRequired, + BackupExists: backupExists); +} diff --git a/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs b/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs new file mode 100644 index 00000000..f8d9fd68 --- /dev/null +++ b/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs @@ -0,0 +1,252 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace EventLogExpert.Components.Tests; + +public sealed class SettingsUpgradeProgressBannerTests : BunitContext +{ + private readonly IBannerService _bannerService = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + public SettingsUpgradeProgressBannerTests() + { + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + _bannerService.BackgroundProgress.Returns((BannerProgressEntry?)null); + + Services.AddSingleton(_bannerService); + Services.AddSingleton(_traceLogger); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public void SettingsUpgradeProgressBanner_BackgroundProgressOnly_RendersNothing() + { + // Arrange + _bannerService.BackgroundProgress.Returns(BuildProgress(UpgradeProgressScope.Background)); + + // Act + var component = Render(); + + // Assert + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_CancelClicked_InvokesProgressCancelDelegate() + { + // Arrange + int cancelInvocationCount = 0; + _bannerService.SettingsProgress.Returns(BuildProgress(cancel: () => cancelInvocationCount++)); + + // Act + var component = Render(); + await component.Find("aside.settings-upgrade-banner button.banner-action").ClickAsync(new()); + + // Assert + Assert.Equal(1, cancelInvocationCount); + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_CancelThrows_LogsViaTraceLoggerAndDoesNotPropagate() + { + // Arrange + _bannerService.SettingsProgress.Returns( + BuildProgress(cancel: () => throw new InvalidOperationException("cts disposed"))); + + // Act + var component = Render(); + await component.Find("aside.settings-upgrade-banner button.banner-action").ClickAsync(new()); + + // Assert + Assert.Single(component.FindAll("aside.settings-upgrade-banner")); + + _traceLogger.Received(1).Error(Arg.Is(h => + h.ToString().Contains(nameof(SettingsUpgradeProgressBanner)) && + h.ToString().Contains("cts disposed"))); + } + + [Fact] + public void SettingsUpgradeProgressBanner_DisposeIsIdempotent() + { + // Arrange + var component = Render(); + var instance = component.Instance; + + // Act + Assert + instance.Dispose(); + instance.Dispose(); + + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_DisposeUnsubscribesFromStateChanged() + { + // Arrange + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + + var component = Render(); + component.Instance.Dispose(); + + // Act + _bannerService.SettingsProgress.Returns(BuildProgress()); + _bannerService.StateChanged += Raise.Event(); + + await Task.Yield(); + + // Assert + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + } + + [Fact] + public void SettingsUpgradeProgressBanner_QueuedBatchesAfterOne_UsesSingularBatchLabel() + { + // Arrange + _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 1)); + + // Act + var component = Render(); + + // Assert + var subtitle = component.Find("aside.settings-upgrade-banner .banner-subtitle"); + Assert.Contains("+1 batch queued", subtitle.TextContent); + Assert.DoesNotContain("batches", subtitle.TextContent); + } + + [Fact] + public void SettingsUpgradeProgressBanner_QueuedBatchesAfterTwo_UsesPluralBatchesLabel() + { + // Arrange + _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 2)); + + // Act + var component = Render(); + + // Assert + var subtitle = component.Find("aside.settings-upgrade-banner .banner-subtitle"); + Assert.Contains("+2 batches queued", subtitle.TextContent); + } + + [Fact] + public void SettingsUpgradeProgressBanner_QueuedBatchesAfterZero_DoesNotRenderSubtitle() + { + // Arrange + _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 0)); + + // Act + var component = Render(); + + // Assert + Assert.Empty(component.FindAll("aside.settings-upgrade-banner .banner-subtitle")); + } + + [Fact] + public void SettingsUpgradeProgressBanner_SettingsProgressNull_RendersNothing() + { + // Arrange + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + + // Act + var component = Render(); + + // Assert + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + } + + [Fact] + public void SettingsUpgradeProgressBanner_SettingsProgressWithBatchSizeOne_UsesSingularDatabaseLabel() + { + // Arrange + _bannerService.SettingsProgress.Returns( + BuildProgress(currentBatchSize: 1, currentEntryName: string.Empty)); + + // Act + var component = Render(); + + // Assert + var banner = component.Find("aside.settings-upgrade-banner"); + Assert.Contains("Preparing upgrade of 1 database", banner.TextContent); + Assert.DoesNotContain("databases", banner.TextContent); + } + + [Fact] + public void SettingsUpgradeProgressBanner_SettingsProgressWithEmptyEntryName_RendersPreparingMessage() + { + // Arrange + _bannerService.SettingsProgress.Returns( + BuildProgress(currentBatchSize: 3, currentEntryName: string.Empty)); + + // Act + var component = Render(); + + // Assert + var banner = component.Find("aside.settings-upgrade-banner"); + Assert.Contains("Preparing upgrade of 3 databases", banner.TextContent); + Assert.DoesNotContain("Upgrading database 0", banner.TextContent); + } + + [Fact] + public void + SettingsUpgradeProgressBanner_SettingsProgressWithEntryName_RendersUpgradeProgressBannerWithCancelButton() + { + // Arrange + _bannerService.SettingsProgress.Returns(BuildProgress( + currentBatchPosition: 2, + currentBatchSize: 5, + currentEntryName: "MyDb.evtx", + currentPhase: UpgradePhase.MigratingSchema)); + + // Act + var component = Render(); + + // Assert + var banner = component.Find("aside.settings-upgrade-banner"); + Assert.Contains("Upgrading database 2 of 5", banner.TextContent); + Assert.Contains("MyDb.evtx", banner.TextContent); + Assert.Contains("MigratingSchema", banner.TextContent); + Assert.Equal("Cancel", component.Find("aside.settings-upgrade-banner button.banner-action").TextContent.Trim()); + Assert.Single(component.FindAll("aside.settings-upgrade-banner .banner-spinner")); + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_StateChangedRaised_RerendersWithNewProgress() + { + // Arrange + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + var component = Render(); + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + + // Act + _bannerService.SettingsProgress.Returns(BuildProgress(currentEntryName: "x.evtx")); + _bannerService.StateChanged += Raise.Event(); + + // Assert + await component.WaitForAssertionAsync(() => + Assert.Single(component.FindAll("aside.settings-upgrade-banner"))); + } + + private static BannerProgressEntry BuildProgress( + UpgradeProgressScope scope = UpgradeProgressScope.SettingsTriggered, + int currentBatchPosition = 1, + int currentBatchSize = 1, + string currentEntryName = "x.evtx", + UpgradePhase currentPhase = UpgradePhase.MigratingSchema, + int queuedBatchesAfter = 0, + Action? cancel = null) => + new( + Guid.NewGuid(), + scope, + currentBatchPosition, + currentBatchSize, + currentEntryName, + currentPhase, + queuedBatchesAfter, + cancel ?? (() => { })); +} diff --git a/src/EventLogExpert.Components/BannerHost.razor b/src/EventLogExpert.Components/BannerHost.razor index 7c607830..0e66ecb6 100644 --- a/src/EventLogExpert.Components/BannerHost.razor +++ b/src/EventLogExpert.Components/BannerHost.razor @@ -1,16 +1,43 @@ -@using EventLogExpert.UI.Services @{ - Exception? error = BannerService.UnhandledError; - IReadOnlyList criticals = BannerService.CriticalAlerts; + Exception? currentCritical = BannerService.CurrentCritical; + IReadOnlyList errors = BannerService.ErrorBanners; IReadOnlyList infos = BannerService.InfoBanners; - BannerView view = _currentView = BannerViewSelector.Select(error, criticals, infos); + IReadOnlyList attentionEntries = BannerService.AttentionEntries; + bool attentionDismissed = BannerService.AttentionDismissed; + BannerProgressEntry? backgroundProgress = BannerService.BackgroundProgress; + + (BannerCycleItem? selected, BannerView view) = RebuildItemsAndPickSelected( + currentCritical, errors, attentionEntries, attentionDismissed, backgroundProgress, infos); + _currentView = view; + bool showCyclePagination = _items.Count > 1 && view != BannerView.Critical; + + RenderFragment cycleNav =@ + @if (showCyclePagination) + { + + + + } + ; } @switch (view) { - case BannerView.Error when error is { } unhandledError: -