From 7868bfc786b013185003606e03f69341e1c22d99 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sun, 19 Jul 2026 07:08:17 +0000 Subject: [PATCH 1/2] Bump dependencies and refresh security pins --- Directory.Packages.props | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 433864ca..374299b6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,28 +13,39 @@ dotnet list package, include-transitive) so the ExplorerExtension companion exe and the MAUI head share a single Microsoft.WindowsAppSDK version. --> - - - - - - + + + + + + - - - + + + + + \ No newline at end of file From 88bbf9c48e62510037ae7ee126137ebe0ff24132 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sun, 19 Jul 2026 07:18:03 +0000 Subject: [PATCH 2/2] Upgrade NSubstitute to 6.0.0 and migrate test matchers --- Directory.Packages.props | 2 +- .../Database/DatabaseServiceTests.cs | 8 +- .../FilterLibraryMigrationIntegrationTests.cs | 4 +- .../Alerts/AlertDialogServiceTests.cs | 5 +- .../DatabaseOperationCoordinatorTests.cs | 40 +- .../DatabaseCoordinationEffectsReloadTests.cs | 12 +- .../EventLog/EffectsTests.cs | 132 +- .../EventLog/PartialLoadCoordinatorTests.cs | 2 +- .../FilterLenses/FilterLensCommandsTests.cs | 28 +- .../FilterLenses/FilterLensEffectsTests.cs | 4 +- .../FilterLibraryCommandsTests.cs | 28 +- .../FilterLibraryEffectsTests.cs | 142 +- .../FilterPane/EffectsTests.cs | 13 + .../LogTable/EffectsTests.cs | 26 +- .../Scenarios/Favorites/EffectsTests.cs | 5 +- .../ScenarioFavoriteCommandsTests.cs | 3 +- .../ScenarioApplyIntegrationTests.cs | 2 +- .../Scenarios/ScenarioApplyServiceTests.cs | 10 +- .../Scenarios/ScenarioLaunchServiceTests.cs | 4 +- .../Deployment/DeploymentServiceTests.cs | 52 +- .../Update/UpdateServiceTests.cs | 8 +- .../Banner/AttentionBannerTests.cs | 4 +- .../Banner/CriticalBannerTests.cs | 2 +- .../Dashboard/EmptyStateDashboardTests.cs | 6 +- .../Database/DatabaseEntryRowTests.cs | 4 +- .../Database/DatabaseRecoveryHostTests.cs | 6 +- .../Tabs/CreateDatabaseTabTests.cs | 2 +- .../Tabs/ManageDatabasesTabTests.cs | 2842 ++++++++--------- .../DetailsPane/DetailsPaneTests.cs | 2 +- .../ScenarioClipboardExporterTests.cs | 2 +- .../FilterLibrary/FilterLibraryModalTests.cs | 14 +- .../FilterLibrary/LibraryEntryRowTests.cs | 10 +- .../LibrarySavedTabHeaderTests.cs | 2 +- .../FilterPane/FilterPaneTests.cs | 2 +- .../LogTable/LogTablePaneGroupingTests.cs | 22 +- .../ActivationDispatcherTests.cs | 8 +- 36 files changed, 1755 insertions(+), 1703 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 374299b6..f3926d7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,7 +32,7 @@ - + diff --git a/tests/Integration/EventLogExpert.Runtime.IntegrationTests/Database/DatabaseServiceTests.cs b/tests/Integration/EventLogExpert.Runtime.IntegrationTests/Database/DatabaseServiceTests.cs index dde94267..55fc05c7 100644 --- a/tests/Integration/EventLogExpert.Runtime.IntegrationTests/Database/DatabaseServiceTests.cs +++ b/tests/Integration/EventLogExpert.Runtime.IntegrationTests/Database/DatabaseServiceTests.cs @@ -878,7 +878,7 @@ public async Task ImportAsync_FreshlyImportedV4Db_ShouldDefaultDisabled_AndNotEn Assert.Equal(DatabaseStatus.Ready, entry.Status); preferences.Received().DisabledDatabasesPreference = - Arg.Is>(disabled => disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); + Arg.Is>(disabled => disabled != null && disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); } [Fact] @@ -914,7 +914,7 @@ public async Task ImportAsync_ReimportedDb_NotOnSkipList_ShouldOverwriteAndPrese Assert.True(entry.IsEnabled); preferences.DidNotReceive().DisabledDatabasesPreference = - Arg.Is>(disabled => disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); + Arg.Is>(disabled => disabled != null && disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); } [Fact] @@ -952,7 +952,7 @@ public async Task ImportAsync_ReimportedDb_OnSkipList_ShouldPreserveExistingFile Assert.Equal(DatabaseStatus.Ready, entry.Status); preferences.DidNotReceive().DisabledDatabasesPreference = - Arg.Is>(disabled => disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); + Arg.Is>(disabled => disabled != null && disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); } [Fact] @@ -2040,7 +2040,7 @@ public void Toggle_WhenCalled_ShouldFlipIsEnabledAndPersist() Assert.False(service.Entries[0].IsEnabled); preferences.Received(1).DisabledDatabasesPreference = - Arg.Is>(disabled => disabled.Contains(Constants.TestDb1)); + Arg.Is>(disabled => disabled != null && disabled.Contains(Constants.TestDb1)); } [Fact] diff --git a/tests/Integration/EventLogExpert.Runtime.IntegrationTests/FilterLibrary/FilterLibraryMigrationIntegrationTests.cs b/tests/Integration/EventLogExpert.Runtime.IntegrationTests/FilterLibrary/FilterLibraryMigrationIntegrationTests.cs index 4bed7c80..cd67e3f5 100644 --- a/tests/Integration/EventLogExpert.Runtime.IntegrationTests/FilterLibrary/FilterLibraryMigrationIntegrationTests.cs +++ b/tests/Integration/EventLogExpert.Runtime.IntegrationTests/FilterLibrary/FilterLibraryMigrationIntegrationTests.cs @@ -63,7 +63,7 @@ public async Task Migration_AddRangeThrows_LegacyDataPreserved_DispatchesLoadSuc Assert.Empty(await realStore.LoadAllAsync(TestContext.Current.CancellationToken)); Assert.True(prefs.ContainsKey(FavoriteFiltersKey)); Assert.False(prefs.ContainsKey(MigrationSectionsKey)); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.IsEmpty)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.IsEmpty)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -198,7 +198,7 @@ public async Task Migration_EndToEnd_EmptyDb_LegacyFavoritesAndGroups_PopulatesS Assert.Equal(2, filterSet.Filters.Count); Assert.Equal(LibraryEntryOrigin.UserSaved, filterSet.Origin); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 3)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 3)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); Assert.True(prefs.ContainsKey(FavoriteFiltersKey)); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Alerts/AlertDialogServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Alerts/AlertDialogServiceTests.cs index bf05ccff..71ca4f6f 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Alerts/AlertDialogServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Alerts/AlertDialogServiceTests.cs @@ -40,7 +40,7 @@ public async Task DisplayPrompt_WhenActiveHost_ShouldRouteInlineAndReturnTypedVa // Assert Assert.Equal("typed-value", result); await host.Received(1).ShowInlineAlertAsync( - Arg.Is(r => r.IsPrompt && r.Title == "Rename" && r.Message == "Enter new name"), + Arg.Is(r => r != null && r.IsPrompt && r.Title == "Rename" && r.Message == "Enter new name"), Arg.Any()); } @@ -133,7 +133,7 @@ public async Task ShowAlert_ShouldMarshalThroughMainThreadService() var mainThread = Substitute.For(); mainThread.InvokeOnMainThreadAsync(Arg.Any>()) - .Returns(call => ((Func)call[0])()); + .Returns(call => call.ArgAt>(0)()); var sut = new AlertDialogService( coordinator, @@ -415,6 +415,7 @@ public async Task ShowAlertTwoButton_WhenActiveHost_ShouldRouteToHostInline() Assert.False(standaloneCalled); await host.Received(1).ShowInlineAlertAsync( Arg.Is(r => + r != null && r.Title == "Confirm" && r.Message == "Are you sure?" && r.AcceptLabel == "Yes" && diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Database/DatabaseOperationCoordinatorTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Database/DatabaseOperationCoordinatorTests.cs index 7cf44b83..c3ba5ba2 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Database/DatabaseOperationCoordinatorTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Database/DatabaseOperationCoordinatorTests.cs @@ -65,8 +65,8 @@ public async Task ApplyPendingTogglesAsync_FiveFilesWithTwoFailures_AllAttempted _databases.Received(1).Toggle("c.db"); _databases.Received(1).Toggle("d.db"); _databases.Received(1).Toggle("e.db"); - _errorBanners.Received(1).ReportError("Failed to Update Database", Arg.Is(m => m.Contains("c.db", StringComparison.Ordinal))); - _errorBanners.Received(1).ReportError("Failed to Update Database", Arg.Is(m => m.Contains("e.db", StringComparison.Ordinal))); + _errorBanners.Received(1).ReportError("Failed to Update Database", Arg.Is(m => m != null && m.Contains("c.db", StringComparison.Ordinal))); + _errorBanners.Received(1).ReportError("Failed to Update Database", Arg.Is(m => m != null && m.Contains("e.db", StringComparison.Ordinal))); _errorBanners.Received(2).ReportError("Failed to Update Database", Arg.Any()); } @@ -136,7 +136,7 @@ public async Task ImportAsync_CallbackReturnsTrue_FileNotAddedToSkipSet() await _databases.Received(1).ImportAsync( Arg.Any>(), - Arg.Is>(s => !s.Contains("exists.db")), + Arg.Is>(s => s != null && !s.Contains("exists.db")), Arg.Any()); } @@ -158,7 +158,7 @@ Task ThrowingCallback(string _, CancellationToken __) => // Conflict defaults to Skip because overwriting is riskier than importing nothing. await _databases.Received(1).ImportAsync( Arg.Any>(), - Arg.Is>(s => s.Contains("exists.db")), + Arg.Is>(s => s != null && s.Contains("exists.db")), Arg.Any()); _errorBanners.DidNotReceiveWithAnyArgs().ReportError(null!, null!); } @@ -175,7 +175,7 @@ public async Task ImportAsync_DatabaseServiceThrows_RoutesErrorBannerAndReturnsN var sut = CreateSut(); var outcome = await sut.ImportAsync(cancellationToken: Ct); - _errorBanners.Received(1).ReportError("Import Failed", Arg.Is(m => m.Contains("boom", StringComparison.Ordinal))); + _errorBanners.Received(1).ReportError("Import Failed", Arg.Is(m => m != null && m.Contains("boom", StringComparison.Ordinal))); _infoBanners.DidNotReceiveWithAnyArgs().ReportInfoBanner(null!, null!, default); Assert.Equal(ImportOutcome.None, outcome); } @@ -218,7 +218,7 @@ public async Task ImportAsync_LongRunningImport_PostOpRoutesDirectlyToInfoBanner _infoBanners.Received(1).ReportInfoBanner( "Import Successful", - Arg.Is(m => m.Contains("1 database has successfully been imported", StringComparison.Ordinal)), + Arg.Is(m => m != null && m.Contains("1 database has successfully been imported", StringComparison.Ordinal)), BannerSeverity.Info); Assert.Equal(1, outcome.ImportedCount); } @@ -253,7 +253,7 @@ public async Task ImportAsync_NullCallbackWithExistingConflict_FileAddedToSkipSe await _databases.Received(1).ImportAsync( Arg.Any>(), - Arg.Is>(s => s.Contains("exists.db")), + Arg.Is>(s => s != null && s.Contains("exists.db")), Arg.Any()); } @@ -306,7 +306,7 @@ public async Task ImportAsync_SameBasenameSourcesInOneBatch_AddsNameToSkipSet() await _databases.Received(1).ImportAsync( Arg.Any>(), - Arg.Is>(skipFileNames => skipFileNames.Contains("A.db")), + Arg.Is>(skipFileNames => skipFileNames != null && skipFileNames.Contains("A.db")), Arg.Any()); } @@ -379,7 +379,7 @@ public async Task ImportAsync_ZeroImportedWithFailures_RoutesErrorBannerNotInfoB var sut = CreateSut(); var outcome = await sut.ImportAsync(cancellationToken: Ct); - _errorBanners.Received(1).ReportError("Import Failed", Arg.Is(m => m.Contains("A.db (bad)", StringComparison.Ordinal))); + _errorBanners.Received(1).ReportError("Import Failed", Arg.Is(m => m != null && m.Contains("A.db (bad)", StringComparison.Ordinal))); _infoBanners.DidNotReceiveWithAnyArgs().ReportInfoBanner(null!, null!, default); Assert.Equal(0, outcome.ImportedCount); Assert.False(outcome.DatabaseStateChanged); @@ -398,7 +398,7 @@ public async Task ImportPathsAsync_EnableOnImportFalse_ImportsGivenPathsWithoutP Assert.Equal(1, outcome.ImportedCount); await _filePicker.DidNotReceiveWithAnyArgs().PickMultipleAsync(null!, null!); await _databases.Received(1).ImportAsync( - Arg.Is>(paths => paths.SequenceEqual(new[] { @"C:\out\A.db" })), + Arg.Is>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\out\A.db" })), Arg.Any>(), Arg.Any()); _databases.DidNotReceiveWithAnyArgs().Toggle(null!); @@ -462,7 +462,7 @@ public async Task RemoveDatabaseAsync_DatabaseThrowsAfterSnapshotPopulated_Snaps _logReload.Received(1).ReopenAfterDatabaseRemoval(Arg.Any>()); _errorBanners.Received(1).ReportError( "Failed to Remove Database", - Arg.Is(m => m.Contains("removal failed mid-flight", StringComparison.Ordinal))); + Arg.Is(m => m != null && m.Contains("removal failed mid-flight", StringComparison.Ordinal))); } [Fact] @@ -601,7 +601,7 @@ public async Task RemoveDatabaseAsync_SuccessWithClosedLogs_ReopensLogsAndReport Assert.True(outcome.Removed); Assert.True(outcome.LogsReopened); _logReload.Received(1).ReopenAfterDatabaseRemoval( - Arg.Is>(l => l.Count == 1 && l[0].Name == "Application")); + Arg.Is>(l => l != null && l.Count == 1 && l[0].Name == "Application")); _errorBanners.DidNotReceiveWithAnyArgs().ReportError(null!, null!); } @@ -644,7 +644,7 @@ public async Task UpgradeDatabaseAsync_DatabaseThrows_ReportsErrorBannerAndClear Assert.Equal(2, stateChangeCount); _errorBanners.Received(1).ReportError( "Database Upgrade Failed", - Arg.Is(m => m.Contains("upgrade body failed", StringComparison.Ordinal))); + Arg.Is(m => m != null && m.Contains("upgrade body failed", StringComparison.Ordinal))); } [Fact] @@ -691,8 +691,8 @@ public async Task UpgradeDatabaseAsync_ResultFailedPopulated_ReportsOneErrorBann var sut = CreateSut(); await sut.UpgradeDatabaseAsync("a.db", cancellationToken: Ct); - _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(m => m.Contains("a.db", StringComparison.Ordinal) && m.Contains("schema-mismatch", StringComparison.Ordinal))); - _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(m => m.Contains("b.db", StringComparison.Ordinal) && m.Contains("io-error", StringComparison.Ordinal))); + _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(m => m != null && m.Contains("a.db", StringComparison.Ordinal) && m.Contains("schema-mismatch", StringComparison.Ordinal))); + _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(m => m != null && m.Contains("b.db", StringComparison.Ordinal) && m.Contains("io-error", StringComparison.Ordinal))); } [Theory] @@ -735,7 +735,7 @@ public async Task UpgradeDatabaseAsync_StateChangedSubscriberThrows_StateStillCl Assert.False(sut.IsAnyUpgradeInFlight); Assert.False(sut.IsUpgradeInFlight("a.db")); await _databases.Received(1).UpgradeBatchAsync( - Arg.Is>(l => l.Count == 1 && l[0] == "a.db"), + Arg.Is>(l => l != null && l.Count == 1 && l[0] == "a.db"), Arg.Any(), Arg.Any()); } @@ -769,7 +769,7 @@ public async Task UpgradeDatabaseAsync_Success_TracksInFlightStateAndRaisesEnter Assert.False(wasInFlightDuringExit, "Exit event should fire after file is removed"); Assert.False(sut.IsAnyUpgradeInFlight); await _databases.Received(1).UpgradeBatchAsync( - Arg.Is>(l => l.Count == 1 && l[0] == "a.db"), + Arg.Is>(l => l != null && l.Count == 1 && l[0] == "a.db"), UpgradeProgressScope.ManageDatabasesTriggered, Arg.Any()); } @@ -842,8 +842,8 @@ public async Task UpgradeDatabasesAsync_PartialFailure_SurfacesEachFailureToErro var actual = await sut.UpgradeDatabasesAsync(["a.db", "b.db", "c.db"], UpgradeProgressScope.ManageDatabasesTriggered, Ct); Assert.Same(result, actual); - _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(s => s.Contains("b.db") && s.Contains("schema mismatch"))); - _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(s => s.Contains("c.db") && s.Contains("io error"))); + _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(s => s != null && s.Contains("b.db") && s.Contains("schema mismatch"))); + _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(s => s != null && s.Contains("c.db") && s.Contains("io error"))); } [Fact] @@ -893,7 +893,7 @@ public async Task UpgradeDatabasesAsync_ServiceThrows_FallbackReturnedAndInFligh Assert.NotNull(result); Assert.Empty(result.Succeeded); Assert.False(sut.IsAnyUpgradeInFlight); - _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(s => s.Contains("disk full"))); + _errorBanners.Received(1).ReportError("Database Upgrade Failed", Arg.Is(s => s != null && s.Contains("disk full"))); } private static DatabaseEntry CreateEntry( diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/DatabaseCoordinationEffectsReloadTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/DatabaseCoordinationEffectsReloadTests.cs index 9e47dce5..c9467872 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/DatabaseCoordinationEffectsReloadTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/DatabaseCoordinationEffectsReloadTests.cs @@ -71,7 +71,7 @@ public async Task PrepareForDatabaseRemovalAsync_CloseFailureMidwayDispatchesAll using var cts = new CancellationTokenSource(); _dispatcher.When(d => d.Dispatch(Arg.Any())).Do(call => { - var action = (CloseLogAction)call.Args()[0]; + var action = call.ArgAt(0); if (action.LogName == "Application") { @@ -103,7 +103,7 @@ public async Task ReloadAllActiveLogsAsync_CalledSequentiallyTwice_CoordinatorLo _dispatcher.When(d => d.Dispatch(Arg.Any())).Do(call => { - var action = (CloseLogAction)call.Args()[0]; + var action = call.ArgAt(0); _closeCoordinator.CompleteCloseFor(action.LogId); }); @@ -165,7 +165,7 @@ public async Task ReloadAllActiveLogsAsync_CloseThrowsThenSubsequentCallProceeds return; } - var action = (CloseLogAction)call.Args()[0]; + var action = call.ArgAt(0); _closeCoordinator.CompleteCloseFor(action.LogId); }); @@ -203,15 +203,15 @@ public async Task ReloadAllActiveLogsAsync_TwoActiveLogs_DispatchesPerLogCloseAn _dispatcher.When(d => d.Dispatch(Arg.Any())).Do(call => { - var action = (CloseLogAction)call.Args()[0]; + var action = call.ArgAt(0); _closeCoordinator.CompleteCloseFor(action.LogId); }); var sut = CreateSut(); await sut.ReloadAllActiveLogsAsync(Ct); - _dispatcher.Received(1).Dispatch(Arg.Is(a => a.LogName == "Application")); - _dispatcher.Received(1).Dispatch(Arg.Is(a => a.LogName == "C:/path/security.evtx")); + _dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.LogName == "Application")); + _dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.LogName == "C:/path/security.evtx")); _dispatcher.DidNotReceive().Dispatch(Arg.Any()); _eventLogCommands.Received(1).OpenLog("Application", LogPathType.Channel, Arg.Any()); _eventLogCommands.Received(1).OpenLog("C:/path/security.evtx", LogPathType.File, Arg.Any()); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs index 19aafb36..905c8ffa 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs @@ -48,7 +48,7 @@ public async Task HandleAddEvent_WhenBufferReachesMaxEvents_ShouldSetFullFlag() // Assert mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.IsFull == true && a.UpdatedBuffer.Count == EventLogState.MaxNewEvents)); } @@ -71,7 +71,7 @@ public async Task HandleAddEvent_WhenContinuouslyUpdateFalse_ShouldBufferEvent() // Assert mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.UpdatedBuffer.Count == 1 && a.UpdatedBuffer[0] == newEvent)); } @@ -118,7 +118,7 @@ public async Task HandleAddEvent_WhenContinuouslyUpdateTrue_AndEventFilteredOut_ await effects.HandleAddEvent(new AddEventAction(newEvent), mockDispatcher); // Assert: raw is ingested unconditionally (Prepend) even though the filtered display append is skipped. - mockDispatcher.Received(1).Dispatch(Arg.Is(a => + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Mode == RawIngestMode.Prepend && a.EventsByLog.ContainsKey(logData.Id))); mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -149,7 +149,7 @@ public async Task HandleAddEvent_WhenContinuouslyUpdateTrue_ShouldIngestRawAndAp // post-ingest store is visible when HandleAddEvent rebuilds the display view over it. mockDispatcher .When(d => d.Dispatch(Arg.Any())) - .Do(callInfo => rawState = RawEventStoreReducers.ReduceIngestRawEvents(rawState, callInfo.Arg())); + .Do(callInfo => rawState = RawEventStoreReducers.ReduceIngestRawEvents(rawState, callInfo.ArgAt(0))); var newEvent = FilterEventBuilder.CreateTestEvent(100, logName: Constants.LogNameTestLog); var action = new AddEventAction(newEvent); @@ -158,11 +158,11 @@ public async Task HandleAddEvent_WhenContinuouslyUpdateTrue_ShouldIngestRawAndAp await effects.HandleAddEvent(action, mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Mode == RawIngestMode.Prepend && a.EventsByLog.ContainsKey(logData.Id))); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogId == logData.Id && a.View != null && a.View.Count == 1 && a.View.EnumerateDetail().First().Id == newEvent.Id)); mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); @@ -198,9 +198,9 @@ public async Task HandleApplyFilter_FilterBranch_ShouldBracketDisplayedEventsUpd Received.InOrder(() => { - mockDispatcher.Dispatch(Arg.Is(a => a.IsLoading)); + mockDispatcher.Dispatch(Arg.Is(a => a != null && a.IsLoading)); mockDispatcher.Dispatch(Arg.Any()); - mockDispatcher.Dispatch(Arg.Is(a => !a.IsLoading)); + mockDispatcher.Dispatch(Arg.Is(a => a != null && !a.IsLoading)); }); } @@ -244,8 +244,8 @@ public async Task HandleApplyFilter_FilterBranch_WhenFilterServiceThrows_ShouldS await Assert.ThrowsAsync(() => effects.HandleApplyFilter(action, mockDispatcher)); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => a.IsLoading)); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => !a.IsLoading)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.IsLoading)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && !a.IsLoading)); mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -287,7 +287,7 @@ public async Task HandleApplyFilter_FilterBranch_WhenFinalizeArrivesDuringOffThr // The pass-1 snapshot saw only event 200 at ContentVersion 0; the post-build re-check saw the // finalize rebuild at ContentVersion 1 and refiltered, so the published view reflects all three. mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.Views.ContainsKey(snapshotData.Id) && a.Views[snapshotData.Id].Count == finalizedRaw.Count && a.Views[snapshotData.Id].EnumerateDetail().Any(e => e.Id == 202))); @@ -330,7 +330,7 @@ public async Task HandleApplyFilter_FilterBranch_WhenLiveTailArrivesDuringOffThr // The pass-1 snapshot saw only event 100 at ContentVersion 0; the post-build re-check saw the // live-tail append at ContentVersion 1 and refiltered, so the published view includes event 101. mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.Views.ContainsKey(snapshotData.Id) && a.Views[snapshotData.Id].Count == liveTailRaw.Count && a.Views[snapshotData.Id].EnumerateDetail().Any(e => e.Id == 101))); @@ -373,7 +373,7 @@ public async Task HandleApplyFilter_FilterBranch_WhenLogClosedDuringFilter_Shoul // Assert: the raw store dropped the log during the off-thread build (the post-snapshot reads // return an empty store), so the post-build re-check finds it gone and DisplayReady omits its slice. mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => !a.Views.ContainsKey(snapshotData.Id))); + .Dispatch(Arg.Is(a => a != null && !a.Views.ContainsKey(snapshotData.Id))); } [Fact] @@ -427,7 +427,7 @@ public async Task HandleApplyFilter_FilterBranch_WhenLogEventsChangeDuringFilter // the live-tail rebuild at ContentVersion 1 and refiltered from current state, so the published view // reflects the updated row set (event 101), not the stale pass-1 rows. mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.Views.ContainsKey(snapshotData.Id) && a.Views[snapshotData.Id].Count == liveTailRaw.Count && a.Views[snapshotData.Id].EnumerateDetail().Any(e => e.Id == 101))); @@ -487,12 +487,12 @@ public async Task HandleApplyFilter_FilterBranch_WhenLogStillStaleAfterRetry_Sho // so the slice is still stale and omitted, and a convergence pass is scheduled while the progress // spinner stays on (cleared later by the converging pass). mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => !a.Views.ContainsKey(snapshotData.Id))); + .Dispatch(Arg.Is(a => a != null && !a.Views.ContainsKey(snapshotData.Id))); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => a.StaleIds.Contains(snapshotData.Id))); + .Dispatch(Arg.Is(a => a != null && a.StaleIds.Contains(snapshotData.Id))); - mockDispatcher.DidNotReceive().Dispatch(Arg.Is(a => !a.IsLoading)); + mockDispatcher.DidNotReceive().Dispatch(Arg.Is(a => a != null && !a.IsLoading)); } [Fact] @@ -524,7 +524,7 @@ public async Task HandleApplyFilter_FilterBranch_WhenSupersededByNewerFilter_Sho var superseded = 0; mockDispatcher - .When(d => d.Dispatch(Arg.Is(a => a.IsLoading))) + .When(d => d.Dispatch(Arg.Is(a => a != null && a.IsLoading))) .Do(_ => { if (Interlocked.Exchange(ref superseded, 1) == 0) @@ -542,10 +542,10 @@ public async Task HandleApplyFilter_FilterBranch_WhenSupersededByNewerFilter_Sho await effects.HandleApplyFilter(new ApplyFilterAction(new Filter(null, [])), mockDispatcher); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => a.Views[logData.Id].EnumerateDetail().Any(e => e.Id == 100))); + .Dispatch(Arg.Is(a => a != null && a.Views[logData.Id].EnumerateDetail().Any(e => e.Id == 100))); // The stale run's finally was suppressed by the token guard, so only the fresh run cleared the spinner. - mockDispatcher.Received(1).Dispatch(Arg.Is(a => !a.IsLoading)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && !a.IsLoading)); } [Fact] @@ -569,7 +569,7 @@ public async Task HandleApplyFilter_ReloadBranch_ShouldClearStaleFilterProgressS .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); var xmlFilter = FilterBuilder.CreateTestFilter(FilterTestConstants.FilterXmlContainsData, isEnabled: true); @@ -578,8 +578,8 @@ public async Task HandleApplyFilter_ReloadBranch_ShouldClearStaleFilterProgressS await effects.HandleApplyFilter(action, mockDispatcher); Assert.True(action.Filter.RequiresXml); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => !a.IsLoading)); - mockDispatcher.DidNotReceive().Dispatch(Arg.Is(a => a.IsLoading)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && !a.IsLoading)); + mockDispatcher.DidNotReceive().Dispatch(Arg.Is(a => a != null && a.IsLoading)); // Surface any HandleCloseLog faults before exiting the test. await Task.WhenAll(closeTasks); @@ -637,7 +637,7 @@ public async Task HandleApplyFilter_WhenCloseAllArrivesMidReopenLoop_ShouldDispa .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); // Hook the FIRST OpenLogAction dispatch to land CloseAll synchronously, which bumps @@ -671,7 +671,7 @@ public async Task HandleApplyFilter_WhenCloseAllArrivesMidReopenLoop_ShouldDispa mockDispatcher.Received(1).Dispatch(Arg.Any()); // The cleanup CloseLog targets the log we already reopened (Log1). - mockDispatcher.Received().Dispatch(Arg.Is(a => a.LogName == Constants.LogNameLog1)); + mockDispatcher.Received().Dispatch(Arg.Is(a => a != null && a.LogName == Constants.LogNameLog1)); // Surface any HandleCloseLog faults before exiting the test. await Task.WhenAll(closeTasks); @@ -734,7 +734,7 @@ public async Task HandleApplyFilter_WhenCloseAllSupersedesReload_ShouldClearPend .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); var xmlFilter = FilterBuilder.CreateTestFilter(FilterTestConstants.FilterXmlContainsData, isEnabled: true); @@ -789,7 +789,7 @@ public async Task HandleApplyFilter_WhenCloseAllSupersedesReload_ShouldNotReopen .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); var xmlFilter = FilterBuilder.CreateTestFilter(FilterTestConstants.FilterXmlContainsData, isEnabled: true); @@ -869,7 +869,7 @@ public async Task HandleApplyFilter_WhenFilterRequiresXml_AwaitsCloseCompletionB .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); var xmlFilter = FilterBuilder.CreateTestFilter(FilterTestConstants.FilterXmlContainsData, isEnabled: true); @@ -891,7 +891,7 @@ public async Task HandleApplyFilter_WhenFilterRequiresXml_AwaitsCloseCompletionB // OpenLog should now have been dispatched (only happens after the close await). mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogName == Constants.LogNameTestLog && a.LogPathType == LogPathType.Channel)); // Surface any HandleCloseLog faults before exiting the test. @@ -936,7 +936,7 @@ public async Task HandleApplyFilter_WhenFilterRequiresXml_ShouldRestoreSelection var mockFilterService = Substitute.For(); mockFilterService.GetFilteredEvents(Arg.Any>(), Arg.Any()) - .Returns(callInfo => callInfo.Arg>().ToList()); + .Returns(callInfo => callInfo.ArgAt>(0).ToList()); var mockServiceScopeFactory = Substitute.For(); var mockServiceScope = Substitute.For(); @@ -972,7 +972,7 @@ public async Task HandleApplyFilter_WhenFilterRequiresXml_ShouldRestoreSelection .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); var xmlFilter = FilterBuilder.CreateTestFilter(FilterTestConstants.FilterXmlContainsData, isEnabled: true); @@ -987,7 +987,7 @@ public async Task HandleApplyFilter_WhenFilterRequiresXml_ShouldRestoreSelection // Assert: SetSelectedEvents dispatched with exactly the restored event (RecordId=42). mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.Selection.Count() == 1 && a.Selection.First().ReloadKey!.Value.RecordId == 42)); // Surface any HandleCloseLog faults before exiting the test. @@ -1013,7 +1013,7 @@ public async Task HandleApplyFilter_WhenFilterRequiresXmlAndLogLacksXml_ShouldCl .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); var xmlFilter = FilterBuilder.CreateTestFilter(FilterTestConstants.FilterXmlContainsData, isEnabled: true); @@ -1027,11 +1027,11 @@ public async Task HandleApplyFilter_WhenFilterRequiresXmlAndLogLacksXml_ShouldCl Assert.True(filter.RequiresXml); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogName == Constants.LogNameTestLog && a.LogId == logData.Id)); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogName == Constants.LogNameTestLog && a.LogPathType == LogPathType.Channel)); // Reload path returns early; no DisplayReady until LoadEvents fires. @@ -1065,7 +1065,7 @@ public async Task HandleApplyFilter_WhenNewerApplyFilterRacesReload_ShouldStillR .When(d => d.Dispatch(Arg.Any())) .Do(callInfo => { - closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + closeTasks.Add(effects.HandleCloseLog(callInfo.ArgAt(0), mockDispatcher)); }); var xmlFilter1 = FilterBuilder.CreateTestFilter(FilterTestConstants.FilterXmlContainsData, isEnabled: true); @@ -1094,7 +1094,7 @@ public async Task HandleApplyFilter_WhenNewerApplyFilterRacesReload_ShouldStillR // would not have happened if the round-2 guard had treated filter2's filter token // bump as supersession). mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogName == Constants.LogNameTestLog && a.LogPathType == LogPathType.Channel)); // Surface any HandleCloseLog faults before exiting the test. @@ -1137,7 +1137,7 @@ public async Task HandleApplyFilter_WhenReloadSupersedesFilterOnly_ShouldDropFil var superseded = 0; mockDispatcher - .When(d => d.Dispatch(Arg.Is(a => a.IsLoading))) + .When(d => d.Dispatch(Arg.Is(a => a != null && a.IsLoading))) .Do(_ => { if (Interlocked.Exchange(ref superseded, 1) == 0) @@ -1153,7 +1153,7 @@ public async Task HandleApplyFilter_WhenReloadSupersedesFilterOnly_ShouldDropFil // The stale filter-only run must NOT have dispatched its DisplayReady for id 999. mockDispatcher.DidNotReceive() - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.Views.ContainsKey(logData.Id) && a.Views[logData.Id].EnumerateDetail().Any(e => e.Id == 999))); } @@ -1321,7 +1321,7 @@ public async Task HandleCloseLog_ShouldRemoveLogAndDispatchCloseAction() await mockLogWatcher.Received(1).RemoveLogAsync(Constants.LogNameTestLog); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogId == logId)); } @@ -1396,9 +1396,9 @@ await effects.HandleConvergeFilter( // Assert: converged slice published, progress cleared, no further convergence scheduled. mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => a.Views.ContainsKey(logData.Id))); + .Dispatch(Arg.Is(a => a != null && a.Views.ContainsKey(logData.Id))); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => !a.IsLoading)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && !a.IsLoading)); mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -1488,7 +1488,7 @@ await effects.HandleConvergeFilter( mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => !a.IsLoading)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && !a.IsLoading)); } [Fact] @@ -1531,7 +1531,7 @@ public async Task HandleLoadEvents_ShouldFilterAndDispatchUpdateTable() await effects.HandleLoadEvents(action, mockDispatcher); // Assert: a pre-built display view for the finalized log is handed to the reducer. - mockDispatcher.Received(1).Dispatch(Arg.Is(a => + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.LogId == logData.Id && a.View != null && a.View.Count == 2)); } @@ -1548,7 +1548,7 @@ public async Task HandleLoadNewEvents_ShouldIngestRawPrependFromBuffer() await effects.HandleLoadNewEvents(mockDispatcher); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Mode == RawIngestMode.Prepend && a.EventsByLog.ContainsKey(logData.Id))); } @@ -1584,14 +1584,14 @@ public async Task HandleLoadNewEvents_ShouldProcessBufferAndDispatchActions() // visible in the store when the batch view is built. mockDispatcher .When(d => d.Dispatch(Arg.Any())) - .Do(callInfo => rawState = RawEventStoreReducers.ReduceIngestRawEvents(rawState, callInfo.Arg())); + .Do(callInfo => rawState = RawEventStoreReducers.ReduceIngestRawEvents(rawState, callInfo.ArgAt(0))); // Act await effects.HandleLoadNewEvents(mockDispatcher); // Assert mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.ViewsByLog.Count == 1 && a.ViewsByLog.ContainsKey(logData.Id) && a.ViewsByLog[logData.Id].Count == 2)); @@ -1599,7 +1599,7 @@ public async Task HandleLoadNewEvents_ShouldProcessBufferAndDispatchActions() mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.UpdatedBuffer.Count == 0 && a.IsFull == false)); } @@ -1628,7 +1628,7 @@ public async Task HandleLoadNewEvents_WhenAllEventsFiltered_ShouldNotDispatchApp mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.UpdatedBuffer.Count == 0 && a.IsFull == false)); } @@ -1670,13 +1670,13 @@ public async Task HandleLoadNewEvents_WhenBufferSpansMultipleLogs_ShouldGroupInt // single batch view is built. mockDispatcher .When(d => d.Dispatch(Arg.Any())) - .Do(callInfo => rawState = RawEventStoreReducers.ReduceIngestRawEvents(rawState, callInfo.Arg())); + .Do(callInfo => rawState = RawEventStoreReducers.ReduceIngestRawEvents(rawState, callInfo.ArgAt(0))); AppendTableEventsBatchAction? captured = null; mockDispatcher .When(dispatcher => dispatcher.Dispatch(Arg.Any())) - .Do(call => captured = (AppendTableEventsBatchAction)call.Args()[0]); + .Do(call => captured = call.ArgAt(0)); // Act await effects.HandleLoadNewEvents(mockDispatcher); @@ -2028,7 +2028,7 @@ public async Task HandleOpenLog_ReverseEagerLoad_WhenReaderInvalid_SurfacesLoadF await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); // A log that fails to open surfaces an error instead of a misleading empty final load, and never seeds the watcher. - dispatcher.Received().Dispatch(Arg.Is(a => + dispatcher.Received().Dispatch(Arg.Is(a => a != null && a.ResolverStatus.Contains("Error") && a.ResolverStatus.Contains(Constants.LogNameApplication))); Assert.Empty(dispatcher.ReceivedCalls().Select(call => call.GetArguments()[0]).OfType()); watcher.DidNotReceive().AddLog(Arg.Any(), Arg.Any(), Arg.Any()); @@ -2049,7 +2049,7 @@ public async Task HandleOpenLog_ReverseEagerLoad_WhenReadStopsOnError_SurfacesLo await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); // A read that stops on a Win32 error is surfaced as a failure, not dispatched as a successful final load. - dispatcher.Received().Dispatch(Arg.Is(a => + dispatcher.Received().Dispatch(Arg.Is(a => a != null && a.ResolverStatus.Contains("Error") && a.ResolverStatus.Contains(Constants.LogNameApplication))); Assert.Empty(dispatcher.ReceivedCalls().Select(call => call.GetArguments()[0]).OfType()); } @@ -2071,8 +2071,8 @@ public async Task HandleOpenLog_ShouldThreadOpenLogsIdIntoDispatchedAddTableActi await effects.HandleOpenLog(action, mockDispatcher); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => a.LogData.Id == logData.Id)); - mockDispatcher.Received().Dispatch(Arg.Is(a => a.LogId == logData.Id)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.LogData.Id == logData.Id)); + mockDispatcher.Received().Dispatch(Arg.Is(a => a != null && a.LogId == logData.Id)); } [Fact] @@ -2111,7 +2111,7 @@ public async Task HandleOpenLog_WhenLogNotInOpenLogs_ShouldDispatchError() // Assert mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.ResolverStatus.Contains("Error") && a.ResolverStatus.Contains(Constants.LogNameTestLog))); } @@ -2133,7 +2133,7 @@ public async Task HandleOpenLog_WhenNoEventResolver_ShouldDispatchError() // Assert mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.ResolverStatus.Contains("Error"))); } @@ -2219,7 +2219,7 @@ public async Task HandleSetOrderBy_SortEffect_ShouldRepublishUnderRequestedConte await effects.Filtering.HandleSetOrderBy(new SetOrderByAction(ColumnName.Source), mockDispatcher); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Version == 7 && a.Views.ContainsKey(logData.Id) && a.Views[logData.Id].HasContext(new SortContext(ColumnName.Source, true, null, false)))); @@ -2326,7 +2326,7 @@ public async Task HandleUpdateTable_WhenSortPending_RepublishesUnderRequestedCon await effects.Filtering.HandleUpdateTable(mockDispatcher); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Version == 7 && a.Views.ContainsKey(logData.Id) && a.Views[logData.Id].HasContext(new SortContext(ColumnName.Source, true, null, false)))); @@ -2370,7 +2370,7 @@ public async Task HandleUpdateTable_WhenSortPendingMultiLog_RepublishesEveryLogU await effects.Filtering.HandleUpdateTable(mockDispatcher); var requested = new SortContext(ColumnName.Source, true, null, false); - mockDispatcher.Received(1).Dispatch(Arg.Is(a => + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Version == 3 && a.Views.ContainsKey(logA.Id) && a.Views[logA.Id].HasContext(requested) && a.Views.ContainsKey(logB.Id) && a.Views[logB.Id].HasContext(requested))); @@ -2394,11 +2394,11 @@ public void ReopenAfterDatabaseRemoval_DispatchesOpenLogPerSnapshotEntry() // Assert mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogName == Constants.LogNameLog1 && a.LogPathType == LogPathType.Channel)); mockDispatcher.Received(1) - .Dispatch(Arg.Is(a => + .Dispatch(Arg.Is(a => a != null && a.LogName == Constants.LogNameLog2 && a.LogPathType == LogPathType.File)); } @@ -2561,7 +2561,7 @@ private static (OpenLogEffects openLog, IDispatcher dispatcher, ILogWatcherServi var resolver = Substitute.For(); resolver.ResolveEvent(Arg.Any()).Returns(callInfo => { - var record = callInfo.Arg(); + var record = callInfo.ArgAt(0); if (resolveDelayMs is not null) { @@ -2639,7 +2639,7 @@ private static (EffectsHarness effects, IDispatcher mockDispatcher) CreateEffect .Returns(new Dictionary>()); mockFilterService.GetFilteredEvents(Arg.Any>(), Arg.Any()) - .Returns(callInfo => callInfo.Arg>().ToList()); + .Returns(callInfo => callInfo.ArgAt>(0).ToList()); var mockLogger = Substitute.For(); var mockLogWatcherService = Substitute.For(); @@ -2762,7 +2762,7 @@ private static (EffectsHarness effects, .Returns(new Dictionary>()); mockFilterService.GetFilteredEvents(Arg.Any>(), Arg.Any()) - .Returns(callInfo => callInfo.Arg>().ToList()); + .Returns(callInfo => callInfo.ArgAt>(0).ToList()); var mockLogger = Substitute.For(); var mockLogWatcherService = Substitute.For(); @@ -2825,7 +2825,7 @@ private static (EffectsHarness effects, .Returns(new Dictionary>()); mockFilterService.GetFilteredEvents(Arg.Any>(), Arg.Any()) - .Returns(callInfo => callInfo.Arg>().ToList()); + .Returns(callInfo => callInfo.ArgAt>(0).ToList()); var mockLogger = Substitute.For(); var mockLogWatcherService = Substitute.For(); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/PartialLoadCoordinatorTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/PartialLoadCoordinatorTests.cs index 0bf4aaee..8b9bfc45 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/PartialLoadCoordinatorTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/PartialLoadCoordinatorTests.cs @@ -185,7 +185,7 @@ private static Harness Create() { var dispatched = new List(); var dispatcher = Substitute.For(); - dispatcher.When(d => d.Dispatch(Arg.Any())).Do(call => dispatched.Add(call.Arg())); + dispatcher.When(d => d.Dispatch(Arg.Any())).Do(call => dispatched.Add(call.ArgAt(0))); var storeHolder = new StoreHolder(); var rawState = Substitute.For>(); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs index 96a7b960..f88c620f 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs @@ -27,7 +27,8 @@ public void RemoveLens_DispatchesRemove() new FilterLensCommands(dispatcher).RemoveLens(lens); - dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens == lens)); + dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens == lens)); } [Theory] @@ -44,6 +45,7 @@ public void ShowEventsNearTime_ChipSuffixMatchesOfferedDurations(int seconds, st new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromSeconds(seconds), TimeZoneInfo.Utc); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Label.EndsWith(expectedSuffix, StringComparison.Ordinal))); } @@ -56,6 +58,7 @@ public void ShowEventsNearTime_DispatchesPushWithCenteredEnabledWindow() new FilterLensCommands(dispatcher).ShowEventsNearTime(anchor, TimeSpan.FromMinutes(5), TimeZoneInfo.Utc); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Kind == LensKind.TimeWindow && action.Lens.Window != null && action.Lens.Window.IsEnabled && @@ -73,6 +76,7 @@ public void ShowEventsNearTime_InRangeNonStandardRadius_RendersLosslessChipSuffi new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromSeconds(90), TimeZoneInfo.Utc); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Label.EndsWith("\u00b190s", StringComparison.Ordinal))); } @@ -89,6 +93,7 @@ public void ShowEventsNearTime_LabelRendersAnchorInDisplayZone() // Built with the same culture-sensitive ":T" the production label uses, so the assertion holds under any ambient // culture; the anchor renders in the +5 display zone (17:00:00), not UTC (12:00:00). dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Label == $"Near {expectedAnchor:T} \u00b15m")); } @@ -103,6 +108,7 @@ public void ShowEventsNearTime_MinValueAnchor_ClampsLowerBoundWithoutThrowing() DateTime.MinValue, TimeSpan.FromSeconds(30), TimeZoneInfo.Utc); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Window!.After == DateTime.MinValue && action.Lens.Window.Before == DateTime.MinValue.AddSeconds(30))); } @@ -158,7 +164,8 @@ public void ShowEventsNearTime_WithOriginLog_TagsLensWithThatLog() new FilterLensCommands(dispatcher).ShowEventsNearTime( new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromMinutes(5), TimeZoneInfo.Utc, "LogA"); - dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens.OriginLog == "LogA")); + dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.OriginLog == "LogA")); } [Fact] @@ -170,6 +177,7 @@ public void ShowParentActivity_Guid_DispatchesActivityIdExcludeLensWithParentLab new FilterLensCommands(dispatcher).ShowParentActivity(relatedActivityId); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Label == $"Parent Activity = {relatedActivityId}" && action.Lens.ExcludeFilters.Count == 1 && action.Lens.ExcludeFilters[0].Compiled != null && @@ -194,7 +202,8 @@ public void ShowParentActivity_WithOriginLog_TagsLensWithThatLog() new FilterLensCommands(dispatcher).ShowParentActivity(Guid.NewGuid(), "LogA"); - dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens.OriginLog == "LogA")); + dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.OriginLog == "LogA")); } [Fact] @@ -205,6 +214,7 @@ public void ShowRelatedByActivityId_Guid_DispatchesPushWithCompiledExcludeLens() new FilterLensCommands(dispatcher).ShowRelatedByActivityId(Guid.NewGuid()); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Kind == LensKind.Property && action.Lens.ExcludeFilters.Count == 1 && action.Lens.ExcludeFilters[0].IsExcluded && @@ -228,7 +238,8 @@ public void ShowRelatedByActivityId_WithOriginLog_TagsLensWithThatLog() new FilterLensCommands(dispatcher).ShowRelatedByActivityId(Guid.NewGuid(), "LogA"); - dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens.OriginLog == "LogA")); + dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.OriginLog == "LogA")); } [Fact] @@ -240,6 +251,7 @@ public void ShowRelatedByRelatedActivityId_Guid_DispatchesPushWithRelatedActivit new FilterLensCommands(dispatcher).ShowRelatedByRelatedActivityId(id); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Label == $"Related Activity ID = {id}" && action.Lens.ExcludeFilters.Count == 1 && action.Lens.ExcludeFilters[0].IsExcluded && @@ -264,7 +276,8 @@ public void ShowRelatedByRelatedActivityId_WithOriginLog_TagsLensWithThatLog() new FilterLensCommands(dispatcher).ShowRelatedByRelatedActivityId(Guid.NewGuid(), "LogA"); - dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens.OriginLog == "LogA")); + dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.OriginLog == "LogA")); } [Fact] @@ -277,6 +290,7 @@ public void ShowTimeRange_DispatchesPushWithInclusiveEnabledUtcWindow() new FilterLensCommands(dispatcher).ShowTimeRange(start, end, TimeZoneInfo.Utc); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Kind == LensKind.TimeWindow && action.Lens.Window != null && action.Lens.Window.IsEnabled && @@ -294,6 +308,7 @@ public void ShowTimeRange_NormalizesAReversedRange() new FilterLensCommands(dispatcher).ShowTimeRange(later, earlier, TimeZoneInfo.Utc); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.Window!.After == earlier && action.Lens.Window.Before == later)); } @@ -309,6 +324,7 @@ public void ShowTimeRange_WithOriginLog_TagsLensWithThatLog() TimeZoneInfo.Utc, "LogA"); - dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens.OriginLog == "LogA")); + dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Lens.OriginLog == "LogA")); } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensEffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensEffectsTests.cs index ed3098f7..c15f2ea6 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensEffectsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensEffectsTests.cs @@ -43,7 +43,8 @@ public async Task HandleLogClosedByUser_LensFromThatLog_DispatchesRemoveForLog() await effects.HandleLogClosedByUser(new LogClosedByUserAction(EventLogId.Create(), "LogA"), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(action => action.LogName == "LogA")); + dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LogName == "LogA")); } [Fact] @@ -71,6 +72,7 @@ public async Task HandlePush_ComposesLensOntoBase_DispatchesApplyFilter_WithoutT await effects.HandlePush(new PushFilterLensAction(lens), dispatcher); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.Filter.Filters.Count == 2 && action.Filter.Filters.Count(filter => filter.IsExcluded) == 1 && action.Filter.Filters.Count(filter => !filter.IsExcluded) == 1)); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryCommandsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryCommandsTests.cs index 9614f43c..356fe3c0 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryCommandsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryCommandsTests.cs @@ -20,7 +20,7 @@ public void AddEntry_Dispatches() sut.AddEntry(entry); - dispatcher.Received(1).Dispatch(Arg.Is(a => ReferenceEquals(a.Entry, entry))); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && ReferenceEquals(a.Entry, entry))); } [Fact] @@ -35,7 +35,7 @@ public void AddFilterToExistingFilterSet_Dispatches() sut.AddFilterToExistingFilterSet(filterSetId, filter, source); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.FilterSetId == filterSetId && a.SourceEntryId == source && ReferenceEquals(a.Filter, filter))); } @@ -49,7 +49,7 @@ public void AddFilterToNewFilterSet_Dispatches() sut.AddFilterToNewFilterSet("New", filter, sourceEntryId: null); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.NewFilterSetName == "New" && a.SourceEntryId == null && ReferenceEquals(a.Filter, filter))); } @@ -62,7 +62,7 @@ public void ApplyEntry_Dispatches() sut.ApplyEntry(id); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == id)); } [Fact] @@ -74,7 +74,7 @@ public void DeleteEntry_Dispatches() sut.DeleteEntry(id); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == id)); } [Fact] @@ -101,7 +101,7 @@ public void RecordFilterApplied_Dispatches() sut.RecordFilterApplied(filter); - dispatcher.Received(1).Dispatch(Arg.Is(a => ReferenceEquals(a.Filter, filter))); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && ReferenceEquals(a.Filter, filter))); } [Fact] @@ -113,7 +113,7 @@ public void ReplaceWithEntry_Dispatches() sut.ReplaceWithEntry(id); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == id)); } [Fact] @@ -125,7 +125,7 @@ public void SaveEntry_Dispatches() sut.SaveEntry(id); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == id)); } [Fact] @@ -139,7 +139,7 @@ public void SaveFilterSet_Dispatches() sut.SaveFilterSet("My Preset", filters); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Name == "My Preset" && a.Filters == filters)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Name == "My Preset" && a.Filters == filters)); } [Fact] @@ -150,7 +150,7 @@ public void SavePaneAsFilterSet_Dispatches() sut.SavePaneAsFilterSet("My Preset"); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Name == "My Preset")); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Name == "My Preset")); } [Fact] @@ -162,7 +162,7 @@ public void SetIsFavorite_Dispatches() sut.SetIsFavorite(id, isFavorite: true); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == id && a.IsFavorite)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == id && a.IsFavorite)); } [Fact] @@ -174,7 +174,7 @@ public void UpdateEntry_Dispatches() sut.UpdateEntry(entry); - dispatcher.Received(1).Dispatch(Arg.Is(a => ReferenceEquals(a.Entry, entry))); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && ReferenceEquals(a.Entry, entry))); } [Fact] @@ -185,7 +185,7 @@ public void RenameTag_Dispatches() sut.RenameTag("bug", "defect"); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.OldName == "bug" && a.NewName == "defect")); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.OldName == "bug" && a.NewName == "defect")); } [Fact] @@ -196,7 +196,7 @@ public void DeleteTag_Dispatches() sut.DeleteTag("bug"); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Name == "bug")); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Name == "bug")); } private static LibraryEntry BuildEntry() diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryEffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryEffectsTests.cs index e5a36b0f..49ddcc12 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryEffectsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLibrary/FilterLibraryEffectsTests.cs @@ -34,7 +34,7 @@ public async Task ApplyBulkTagUpdate_ReissueAgainstLatestSnapshotFails_ReloadsLi bulkCalls++; if (bulkCalls == 1) { - var list = (IReadOnlyList)call[0]; + var list = call.ArgAt>(0); return Task.FromResult>(list.Select(e => e.Id).ToList()); } @@ -94,9 +94,9 @@ await effects.HandleAddFilterToExistingFilterSet( dispatcher); // Did NOT update the filter set (duplicate). - await store.DidNotReceive().UpdateAsync(Arg.Is(e => e.Id == filterSet.Id), Arg.Any()); + await store.DidNotReceive().UpdateAsync(Arg.Is(e => e != null && e.Id == filterSet.Id), Arg.Any()); // But DID promote the source. - await store.Received(1).UpdateAsync(Arg.Is(e => e.Id == source.Id && e.Origin == LibraryEntryOrigin.UserSaved), Arg.Any()); + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.Id == source.Id && e.Origin == LibraryEntryOrigin.UserSaved), Arg.Any()); } [Fact] @@ -147,7 +147,7 @@ await effects.HandleAddFilterToExistingFilterSet( new AddFilterToExistingFilterSetAction(filterSet.Id, basic, SourceEntryId: null), Substitute.For()); - await store.Received(1).UpdateAsync(Arg.Is(e => e.GetType() == typeof(LibraryEntryFilterSet) + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.GetType() == typeof(LibraryEntryFilterSet) && ((LibraryEntryFilterSet)e).Id == filterSet.Id && ((LibraryEntryFilterSet)e).Filters.Count == 2 && ((LibraryEntryFilterSet)e).Filters.Any(f => f.Mode == FilterMode.Advanced) @@ -177,7 +177,7 @@ public async Task HandleAddFilterToNewFilterSet_CreatesFilterSetWithSingleFilter await effects.HandleAddFilterToNewFilterSet(new AddFilterToNewFilterSetAction("New", filter, SourceEntryId: null), dispatcher); - await store.Received(1).AddAsync(Arg.Is(e => e.GetType() == typeof(LibraryEntryFilterSet) + await store.Received(1).AddAsync(Arg.Is(e => e != null && e.GetType() == typeof(LibraryEntryFilterSet) && ((LibraryEntryFilterSet)e).Name == "New" && ((LibraryEntryFilterSet)e).Filters.Count == 1 && ((LibraryEntryFilterSet)e).Filters[0].Id != filter.Id), Arg.Any()); @@ -193,7 +193,7 @@ public async Task HandleAddFilterToNewFilterSet_PromotesAutoTrackedSource() await effects.HandleAddFilterToNewFilterSet(new AddFilterToNewFilterSetAction("New", filter, source.Id), dispatcher); - await store.Received(1).UpdateAsync(Arg.Is(e => + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.Id == source.Id && e.Origin == LibraryEntryOrigin.UserSaved), Arg.Any()); } @@ -218,7 +218,7 @@ public async Task HandleAddLibraryEntry_PersistsAndDispatchesSuccess() await effects.HandleAddLibraryEntry(new AddLibraryEntryAction(entry), dispatcher); await store.Received(1).AddAsync(Arg.Is(entry), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => ReferenceEquals(a.Entry, entry))); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && ReferenceEquals(a.Entry, entry))); } [Fact] @@ -252,8 +252,8 @@ public async Task HandleApplyLibraryEntry_FilterSetEntry_DispatchesMergeFiltersW await effects.HandleApplyLibraryEntry(new ApplyLibraryEntryAction(filterSet.Id), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Filters.Count == 2)); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == filterSet.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Filters.Count == 2)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == filterSet.Id)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -265,8 +265,8 @@ public async Task HandleApplyLibraryEntry_SavedFilterEntry_DispatchesMergeFilter await effects.HandleApplyLibraryEntry(new ApplyLibraryEntryAction(entry.Id), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Filters.Count == 1)); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == entry.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Filters.Count == 1)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == entry.Id)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -304,7 +304,7 @@ public async Task HandleDeleteLibraryEntry_PersistsAndDispatchesSuccess() await effects.HandleDeleteLibraryEntry(new DeleteLibraryEntryAction(id), dispatcher); await store.Received(1).DeleteAsync(Arg.Is(id), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == id)); } [Fact] @@ -338,7 +338,7 @@ public async Task HandleDeleteTag_MatchesMixedCaseStoredTag_RemovesIt() await effects.HandleDeleteTag(new DeleteTagAction("bug"), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 1 && list[0].Tags.SequenceEqual(new[] { "perf" })), Arg.Any()); } @@ -366,7 +366,7 @@ public async Task HandleDeleteTag_RemovesFromAllMatchingEntries() await effects.HandleDeleteTag(new DeleteTagAction("BUG"), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 2 && list.Any(e => e.Id == a.Id && e.Tags.SequenceEqual(new[] { "perf" })) && list.Any(e => e.Id == b.Id && e.Tags.IsEmpty) && @@ -402,8 +402,8 @@ public async Task HandleDeleteTag_StoreSkipsAlreadyGoneRow_DispatchesUpdatedOnly await effects.HandleDeleteTag(new DeleteTagAction("bug"), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(s => s.Entry.Id == a.Id)); - dispatcher.DidNotReceive().Dispatch(Arg.Is(s => s.Entry.Id == b.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(s => s != null && s.Entry.Id == a.Id)); + dispatcher.DidNotReceive().Dispatch(Arg.Is(s => s != null && s.Entry.Id == b.Id)); dispatcher.Received(1).Dispatch(Arg.Any()); announcer.Received(1).Announce("Removed tag 'bug' from 1 entry"); } @@ -466,7 +466,7 @@ public async Task HandleLoadLibrary_BackslashMigratorRunsAfterLegacyMigrator() await effects.HandleLoadLibrary(dispatcher); backslashMigrator.Received(1).BuildMigrationPlan(Arg.Any>()); - await store.Received(1).UpdateAsync(Arg.Is(e => e.Name == "DNS"), Arg.Any()); + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.Name == "DNS"), Arg.Any()); backslashMigrator.Received(1).MarkMigrationCompleted(); dispatcher.Received(1).Dispatch(Arg.Any()); } @@ -507,7 +507,7 @@ public async Task HandleLoadLibrary_BuildEntriesFromLegacyThrows_OuterCatchFires await effects.HandleLoadLibrary(dispatcher); dispatcher.Received(1).Dispatch(Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.IsEmpty)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.IsEmpty)); logger.ReceivedWithAnyArgs(1).Warning(default); } @@ -609,7 +609,7 @@ public async Task HandleLoadLibrary_DispatchesStartedAndSuccessWithStoreEntries( await effects.HandleLoadLibrary(dispatcher); dispatcher.Received(1).Dispatch(Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 1 && a.Entries[0].Id == entry.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 1 && a.Entries[0].Id == entry.Id)); } [Fact] @@ -634,7 +634,7 @@ public async Task HandleLoadLibrary_EmptyStore_AddRangeSucceeds_PostMigrationLoa await effects.HandleLoadLibrary(dispatcher); await store.Received(1).AddRangeAsync(Arg.Any>(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 1 && a.Entries[0].Id == migratedEntry.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 1 && a.Entries[0].Id == migratedEntry.Id)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); migrator.Received(1).MarkMigrationCompleted(sections); logger.ReceivedWithAnyArgs(1).Warning(default); @@ -656,7 +656,7 @@ public async Task HandleLoadLibrary_EmptyStore_AddRangeThrows_DispatchesSuccessE await effects.HandleLoadLibrary(dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.IsEmpty)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.IsEmpty)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); migrator.DidNotReceive().MarkMigrationCompleted(Arg.Any()); logger.ReceivedWithAnyArgs(1).Warning(default); @@ -674,7 +674,7 @@ public async Task HandleLoadLibrary_EmptyStore_MigratorReturnsEmptyEntries_DoesN await effects.HandleLoadLibrary(dispatcher); await store.DidNotReceive().AddRangeAsync(Arg.Any>(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.IsEmpty)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.IsEmpty)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); migrator.Received(1).MarkMigrationCompleted(sections); } @@ -694,9 +694,9 @@ public async Task HandleLoadLibrary_EmptyStore_MigratorReturnsEntries_AddRangeSu await effects.HandleLoadLibrary(dispatcher); - await store.Received(1).AddRangeAsync(Arg.Is>(e => e.Count() == 1), Arg.Any()); + await store.Received(1).AddRangeAsync(Arg.Is>(e => e != null && e.Count() == 1), Arg.Any()); await store.Received(2).LoadAllAsync(Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 1 && a.Entries[0].Id == migratedEntry.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 1 && a.Entries[0].Id == migratedEntry.Id)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); migrator.Received(1).MarkMigrationCompleted(sections); } @@ -709,7 +709,7 @@ public async Task HandleLoadLibrary_EmptyStore_NoMigratableData_DispatchesSucces await effects.HandleLoadLibrary(dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.IsEmpty)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.IsEmpty)); await store.DidNotReceive().AddRangeAsync(Arg.Any>(), Arg.Any()); migrator.Received(1).MarkMigrationCompleted(Arg.Any()); } @@ -728,7 +728,7 @@ public async Task HandleLoadLibrary_MigratorShouldRunReturnsFalse_SkipsBuildEntr migrator.DidNotReceive().BuildEntriesFromLegacy(); migrator.DidNotReceive().MarkMigrationCompleted(Arg.Any()); await store.DidNotReceive().AddRangeAsync(Arg.Any>(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.IsEmpty)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.IsEmpty)); } [Fact] @@ -764,9 +764,9 @@ public async Task HandleLoadLibrary_NonEmptyStore_FilterSetsSameNameDifferentFil await effects.HandleLoadLibrary(dispatcher); // Distinct filter content → migrated filter set is NOT deduped → AddRange called with it. - await store.Received(1).AddRangeAsync(Arg.Is>(e => e.Count() == 1 && e.First() is LibraryEntryFilterSet), Arg.Any()); + await store.Received(1).AddRangeAsync(Arg.Is>(e => e != null && e.Count() == 1 && e.First() is LibraryEntryFilterSet), Arg.Any()); migrator.Received(1).MarkMigrationCompleted(sections); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 2)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 2)); } [Fact] @@ -796,7 +796,7 @@ public async Task HandleLoadLibrary_NonEmptyStore_MigrationEntriesOverlap_Dedups // Bitmask still advances - the section is "complete" from the persistence perspective. migrator.Received(1).MarkMigrationCompleted(sections); // Loaded entries reflect the existing store (no duplicates). - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 2)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 2)); } [Fact] @@ -817,9 +817,9 @@ public async Task HandleLoadLibrary_NonEmptyStore_MigrationEntriesPartiallyOverl await effects.HandleLoadLibrary(dispatcher); // Only the non-overlapping entry is inserted. - await store.Received(1).AddRangeAsync(Arg.Is>(e => e.Count() == 1 && e.First().Id == newEntry.Id), Arg.Any()); + await store.Received(1).AddRangeAsync(Arg.Is>(e => e != null && e.Count() == 1 && e.First().Id == newEntry.Id), Arg.Any()); migrator.Received(1).MarkMigrationCompleted(sections); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 2)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 2)); } [Fact] @@ -841,7 +841,7 @@ public async Task HandleLoadLibrary_NonEmptyStore_PartialMigration_AddRangeThrow await effects.HandleLoadLibrary(dispatcher); migrator.DidNotReceive().MarkMigrationCompleted(Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 1 && a.Entries[0].Id == existingEntry.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 1 && a.Entries[0].Id == existingEntry.Id)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -860,7 +860,7 @@ public async Task HandleLoadLibrary_NonEmptyStore_ShouldRunReturnsFalse_DoesNotI migrator.DidNotReceive().BuildEntriesFromLegacy(); await store.DidNotReceive().AddRangeAsync(Arg.Any>(), Arg.Any()); migrator.DidNotReceive().MarkMigrationCompleted(Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 1)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 1)); } [Fact] @@ -879,7 +879,7 @@ public async Task HandleLoadLibrary_NonEmptyStore_ShouldRunReturnsTrue_StillInvo migrator.Received(1).BuildEntriesFromLegacy(); await store.DidNotReceive().AddRangeAsync(Arg.Any>(), Arg.Any()); migrator.Received(1).MarkMigrationCompleted(Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 1)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 1)); } [Fact] @@ -900,9 +900,9 @@ public async Task HandleLoadLibrary_NonEmptyStore_StillInvokesMigratorWhenShould migrator.Received(1).ShouldRunMigration(); migrator.Received(1).BuildEntriesFromLegacy(); - await store.Received(1).AddRangeAsync(Arg.Is>(e => e.Count() == 1), Arg.Any()); + await store.Received(1).AddRangeAsync(Arg.Is>(e => e != null && e.Count() == 1), Arg.Any()); migrator.Received(1).MarkMigrationCompleted(sections); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entries.Count == 2)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entries.Count == 2)); } [Fact] @@ -968,7 +968,7 @@ public async Task HandleRecordEntryApplied_NotFavoriteBumpSucceeds_DispatchesUpd await effects.HandleRecordEntryApplied(new RecordEntryAppliedAction(entry.Id), dispatcher); await store.Received(1).TryBumpLastUsedIfNotFavoriteAsync(entry.Id, Arg.Any(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.Id == entry.Id && a.Entry.LastUsedUtc != null)); } @@ -1052,7 +1052,7 @@ public async Task HandleRecordEntryApplied_PrunesOldestAutoTrackedEntries_WhenCa // SQL-guarded delete no-ops if the row was concurrently promoted/favorited. await store.Received(1).TryDeleteAutoTrackedIfNotFavoriteAsync(Arg.Is(oldestId), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == oldestId)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == oldestId)); } [Fact] @@ -1088,7 +1088,7 @@ public async Task HandleRecordFilterApplied_CollisionBranch_BumpsExistingAndDisp await store.Received(1).AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()); await store.Received(1).TryBumpLastUsedIfNotFavoriteAsync(existing.Id, Arg.Any(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.Id == existing.Id && a.Entry.LastUsedUtc != existing.LastUsedUtc)); } @@ -1159,7 +1159,7 @@ public async Task HandleRecordFilterApplied_CollisionBranch_StateAlreadyHasEntry await store.DidNotReceive().TryDeleteAutoTrackedIfNotFavoriteAsync(Arg.Any(), Arg.Any()); dispatcher.DidNotReceive().Dispatch(Arg.Any()); // Bump + AddSuccess dispatch still happen as part of the collision-bump path. - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entry.Id == alreadyInState.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.Id == alreadyInState.Id)); } [Fact] @@ -1179,7 +1179,7 @@ public async Task HandleRecordFilterApplied_DifferentMode_TreatedAsDistinctEntry }; var (effects, store, dispatcher, _, _) = CreateEffects(state: new FilterLibraryState { Entries = [existing] }); store.AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()) - .Returns(call => (call.Arg(), true)); + .Returns(call => (call.ArgAt(0), true)); await effects.HandleRecordFilterApplied(new RecordFilterAppliedAction(basic), dispatcher); @@ -1225,7 +1225,7 @@ public async Task HandleRecordFilterApplied_NoExisting_InsertsAutoTrackedEntry() store.AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()) .Returns(call => { - var candidate = call.Arg(); + var candidate = call.ArgAt(0); return (candidate, true); }); var filter = SavedFilter.TryCreate("Level == 4"); @@ -1234,7 +1234,7 @@ public async Task HandleRecordFilterApplied_NoExisting_InsertsAutoTrackedEntry() await effects.HandleRecordFilterApplied(new RecordFilterAppliedAction(filter), dispatcher); await store.Received(1).AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.GetType() == typeof(LibraryEntrySavedFilter) && ((LibraryEntrySavedFilter)a.Entry).Origin == LibraryEntryOrigin.AutoTracked && ((LibraryEntrySavedFilter)a.Entry).LastUsedUtc != null @@ -1267,14 +1267,14 @@ public async Task HandleRecordFilterApplied_PrunesOldestAutoTrackedEntries_WhenI var (effects, store, dispatcher, _, _) = CreateEffects(state: new FilterLibraryState { Entries = [.. entries] }); store.AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()) - .Returns(call => (call.Arg(), true)); + .Returns(call => (call.ArgAt(0), true)); store.TryDeleteAutoTrackedIfNotFavoriteAsync(Arg.Any(), Arg.Any()).Returns(true); await effects.HandleRecordFilterApplied(new RecordFilterAppliedAction(newFilter), dispatcher); await store.Received(1).AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()); await store.Received(1).TryDeleteAutoTrackedIfNotFavoriteAsync(Arg.Is(oldestId), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == oldestId)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == oldestId)); } [Fact] @@ -1317,7 +1317,7 @@ public async Task HandleRecordFilterApplied_TupleMatch_BumpsLastUsedAndDispatche await effects.HandleRecordFilterApplied(new RecordFilterAppliedAction(filter), dispatcher); await store.Received(1).TryBumpLastUsedIfNotFavoriteAsync(existing.Id, Arg.Any(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.Id == existing.Id && a.Entry.LastUsedUtc != existing.LastUsedUtc)); await store.DidNotReceive().AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()); } @@ -1343,7 +1343,7 @@ public async Task HandleRecordFilterApplied_UserSavedExisting_BumpsAndSkipsAutoT await store.Received(1).TryBumpLastUsedIfNotFavoriteAsync(existing.Id, Arg.Any(), Arg.Any()); await store.DidNotReceive().AddOrReturnExistingFilterAsync(Arg.Any(), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entry.Id == existing.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.Id == existing.Id)); } [Fact] @@ -1354,7 +1354,7 @@ public async Task HandleRenameTag_CollidingTags_DedupesPreservingNewCanonical() await effects.HandleRenameTag(new RenameTagAction("bug", "defect"), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 1 && list[0].Tags.SequenceEqual(new[] { "defect" })), Arg.Any()); } @@ -1385,7 +1385,7 @@ public async Task HandleRenameTag_FilterSetEntry_RewritesTagsViaReplaceTagsHelpe await effects.HandleRenameTag(new RenameTagAction("bug", "defect"), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 1 && list[0] is LibraryEntryFilterSet && list[0].Tags.SequenceEqual(new[] { "defect" })), Arg.Any()); } @@ -1397,7 +1397,7 @@ public async Task HandleRenameTag_MatchesMixedCaseStoredTag_HealsToCanonical() await effects.HandleRenameTag(new RenameTagAction("bug", "defect"), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 1 && list[0].Tags.SequenceEqual(new[] { "defect" })), Arg.Any()); dispatcher.Received(1).Dispatch(Arg.Any()); } @@ -1422,7 +1422,7 @@ public async Task HandleRenameTag_NormalizesBothNames_StoresLowercase() await effects.HandleRenameTag(new RenameTagAction(" BUG ", "Defect "), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 1 && list[0].Tags.SequenceEqual(new[] { "defect" })), Arg.Any()); } @@ -1436,7 +1436,7 @@ public async Task HandleRenameTag_RewritesAllMatchingEntries_DispatchesSuccessPe await effects.HandleRenameTag(new RenameTagAction("bug", "defect"), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 2 && list.Any(e => e.Id == a.Id && e.Tags.SequenceEqual(new[] { "defect", "perf" })) && list.Any(e => e.Id == b.Id && e.Tags.SequenceEqual(new[] { "defect" })) && @@ -1453,7 +1453,7 @@ public async Task HandleRenameTag_SkipsEntriesWithoutTag() await effects.HandleRenameTag(new RenameTagAction("bug", "defect"), dispatcher); - await store.Received(1).UpdateRangeAsync(Arg.Is>(list => + await store.Received(1).UpdateRangeAsync(Arg.Is>(list => list != null && list.Count == 1 && list[0].Id == withTag.Id), Arg.Any()); } @@ -1469,8 +1469,8 @@ public async Task HandleRenameTag_StoreSkipsAlreadyGoneRow_DispatchesUpdatedOnly await effects.HandleRenameTag(new RenameTagAction("bug", "defect"), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(s => s.Entry.Id == a.Id)); - dispatcher.DidNotReceive().Dispatch(Arg.Is(s => s.Entry.Id == b.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(s => s != null && s.Entry.Id == a.Id)); + dispatcher.DidNotReceive().Dispatch(Arg.Is(s => s != null && s.Entry.Id == b.Id)); dispatcher.Received(1).Dispatch(Arg.Any()); announcer.Received(1).Announce("Renamed tag 'bug' to 'defect' in 1 entry"); } @@ -1526,8 +1526,8 @@ public async Task HandleReplaceWithLibraryEntry_FilterSetEntry_DispatchesReplace await effects.HandleReplaceWithLibraryEntry(new ReplaceWithLibraryEntryAction(filterSet.Id), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Filters.Count == 2)); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == filterSet.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Filters.Count == 2)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == filterSet.Id)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -1539,8 +1539,8 @@ public async Task HandleReplaceWithLibraryEntry_SavedFilterEntry_DispatchesRepla await effects.HandleReplaceWithLibraryEntry(new ReplaceWithLibraryEntryAction(entry.Id), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Filters.Count == 1)); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.EntryId == entry.Id)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Filters.Count == 1)); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.EntryId == entry.Id)); } [Fact] @@ -1587,7 +1587,7 @@ public async Task HandleSaveEntry_AutoTrackedEntry_PromotesToUserSaved() await effects.HandleSaveEntry(new SaveEntryAction(entry.Id), dispatcher); - await store.Received(1).UpdateAsync(Arg.Is(e => + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.Id == entry.Id && e.Origin == LibraryEntryOrigin.UserSaved), Arg.Any()); } @@ -1604,7 +1604,7 @@ public async Task HandleSaveEntry_ReprojectsOriginOntoLatestSnapshot_NotPreAwait await effects.HandleSaveEntry(new SaveEntryAction(staleEntry.Id), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.Id == staleEntry.Id && a.Entry.Name == "NewName" && a.Entry.Origin == LibraryEntryOrigin.UserSaved)); @@ -1633,7 +1633,7 @@ public async Task HandleSaveEntry_WhenEntryDeletedDuringStoreAwait_DoesNotDispat await effects.HandleSaveEntry(new SaveEntryAction(entry.Id), dispatcher); await store.Received(1).UpdateAsync( - Arg.Is(e => e.Id == entry.Id && e.Origin == LibraryEntryOrigin.UserSaved), + Arg.Is(e => e != null && e.Id == entry.Id && e.Origin == LibraryEntryOrigin.UserSaved), Arg.Any()); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -1650,13 +1650,13 @@ public async Task HandleSaveFilterSet_CreatesNewFilterSetWithRegeneratedFilterId await effects.HandleSaveFilterSet(new SaveFilterSetAction("My Preset", [f1, f2]), dispatcher); - await store.Received(1).AddAsync(Arg.Is(e => e.GetType() == typeof(LibraryEntryFilterSet) + await store.Received(1).AddAsync(Arg.Is(e => e != null && e.GetType() == typeof(LibraryEntryFilterSet) && ((LibraryEntryFilterSet)e).Name == "My Preset" && ((LibraryEntryFilterSet)e).Origin == LibraryEntryOrigin.UserSaved && ((LibraryEntryFilterSet)e).Filters.Count == 2 && ((LibraryEntryFilterSet)e).Filters.All(f => !originalIds.Contains(f.Id)) && ((LibraryEntryFilterSet)e).Filters.All(f => !f.IsEnabled)), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => a.Entry.GetType() == typeof(LibraryEntryFilterSet))); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.GetType() == typeof(LibraryEntryFilterSet))); } [Fact] @@ -1702,7 +1702,7 @@ public async Task HandleSavePaneAsFilterSet_PaneHasFilters_DispatchesSaveFilterS await effects.HandleSavePaneAsFilterSet(new SavePaneAsFilterSetAction("Pane Preset"), dispatcher); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Name == "Pane Preset" && a.Filters.Count == 1 && a.Filters[0] == f1)); } @@ -1738,7 +1738,7 @@ public async Task HandleSetIsFavorite_False_OnFilter_BumpsLastUsedToNow() await effects.HandleSetIsFavorite(new SetIsFavoriteAction(filter.Id, IsFavorite: false), dispatcher); - await store.Received(1).UpdateAsync(Arg.Is(e => + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.Id == filter.Id && !e.IsFavorite && e.LastUsedUtc != null), Arg.Any()); } @@ -1758,7 +1758,7 @@ public async Task HandleSetIsFavorite_False_OnFilterSet_LeavesLastUsedNull() await effects.HandleSetIsFavorite(new SetIsFavoriteAction(filterSet.Id, IsFavorite: false), dispatcher); - await store.Received(1).UpdateAsync(Arg.Is(e => e.Id == filterSet.Id && !e.IsFavorite && e.LastUsedUtc == null), Arg.Any()); + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.Id == filterSet.Id && !e.IsFavorite && e.LastUsedUtc == null), Arg.Any()); } [Fact] @@ -1773,9 +1773,9 @@ public async Task HandleSetIsFavorite_True_SetsFavoriteClearsLastUsedAndPromotes await effects.HandleSetIsFavorite(new SetIsFavoriteAction(entry.Id, IsFavorite: true), dispatcher); - await store.Received(1).UpdateAsync(Arg.Is(e => + await store.Received(1).UpdateAsync(Arg.Is(e => e != null && e.Id == entry.Id && e.IsFavorite && e.LastUsedUtc == null && e.Origin == LibraryEntryOrigin.UserSaved), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && a.Entry.IsFavorite && a.Entry.LastUsedUtc == null && a.Entry.Origin == LibraryEntryOrigin.UserSaved)); } @@ -1799,7 +1799,7 @@ public async Task HandleUpdateLibraryEntry_PersistsAndDispatchesSuccess() await effects.HandleUpdateLibraryEntry(new UpdateLibraryEntryAction(entry), dispatcher); await store.Received(1).UpdateAsync(Arg.Is(entry), Arg.Any()); - dispatcher.Received(1).Dispatch(Arg.Is(a => ReferenceEquals(a.Entry, entry))); + dispatcher.Received(1).Dispatch(Arg.Is(a => a != null && ReferenceEquals(a.Entry, entry))); } [Fact] @@ -1940,7 +1940,7 @@ private static (Effects effects, IFilterLibraryStore store, IDispatcher dispatch if (!storeWasSupplied) { store.LoadAllAsync(Arg.Any()).Returns([]); - store.UpdateRangeAsync(Arg.Any>(), Arg.Any()).ReturnsForAnyArgs(ci => ((IReadOnlyList)ci[0]).Select(e => e.Id).ToList()); + store.UpdateRangeAsync(Arg.Any>(), Arg.Any()).ReturnsForAnyArgs(ci => ci.ArgAt>(0).Select(e => e.Id).ToList()); } var stateMock = Substitute.For>(); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/EffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/EffectsTests.cs index b990caf6..db7fef90 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/EffectsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/EffectsTests.cs @@ -75,6 +75,7 @@ public async Task HandleAddFilter_WhenFilterHasBasicFilter_ShouldRecordFilterApp // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.Filter.ComparisonText == FilterTestConstants.FilterIdEquals100)); } @@ -92,6 +93,7 @@ public async Task HandleAddFilter_WhenFilterHasNoBasicFilter_ShouldRecordFilterA // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.Filter.ComparisonText == FilterTestConstants.FilterIdEquals100)); } @@ -165,6 +167,7 @@ public async Task HandleSetFilter_WhenFilterHasBasicFilter_ShouldRecordFilterApp // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.Filter.ComparisonText == FilterTestConstants.FilterIdEquals100)); } @@ -182,6 +185,7 @@ public async Task HandleSetFilter_WhenFilterHasNoBasicFilter_ShouldRecordFilterA // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.Filter.ComparisonText == FilterTestConstants.FilterIdEquals100)); } @@ -210,6 +214,7 @@ public async Task HandleSetFilterDateRange_WhenAfterIsNull_ShouldUseRangeFromAct // Assert var expectedAfter = new DateTime(2024, 1, 1, 8, 0, 0, DateTimeKind.Utc); mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.DateFilter != null && x.DateFilter.After == expectedAfter && x.DateFilter.Before == unrelatedBefore)); @@ -240,6 +245,7 @@ public async Task HandleSetFilterDateRange_WhenBeforeIsNull_ShouldUseRangeFromAc // Assert var expectedBefore = new DateTime(2024, 1, 1, 15, 0, 0, DateTimeKind.Utc); mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.DateFilter != null && x.DateFilter.After == unrelatedAfter && x.DateFilter.Before == expectedBefore)); @@ -281,6 +287,7 @@ public async Task HandleSetFilterDateRange_WhenBothNullAcrossMultipleLogs_Should // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.DateFilter != null && x.DateFilter.After == new DateTime(2024, 1, 1, 4, 0, 0, DateTimeKind.Utc) && x.DateFilter.Before == new DateTime(2024, 1, 5, 22, 0, 0, DateTimeKind.Utc))); @@ -301,6 +308,7 @@ public async Task HandleSetFilterDateRange_WhenBothProvided_ShouldUseProvidedVal // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.DateFilter != null && x.DateFilter.After == after && x.DateFilter.Before == before)); @@ -323,6 +331,7 @@ public async Task HandleSetFilterDateRange_WhenExistingDateRangeHasAfter_ShouldU // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.DateFilter != null && x.DateFilter.After == existingAfter && x.DateFilter.Before == newBefore)); @@ -345,6 +354,7 @@ public async Task HandleSetFilterDateRange_WhenExistingDateRangeHasBefore_Should // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.DateFilter != null && x.DateFilter.After == newAfter && x.DateFilter.Before == existingBefore)); @@ -362,6 +372,7 @@ public async Task HandleSetFilterDateRange_WhenFilterDateModelIsNull_ShouldDispa // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.DateFilter == null)); } @@ -482,6 +493,7 @@ public async Task UpdateEventTableFilters_WhenFilterPaneDisabled_ShouldOnlyKeepE // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.Filter.Filters.Count == 1 && x.Filter.Filters[0].IsExcluded)); } @@ -505,6 +517,7 @@ public async Task UpdateEventTableFilters_WhenFilterPaneEnabled_ShouldIncludeEna // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(x => + x != null && x.Filter.Filters.Count == 1 && x.Filter.Filters[0].ComparisonText == FilterTestConstants.FilterIdEquals100)); } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EffectsTests.cs index 6b0b8317..85b081d9 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EffectsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EffectsTests.cs @@ -30,6 +30,7 @@ public async Task HandleLoadColumns_ShouldLoadAllColumnsFromPreferences() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LoadedColumns.Count == Enum.GetValues().Length)); } @@ -51,6 +52,7 @@ public async Task HandleLoadColumns_ShouldLoadWidthsFromPreferences() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.ColumnWidths[ColumnName.Level] == 150)); } @@ -70,6 +72,7 @@ public async Task HandleLoadColumns_ShouldMarkDisabledColumnsAsFalse() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LoadedColumns[ColumnName.Source] == false && action.LoadedColumns[ColumnName.EventId] == false)); } @@ -91,6 +94,7 @@ public async Task HandleLoadColumns_ShouldMarkEnabledColumnsAsTrue() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LoadedColumns[ColumnName.Level] == true && action.LoadedColumns[ColumnName.DateAndTime] == true)); } @@ -108,6 +112,7 @@ public async Task HandleLoadColumns_ShouldUseDefaultOrderWhenNotSaved() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.ColumnOrder.SequenceEqual(s_columnDefaults.ColumnOrder))); } @@ -124,6 +129,7 @@ public async Task HandleLoadColumns_ShouldUseDefaultWidthsWhenNotSaved() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.ColumnWidths[ColumnName.Level] == s_columnDefaults.GetColumnWidth(ColumnName.Level))); } @@ -142,6 +148,7 @@ public async Task HandleLoadColumns_ShouldUseSavedOrderWhenPresent() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.ColumnOrder[0] == ColumnName.Source && action.ColumnOrder[1] == ColumnName.Level)); } @@ -159,6 +166,7 @@ public async Task HandleLoadColumns_WhenNoColumnsEnabled_ShouldMarkAllAsFalse() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LoadedColumns.All(kvp => kvp.Value == false))); } @@ -180,7 +188,7 @@ public async Task HandleReorderColumn_ShouldPersistToPreferences() // Assert _ = mockPreferencesProvider.Received(1).ColumnOrderPreference = - Arg.Is>(order => order.First() == ColumnName.Source); + Arg.Is>(order => order != null && order.First() == ColumnName.Source); } [Fact] @@ -195,15 +203,17 @@ public async Task HandleResetColumnDefaults_ShouldResetAllColumnSettingsToDefaul // Assert _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = - Arg.Is>(c => c.SequenceEqual(s_columnDefaults.EnabledColumns)); + Arg.Is>(columns => + columns != null && columns.SequenceEqual(s_columnDefaults.EnabledColumns)); _ = mockPreferencesProvider.Received(1).ColumnWidthsPreference = - Arg.Is>(w => w.Count == 0); + Arg.Is>(widths => widths != null && widths.Count == 0); _ = mockPreferencesProvider.Received(1).ColumnOrderPreference = - Arg.Is>(o => !o.Any()); + Arg.Is>(order => order != null && !order.Any()); var expectedWidths = s_columnDefaults.ColumnWidths.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.ColumnWidths.Count == expectedWidths.Count && action.ColumnWidths.All(kvp => expectedWidths.ContainsKey(kvp.Key) && expectedWidths[kvp.Key] == kvp.Value) && @@ -237,7 +247,7 @@ public async Task HandleSetColumnWidth_ShouldPersistToPreferences() // Assert _ = mockPreferencesProvider.Received(1).ColumnWidthsPreference = - Arg.Is>(width => width[ColumnName.Level] == 200); + Arg.Is>(width => width != null && width[ColumnName.Level] == 200); } [Fact] @@ -260,6 +270,7 @@ public async Task HandleToggleColumn_ShouldOnlyChangeToggledColumn() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LoadedColumns[ColumnName.Level] == true && action.LoadedColumns[ColumnName.DateAndTime] == false && action.LoadedColumns[ColumnName.Source] == true && @@ -285,6 +296,7 @@ public async Task HandleToggleColumn_ShouldUpdatePreferences() // Assert _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = Arg.Is>(columns => + columns != null && columns.Contains(ColumnName.Source)); } @@ -306,6 +318,7 @@ public async Task HandleToggleColumn_WhenColumnDisabled_ShouldEnableIt() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LoadedColumns[ColumnName.Level] == true && action.LoadedColumns[ColumnName.DateAndTime] == true && action.LoadedColumns[ColumnName.Source] == true)); @@ -330,6 +343,7 @@ public async Task HandleToggleColumn_WhenColumnEnabled_ShouldDisableIt() // Assert mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.LoadedColumns[ColumnName.Level] == false && action.LoadedColumns[ColumnName.DateAndTime] == true && action.LoadedColumns[ColumnName.Source] == true)); @@ -355,6 +369,7 @@ public async Task HandleToggleColumn_WhenDisabling_ShouldRemoveFromPreferences() // Assert var _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = Arg.Is>(columns => + columns != null && columns.Contains(ColumnName.Level) && columns.Contains(ColumnName.DateAndTime) && !columns.Contains(ColumnName.Source) && @@ -379,6 +394,7 @@ public async Task HandleToggleColumn_WhenEnabling_ShouldPersistToPreferences() // Assert _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = Arg.Is>(columns => + columns != null && columns.Contains(ColumnName.Level) && columns.Contains(ColumnName.Source) && columns.Count() == 2); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/EffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/EffectsTests.cs index f23c6204..edf2bd8d 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/EffectsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/EffectsTests.cs @@ -41,6 +41,7 @@ public async Task HandleLoadScenarioFavorites_Success_DispatchesLoadedIds() await sut.HandleLoadScenarioFavorites(_dispatcher); _dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.FavoriteScenarioIds.Contains("application-crashes") && action.FavoriteScenarioIds.Contains("failed-services-at-boot"))); } @@ -69,7 +70,7 @@ await sut.HandleSetScenarioFavorite( await _store.Received(1).AddAsync("application-crashes", Arg.Any()); _announcer.Received(1).Announce("Added Application crashes to favorites"); _dispatcher.Received(1).Dispatch(Arg.Is(action => - action.ScenarioId == "application-crashes" && action.IsFavorite)); + action != null && action.ScenarioId == "application-crashes" && action.IsFavorite)); } [Fact] @@ -112,6 +113,6 @@ await sut.HandleSetScenarioFavorite( await _store.Received(1).DeleteAsync("application-crashes", Arg.Any()); _announcer.Received(1).Announce("Removed Application crashes from favorites"); - _dispatcher.Received(1).Dispatch(Arg.Is(action => !action.IsFavorite)); + _dispatcher.Received(1).Dispatch(Arg.Is(action => action != null && !action.IsFavorite)); } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/ScenarioFavoriteCommandsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/ScenarioFavoriteCommandsTests.cs index 5bccfdd0..7f7242e4 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/ScenarioFavoriteCommandsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/Favorites/ScenarioFavoriteCommandsTests.cs @@ -29,6 +29,7 @@ public void SetFavorite_Favorite_DispatchesActionWithArguments() sut.SetFavorite("application-crashes", "Application crashes", isFavorite: true); dispatcher.Received(1).Dispatch(Arg.Is(action => + action != null && action.ScenarioId == "application-crashes" && action.ScenarioName == "Application crashes" && action.IsFavorite)); @@ -43,6 +44,6 @@ public void SetFavorite_Unfavorite_DispatchesActionWithFalse() sut.SetFavorite("application-crashes", "Application crashes", isFavorite: false); dispatcher.Received(1).Dispatch(Arg.Is(action => - action.ScenarioId == "application-crashes" && !action.IsFavorite)); + action != null && action.ScenarioId == "application-crashes" && !action.IsFavorite)); } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyIntegrationTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyIntegrationTests.cs index 0a4e2e9c..51663b0c 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyIntegrationTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyIntegrationTests.cs @@ -56,7 +56,7 @@ public async Task ReplaceFiltersAction_FromScenario_LandsOnAppliedFilter() ApplyFilterAction? captured = null; dispatcher.When(target => target.Dispatch(Arg.Any())) - .Do(call => captured = (ApplyFilterAction)call[0]); + .Do(call => captured = call.ArgAt(0)); await effects.HandleReplaceFilters(dispatcher); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyServiceTests.cs index e18ebc20..f63a9c27 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioApplyServiceTests.cs @@ -24,7 +24,8 @@ public void ApplyInApp_Merge_DispatchesMergeFiltersWithBuiltRows() service.ApplyInApp(scenario, replace: false); dispatcher.Received(1).Dispatch(Arg.Is(action => - action.Filters.Count == expected.Count + action != null + && action.Filters.Count == expected.Count && action.Filters[0].ComparisonText == expected[0].ComparisonText)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -39,7 +40,7 @@ public void ApplyInApp_Merge_ThroughReducer_IsIdempotent() MergeFiltersAction? captured = null; dispatcher.When(target => target.Dispatch(Arg.Any())) - .Do(call => captured = (MergeFiltersAction)call[0]); + .Do(call => captured = call.ArgAt(0)); service.ApplyInApp(scenario, replace: false); @@ -74,7 +75,8 @@ public void ApplyInApp_Replace_DispatchesReplaceFilters() service.ApplyInApp(scenario, replace: true); dispatcher.Received(1).Dispatch(Arg.Is(action => - action.Filters.Count == expected.Count + action != null + && action.Filters.Count == expected.Count && action.Filters[0].ComparisonText == expected[0].ComparisonText)); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -89,7 +91,7 @@ public void ApplyInApp_Replace_ThroughReducer_SwapsRows() ReplaceFiltersAction? captured = null; dispatcher.When(target => target.Dispatch(Arg.Any())) - .Do(call => captured = (ReplaceFiltersAction)call[0]); + .Do(call => captured = call.ArgAt(0)); var seeded = FilterPaneReducers.ReduceMergeFilters( new FilterPaneState(), diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index e2e6f16a..81baeead 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -37,7 +37,7 @@ public async Task LaunchAsync_NullWindow_ClearsDateFilter() await service.LaunchAsync(scenario, dateWindow: null); - dispatcher.Received().Dispatch(Arg.Is(action => action.DateFilter == null)); + dispatcher.Received().Dispatch(Arg.Is(action => action != null && action.DateFilter == null)); } [Fact] @@ -141,7 +141,7 @@ public async Task LaunchAsync_ZeroOpenFreshView_RestoresFilterStateCapturedBefor await service.LaunchAsync(scenario, dateWindow: null); dispatcher.Received(1) - .Dispatch(Arg.Is(action => ReferenceEquals(action.State, priorState))); + .Dispatch(Arg.Is(action => action != null && ReferenceEquals(action.State, priorState))); } private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuActionService Menu, ScenarioDefinition Scenario) diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Update/Deployment/DeploymentServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Update/Deployment/DeploymentServiceTests.cs index a36b32f0..d93696eb 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Update/Deployment/DeploymentServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Update/Deployment/DeploymentServiceTests.cs @@ -77,8 +77,8 @@ public void RestartNowAndUpdate_WhenApplicationRestartSucceeds_ShouldCallPackage // NSubstitute's Received() doesn't reliably return configured values for IAsyncOperationWithProgress _ = mockPackageDeploymentService.Received(1) .AddPackageAsync( - Arg.Is(uri => uri.LocalPath == Constants.DownloadPath), - Arg.Is(opt => + Arg.Is(uri => uri != null && uri.LocalPath == Constants.DownloadPath), + Arg.Is(opt => opt != null && opt.ForceUpdateFromAnyVersion == true && opt.ForceTargetAppShutdown == true && opt.DeferRegistrationWhenPackagesAreInUse == false)); @@ -133,11 +133,11 @@ public async Task RestartNowAndUpdate_WhenDeploymentCanceledOrStarted_ShouldClea mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -173,11 +173,11 @@ public async Task RestartNowAndUpdate_WhenDeploymentCompleted_ShouldSetRelaunchM mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -215,11 +215,11 @@ public async Task RestartNowAndUpdate_WhenDeploymentFails_ShouldShowAlertAndClea mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -236,7 +236,7 @@ public async Task RestartNowAndUpdate_WhenDeploymentFails_ShouldShowAlertAndClea await mockMainThreadService.Received(1).InvokeOnMainThreadAsync(Arg.Any>()); await mockAlertDialogService.Received(1).ShowAlert( Constants.UpdateFailureTitle, - Arg.Is(msg => msg.Contains(testException.ToString())), + Arg.Is(msg => msg != null && msg.Contains(testException.ToString())), Constants.UpdateFailureOk); mockAppTitleService.Received(1).SetProgressString(null); @@ -263,11 +263,11 @@ public async Task RestartNowAndUpdate_WhenDeploymentFailsAutoScan_ShouldNotShowA mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -308,11 +308,11 @@ public async Task RestartNowAndUpdate_WhenProgressReported_ShouldUpdateAppTitle( mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -349,8 +349,8 @@ public void UpdateOnNextRestart_WhenCalled_ShouldCallPackageDeploymentWithDeferr // NSubstitute's Received() doesn't reliably return configured values for IAsyncOperationWithProgress _ = mockPackageDeploymentService.Received(1) .AddPackageAsync( - Arg.Is(uri => uri.LocalPath == Constants.DownloadPath), - Arg.Is(opt => + Arg.Is(uri => uri != null && uri.LocalPath == Constants.DownloadPath), + Arg.Is(opt => opt != null && opt.ForceUpdateFromAnyVersion == true && opt.ForceTargetAppShutdown == false && opt.DeferRegistrationWhenPackagesAreInUse == true)); @@ -420,11 +420,11 @@ public async Task UpdateOnNextRestart_WhenDeploymentCompleted_ShouldSetRelaunchM mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -458,11 +458,11 @@ public async Task UpdateOnNextRestart_WhenDeploymentFails_ShouldShowAlertAndClea mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -478,7 +478,7 @@ public async Task UpdateOnNextRestart_WhenDeploymentFails_ShouldShowAlertAndClea await mockMainThreadService.Received(1).InvokeOnMainThreadAsync(Arg.Any>()); await mockAlertDialogService.Received(1).ShowAlert( Constants.UpdateFailureTitle, - Arg.Is(msg => msg.Contains(testException.ToString())), + Arg.Is(msg => msg != null && msg.Contains(testException.ToString())), Constants.UpdateFailureOk); mockAppTitleService.Received(1).SetProgressString(null); @@ -502,11 +502,11 @@ public async Task UpdateOnNextRestart_WhenDeploymentFailsAutoScan_ShouldNotShowA mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -543,11 +543,11 @@ public async Task UpdateOnNextRestart_WhenMultipleProgressReports_ShouldUpdateAp mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, @@ -583,11 +583,11 @@ public async Task UpdateOnNextRestart_WhenProgressReported_ShouldUpdateAppTitle( mockMainThreadService.InvokeOnMainThread(Arg.Any()).Returns(callInfo => { - callInfo.Arg().Invoke(); + callInfo.ArgAt(0).Invoke(); return Task.CompletedTask; }); - mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.Arg>()()); + mockMainThreadService.InvokeOnMainThreadAsync(Arg.Any>()).Returns(callInfo => callInfo.ArgAt>(0)()); var deploymentService = CreateDeploymentService( appTitleService: mockAppTitleService, diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs index d2f52d26..8120e9dc 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs @@ -97,7 +97,7 @@ public async Task CheckForUpdates_DeploymentThrowsException_ShouldShowAlert() // Assert await mockAlertDialogService.Received(1) - .ShowAlert("Update Failure", Arg.Is(s => s.Contains("Deployment failed")), "OK"); + .ShowAlert("Update Failure", Arg.Is(s => s != null && s.Contains("Deployment failed")), "OK"); } [Fact] @@ -217,7 +217,7 @@ public async Task CheckForUpdates_GetReleasesThrowsException_ShouldShowAlert() // Assert await mockAlertDialogService.Received(1) - .ShowAlert("Update Failure", Arg.Is(s => s.Contains("Network error")), "OK"); + .ShowAlert("Update Failure", Arg.Is(s => s != null && s.Contains("Network error")), "OK"); mockDeploymentService.DidNotReceive().RestartNowAndUpdate(Arg.Any(), Arg.Any()); mockDeploymentService.DidNotReceive().UpdateOnNextRestart(Arg.Any(), Arg.Any()); @@ -447,7 +447,7 @@ public async Task CheckForUpdates_NoReleases_ShouldShowAlert() mockDeploymentService.DidNotReceive().UpdateOnNextRestart(Arg.Any(), Arg.Any()); await mockAlertDialogService.Received(1) - .ShowAlert("Update Failure", Arg.Is(s => s.Contains("No releases available")), "OK"); + .ShowAlert("Update Failure", Arg.Is(s => s != null && s.Contains("No releases available")), "OK"); } [Fact] @@ -807,7 +807,7 @@ public async Task GetReleaseNotes_NoCurrentChanges_ShouldShowFailureAlertAndRetu // Assert await mockAlertDialogService.Received(1) - .ShowAlert(Arg.Is(s => s.Contains("Release Notes")), Arg.Is(s => s.Contains("Failed to get release notes")), "OK"); + .ShowAlert(Arg.Is(s => s != null && s.Contains("Release Notes")), Arg.Is(s => s != null && s.Contains("Failed to get release notes")), "OK"); Assert.Null(result); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Banner/AttentionBannerTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Banner/AttentionBannerTests.cs index e7e6d69f..fbe76b53 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Banner/AttentionBannerTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Banner/AttentionBannerTests.cs @@ -76,7 +76,7 @@ public async Task AttentionBanner_OpenDatabasesReturnsFalse_DismissesAttentionIm _attentionBannerService.Received(1).DismissAttention(); _errorBannerService.Received(1) - .ReportError("Databases", Arg.Is(s => s.Contains("Failed to open databases"))); + .ReportError("Databases", Arg.Is(s => s != null && s.Contains("Failed to open databases"))); Assert.NotNull(captured); Assert.Equal(BannerView.Error, captured.View); } @@ -113,7 +113,7 @@ public async Task AttentionBanner_OpenDatabasesThrowsUnexpectedly_DismissesAtten _attentionBannerService.Received(1).DismissAttention(); _errorBannerService.Received(1) - .ReportError("Databases", Arg.Is(s => s.Contains("modal boom"))); + .ReportError("Databases", Arg.Is(s => s != null && s.Contains("modal boom"))); _traceLogger.Received(1).Error(Arg.Is(h => h.ToString().Contains(nameof(AttentionBanner)) && h.ToString().Contains("modal boom"))); Assert.NotNull(captured); diff --git a/tests/Unit/EventLogExpert.UI.Tests/Banner/CriticalBannerTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Banner/CriticalBannerTests.cs index c16f0722..dfb0a540 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Banner/CriticalBannerTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Banner/CriticalBannerTests.cs @@ -44,7 +44,7 @@ public async Task CriticalBanner_CopyDetailsClicked_CopiesExceptionAndShowsCopie component.Find("aside.banner-critical .banner-actions button:contains('Copy details')").Click(); await _clipboardService.Received(1) - .CopyTextAsync(Arg.Is(s => s.Contains("InvalidOperationException") && s.Contains("kaboom"))); + .CopyTextAsync(Arg.Is(s => s != null && s.Contains("InvalidOperationException") && s.Contains("kaboom"))); Assert.Single(component.FindAll("aside.banner-critical .banner-feedback .banner-chip")); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs index 597a1bdf..2d13c375 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs @@ -118,7 +118,7 @@ public void DetailLaunch_InvokesScenarioLaunchAndAnnounces() cut.WaitForAssertion(() => { _scenarioLaunch.Received(1) - .LaunchAsync(Arg.Is(scenario => scenario.Id == "application-crashes"), null); + .LaunchAsync(Arg.Is(scenario => scenario != null && scenario.Id == "application-crashes"), null); _announcer.Received(1).Announce(Arg.Any()); }); } @@ -136,7 +136,7 @@ public void DetailLaunch_WhenLaunchOpensNothing_AnnouncesFailure() cut.Find(ActiveDetailLaunch).Click(); cut.WaitForAssertion(() => - _announcer.Received(1).Announce(Arg.Is(message => message.Contains("No channels")))); + _announcer.Received(1).Announce(Arg.Is(message => message != null && message.Contains("No channels")))); } [Fact] @@ -205,7 +205,7 @@ public void QuickLaunch_OpenApplicationAndSystem_InvokesCombineOpen() FindLaunch(cut, "Open Application + System (live)").Click(); cut.WaitForAssertion(() => _actions.Received(1).OpenLiveLogsAsync( - Arg.Is>(channels => channels.SequenceEqual(new[] { "Application", "System" })), + Arg.Is>(channels => channels != null && channels.SequenceEqual(new[] { "Application", "System" })), false)); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs index 60dbdce3..9d5ef604 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs @@ -114,7 +114,7 @@ public async Task ContextMenu_InNormalMode_OpensMenuWithRemoveItem() await component.Find(".db-entry-row").TriggerEventAsync("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 }); - _menuService.Received(1).OpenAt(100, 200, Arg.Is>(items => items.Count == 1)); + _menuService.Received(1).OpenAt(100, 200, Arg.Is>(items => items != null && items.Count == 1)); } [Fact] @@ -143,7 +143,7 @@ public async Task ContextMenu_RemoveItem_HasRemoveLabel() var entry = MakeEntry(DatabaseStatus.Ready, "MyProvider.db"); IReadOnlyList? capturedItems = null; _menuService.When(s => s.OpenAt(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any())) - .Do(call => capturedItems = call.Arg>()); + .Do(call => capturedItems = call.ArgAt>(2)); var component = RenderRow(entry); await component.Find(".db-entry-row").TriggerEventAsync("oncontextmenu", new MouseEventArgs()); diff --git a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseRecoveryHostTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseRecoveryHostTests.cs index 7de7f754..2cc5c070 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseRecoveryHostTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseRecoveryHostTests.cs @@ -41,14 +41,14 @@ public DatabaseRecoveryHostTests() _mainThreadService.InvokeOnMainThread(Arg.Any()) .Returns(call => { - ((Action)call[0])(); + call.ArgAt(0)(); return Task.CompletedTask; }); _mainThreadService.InvokeOnMainThreadAsync(Arg.Any>()) .Returns(async call => { - await ((Func)call[0])(); + await call.ArgAt>(0)(); }); } @@ -273,7 +273,7 @@ public void DatabaseRecoveryHost_EntriesChanged_DispatchFails_ReportsCritical() return Task.FromException(dispatchFailure); } - ((Action)call[0])(); + call.ArgAt(0)(); return Task.CompletedTask; }); diff --git a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/CreateDatabaseTabTests.cs b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/CreateDatabaseTabTests.cs index 7b204680..7cab84cf 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/CreateDatabaseTabTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/CreateDatabaseTabTests.cs @@ -374,7 +374,7 @@ private List ConfigureCreateSucceededWritingDatabaseFile() .CreateAsync(default!, default!, default, default) .ReturnsForAnyArgs(callInfo => { - var targetPath = callInfo.Arg().TargetPath; + var targetPath = callInfo.ArgAt(0).TargetPath; File.WriteAllText(targetPath, string.Empty); writtenDatabasePaths.Add(targetPath); return Task.FromResult(new DatabaseToolsResult(DatabaseToolsOutcome.Succeeded, null, TimeSpan.Zero)); diff --git a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs index be9d4872..74c0e993 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs @@ -1,1428 +1,1428 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using Bunit; -using EventLogExpert.Logging.Abstractions; -using EventLogExpert.Runtime.Alerts; -using EventLogExpert.Runtime.Announcement; -using EventLogExpert.Runtime.Banner; -using EventLogExpert.Runtime.Database; -using EventLogExpert.Runtime.Database.Upgrade; -using EventLogExpert.Runtime.EventLog; -using EventLogExpert.UI.DatabaseTools; -using EventLogExpert.UI.DatabaseTools.Tabs; -using EventLogExpert.UI.Tests.TestUtils; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using System.Collections.Frozen; -using System.Reflection; -using TestContext = Xunit.TestContext; - -namespace EventLogExpert.UI.Tests.DatabaseTools.Tabs; - -public sealed class ManageDatabasesTabTests : BunitContext -{ - private readonly IAnnouncementService _announcementService = Substitute.For(); - private readonly IDatabaseOperationCoordinator _coordinator = Substitute.For(); - private readonly FakeDatabaseService _databaseService = new(); - private readonly ILogReloadCoordinator _logReloadCoordinator = Substitute.For(); - private readonly IProgressBannerService _progressBannerService = Substitute.For(); - private readonly ITraceLogger _traceLogger = Substitute.For(); - - public ManageDatabasesTabTests() - { - _progressBannerService.ManageDatabasesProgress.Returns((BannerProgressEntry?)null); - _logReloadCoordinator.HasActiveLogs.Returns(false); - - Services.AddSingleton(_announcementService); - Services.AddSingleton(_coordinator); - Services.AddSingleton(_databaseService); - Services.AddSingleton(_logReloadCoordinator); - Services.AddSingleton(_progressBannerService); - Services.AddSingleton(_traceLogger); - Services.AddMenuServiceMock(); - - JSInterop.Mode = JSRuntimeMode.Loose; - } - - [Fact] - public async Task ApplyPendingTogglesAsync_WhenIsUpgradeBlocked_ReturnsFalseAndDoesNotInvokeCoordinator() - { - _coordinator.IsAnyUpgradeInFlight.Returns(true); - var component = Render(); - - var saved = await component.InvokeAsync(component.Instance.ApplyPendingTogglesAsync); - - Assert.False(saved); - await _coordinator.DidNotReceive().ApplyPendingTogglesAsync(Arg.Any>(), Arg.Any()); - } - - [Fact] - public async Task ApplyPendingTogglesAsync_WithNoPendingToggles_ReturnsTrue_NoAnnounce() - { - var component = Render(); - - var saved = await component.InvokeAsync(component.Instance.ApplyPendingTogglesAsync); - - Assert.True(saved); - _announcementService.DidNotReceive().Announce(Arg.Any()); - } - - [Fact] - public async Task Background_PreProgress_IsFileInActiveBatch_DetectsFileInBatch() - { - var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: string.Empty, - currentPhase: UpgradePhase.BackingUp, - scope: UpgradeProgressScope.Background, - batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); - - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await InvokeRemoveDatabaseAsync(component, entry); - - var captured = Assert.Single(alertSurface.Requests); - Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task BulkDelete_AnySucceeded_AutoExitsSelectionMode() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - _coordinator.RemoveDatabaseAsync( - Arg.Any(), - Arg.Any>>(), - Arg.Any()) - .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - - var deleteBtn = component.Find(".manage-databases-bulk-strip .button-red"); - await component.InvokeAsync(() => deleteBtn.Click()); - - Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); - } - - [Fact] - public async Task BulkRemove_ConfirmationAccepted_InvokesCoordinatorPerFile() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - _coordinator.RemoveDatabaseAsync( - Arg.Any(), - Arg.Any>>(), - Arg.Any()) - .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Alerts; +using EventLogExpert.Runtime.Announcement; +using EventLogExpert.Runtime.Banner; +using EventLogExpert.Runtime.Database; +using EventLogExpert.Runtime.Database.Upgrade; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.UI.DatabaseTools; +using EventLogExpert.UI.DatabaseTools.Tabs; +using EventLogExpert.UI.Tests.TestUtils; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using System.Collections.Frozen; +using System.Reflection; +using TestContext = Xunit.TestContext; + +namespace EventLogExpert.UI.Tests.DatabaseTools.Tabs; + +public sealed class ManageDatabasesTabTests : BunitContext +{ + private readonly IAnnouncementService _announcementService = Substitute.For(); + private readonly IDatabaseOperationCoordinator _coordinator = Substitute.For(); + private readonly FakeDatabaseService _databaseService = new(); + private readonly ILogReloadCoordinator _logReloadCoordinator = Substitute.For(); + private readonly IProgressBannerService _progressBannerService = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + public ManageDatabasesTabTests() + { + _progressBannerService.ManageDatabasesProgress.Returns((BannerProgressEntry?)null); + _logReloadCoordinator.HasActiveLogs.Returns(false); + + Services.AddSingleton(_announcementService); + Services.AddSingleton(_coordinator); + Services.AddSingleton(_databaseService); + Services.AddSingleton(_logReloadCoordinator); + Services.AddSingleton(_progressBannerService); + Services.AddSingleton(_traceLogger); + Services.AddMenuServiceMock(); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public async Task ApplyPendingTogglesAsync_WhenIsUpgradeBlocked_ReturnsFalseAndDoesNotInvokeCoordinator() + { + _coordinator.IsAnyUpgradeInFlight.Returns(true); + var component = Render(); + + var saved = await component.InvokeAsync(component.Instance.ApplyPendingTogglesAsync); + + Assert.False(saved); + await _coordinator.DidNotReceive().ApplyPendingTogglesAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ApplyPendingTogglesAsync_WithNoPendingToggles_ReturnsTrue_NoAnnounce() + { + var component = Render(); + + var saved = await component.InvokeAsync(component.Instance.ApplyPendingTogglesAsync); + + Assert.True(saved); + _announcementService.DidNotReceive().Announce(Arg.Any()); + } + + [Fact] + public async Task Background_PreProgress_IsFileInActiveBatch_DetectsFileInBatch() + { + var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: string.Empty, + currentPhase: UpgradePhase.BackingUp, + scope: UpgradeProgressScope.Background, + batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await InvokeRemoveDatabaseAsync(component, entry); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BulkDelete_AnySucceeded_AutoExitsSelectionMode() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _coordinator.RemoveDatabaseAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); - await component.InvokeAsync(() => bulkRemove.Click()); - - await _coordinator.Received(1).RemoveDatabaseAsync( - "a.db", - Arg.Any>>(), - Arg.Any()); - await _coordinator.Received(1).RemoveDatabaseAsync( - "b.db", - Arg.Any>>(), - Arg.Any()); - } - - [Fact] - public async Task BulkRemove_ConfirmationDeclined_DoesNotInvokeCoordinator() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + + var deleteBtn = component.Find(".manage-databases-bulk-strip .button-red"); + await component.InvokeAsync(() => deleteBtn.Click()); + + Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); + } + + [Fact] + public async Task BulkRemove_ConfirmationAccepted_InvokesCoordinatorPerFile() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _coordinator.RemoveDatabaseAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); - await component.InvokeAsync(() => bulkRemove.Click()); - - await _coordinator.DidNotReceive().RemoveDatabaseAsync( - Arg.Any(), - Arg.Any>>(), - Arg.Any()); - } - - [Fact] - public async Task BulkRemove_DuringBackgroundUpgrade_DualSignal_AcceptanceLabelMentionsCancel() - { - var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - - _coordinator.IsUpgradeInFlight("a.db").Returns(false); - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: "a.db", - scope: UpgradeProgressScope.Background)); - - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); + await component.InvokeAsync(() => bulkRemove.Click()); + + await _coordinator.Received(1).RemoveDatabaseAsync( + "a.db", + Arg.Any>>(), + Arg.Any()); + await _coordinator.Received(1).RemoveDatabaseAsync( + "b.db", + Arg.Any>>(), + Arg.Any()); + } + + [Fact] + public async Task BulkRemove_ConfirmationDeclined_DoesNotInvokeCoordinator() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); - var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); - await component.InvokeAsync(() => bulkRemove.Click()); - - var captured = Assert.Single(alertSurface.Requests); - Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - Assert.Contains("a.db", captured.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task BulkRemove_LogsReopened_CallsConsumeReopenedAsBaseline() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: true, status: DatabaseStatus.Ready)]; - - _coordinator.RemoveDatabaseAsync( - Arg.Any(), - Arg.Any>>(), - Arg.Any()) - .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, true)); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs( - UpgradeBatchId.Create(), - new UpgradeBatchResult(["a.db"], [], []), - wasCancelled: false)); - Assert.True(component.Instance.HasDatabaseStateChanged); - - await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); - await component.InvokeAsync(() => bulkRemove.Click()); - - Assert.False(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public async Task BulkRemove_PartialFailure_KeepsFailedSelected_StaysInSelectionMode() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - _coordinator.RemoveDatabaseAsync("a.db", Arg.Any>>(), Arg.Any()) - .Returns(_ => throw new InvalidOperationException("disk full")); - _coordinator.RemoveDatabaseAsync("b.db", Arg.Any>>(), Arg.Any()) - .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); - await component.InvokeAsync(() => bulkRemove.Click()); - - Assert.True(component.Instance.IsInSelectionMode); - Assert.True(component.Instance.HasBulkSelection); - Assert.Equal("true", component.Find("#manage-select-button").GetAttribute("aria-pressed")); - } - - [Fact] - public async Task BulkRemove_PerFileFailure_AnnouncesFirstFailureDetail() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - _coordinator.RemoveDatabaseAsync("a.db", Arg.Any>>(), Arg.Any()) - .Returns(_ => throw new InvalidOperationException("disk full")); - _coordinator.RemoveDatabaseAsync("b.db", Arg.Any>>(), Arg.Any()) - .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); + await component.InvokeAsync(() => bulkRemove.Click()); + + await _coordinator.DidNotReceive().RemoveDatabaseAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()); + } + + [Fact] + public async Task BulkRemove_DuringBackgroundUpgrade_DualSignal_AcceptanceLabelMentionsCancel() + { + var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + + _coordinator.IsUpgradeInFlight("a.db").Returns(false); + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: "a.db", + scope: UpgradeProgressScope.Background)); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); - await component.InvokeAsync(() => bulkRemove.Click()); - - _announcementService.Received().Announce(Arg.Is(s => s.Contains("a.db") && s.Contains("disk full"))); - } - - [Fact] - public async Task BulkUpgrade_AllEligible_CallsCoordinatorWithEntireBatch() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), - Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeFailed)]; - _coordinator.UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()) - .Returns(new UpgradeBatchResult(Succeeded: ["a.db", "b.db"], Cancelled: [], Failed: [])); - - var component = Render(); - await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); - await component.InvokeAsync(() => upgradeBtn.Click()); - - await _coordinator.Received(1).UpgradeDatabasesAsync( - Arg.Is>(l => l.Count == 2 && l.Contains("a.db") && l.Contains("b.db")), - UpgradeProgressScope.ManageDatabasesTriggered, - Arg.Any()); - } - - [Fact] - public async Task BulkUpgrade_ButtonAppears_OnlyWhenEligibleRowSelected() - { - _databaseService.Entries = [ - Entry("ready.db", isEnabled: true, status: DatabaseStatus.Ready), - Entry("needs.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; - var component = Render(); - await EnterSelectionModeAsync(component); - - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - Assert.Empty(component.FindAll(".manage-databases-bulk-strip .button-green")); - - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); - Assert.Contains("Upgrade 1", upgradeBtn.TextContent); - } - - [Fact] - public async Task BulkUpgrade_CleanSuccess_AutoExitsSelectionMode() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; - _coordinator.UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()) - .Returns(new UpgradeBatchResult(Succeeded: ["a.db"], Cancelled: [], Failed: [])); - - var component = Render(); - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - - var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); - await component.InvokeAsync(() => upgradeBtn.Click()); - - Assert.False(component.Instance.HasBulkSelection); - Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); - } - - [Fact] - public async Task BulkUpgrade_GateDenied_CoordinatorReturnsNull_ShowsInfoAlert() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; - _coordinator.UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()) - .Returns((UpgradeBatchResult?)null); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - - var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); - await component.InvokeAsync(() => upgradeBtn.Click()); - - var captured = Assert.Single(alertSurface.Requests); - Assert.Equal("Upgrade not started", captured.Title); - Assert.Contains("already in progress", captured.Message); - } - - [Fact] - public async Task BulkUpgrade_GateDenied_StaysInSelectionMode() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; - _coordinator.UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()) - .Returns((UpgradeBatchResult?)null); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - - await component.InvokeAsync(() => component.Find(".manage-databases-bulk-strip .button-green").Click()); - - Assert.True(component.Instance.HasBulkSelection); - } - - [Fact] - public async Task BulkUpgrade_MixedSelection_CancelledPrompt_DoesNotCallCoordinator() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); - await component.InvokeAsync(() => upgradeBtn.Click()); - - await _coordinator.DidNotReceive().UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task BulkUpgrade_MixedSelection_ShowsSubsetConfirm_AcceptedRunsBatchWithEligibleOnly() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - _coordinator.UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()) - .Returns(new UpgradeBatchResult(Succeeded: ["a.db"], Cancelled: [], Failed: [])); - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - - var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); - await component.InvokeAsync(() => upgradeBtn.Click()); - - var prompt = Assert.Single(alertSurface.Requests); - Assert.Contains("1 of 2", prompt.Message); - Assert.Contains("b.db", prompt.Message); - Assert.Contains("Already up to date", prompt.Message); - Assert.Equal("Upgrade 1", prompt.AcceptLabel); - - await _coordinator.Received(1).UpgradeDatabasesAsync( - Arg.Is>(l => l.Count == 1 && l.Contains("a.db")), - UpgradeProgressScope.ManageDatabasesTriggered, - Arg.Any()); - } - - [Fact] - public async Task BulkUpgrade_PartialFailure_StaysInSelectionMode() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), - Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; - _coordinator.UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()) - .Returns(new UpgradeBatchResult( - Succeeded: ["a.db"], - Cancelled: [], - Failed: [new UpgradeFailure("b.db", "boom")])); - - var component = Render(); - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".manage-databases-master-btn").Click()); - - var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); - await component.InvokeAsync(() => upgradeBtn.Click()); - - Assert.True(component.Instance.HasBulkSelection); - Assert.Equal("true", component.Find("#manage-select-button").GetAttribute("aria-pressed")); - } - - [Fact] - public async Task BulkUpgrade_ZeroEligible_UpgradeButtonHidden() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - var component = Render(); - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - - Assert.Empty(component.FindAll(".manage-databases-bulk-strip .button-green")); - - await _coordinator.DidNotReceive().UpgradeDatabasesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task CancelThenRemove_DuringPreProgressWindow_CancelsActualBatchAndProceeds() - { - var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - var batchId = UpgradeBatchId.Create(); - var cancelCalled = false; - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: string.Empty, - currentPhase: UpgradePhase.BackingUp, - scope: UpgradeProgressScope.Background, - batchId: batchId, - cancel: () => cancelCalled = true, - batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); - - var component = Render(); - - var cancelTask = InvokeCancelUpgradesAsync(component, ["b.db"]); - - await Task.Delay(50, TestContext.Current.CancellationToken); - Assert.True(cancelCalled); - Assert.False(cancelTask.IsCompleted); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs( - batchId, - new UpgradeBatchResult([], ["b.db"], []), - wasCancelled: true)); - - await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); - } - - [Fact] - public async Task CancelThenRemove_FileNotYetReachedInActiveBatch_CancelsBatchAndProceeds() - { - var entry = Entry("c.db", isEnabled: false, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - var batchId = UpgradeBatchId.Create(); - var cancelCalled = false; - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: "a.db", - currentPhase: UpgradePhase.MigratingSchema, - scope: UpgradeProgressScope.Background, - batchId: batchId, - cancel: () => cancelCalled = true, - batchFileNames: new[] { "a.db", "b.db", "c.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); - - var component = Render(); - - var cancelTask = InvokeCancelUpgradesAsync(component, ["c.db"]); - - await Task.Delay(50, TestContext.Current.CancellationToken); - Assert.True(cancelCalled); - Assert.False(cancelTask.IsCompleted); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs( - batchId, - new UpgradeBatchResult([], ["c.db"], []), - wasCancelled: true)); - - await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); - } - - [Fact] - public async Task CancelThenRemove_PreProgressWindow_AwaitsUpgradeBatchCompletedBeforeRemoveAndProceedsPromptly() - { - var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - var batchId = UpgradeBatchId.Create(); - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: string.Empty, - currentPhase: UpgradePhase.BackingUp, - scope: UpgradeProgressScope.Background, - batchId: batchId, - batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); - - var component = Render(); - - var cancelTask = InvokeCancelUpgradesAsync(component, ["b.db"]); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - Assert.False(cancelTask.IsCompleted); - - var beforeRaise = DateTime.UtcNow; - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs( - batchId, - new UpgradeBatchResult([], ["b.db"], []), - wasCancelled: true)); - - await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); - var elapsed = DateTime.UtcNow - beforeRaise; - // Must complete well under s_cancelTimeout (30s) — otherwise a hang regression would pass. - Assert.True(elapsed < TimeSpan.FromSeconds(2), $"Cancel flow took {elapsed} after batch completion (should be <2s)."); - } - - [Fact] - public async Task CancelThenRemove_QueuedBatchFile_AwaitsConsumerPickupAndCompletionBeforeRemove() - { - var entry = Entry("c.db", isEnabled: false, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - var batchId = UpgradeBatchId.Create(); - var cancelCalled = false; - _databaseService.QueuedBatchesForTest = [new QueuedBatchInfo( - batchId, - UpgradeProgressScope.Background, - new[] { "c.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase), - () => cancelCalled = true)]; - - var component = Render(); - - var cancelTask = InvokeCancelUpgradesAsync(component, ["c.db"]); - - await Task.Delay(100, TestContext.Current.CancellationToken); - Assert.True(cancelCalled); - Assert.False(cancelTask.IsCompleted); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs( - batchId, - new UpgradeBatchResult([], ["c.db"], []), - wasCancelled: true)); - - await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); - } - - [Fact] - public async Task ClearSelection_EmptiesSet_HidesBulkStrip() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - Assert.Contains("2 selected", component.Find(".manage-databases-bulk-count").TextContent); - - // Master checkbox in the selection header clears when all are selected. - var masterBtn = component.Find(".manage-databases-master-btn"); - await component.InvokeAsync(() => masterBtn.Click()); - - Assert.False(component.Instance.HasBulkSelection); - Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); - } - - [Fact] - public async Task ClearSelection_UpdatesAriaLiveAnnouncement() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); - var liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); - Assert.NotEqual(string.Empty, liveRegion.TextContent.Trim()); - - // With 1 of 1 selected, master is "all"; clicking clears. - var masterBtn = component.Find(".manage-databases-master-btn"); - await component.InvokeAsync(() => masterBtn.Click()); - - liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); - Assert.DoesNotContain("selected", liveRegion.TextContent, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ConfirmationMessage_WhenNoActiveLogs_DoesNotIncludeCloseReopenWarning() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - _logReloadCoordinator.HasActiveLogs.Returns(false); - - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await InvokeRemoveDatabaseAsync(component, _databaseService.Entries[0]); - - var captured = Assert.Single(alertSurface.Requests); - Assert.DoesNotContain("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ConfirmationMessage_WhenRemovingActiveDbWithOpenLogs_IncludesCloseReopenWarning() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - _logReloadCoordinator.HasActiveLogs.Returns(true); - - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await InvokeRemoveDatabaseAsync(component, _databaseService.Entries[0]); - - var captured = Assert.Single(alertSurface.Requests); - Assert.Contains("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ConfirmationMessage_WhenRemovingDisabledDb_DoesNotIncludeCloseReopenWarning() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - _logReloadCoordinator.HasActiveLogs.Returns(true); - - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await InvokeRemoveDatabaseAsync(component, _databaseService.Entries[0]); - - var captured = Assert.Single(alertSurface.Requests); - Assert.DoesNotContain("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task DisposeAsync_UnsubscribesAllEvents() - { - var component = Render(); - - Assert.Equal(1, _databaseService.EntriesChangedHandlerCount); - Assert.Equal(1, _databaseService.UpgradeBatchCompletedHandlerCount); - - await component.InvokeAsync(() => component.Instance.DisposeAsync().AsTask()); - - Assert.Equal(0, _databaseService.EntriesChangedHandlerCount); - Assert.Equal(0, _databaseService.UpgradeBatchCompletedHandlerCount); - } - - [Fact] - public async Task EntriesChanged_DuringSelection_EmptyList_AutoExitsSelectionMode() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - await EnterSelectionModeAsync(component); - Assert.Equal("true", component.Find("#manage-select-button").GetAttribute("aria-pressed")); - - _databaseService.Entries = []; - _databaseService.RaiseEntriesChanged(); - await component.InvokeAsync(() => { }); - - Assert.False(component.Instance.IsInSelectionMode); - Assert.Empty(component.FindAll("#manage-select-button")); - } - - [Fact] - public async Task ExitSelectionModeWithFocusAsync_WhenInSelectionMode_ExitsAndClearsSelection() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - Assert.True(component.Instance.HasBulkSelection); - - await component.InvokeAsync(() => component.Instance.ExitSelectionModeWithFocusAsync()); - - Assert.False(component.Instance.HasBulkSelection); - Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); - } - - [Fact] - public async Task ExitSelectionModeWithFocusAsync_WhenNotInSelectionMode_IsNoOp() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - await component.InvokeAsync(() => component.Instance.ExitSelectionModeWithFocusAsync()); - - Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); - } - - [Fact] - public async Task GetUpgradeProgressForEntry_Display_OnlyMatchesCurrentEntryName_NotBatchFileNames() - { - var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); - _databaseService.Entries = [entry]; - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: string.Empty, - currentPhase: UpgradePhase.BackingUp, - scope: UpgradeProgressScope.Background, - batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); - - var component = Render(); - - Assert.Empty(component.FindAll(".db-entry-upgrading-text")); - await Task.CompletedTask; - } - - [Fact] - public void HasDatabaseStateChanged_AfterEnabledEntryRemovedFromActiveSet_True() - { - // Active set was {a}; mutate underlying entries so set becomes {}; flag computed via diff. - var entry = Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - var component = Render(); - - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - - Assert.True(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public async Task HasDatabaseStateChanged_AfterImportNoChanges_False() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - _coordinator.ImportAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(ImportOutcome.None); - var component = Render(); - - var importBtn = component.Find("#manage-import-button"); - await component.InvokeAsync(() => importBtn.ClickAsync(new MouseEventArgs())); - - Assert.False(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public async Task HasDatabaseStateChanged_AfterImportSuccess_True_ViaStickyFlag() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - _coordinator.ImportAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new ImportOutcome(ImportedCount: 1, Failures: [], UpgradeFailures: [])); - var component = Render(); - - var importBtn = component.Find("#manage-import-button"); - await component.InvokeAsync(() => importBtn.ClickAsync(new MouseEventArgs())); - - Assert.True(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public void HasDatabaseStateChanged_AfterUpgradeCancelledWithNoSucceeded_False() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - var component = Render(); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult([], ["a.db"], []), wasCancelled: true)); - - Assert.False(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public void HasDatabaseStateChanged_AfterUpgradeFailedWithNoSucceeded_False() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - var component = Render(); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs( - UpgradeBatchId.Create(), - new UpgradeBatchResult([], [], [new UpgradeFailure("a.db", "boom")]), - wasCancelled: false)); - - Assert.False(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public void HasDatabaseStateChanged_AfterUpgradeSuccess_True_ViaStickyFlag() - { - var entry = Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - var component = Render(); - - // Schema migrated in place — entry stays in the active set, but the underlying data changed. - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult(["a.db"], [], []), wasCancelled: false)); - - Assert.True(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public void HasDatabaseStateChanged_InitialState_False() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - - var component = Render(); - - Assert.False(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public void HasDatabaseStateChanged_PartiallySucceededWasCancelled_True() - { - // Multi-file batch: A succeeds, B is cancelled mid-flight. A's schema is permanently migrated; - // flag must fire even though args.WasCancelled is true. - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - var component = Render(); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs( - UpgradeBatchId.Create(), - new UpgradeBatchResult(["a.db"], ["b.db"], []), - wasCancelled: true)); - - Assert.True(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public void HasDatabaseStateChanged_UpgradeSucceededForDisabledEntry_False() - { - // Background-scope upgrades of disabled (non-active) DBs must not trigger the reload prompt: - // the file isn't in the active resolver set, so open logs aren't affected. - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult(["a.db"], [], []), wasCancelled: false)); - - Assert.False(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public void IsUpgradeInFlight_TracksCoordinator() - { - _coordinator.IsAnyUpgradeInFlight.Returns(true); - var component = Render(); - - Assert.True(component.Instance.IsUpgradeInFlight); - } - - [Fact] - public async Task MasterCheckbox_AllSelected_ShowsCheckedIconAndClearLabel() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - - var btn = component.Find(".manage-databases-master-btn"); - var icon = component.Find(".manage-databases-master-btn i"); - Assert.Contains("bi-check-square-fill", icon.GetAttribute("class") ?? string.Empty); - Assert.Equal("Clear selection", btn.GetAttribute("aria-label")); - } - - [Fact] - public async Task MasterCheckbox_Indeterminate_WhenSomeButNotAllSelected() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.FindAll(".db-entry-row input[type='checkbox']")[0] - .ChangeAsync(new ChangeEventArgs { Value = true })); - - var icon = component.Find(".manage-databases-master-btn i"); - Assert.Contains("bi-dash-square-fill", icon.GetAttribute("class") ?? string.Empty); - Assert.Equal("Select all", component.Find(".manage-databases-master-btn").GetAttribute("aria-label")); - } - - [Fact] - public async Task MasterCheckbox_ShowsAfterEnteringSelectionMode_BeforeAnySelection() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - Assert.Empty(component.FindAll(".manage-databases-selection-header")); - - await EnterSelectionModeAsync(component); - - var icon = component.Find(".manage-databases-master-btn i"); - Assert.Contains("bi-square", icon.GetAttribute("class") ?? string.Empty); - Assert.Contains("0 of 1 selected", component.Find(".manage-databases-selection-count").TextContent); - } - - [Fact] - public async Task RebaselineActiveSnapshotOnly_PreservesStickyFlags() - { - // Modal opens mid-classification (empty snapshot), user-initiated upgrade succeeds during the - // wait (_schemaUpgradeOccurred=true), classification completes. The snapshot rebaselines but - // the sticky flag must survive so the close-time reload prompt still fires. - var tcs = new TaskCompletionSource(); - _databaseService.InitialClassificationTask = tcs.Task; - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; - var component = Render(); - - _databaseService.RaiseUpgradeBatchCompleted( - new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult(["a.db"], [], []), wasCancelled: false)); - - Assert.True(component.Instance.HasDatabaseStateChanged); - - tcs.SetResult(); - await Task.Delay(50, TestContext.Current.CancellationToken); - - Assert.True(component.Instance.HasDatabaseStateChanged); - } - - [Fact] - public async Task Remove_FileInQueuedBatch_IsAnyFileUpgradingReturnsTrue() - { - var entry = Entry("c.db", isEnabled: false, status: DatabaseStatus.Ready); - _databaseService.Entries = [entry]; - _databaseService.QueuedBatchesForTest = [new QueuedBatchInfo( - UpgradeBatchId.Create(), - UpgradeProgressScope.Background, - new[] { "c.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase), - () => { })]; - - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await InvokeRemoveDatabaseAsync(component, entry); - - var captured = Assert.Single(alertSurface.Requests); - Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Render_BulkStrip_HiddenWhenNoSelection() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - - var component = Render(); - - Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); - Assert.False(component.Instance.HasBulkSelection); - } - - [Fact] - public void Render_ClassificationPendingNotice_WhenClassificationInProgress() - { - _databaseService.Entries = []; - _databaseService.InitialClassificationTask = new TaskCompletionSource().Task; - - var component = Render(); - - Assert.Single(component.FindAll(".manage-status-banner--info")); - Assert.Empty(component.FindAll(".manage-databases-empty")); - } - - [Fact] - public void Render_DoesNotRenderRemovedSharedProgressBanner() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; - _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress(currentEntryName: "a.db")); - - var component = Render(); - - Assert.Empty(component.FindAll("aside.manage-databases-upgrade-banner")); - } - - [Fact] - public void Render_EmptyState_WhenNoEntries_AndClassificationComplete() - { - _databaseService.Entries = []; - _databaseService.InitialClassificationTask = Task.CompletedTask; - - var component = Render(); - - Assert.Single(component.FindAll(".manage-databases-empty")); - } - - [Fact] - public void Render_HasPendingChanges_False_HidesSaveStrip() - { - var component = Render(); - - Assert.Empty(component.FindAll(".manage-databases-save-strip")); - } - - [Fact] - public void Render_ImportButton_Present() - { - var component = Render(); - - Assert.Single(component.FindAll("#manage-import-button")); - } - - [Fact] - public void Render_PersistentAriaLiveRegion_PresentRegardlessOfSelection() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - - var component = Render(); - - var statusRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); - Assert.NotNull(statusRegion); - Assert.Equal("true", statusRegion.GetAttribute("aria-atomic")); - } - - [Fact] - public void Render_SelectButton_HiddenWhenNoEntries() - { - _databaseService.Entries = []; - - var component = Render(); - - Assert.Empty(component.FindAll("#manage-select-button")); - } - - [Fact] - public void Render_SelectButton_PresentByDefaultWithSelectLabel() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - - var component = Render(); - - var selectBtn = component.Find("#manage-select-button"); - Assert.Equal("false", selectBtn.GetAttribute("aria-pressed")); - Assert.Contains("Select", selectBtn.TextContent); - Assert.DoesNotContain("Done", selectBtn.TextContent); - } - - [Fact] - public void Render_TrashButton_NotPresentInAnyRow() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; - - var component = Render(); - - Assert.Empty(component.FindAll(".db-entry-remove-btn")); - } - - [Fact] - public async Task RestoreFromBackup_ServiceReturnsFalse_DoesNotSetRestorationFlag() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.UpgradeRequired, backupExists: true)]; - _databaseService.RestoreFromBackupReturnValue = false; - var component = Render(); - - await component.InvokeAsync(() => - ((Task)component.Instance.GetType() - .GetMethod("RestoreFromBackup", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(component.Instance, [_databaseService.Entries[0]])!)); - - Assert.Equal(1, _databaseService.RestoreFromBackupCalls); - Assert.False(component.Instance.HasDatabaseStateChanged); - _announcementService.Received(1).Announce(Arg.Is(s => s.Contains("Could not restore"))); - _announcementService.DidNotReceive().Announce(Arg.Is(s => s.StartsWith("Restored "))); - } - - [Fact] - public async Task RestoreFromBackup_ServiceReturnsTrue_SetsStickyFlag_AndAnnouncesSuccess() - { - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.UpgradeRequired, backupExists: true)]; - _databaseService.RestoreFromBackupReturnValue = true; - var component = Render(); - - await component.InvokeAsync(() => - ((Task)component.Instance.GetType() - .GetMethod("RestoreFromBackup", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(component.Instance, [_databaseService.Entries[0]])!)); - - Assert.Equal(1, _databaseService.RestoreFromBackupCalls); - Assert.True(component.Instance.HasDatabaseStateChanged); - _announcementService.Received(1).Announce(Arg.Is(s => s.StartsWith("Restored "))); - } - - [Fact] - public async Task RestoreFromBackup_WhileUpgradeInFlight_DoesNotInvokeService() - { - // Prevents the race where DatabaseRecoveryService.RestoreFromBackupAsync triggers - // ClassifyEntriesAsync, deleting a .upgrade.bak that an in-flight upgrade still needs. - _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.UpgradeRequired, backupExists: true)]; - _coordinator.IsAnyUpgradeInFlight.Returns(true); - var component = Render(); - - await component.InvokeAsync(() => - ((Task)component.Instance.GetType() - .GetMethod("RestoreFromBackup", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(component.Instance, [_databaseService.Entries[0]])!)); - - Assert.Equal(0, _databaseService.RestoreFromBackupCalls); - _announcementService.Received(1).Announce(Arg.Is(s => s.Contains("Cannot restore"))); - } - - [Fact] - public void RowReceivesNullUpgradeProgress_WhenNeitherSlotMatchesFileName() - { - var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); - _databaseService.Entries = [entry]; - _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress(currentEntryName: "different.db")); - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: "another.db", - scope: UpgradeProgressScope.Background)); - - var component = Render(); - - Assert.Empty(component.FindAll(".db-entry-upgrading-text")); - Assert.Empty(component.FindAll(".db-entry-cancel-btn")); - } - - [Fact] - public void RowReceivesUpgradeProgress_WhenBackgroundSlotMatches() - { - var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); - _databaseService.Entries = [entry]; - _progressBannerService.BackgroundProgress.Returns(MakeProgress( - currentEntryName: "b.db", - currentBatchSize: 3, - currentBatchPosition: 2, - scope: UpgradeProgressScope.Background)); - _coordinator.IsUpgradeInFlight("b.db").Returns(false); - - var component = Render(); - - var text = component.Find(".db-entry-upgrading-text"); - Assert.Contains("Migrating schema 2 of 3", text.TextContent); - } - - [Fact] - public void RowReceivesUpgradeProgress_WhenManageDatabasesSlotMatches() - { - var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); - _databaseService.Entries = [entry]; - _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress( - currentEntryName: "a.db", - currentBatchSize: 2, - currentBatchPosition: 1)); - _coordinator.IsUpgradeInFlight("a.db").Returns(true); - - var component = Render(); - - var text = component.Find(".db-entry-upgrading-text"); - Assert.Contains("Migrating schema 1 of 2", text.TextContent); - } - - [Fact] - public async Task SelectAll_AddsEveryEntryToSelection() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("c.db", isEnabled: false, status: DatabaseStatus.UpgradeFailed)]; - var component = Render(); - await EnterSelectionModeAsync(component); - - // Master checkbox: 0 selected → click selects all. - var masterBtn = component.Find(".manage-databases-master-btn"); - await component.InvokeAsync(() => masterBtn.Click()); - - Assert.Contains("3 selected", component.Find(".manage-databases-bulk-count").TextContent); - Assert.Contains("Upgrade 2", component.Find(".manage-databases-bulk-strip .button-green").TextContent); - } - - [Fact] - public async Task SingleRowRemove_DuringCoordinatorUpgrade_DualSignal_AcceptanceLabelMentionsCancel() - { - var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); - _databaseService.Entries = [entry]; - - _coordinator.IsUpgradeInFlight("a.db").Returns(true); - _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress( - currentEntryName: "a.db", - scope: UpgradeProgressScope.ManageDatabasesTriggered)); - - var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; - var component = RenderWithAlertSurface(alertSurface); - - await InvokeRemoveDatabaseAsync(component, entry); - - var captured = Assert.Single(alertSurface.Requests); - Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ToggleSelection_AddsToSelection_RevealsBulkStrip() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); + await component.InvokeAsync(() => bulkRemove.Click()); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("a.db", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BulkRemove_LogsReopened_CallsConsumeReopenedAsBaseline() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: true, status: DatabaseStatus.Ready)]; + + _coordinator.RemoveDatabaseAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, true)); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + UpgradeBatchId.Create(), + new UpgradeBatchResult(["a.db"], [], []), + wasCancelled: false)); + Assert.True(component.Instance.HasDatabaseStateChanged); + await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); - - Assert.True(component.Instance.HasBulkSelection); - Assert.Single(component.FindAll(".manage-databases-bulk-strip")); - Assert.Contains("1 selected", component.Find(".manage-databases-bulk-count").TextContent); - } - - [Fact] - public async Task ToggleSelection_OnRemovedEntry_PrunedFromSelection() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); + await component.InvokeAsync(() => bulkRemove.Click()); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public async Task BulkRemove_PartialFailure_KeepsFailedSelected_StaysInSelectionMode() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _coordinator.RemoveDatabaseAsync("a.db", Arg.Any>>(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("disk full")); + _coordinator.RemoveDatabaseAsync("b.db", Arg.Any>>(), Arg.Any()) + .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - Assert.Contains("2 selected", component.Find(".manage-databases-bulk-count").TextContent); - - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - _databaseService.RaiseEntriesChanged(); - await component.InvokeAsync(() => { }); - - Assert.True(component.Instance.HasBulkSelection); - Assert.Contains("1 selected", component.Find(".manage-databases-bulk-count").TextContent); - } - - [Fact] - public async Task ToggleSelection_TwiceOnSameRow_RemovesFromSelection() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); + await component.InvokeAsync(() => bulkRemove.Click()); + + Assert.True(component.Instance.IsInSelectionMode); + Assert.True(component.Instance.HasBulkSelection); + Assert.Equal("true", component.Find("#manage-select-button").GetAttribute("aria-pressed")); + } + + [Fact] + public async Task BulkRemove_PerFileFailure_AnnouncesFirstFailureDetail() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _coordinator.RemoveDatabaseAsync("a.db", Arg.Any>>(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("disk full")); + _coordinator.RemoveDatabaseAsync("b.db", Arg.Any>>(), Arg.Any()) + .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); - Assert.True(component.Instance.HasBulkSelection); - - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); - - Assert.False(component.Instance.HasBulkSelection); - Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); - } - - [Fact] - public async Task ToggleSelection_UpdatesAriaLiveAnnouncement() - { - _databaseService.Entries = [ - Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), - Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - var liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); - Assert.Equal(string.Empty, liveRegion.TextContent.Trim()); - - await EnterSelectionModeAsync(component); - var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); - await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); - liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); - Assert.Contains("1", liveRegion.TextContent); - Assert.Contains("selected", liveRegion.TextContent, StringComparison.OrdinalIgnoreCase); - - await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); - liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); - Assert.Contains("2", liveRegion.TextContent); - } - - [Fact] - public async Task ToggleSelectionMode_EntersMode_RevealsCheckboxes_ChangesLabelToDone() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - var wrapper = component.Find(".db-entry-checkbox"); - Assert.DoesNotContain("db-entry-checkbox--visible", wrapper.GetAttribute("class") ?? string.Empty); - - await EnterSelectionModeAsync(component); - - var selectBtn = component.Find("#manage-select-button"); - Assert.Equal("true", selectBtn.GetAttribute("aria-pressed")); - Assert.Contains("Done", selectBtn.TextContent); - - wrapper = component.Find(".db-entry-checkbox"); - Assert.Contains("db-entry-checkbox--visible", wrapper.GetAttribute("class") ?? string.Empty); - } - - [Fact] - public async Task ToggleSelectionMode_Exits_ClearsSelection() - { - _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; - var component = Render(); - - await EnterSelectionModeAsync(component); - await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") - .ChangeAsync(new ChangeEventArgs { Value = true })); - Assert.True(component.Instance.HasBulkSelection); - - await EnterSelectionModeAsync(component); - - Assert.False(component.Instance.HasBulkSelection); - var selectBtn = component.Find("#manage-select-button"); - Assert.Equal("false", selectBtn.GetAttribute("aria-pressed")); - } - - private static async Task EnterSelectionModeAsync(IRenderedComponent component) - { - await component.InvokeAsync(() => component.Find("#manage-select-button").Click()); - } - - private static DatabaseEntry Entry(string fileName, bool isEnabled, DatabaseStatus status, bool backupExists = false) => - new(fileName, $@"C:\dbs\{fileName}", isEnabled, status, backupExists); - - private static Task InvokeCancelUpgradesAsync( - IRenderedComponent component, - IReadOnlyList fileNames) - { - // Reflection bypasses the bunit click + alert-surface chain so the test can deterministically - // interleave the batch-completion signal with the await on pendingBatches. - var method = typeof(ManageDatabasesTab).GetMethod( - "CancelUpgradesAndAwaitCompletionAsync", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); - return (Task)method!.Invoke(component.Instance, [fileNames])!; - } - - private static Task InvokeRemoveDatabaseAsync( - IRenderedComponent component, - DatabaseEntry entry) - { - // Single-row remove is now triggered via the right-click context menu; invoke - // the private path directly to keep tests focused on remove semantics. - var method = typeof(ManageDatabasesTab).GetMethod( - "RemoveDatabase", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); - return component.InvokeAsync(() => (Task)method!.Invoke(component.Instance, [entry])!); - } - - private static BannerProgressEntry MakeProgress( - string currentEntryName = "a.db", - UpgradePhase currentPhase = UpgradePhase.MigratingSchema, - int currentBatchPosition = 1, - int currentBatchSize = 1, - int queuedBatchesAfter = 0, - UpgradeProgressScope scope = UpgradeProgressScope.ManageDatabasesTriggered, - UpgradeBatchId? batchId = null, - Action? cancel = null, - IReadOnlySet? batchFileNames = null) => - new( - batchId ?? UpgradeBatchId.Create(), - scope, - currentBatchPosition, - currentBatchSize, - currentEntryName, - currentPhase, - queuedBatchesAfter, - cancel ?? (() => { })) - { - BatchFileNames = batchFileNames ?? FrozenSet.Empty - }; - - private IRenderedComponent RenderWithAlertSurface(IInlineAlertSurface alertSurface) => - Render(parameters => parameters - .AddCascadingValue(alertSurface)); -} + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var bulkRemove = component.Find(".manage-databases-bulk-strip .button-red"); + await component.InvokeAsync(() => bulkRemove.Click()); + + _announcementService.Received().Announce(Arg.Is(s => s != null && s.Contains("a.db") && s.Contains("disk full"))); + } + + [Fact] + public async Task BulkUpgrade_AllEligible_CallsCoordinatorWithEntireBatch() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), + Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeFailed)]; + _coordinator.UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new UpgradeBatchResult(Succeeded: ["a.db", "b.db"], Cancelled: [], Failed: [])); + + var component = Render(); + await EnterSelectionModeAsync(component); + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); + await component.InvokeAsync(() => upgradeBtn.Click()); + + await _coordinator.Received(1).UpgradeDatabasesAsync( + Arg.Is>(l => l != null && l.Count == 2 && l.Contains("a.db") && l.Contains("b.db")), + UpgradeProgressScope.ManageDatabasesTriggered, + Arg.Any()); + } + + [Fact] + public async Task BulkUpgrade_ButtonAppears_OnlyWhenEligibleRowSelected() + { + _databaseService.Entries = [ + Entry("ready.db", isEnabled: true, status: DatabaseStatus.Ready), + Entry("needs.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; + var component = Render(); + await EnterSelectionModeAsync(component); + + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + Assert.Empty(component.FindAll(".manage-databases-bulk-strip .button-green")); + + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); + Assert.Contains("Upgrade 1", upgradeBtn.TextContent); + } + + [Fact] + public async Task BulkUpgrade_CleanSuccess_AutoExitsSelectionMode() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; + _coordinator.UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new UpgradeBatchResult(Succeeded: ["a.db"], Cancelled: [], Failed: [])); + + var component = Render(); + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + + var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); + await component.InvokeAsync(() => upgradeBtn.Click()); + + Assert.False(component.Instance.HasBulkSelection); + Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); + } + + [Fact] + public async Task BulkUpgrade_GateDenied_CoordinatorReturnsNull_ShowsInfoAlert() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; + _coordinator.UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns((UpgradeBatchResult?)null); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + + var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); + await component.InvokeAsync(() => upgradeBtn.Click()); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Equal("Upgrade not started", captured.Title); + Assert.Contains("already in progress", captured.Message); + } + + [Fact] + public async Task BulkUpgrade_GateDenied_StaysInSelectionMode() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; + _coordinator.UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns((UpgradeBatchResult?)null); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + + await component.InvokeAsync(() => component.Find(".manage-databases-bulk-strip .button-green").Click()); + + Assert.True(component.Instance.HasBulkSelection); + } + + [Fact] + public async Task BulkUpgrade_MixedSelection_CancelledPrompt_DoesNotCallCoordinator() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await EnterSelectionModeAsync(component); + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); + await component.InvokeAsync(() => upgradeBtn.Click()); + + await _coordinator.DidNotReceive().UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task BulkUpgrade_MixedSelection_ShowsSubsetConfirm_AcceptedRunsBatchWithEligibleOnly() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _coordinator.UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new UpgradeBatchResult(Succeeded: ["a.db"], Cancelled: [], Failed: [])); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await EnterSelectionModeAsync(component); + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); + await component.InvokeAsync(() => upgradeBtn.Click()); + + var prompt = Assert.Single(alertSurface.Requests); + Assert.Contains("1 of 2", prompt.Message); + Assert.Contains("b.db", prompt.Message); + Assert.Contains("Already up to date", prompt.Message); + Assert.Equal("Upgrade 1", prompt.AcceptLabel); + + await _coordinator.Received(1).UpgradeDatabasesAsync( + Arg.Is>(l => l != null && l.Count == 1 && l.Contains("a.db")), + UpgradeProgressScope.ManageDatabasesTriggered, + Arg.Any()); + } + + [Fact] + public async Task BulkUpgrade_PartialFailure_StaysInSelectionMode() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), + Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; + _coordinator.UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new UpgradeBatchResult( + Succeeded: ["a.db"], + Cancelled: [], + Failed: [new UpgradeFailure("b.db", "boom")])); + + var component = Render(); + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".manage-databases-master-btn").Click()); + + var upgradeBtn = component.Find(".manage-databases-bulk-strip .button-green"); + await component.InvokeAsync(() => upgradeBtn.Click()); + + Assert.True(component.Instance.HasBulkSelection); + Assert.Equal("true", component.Find("#manage-select-button").GetAttribute("aria-pressed")); + } + + [Fact] + public async Task BulkUpgrade_ZeroEligible_UpgradeButtonHidden() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + var component = Render(); + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + + Assert.Empty(component.FindAll(".manage-databases-bulk-strip .button-green")); + + await _coordinator.DidNotReceive().UpgradeDatabasesAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task CancelThenRemove_DuringPreProgressWindow_CancelsActualBatchAndProceeds() + { + var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + var batchId = UpgradeBatchId.Create(); + var cancelCalled = false; + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: string.Empty, + currentPhase: UpgradePhase.BackingUp, + scope: UpgradeProgressScope.Background, + batchId: batchId, + cancel: () => cancelCalled = true, + batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); + + var component = Render(); + + var cancelTask = InvokeCancelUpgradesAsync(component, ["b.db"]); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.True(cancelCalled); + Assert.False(cancelTask.IsCompleted); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + batchId, + new UpgradeBatchResult([], ["b.db"], []), + wasCancelled: true)); + + await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CancelThenRemove_FileNotYetReachedInActiveBatch_CancelsBatchAndProceeds() + { + var entry = Entry("c.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + var batchId = UpgradeBatchId.Create(); + var cancelCalled = false; + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: "a.db", + currentPhase: UpgradePhase.MigratingSchema, + scope: UpgradeProgressScope.Background, + batchId: batchId, + cancel: () => cancelCalled = true, + batchFileNames: new[] { "a.db", "b.db", "c.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); + + var component = Render(); + + var cancelTask = InvokeCancelUpgradesAsync(component, ["c.db"]); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.True(cancelCalled); + Assert.False(cancelTask.IsCompleted); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + batchId, + new UpgradeBatchResult([], ["c.db"], []), + wasCancelled: true)); + + await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CancelThenRemove_PreProgressWindow_AwaitsUpgradeBatchCompletedBeforeRemoveAndProceedsPromptly() + { + var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + var batchId = UpgradeBatchId.Create(); + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: string.Empty, + currentPhase: UpgradePhase.BackingUp, + scope: UpgradeProgressScope.Background, + batchId: batchId, + batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); + + var component = Render(); + + var cancelTask = InvokeCancelUpgradesAsync(component, ["b.db"]); + + await Task.Delay(100, TestContext.Current.CancellationToken); + + Assert.False(cancelTask.IsCompleted); + + var beforeRaise = DateTime.UtcNow; + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + batchId, + new UpgradeBatchResult([], ["b.db"], []), + wasCancelled: true)); + + await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + var elapsed = DateTime.UtcNow - beforeRaise; + // Must complete well under s_cancelTimeout (30s) — otherwise a hang regression would pass. + Assert.True(elapsed < TimeSpan.FromSeconds(2), $"Cancel flow took {elapsed} after batch completion (should be <2s)."); + } + + [Fact] + public async Task CancelThenRemove_QueuedBatchFile_AwaitsConsumerPickupAndCompletionBeforeRemove() + { + var entry = Entry("c.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + var batchId = UpgradeBatchId.Create(); + var cancelCalled = false; + _databaseService.QueuedBatchesForTest = [new QueuedBatchInfo( + batchId, + UpgradeProgressScope.Background, + new[] { "c.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase), + () => cancelCalled = true)]; + + var component = Render(); + + var cancelTask = InvokeCancelUpgradesAsync(component, ["c.db"]); + + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.True(cancelCalled); + Assert.False(cancelTask.IsCompleted); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + batchId, + new UpgradeBatchResult([], ["c.db"], []), + wasCancelled: true)); + + await cancelTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ClearSelection_EmptiesSet_HidesBulkStrip() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await EnterSelectionModeAsync(component); + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + Assert.Contains("2 selected", component.Find(".manage-databases-bulk-count").TextContent); + + // Master checkbox in the selection header clears when all are selected. + var masterBtn = component.Find(".manage-databases-master-btn"); + await component.InvokeAsync(() => masterBtn.Click()); + + Assert.False(component.Instance.HasBulkSelection); + Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); + } + + [Fact] + public async Task ClearSelection_UpdatesAriaLiveAnnouncement() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + var liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.NotEqual(string.Empty, liveRegion.TextContent.Trim()); + + // With 1 of 1 selected, master is "all"; clicking clears. + var masterBtn = component.Find(".manage-databases-master-btn"); + await component.InvokeAsync(() => masterBtn.Click()); + + liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.DoesNotContain("selected", liveRegion.TextContent, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ConfirmationMessage_WhenNoActiveLogs_DoesNotIncludeCloseReopenWarning() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + _logReloadCoordinator.HasActiveLogs.Returns(false); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await InvokeRemoveDatabaseAsync(component, _databaseService.Entries[0]); + + var captured = Assert.Single(alertSurface.Requests); + Assert.DoesNotContain("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ConfirmationMessage_WhenRemovingActiveDbWithOpenLogs_IncludesCloseReopenWarning() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + _logReloadCoordinator.HasActiveLogs.Returns(true); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await InvokeRemoveDatabaseAsync(component, _databaseService.Entries[0]); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ConfirmationMessage_WhenRemovingDisabledDb_DoesNotIncludeCloseReopenWarning() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _logReloadCoordinator.HasActiveLogs.Returns(true); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await InvokeRemoveDatabaseAsync(component, _databaseService.Entries[0]); + + var captured = Assert.Single(alertSurface.Requests); + Assert.DoesNotContain("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DisposeAsync_UnsubscribesAllEvents() + { + var component = Render(); + + Assert.Equal(1, _databaseService.EntriesChangedHandlerCount); + Assert.Equal(1, _databaseService.UpgradeBatchCompletedHandlerCount); + + await component.InvokeAsync(() => component.Instance.DisposeAsync().AsTask()); + + Assert.Equal(0, _databaseService.EntriesChangedHandlerCount); + Assert.Equal(0, _databaseService.UpgradeBatchCompletedHandlerCount); + } + + [Fact] + public async Task EntriesChanged_DuringSelection_EmptyList_AutoExitsSelectionMode() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + await EnterSelectionModeAsync(component); + Assert.Equal("true", component.Find("#manage-select-button").GetAttribute("aria-pressed")); + + _databaseService.Entries = []; + _databaseService.RaiseEntriesChanged(); + await component.InvokeAsync(() => { }); + + Assert.False(component.Instance.IsInSelectionMode); + Assert.Empty(component.FindAll("#manage-select-button")); + } + + [Fact] + public async Task ExitSelectionModeWithFocusAsync_WhenInSelectionMode_ExitsAndClearsSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + Assert.True(component.Instance.HasBulkSelection); + + await component.InvokeAsync(() => component.Instance.ExitSelectionModeWithFocusAsync()); + + Assert.False(component.Instance.HasBulkSelection); + Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); + } + + [Fact] + public async Task ExitSelectionModeWithFocusAsync_WhenNotInSelectionMode_IsNoOp() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await component.InvokeAsync(() => component.Instance.ExitSelectionModeWithFocusAsync()); + + Assert.Equal("false", component.Find("#manage-select-button").GetAttribute("aria-pressed")); + } + + [Fact] + public async Task GetUpgradeProgressForEntry_Display_OnlyMatchesCurrentEntryName_NotBatchFileNames() + { + var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); + _databaseService.Entries = [entry]; + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: string.Empty, + currentPhase: UpgradePhase.BackingUp, + scope: UpgradeProgressScope.Background, + batchFileNames: new[] { "b.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase))); + + var component = Render(); + + Assert.Empty(component.FindAll(".db-entry-upgrading-text")); + await Task.CompletedTask; + } + + [Fact] + public void HasDatabaseStateChanged_AfterEnabledEntryRemovedFromActiveSet_True() + { + // Active set was {a}; mutate underlying entries so set becomes {}; flag computed via diff. + var entry = Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + var component = Render(); + + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + + Assert.True(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public async Task HasDatabaseStateChanged_AfterImportNoChanges_False() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + _coordinator.ImportAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(ImportOutcome.None); + var component = Render(); + + var importBtn = component.Find("#manage-import-button"); + await component.InvokeAsync(() => importBtn.ClickAsync(new MouseEventArgs())); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public async Task HasDatabaseStateChanged_AfterImportSuccess_True_ViaStickyFlag() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + _coordinator.ImportAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new ImportOutcome(ImportedCount: 1, Failures: [], UpgradeFailures: [])); + var component = Render(); + + var importBtn = component.Find("#manage-import-button"); + await component.InvokeAsync(() => importBtn.ClickAsync(new MouseEventArgs())); + + Assert.True(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public void HasDatabaseStateChanged_AfterUpgradeCancelledWithNoSucceeded_False() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + var component = Render(); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult([], ["a.db"], []), wasCancelled: true)); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public void HasDatabaseStateChanged_AfterUpgradeFailedWithNoSucceeded_False() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + var component = Render(); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + UpgradeBatchId.Create(), + new UpgradeBatchResult([], [], [new UpgradeFailure("a.db", "boom")]), + wasCancelled: false)); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public void HasDatabaseStateChanged_AfterUpgradeSuccess_True_ViaStickyFlag() + { + var entry = Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + var component = Render(); + + // Schema migrated in place — entry stays in the active set, but the underlying data changed. + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult(["a.db"], [], []), wasCancelled: false)); + + Assert.True(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public void HasDatabaseStateChanged_InitialState_False() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + + var component = Render(); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public void HasDatabaseStateChanged_PartiallySucceededWasCancelled_True() + { + // Multi-file batch: A succeeds, B is cancelled mid-flight. A's schema is permanently migrated; + // flag must fire even though args.WasCancelled is true. + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + var component = Render(); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + UpgradeBatchId.Create(), + new UpgradeBatchResult(["a.db"], ["b.db"], []), + wasCancelled: true)); + + Assert.True(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public void HasDatabaseStateChanged_UpgradeSucceededForDisabledEntry_False() + { + // Background-scope upgrades of disabled (non-active) DBs must not trigger the reload prompt: + // the file isn't in the active resolver set, so open logs aren't affected. + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult(["a.db"], [], []), wasCancelled: false)); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public void IsUpgradeInFlight_TracksCoordinator() + { + _coordinator.IsAnyUpgradeInFlight.Returns(true); + var component = Render(); + + Assert.True(component.Instance.IsUpgradeInFlight); + } + + [Fact] + public async Task MasterCheckbox_AllSelected_ShowsCheckedIconAndClearLabel() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + + var btn = component.Find(".manage-databases-master-btn"); + var icon = component.Find(".manage-databases-master-btn i"); + Assert.Contains("bi-check-square-fill", icon.GetAttribute("class") ?? string.Empty); + Assert.Equal("Clear selection", btn.GetAttribute("aria-label")); + } + + [Fact] + public async Task MasterCheckbox_Indeterminate_WhenSomeButNotAllSelected() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.FindAll(".db-entry-row input[type='checkbox']")[0] + .ChangeAsync(new ChangeEventArgs { Value = true })); + + var icon = component.Find(".manage-databases-master-btn i"); + Assert.Contains("bi-dash-square-fill", icon.GetAttribute("class") ?? string.Empty); + Assert.Equal("Select all", component.Find(".manage-databases-master-btn").GetAttribute("aria-label")); + } + + [Fact] + public async Task MasterCheckbox_ShowsAfterEnteringSelectionMode_BeforeAnySelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + Assert.Empty(component.FindAll(".manage-databases-selection-header")); + + await EnterSelectionModeAsync(component); + + var icon = component.Find(".manage-databases-master-btn i"); + Assert.Contains("bi-square", icon.GetAttribute("class") ?? string.Empty); + Assert.Contains("0 of 1 selected", component.Find(".manage-databases-selection-count").TextContent); + } + + [Fact] + public async Task RebaselineActiveSnapshotOnly_PreservesStickyFlags() + { + // Modal opens mid-classification (empty snapshot), user-initiated upgrade succeeds during the + // wait (_schemaUpgradeOccurred=true), classification completes. The snapshot rebaselines but + // the sticky flag must survive so the close-time reload prompt still fires. + var tcs = new TaskCompletionSource(); + _databaseService.InitialClassificationTask = tcs.Task; + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + var component = Render(); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs(UpgradeBatchId.Create(), new UpgradeBatchResult(["a.db"], [], []), wasCancelled: false)); + + Assert.True(component.Instance.HasDatabaseStateChanged); + + tcs.SetResult(); + await Task.Delay(50, TestContext.Current.CancellationToken); + + Assert.True(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public async Task Remove_FileInQueuedBatch_IsAnyFileUpgradingReturnsTrue() + { + var entry = Entry("c.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + _databaseService.QueuedBatchesForTest = [new QueuedBatchInfo( + UpgradeBatchId.Create(), + UpgradeProgressScope.Background, + new[] { "c.db" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase), + () => { })]; + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await InvokeRemoveDatabaseAsync(component, entry); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Render_BulkStrip_HiddenWhenNoSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + + var component = Render(); + + Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); + Assert.False(component.Instance.HasBulkSelection); + } + + [Fact] + public void Render_ClassificationPendingNotice_WhenClassificationInProgress() + { + _databaseService.Entries = []; + _databaseService.InitialClassificationTask = new TaskCompletionSource().Task; + + var component = Render(); + + Assert.Single(component.FindAll(".manage-status-banner--info")); + Assert.Empty(component.FindAll(".manage-databases-empty")); + } + + [Fact] + public void Render_DoesNotRenderRemovedSharedProgressBanner() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; + _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress(currentEntryName: "a.db")); + + var component = Render(); + + Assert.Empty(component.FindAll("aside.manage-databases-upgrade-banner")); + } + + [Fact] + public void Render_EmptyState_WhenNoEntries_AndClassificationComplete() + { + _databaseService.Entries = []; + _databaseService.InitialClassificationTask = Task.CompletedTask; + + var component = Render(); + + Assert.Single(component.FindAll(".manage-databases-empty")); + } + + [Fact] + public void Render_HasPendingChanges_False_HidesSaveStrip() + { + var component = Render(); + + Assert.Empty(component.FindAll(".manage-databases-save-strip")); + } + + [Fact] + public void Render_ImportButton_Present() + { + var component = Render(); + + Assert.Single(component.FindAll("#manage-import-button")); + } + + [Fact] + public void Render_PersistentAriaLiveRegion_PresentRegardlessOfSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + + var component = Render(); + + var statusRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.NotNull(statusRegion); + Assert.Equal("true", statusRegion.GetAttribute("aria-atomic")); + } + + [Fact] + public void Render_SelectButton_HiddenWhenNoEntries() + { + _databaseService.Entries = []; + + var component = Render(); + + Assert.Empty(component.FindAll("#manage-select-button")); + } + + [Fact] + public void Render_SelectButton_PresentByDefaultWithSelectLabel() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + + var component = Render(); + + var selectBtn = component.Find("#manage-select-button"); + Assert.Equal("false", selectBtn.GetAttribute("aria-pressed")); + Assert.Contains("Select", selectBtn.TextContent); + Assert.DoesNotContain("Done", selectBtn.TextContent); + } + + [Fact] + public void Render_TrashButton_NotPresentInAnyRow() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired)]; + + var component = Render(); + + Assert.Empty(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public async Task RestoreFromBackup_ServiceReturnsFalse_DoesNotSetRestorationFlag() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.UpgradeRequired, backupExists: true)]; + _databaseService.RestoreFromBackupReturnValue = false; + var component = Render(); + + await component.InvokeAsync(() => + ((Task)component.Instance.GetType() + .GetMethod("RestoreFromBackup", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(component.Instance, [_databaseService.Entries[0]])!)); + + Assert.Equal(1, _databaseService.RestoreFromBackupCalls); + Assert.False(component.Instance.HasDatabaseStateChanged); + _announcementService.Received(1).Announce(Arg.Is(s => s != null && s.Contains("Could not restore"))); + _announcementService.DidNotReceive().Announce(Arg.Is(s => s != null && s.StartsWith("Restored "))); + } + + [Fact] + public async Task RestoreFromBackup_ServiceReturnsTrue_SetsStickyFlag_AndAnnouncesSuccess() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.UpgradeRequired, backupExists: true)]; + _databaseService.RestoreFromBackupReturnValue = true; + var component = Render(); + + await component.InvokeAsync(() => + ((Task)component.Instance.GetType() + .GetMethod("RestoreFromBackup", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(component.Instance, [_databaseService.Entries[0]])!)); + + Assert.Equal(1, _databaseService.RestoreFromBackupCalls); + Assert.True(component.Instance.HasDatabaseStateChanged); + _announcementService.Received(1).Announce(Arg.Is(s => s != null && s.StartsWith("Restored "))); + } + + [Fact] + public async Task RestoreFromBackup_WhileUpgradeInFlight_DoesNotInvokeService() + { + // Prevents the race where DatabaseRecoveryService.RestoreFromBackupAsync triggers + // ClassifyEntriesAsync, deleting a .upgrade.bak that an in-flight upgrade still needs. + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.UpgradeRequired, backupExists: true)]; + _coordinator.IsAnyUpgradeInFlight.Returns(true); + var component = Render(); + + await component.InvokeAsync(() => + ((Task)component.Instance.GetType() + .GetMethod("RestoreFromBackup", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(component.Instance, [_databaseService.Entries[0]])!)); + + Assert.Equal(0, _databaseService.RestoreFromBackupCalls); + _announcementService.Received(1).Announce(Arg.Is(s => s != null && s.Contains("Cannot restore"))); + } + + [Fact] + public void RowReceivesNullUpgradeProgress_WhenNeitherSlotMatchesFileName() + { + var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); + _databaseService.Entries = [entry]; + _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress(currentEntryName: "different.db")); + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: "another.db", + scope: UpgradeProgressScope.Background)); + + var component = Render(); + + Assert.Empty(component.FindAll(".db-entry-upgrading-text")); + Assert.Empty(component.FindAll(".db-entry-cancel-btn")); + } + + [Fact] + public void RowReceivesUpgradeProgress_WhenBackgroundSlotMatches() + { + var entry = Entry("b.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); + _databaseService.Entries = [entry]; + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: "b.db", + currentBatchSize: 3, + currentBatchPosition: 2, + scope: UpgradeProgressScope.Background)); + _coordinator.IsUpgradeInFlight("b.db").Returns(false); + + var component = Render(); + + var text = component.Find(".db-entry-upgrading-text"); + Assert.Contains("Migrating schema 2 of 3", text.TextContent); + } + + [Fact] + public void RowReceivesUpgradeProgress_WhenManageDatabasesSlotMatches() + { + var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); + _databaseService.Entries = [entry]; + _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress( + currentEntryName: "a.db", + currentBatchSize: 2, + currentBatchPosition: 1)); + _coordinator.IsUpgradeInFlight("a.db").Returns(true); + + var component = Render(); + + var text = component.Find(".db-entry-upgrading-text"); + Assert.Contains("Migrating schema 1 of 2", text.TextContent); + } + + [Fact] + public async Task SelectAll_AddsEveryEntryToSelection() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("c.db", isEnabled: false, status: DatabaseStatus.UpgradeFailed)]; + var component = Render(); + await EnterSelectionModeAsync(component); + + // Master checkbox: 0 selected → click selects all. + var masterBtn = component.Find(".manage-databases-master-btn"); + await component.InvokeAsync(() => masterBtn.Click()); + + Assert.Contains("3 selected", component.Find(".manage-databases-bulk-count").TextContent); + Assert.Contains("Upgrade 2", component.Find(".manage-databases-bulk-strip .button-green").TextContent); + } + + [Fact] + public async Task SingleRowRemove_DuringCoordinatorUpgrade_DualSignal_AcceptanceLabelMentionsCancel() + { + var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); + _databaseService.Entries = [entry]; + + _coordinator.IsUpgradeInFlight("a.db").Returns(true); + _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress( + currentEntryName: "a.db", + scope: UpgradeProgressScope.ManageDatabasesTriggered)); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await InvokeRemoveDatabaseAsync(component, entry); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ToggleSelection_AddsToSelection_RevealsBulkStrip() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + + Assert.True(component.Instance.HasBulkSelection); + Assert.Single(component.FindAll(".manage-databases-bulk-strip")); + Assert.Contains("1 selected", component.Find(".manage-databases-bulk-count").TextContent); + } + + [Fact] + public async Task ToggleSelection_OnRemovedEntry_PrunedFromSelection() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await EnterSelectionModeAsync(component); + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + Assert.Contains("2 selected", component.Find(".manage-databases-bulk-count").TextContent); + + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _databaseService.RaiseEntriesChanged(); + await component.InvokeAsync(() => { }); + + Assert.True(component.Instance.HasBulkSelection); + Assert.Contains("1 selected", component.Find(".manage-databases-bulk-count").TextContent); + } + + [Fact] + public async Task ToggleSelection_TwiceOnSameRow_RemovesFromSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + Assert.True(component.Instance.HasBulkSelection); + + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + + Assert.False(component.Instance.HasBulkSelection); + Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); + } + + [Fact] + public async Task ToggleSelection_UpdatesAriaLiveAnnouncement() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + var liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.Equal(string.Empty, liveRegion.TextContent.Trim()); + + await EnterSelectionModeAsync(component); + var checkboxes = component.FindAll(".db-entry-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.Contains("1", liveRegion.TextContent); + Assert.Contains("selected", liveRegion.TextContent, StringComparison.OrdinalIgnoreCase); + + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.Contains("2", liveRegion.TextContent); + } + + [Fact] + public async Task ToggleSelectionMode_EntersMode_RevealsCheckboxes_ChangesLabelToDone() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + var wrapper = component.Find(".db-entry-checkbox"); + Assert.DoesNotContain("db-entry-checkbox--visible", wrapper.GetAttribute("class") ?? string.Empty); + + await EnterSelectionModeAsync(component); + + var selectBtn = component.Find("#manage-select-button"); + Assert.Equal("true", selectBtn.GetAttribute("aria-pressed")); + Assert.Contains("Done", selectBtn.TextContent); + + wrapper = component.Find(".db-entry-checkbox"); + Assert.Contains("db-entry-checkbox--visible", wrapper.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public async Task ToggleSelectionMode_Exits_ClearsSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await EnterSelectionModeAsync(component); + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true })); + Assert.True(component.Instance.HasBulkSelection); + + await EnterSelectionModeAsync(component); + + Assert.False(component.Instance.HasBulkSelection); + var selectBtn = component.Find("#manage-select-button"); + Assert.Equal("false", selectBtn.GetAttribute("aria-pressed")); + } + + private static async Task EnterSelectionModeAsync(IRenderedComponent component) + { + await component.InvokeAsync(() => component.Find("#manage-select-button").Click()); + } + + private static DatabaseEntry Entry(string fileName, bool isEnabled, DatabaseStatus status, bool backupExists = false) => + new(fileName, $@"C:\dbs\{fileName}", isEnabled, status, backupExists); + + private static Task InvokeCancelUpgradesAsync( + IRenderedComponent component, + IReadOnlyList fileNames) + { + // Reflection bypasses the bunit click + alert-surface chain so the test can deterministically + // interleave the batch-completion signal with the await on pendingBatches. + var method = typeof(ManageDatabasesTab).GetMethod( + "CancelUpgradesAndAwaitCompletionAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + return (Task)method!.Invoke(component.Instance, [fileNames])!; + } + + private static Task InvokeRemoveDatabaseAsync( + IRenderedComponent component, + DatabaseEntry entry) + { + // Single-row remove is now triggered via the right-click context menu; invoke + // the private path directly to keep tests focused on remove semantics. + var method = typeof(ManageDatabasesTab).GetMethod( + "RemoveDatabase", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + return component.InvokeAsync(() => (Task)method!.Invoke(component.Instance, [entry])!); + } + + private static BannerProgressEntry MakeProgress( + string currentEntryName = "a.db", + UpgradePhase currentPhase = UpgradePhase.MigratingSchema, + int currentBatchPosition = 1, + int currentBatchSize = 1, + int queuedBatchesAfter = 0, + UpgradeProgressScope scope = UpgradeProgressScope.ManageDatabasesTriggered, + UpgradeBatchId? batchId = null, + Action? cancel = null, + IReadOnlySet? batchFileNames = null) => + new( + batchId ?? UpgradeBatchId.Create(), + scope, + currentBatchPosition, + currentBatchSize, + currentEntryName, + currentPhase, + queuedBatchesAfter, + cancel ?? (() => { })) + { + BatchFileNames = batchFileNames ?? FrozenSet.Empty + }; + + private IRenderedComponent RenderWithAlertSurface(IInlineAlertSurface alertSurface) => + Render(parameters => parameters + .AddCascadingValue(alertSurface)); +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs b/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs index 965ba80e..3c819ef2 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs @@ -102,7 +102,7 @@ public void CopyEventButton_InvokesClipboardWithEventText() cut.Find(".details-copy-event").Click(); - _clipboard.Received(1).CopyTextAsync(Arg.Is(text => text.Contains("LogonType: 3 (Network)"))); + _clipboard.Received(1).CopyTextAsync(Arg.Is(text => text != null && text.Contains("LogonType: 3 (Network)"))); } [Fact] diff --git a/tests/Unit/EventLogExpert.UI.Tests/FilterEditor/ScenarioClipboardExporterTests.cs b/tests/Unit/EventLogExpert.UI.Tests/FilterEditor/ScenarioClipboardExporterTests.cs index c73ea0e5..1ff0ddcb 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/FilterEditor/ScenarioClipboardExporterTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/FilterEditor/ScenarioClipboardExporterTests.cs @@ -35,7 +35,7 @@ public async Task AnnounceAsync_WhenSubstantiveWarnings_ShowsAlertNotAnnouncemen await _alertDialog.Received(1).ShowAlert( "Scenario JSON exported with warnings", - Arg.Is(message => message.Contains("single-row color guardrail")), + Arg.Is(message => message != null && message.Contains("single-row color guardrail")), "OK"); _announcements.DidNotReceive().Announce(Arg.Any()); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/FilterLibraryModalTests.cs b/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/FilterLibraryModalTests.cs index 6d44edbd..f464ecae 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/FilterLibraryModalTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/FilterLibraryModalTests.cs @@ -80,7 +80,7 @@ public void ApplyImportPreflight_AmbiguousMatches_AddsIncomingAsNewWithNormalize var existing = BuildSavedFilter("Existing"); var incoming = BuildSavedFilter("Incoming") with { Id = existing.Id, Tags = [" Alpha ", "alpha", "Beta"] }; LibraryEntry? added = null; - _commands.When(c => c.AddEntry(Arg.Any())).Do(call => added = call.Arg()); + _commands.When(c => c.AddEntry(Arg.Any())).Do(call => added = call.ArgAt(0)); var preflight = new ImportPreflight( [], [], @@ -114,7 +114,7 @@ public void ApplyImportPreflight_Replace_PreservesUserStateAndNormalizesTags() Tags = [" Alpha ", "alpha", "Beta"], }; LibraryEntry? updated = null; - _commands.When(c => c.UpdateEntry(Arg.Any())).Do(call => updated = call.Arg()); + _commands.When(c => c.UpdateEntry(Arg.Any())).Do(call => updated = call.ArgAt(0)); var preflight = new ImportPreflight( [], [(existing, incoming)], @@ -140,7 +140,7 @@ public void ApplyImportPreflight_Update_CoalescesDuplicateExistingMatches() var incomingA = BuildSavedFilter("IncomingA") with { Tags = [" Alpha "] }; var incomingB = BuildSavedFilter("IncomingB") with { Tags = ["Beta"] }; LibraryEntry? updated = null; - _commands.When(c => c.UpdateEntry(Arg.Any())).Do(call => updated = call.Arg()); + _commands.When(c => c.UpdateEntry(Arg.Any())).Do(call => updated = call.ArgAt(0)); var preflight = new ImportPreflight( [], [], @@ -266,7 +266,7 @@ public void FilterLibraryModal_RegistersAsInlineAlertHost() var component = Render(); - _modalCoordinator.Received().RegisterModal(Arg.Is(r => r.InlineAlertHost == component.Instance)); + _modalCoordinator.Received().RegisterModal(Arg.Is(r => r != null && r.InlineAlertHost == component.Instance)); } [Fact] @@ -307,7 +307,7 @@ public void OnInitialized_RegistersComponentAsIInlineAlertHost() var component = Render(); _modalCoordinator.Received(1).RegisterModal(Arg.Is(r => - r.InlineAlertHost == component.Instance)); + r != null && r.InlineAlertHost == component.Instance)); } [Fact] @@ -699,7 +699,7 @@ public async Task TagFilterBar_EscWithActiveTags_ClearsTagsAndVetoesClose() await bar.KeyDownAsync(new KeyboardEventArgs { Key = "Escape" }); Assert.Equal(2, component.Find("[role='tabpanel'].active").QuerySelectorAll(".library-entry").Length); - _announcements.Received().Announce(Arg.Is(s => s.Contains("Tag filters cleared"))); + _announcements.Received().Announce(Arg.Is(s => s != null && s.Contains("Tag filters cleared"))); } [Fact] @@ -712,7 +712,7 @@ public async Task TagFilterBar_EscWithNoActiveTags_DoesNotAnnounceClear() var bar = component.Find(".library-tag-filter-bar"); await bar.KeyDownAsync(new KeyboardEventArgs { Key = "Escape" }); - _announcements.DidNotReceive().Announce(Arg.Is(s => s.Contains("Tag filters cleared"))); + _announcements.DidNotReceive().Announce(Arg.Is(s => s != null && s.Contains("Tag filters cleared"))); } [Fact] diff --git a/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibraryEntryRowTests.cs b/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibraryEntryRowTests.cs index fa1d3164..a99bf067 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibraryEntryRowTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibraryEntryRowTests.cs @@ -117,7 +117,7 @@ public async Task DeleteOnFilterSet_ShowsConfirm() var items = await CapturedMoreMenuItemsAsync(component); await items.First(i => i.Label == "Delete").OnClickAsync!.Invoke(); - await _alerts.Received(1).ShowAlert("Delete from library?", Arg.Is(m => m.Contains("filter set 'P' with 2 filters")), "Delete", "Cancel"); + await _alerts.Received(1).ShowAlert("Delete from library?", Arg.Is(m => m != null && m.Contains("filter set 'P' with 2 filters")), "Delete", "Cancel"); } [Fact] @@ -226,7 +226,7 @@ public async Task FavoriteClick_InvokesOnToggleFavoriteWithNewState() Assert.NotNull(captured); Assert.Equal(entry.Id, captured.EntryId); Assert.True(captured.NewIsFavorite); - _announcements.Received(1).Announce(Arg.Is(s => s.Contains("Marked X as favorite"))); + _announcements.Received(1).Announce(Arg.Is(s => s != null && s.Contains("Marked X as favorite"))); } [Fact] @@ -392,7 +392,7 @@ public async Task RemovingInlineTagChip_DispatchesUpdateEntryWithoutThatTag() await component.Find("button[aria-label='Remove tag bug']").ClickAsync(new MouseEventArgs()); _commands.Received(1).SetEntryTags(entry.Id, Arg.Is>( - tags => tags.SequenceEqual(new[] { "perf" }))); + tags => tags != null && tags.SequenceEqual(new[] { "perf" }))); _announcements.Received(1).Announce("Removed tag 'bug' from X"); } @@ -459,7 +459,7 @@ public async Task SaveToLibrary_InvokesCallbackAndAnnounces() await items.First(i => i.Label == "Save to Library").OnClickAsync!.Invoke(); Assert.True(invoked); - _announcements.Received(1).Announce(Arg.Is(s => s.Contains("Saved X to library"))); + _announcements.Received(1).Announce(Arg.Is(s => s != null && s.Contains("Saved X to library"))); } [Fact] @@ -526,7 +526,7 @@ private async Task> CapturedMoreMenuItemsAsync(IRendered { IReadOnlyList? captured = null; _menuService.WhenForAnyArgs(s => s.OpenAt(0, 0, null!, false, false)) - .Do(call => captured = (IReadOnlyList)call[2]!); + .Do(call => captured = call.ArgAt>(2)); await component.Find("button[aria-label^='More actions for']").ClickAsync(new MouseEventArgs()); Assert.NotNull(captured); return captured; diff --git a/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibrarySavedTabHeaderTests.cs b/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibrarySavedTabHeaderTests.cs index 3b2389cb..9771da1b 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibrarySavedTabHeaderTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/FilterLibrary/LibrarySavedTabHeaderTests.cs @@ -145,7 +145,7 @@ public async Task ValidName_AndCommittedFilter_AddsEntryAndCollapses() Assert.NotNull(captured); Assert.Equal("My New", captured.Name); Assert.Equal(LibraryEntryOrigin.UserSaved, captured.Origin); - _announcements.Received().Announce(Arg.Is(s => s.Contains("My New"))); + _announcements.Received().Announce(Arg.Is(s => s != null && s.Contains("My New"))); Assert.NotNull(component.Find(".library-saved-tab-new-button")); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/FilterPane/FilterPaneTests.cs b/tests/Unit/EventLogExpert.UI.Tests/FilterPane/FilterPaneTests.cs index 45f92f6e..24914535 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/FilterPane/FilterPaneTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/FilterPane/FilterPaneTests.cs @@ -323,7 +323,7 @@ public async Task CopyScenario_ExportsOnlyEnabledRows() await copyButton.ClickAsync(new MouseEventArgs()); authoring.Received(1).ExportRows( - Arg.Is>(rows => rows.Count == 1 && rows[0].IsEnabled), + Arg.Is>(rows => rows != null && rows.Count == 1 && rows[0].IsEnabled), Arg.Any>()); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneGroupingTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneGroupingTests.cs index e5d7a510..d7b76a0b 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneGroupingTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneGroupingTests.cs @@ -174,7 +174,7 @@ public async Task Grouped_GroupContextMenu_CollapseAll_DispatchesCommand() .When(m => m.OpenAt( Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any())) - .Do(call => items = call.Arg>()); + .Do(call => items = call.ArgAt>(2)); var cut = RenderGrouped(Collapsed(), Event(1, "Alpha")); cut.Find("tr.group-header-row").TriggerEvent("oncontextmenu", new MouseEventArgs()); @@ -192,7 +192,7 @@ public async Task Grouped_GroupContextMenu_SelectGroup_SelectsGroupedEvents() .When(m => m.OpenAt( Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any())) - .Do(call => items = call.Arg>()); + .Do(call => items = call.ArgAt>(2)); var cut = RenderGrouped(Collapsed(), Event(1, "Alpha"), Event(2, "Alpha"), Event(3, "Beta")); cut.Find("tr.group-header-row").TriggerEvent("oncontextmenu", new MouseEventArgs()); @@ -200,7 +200,7 @@ public async Task Grouped_GroupContextMenu_SelectGroup_SelectsGroupedEvents() await items!.First(item => item.Label == "Select Group").OnClickAsync!(); _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 2), + Arg.Is>(c => c != null && c.Count == 2), Arg.Any()); } @@ -353,7 +353,7 @@ public void Grouped_PageDown_MovesToLastVisibleEvent() Press(cut, "PageDown"); // clamps to the last visible row (event 2) _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 1), + Arg.Is>(c => c != null && c.Count == 1), Arg.Any()); Assert.Equal(3, LastFocusedRow()); } @@ -368,7 +368,7 @@ public void Grouped_PlainArrowOntoEvent_SelectsEvent() Press(cut, "ArrowDown"); // land on the first event _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 1), + Arg.Is>(c => c != null && c.Count == 1), Arg.Any()); } @@ -452,7 +452,7 @@ public void Grouped_RightClickHeader_OpensGroupContextMenuWithActions() .When(m => m.OpenAt( Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any())) - .Do(call => items = call.Arg>()); + .Do(call => items = call.ArgAt>(2)); var cut = RenderGrouped(Collapsed(), Event(1, "Alpha"), Event(2, "Alpha")); cut.Find("tr.group-header-row").TriggerEvent("oncontextmenu", new MouseEventArgs()); @@ -485,7 +485,7 @@ public void Grouped_RightOnExpandedHeader_SelectsFirstChild() Press(cut, "ArrowRight"); // focus + select first child _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 1), + Arg.Is>(c => c != null && c.Count == 1), Arg.Any()); _logTableCommands.DidNotReceive().ToggleGroupCollapsed(Arg.Any()); } @@ -503,7 +503,7 @@ public void Grouped_ShiftArrowDown_AcrossCollapsedGroup_IncludesHiddenEvents() Press(cut, "ArrowDown", shift: true); // skips the collapsed Beta header to event 4 _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 4), + Arg.Is>(c => c != null && c.Count == 4), Arg.Any()); } @@ -519,7 +519,7 @@ public void Grouped_ShiftArrowUp_AcrossCollapsedGroup_IncludesHiddenEvents() Press(cut, "ArrowUp", shift: true); // skips the collapsed Beta header to event 1 _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 4), + Arg.Is>(c => c != null && c.Count == 4), Arg.Any()); } @@ -558,7 +558,7 @@ public void Grouped_UngroupWithHeaderCursor_ResumesFromFormerGroupsFirstEvent() Press(cut, "ArrowDown"); _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 1 && c.Any(entry => entry.ReloadKey!.Value.RecordId == alpha.RecordId)), + Arg.Is>(c => c != null && c.Count == 1 && c.Any(entry => entry.ReloadKey!.Value.RecordId == alpha.RecordId)), Arg.Any()); } @@ -682,7 +682,7 @@ public void Ungrouped_ArrowDown_SelectsNextEvent() Press(cut, "ArrowDown"); // selects the next event _eventLogCommands.Received(1).SetSelectedEvents( - Arg.Is>(c => c.Count == 1), + Arg.Is>(c => c != null && c.Count == 1), Arg.Any()); } diff --git a/tests/Unit/EventLogExpert.Windows.Tests/ActivationDispatcherTests.cs b/tests/Unit/EventLogExpert.Windows.Tests/ActivationDispatcherTests.cs index 085ea244..2e6cae89 100644 --- a/tests/Unit/EventLogExpert.Windows.Tests/ActivationDispatcherTests.cs +++ b/tests/Unit/EventLogExpert.Windows.Tests/ActivationDispatcherTests.cs @@ -58,7 +58,7 @@ public async Task ProcessBatch_FolderFailureSurfacedAsAlert_BeforeOpenBatch() var dialogService = Substitute.For(); var mainThread = Substitute.For(); mainThread.InvokeOnMainThreadAsync(Arg.Any>()) - .Returns(callInfo => callInfo.Arg>().Invoke()); + .Returns(callInfo => callInfo.ArgAt>(0).Invoke()); var (dispatcher, channel) = CreateDispatcher(dialogService: dialogService, mainThread: mainThread); var alertOrderedBeforeOpen = false; @@ -94,7 +94,7 @@ public async Task ProcessBatch_OnCancellationBeforeOpenBatch_DoesNotInvokeOpenBa { var mainThread = Substitute.For(); mainThread.InvokeOnMainThreadAsync(Arg.Any>()) - .Returns(callInfo => callInfo.Arg>().Invoke()); + .Returns(callInfo => callInfo.ArgAt>(0).Invoke()); var (dispatcher, channel) = CreateDispatcher(mainThread: mainThread); var openBatchInvoked = false; @@ -123,7 +123,7 @@ public async Task ProcessBatch_RoutesFilesAsLogPathTypeFile_OnMainThread() { var mainThread = Substitute.For(); mainThread.InvokeOnMainThreadAsync(Arg.Any>()) - .Returns(callInfo => callInfo.Arg>().Invoke()); + .Returns(callInfo => callInfo.ArgAt>(0).Invoke()); var (dispatcher, channel) = CreateDispatcher(mainThread: mainThread); IEnumerable<(string Path, LogPathType Type)>? capturedPaths = null; @@ -244,7 +244,7 @@ private static (ActivationDispatcher dispatcher, Channel channel { // Default: run on-thread to keep tests deterministic. resolvedMainThread.InvokeOnMainThreadAsync(Arg.Any>()) - .Returns(callInfo => callInfo.Arg>().Invoke()); + .Returns(callInfo => callInfo.ArgAt>(0).Invoke()); } var dispatcher = new ActivationDispatcher(