// Feature: cnc-inspection-report-viewer // Task 4.1: Unit tests for LoadRunsAsync error and cancellation paths // Task 4.2: Unit tests for LoadDetailAsync selection-change and cancellation paths using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Moq; using XP.Common.Logging.Interfaces; using XplorePlane.Models; using XplorePlane.Services.InspectionResults; using XplorePlane.Services.Storage; using XplorePlane.ViewModels.Cnc; using Xunit; namespace XplorePlane.Tests.ViewModels { public class InspectionReportViewerViewModelTests { // ── Helpers ────────────────────────────────────────────────────────────────── /// /// Creates a ViewModel with a mock store that returns an empty list by default. /// The constructor triggers an initial LoadRunsAsync call; callers should await /// the returned task to ensure the initial load completes before the test begins. /// private static (InspectionReportViewerViewModel vm, Task initialLoad) CreateVm( Mock mockStore = null, Mock mockLogger = null, Mock mockDataPathService = null) { mockStore ??= new Mock(); mockLogger ??= new Mock(); mockDataPathService ??= new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); // Note: IInspectionResultStore.QueryRunsAsync does NOT take a CancellationToken. // Cancellation is handled by the ViewModel checking ct.IsCancellationRequested after the await. mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ReturnsAsync(new List()); // Capture the initial load task by intercepting the first QueryRunsAsync call var initialLoadTcs = new TaskCompletionSource(); var originalSetup = mockStore.Object; var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // The constructor fires LoadRunsAsync as fire-and-forget. // We return a small delay task to let it complete. var initialLoad = Task.Delay(50); return (vm, initialLoad); } private static InspectionRunDetail CreateMockDetail(Guid runId, string programName = "TestProgram") { return new InspectionRunDetail { Run = new InspectionRunRecord { RunId = runId, ProgramName = programName, WorkpieceId = "WP001", SerialNumber = "SN001", StartedAt = DateTime.UtcNow, CompletedAt = DateTime.UtcNow.AddMinutes(5), OverallPass = true, Status = InspectionRunStatus.Completed, NodeCount = 2 }, Nodes = new List { new InspectionNodeResult { NodeId = Guid.NewGuid(), NodeIndex = 0, NodeName = "Node_0", PipelineName = "Pipeline_0", NodePass = true, Status = InspectionNodeStatus.Succeeded, DurationMs = 100 }, new InspectionNodeResult { NodeId = Guid.NewGuid(), NodeIndex = 1, NodeName = "Node_1", PipelineName = "Pipeline_1", NodePass = true, Status = InspectionNodeStatus.Succeeded, DurationMs = 150 } }, Metrics = new List(), Assets = new List(), PipelineSnapshots = new List(), Events = new List() }; } // ── Task 4.1: LoadRunsAsync error and cancellation paths ───────────────────── /// /// Test: When QueryRunsAsync throws an exception, HasRunListError becomes true and RunListError is set. /// Requirements: 2.6 /// [Fact] public async Task LoadRunsAsync_WhenErrorOccurs_SetsHasRunListError() { // Arrange var expectedErrorMessage = "Database connection failed"; var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException(expectedErrorMessage)); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Act - call LoadRunsAsync directly and await it await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); // Assert Assert.True(vm.HasRunListError, "HasRunListError should be true after error"); Assert.NotNull(vm.RunListError); Assert.Contains(expectedErrorMessage, vm.RunListError); Assert.False(vm.IsRunListLoading, "IsRunListLoading should be false after error"); Assert.Empty(vm.RunRows); } /// /// Test: When QueryRunsAsync throws an exception, RunRows is cleared. /// Requirements: 2.6 /// [Fact] public async Task LoadRunsAsync_WhenErrorOccurs_ClearsRunRows() { // Arrange: first call (from constructor) succeeds, // second call (first explicit) succeeds and populates RunRows, // third call (second explicit) fails and should clear RunRows. var callCount = 0; var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .Returns(query => { callCount++; // call 1: constructor's fire-and-forget → return empty list // call 2: first explicit call → return one record // call 3: second explicit call → throw if (callCount <= 1) { return Task.FromResult>(new List()); } if (callCount == 2) { return Task.FromResult>(new List { new InspectionRunRecord { RunId = Guid.NewGuid(), ProgramName = "Program1", WorkpieceId = "WP001", SerialNumber = "SN001", StartedAt = DateTime.UtcNow, OverallPass = true, Status = InspectionRunStatus.Completed, NodeCount = 1 } }); } throw new InvalidOperationException("Third call failed"); }); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Wait for constructor's fire-and-forget to complete await Task.Delay(100); // First explicit load succeeds await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); Assert.Single(vm.RunRows); Assert.False(vm.HasRunListError); // Act - second explicit load fails await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); // Assert - RunRows should be cleared on error Assert.True(vm.HasRunListError); Assert.Empty(vm.RunRows); } /// /// Test: When a second LoadRunsAsync call is made before the first completes, /// the first is cancelled silently (no error shown). /// Requirements: 11.5 /// [Fact] public async Task LoadRunsAsync_WhenCancelledBySubsequentCall_IsSilent() { // Arrange: first call blocks until the second call cancels it via CTS var firstCallStarted = new SemaphoreSlim(0, 1); var firstCallCanProceed = new SemaphoreSlim(0, 1); var callCount = 0; var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .Returns(async query => { var n = Interlocked.Increment(ref callCount); if (n == 1) { firstCallStarted.Release(); await firstCallCanProceed.WaitAsync(); } return new List(); }); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Start first load (fire-and-forget) var firstLoad = vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); // Wait for first call to start await firstCallStarted.WaitAsync(TimeSpan.FromSeconds(2)); // Act - start second load while first is still blocked // This cancels the first via _runListCts.Cancel() var secondLoad = vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); // Allow first call to return (result will be discarded because ct.IsCancellationRequested) firstCallCanProceed.Release(); await Task.WhenAll(firstLoad, secondLoad); // Assert - cancellation of the first call should be silent Assert.False(vm.HasRunListError, "HasRunListError should be false after cancellation"); Assert.Null(vm.RunListError); Assert.False(vm.IsRunListLoading, "IsRunListLoading should be false after second load completes"); } /// /// Test: When RetryRunListCommand is executed, QueryRunsAsync is called again. /// Requirements: 2.6 /// [Fact] public async Task RetryRunListCommand_ReInvokesQueryRunsAsync() { // Arrange: constructor fires call 1 (empty list, succeeds), // first explicit call is call 2 (fails), // second explicit call (simulating retry) is call 3 (succeeds). var callCount = 0; var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .Returns(query => { callCount++; // call 1: constructor → empty list (success) if (callCount == 1) return Task.FromResult>(new List()); // call 2: first explicit → fail if (callCount == 2) throw new InvalidOperationException("Second call failed"); // call 3+: retry → succeed return Task.FromResult>(new List { new InspectionRunRecord { RunId = Guid.NewGuid(), ProgramName = "RetryProgram", WorkpieceId = "WP001", SerialNumber = "SN001", StartedAt = DateTime.UtcNow, OverallPass = true, Status = InspectionRunStatus.Completed, NodeCount = 1 } }); }); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Wait for constructor's fire-and-forget to complete await Task.Delay(100); // First explicit load fails (call 2) await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); Assert.True(vm.HasRunListError, "HasRunListError should be true after first call"); Assert.Equal(2, callCount); // Act - Execute retry (call 3) await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); // Assert - retry should have called QueryRunsAsync again and succeeded Assert.Equal(3, callCount); Assert.False(vm.HasRunListError, "HasRunListError should be false after successful retry"); Assert.Null(vm.RunListError); Assert.Single(vm.RunRows); Assert.Equal("RetryProgram", vm.RunRows[0].ProgramName); } /// /// Test: RetryRunListCommand re-invokes the query (command-level test). /// Requirements: 2.6 /// [Fact] public async Task RetryRunListCommand_CommandExecution_CallsQueryRunsAsync() { // Arrange: constructor fires call 1 (empty list, succeeds), // first explicit call is call 2 (fails), // RetryRunListCommand fires call 3 (succeeds). var callCount = 0; var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .Returns(query => { callCount++; // call 1: constructor → empty list (success) if (callCount == 1) return Task.FromResult>(new List()); // call 2: first explicit → fail if (callCount == 2) throw new InvalidOperationException("Second call failed"); // call 3+: retry → succeed return Task.FromResult>(new List { new InspectionRunRecord { RunId = Guid.NewGuid(), ProgramName = "RetryProgram", WorkpieceId = "WP001", SerialNumber = "SN001", StartedAt = DateTime.UtcNow, OverallPass = true, Status = InspectionRunStatus.Completed, NodeCount = 1 } }); }); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Wait for constructor's fire-and-forget to complete await Task.Delay(100); // First explicit load fails (call 2) await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); Assert.True(vm.HasRunListError); Assert.Equal(2, callCount); // Act - Execute retry command and wait for it to complete (call 3) vm.RetryRunListCommand.Execute(); // Give the async command time to complete await Task.Delay(200); // Assert Assert.Equal(3, callCount); Assert.False(vm.HasRunListError); Assert.Single(vm.RunRows); } /// /// Test: Successful load clears error state and populates RunRows. /// Requirements: 2.6 /// [Fact] public async Task LoadRunsAsync_WhenSuccessful_PopulatesRunRowsAndClearsErrors() { // Arrange var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ReturnsAsync(new List { new InspectionRunRecord { RunId = Guid.NewGuid(), ProgramName = "Program1", WorkpieceId = "WP001", SerialNumber = "SN001", StartedAt = DateTime.UtcNow, OverallPass = true, Status = InspectionRunStatus.Completed, NodeCount = 2 }, new InspectionRunRecord { RunId = Guid.NewGuid(), ProgramName = "Program2", WorkpieceId = "WP002", SerialNumber = "SN002", StartedAt = DateTime.UtcNow.AddMinutes(-10), OverallPass = false, Status = InspectionRunStatus.Error, NodeCount = 1 } }); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Act await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 }); // Assert Assert.False(vm.HasRunListError, "HasRunListError should be false after successful load"); Assert.Null(vm.RunListError); Assert.False(vm.IsRunListLoading, "IsRunListLoading should be false after load completes"); Assert.Equal(2, vm.RunRows.Count); Assert.Equal("Program1", vm.RunRows[0].ProgramName); Assert.Equal("Program2", vm.RunRows[1].ProgramName); } // ── Task 4.2: LoadDetailAsync selection-change and cancellation paths ──────── /// /// Test: Switching SelectedRun cancels prior load (second load wins). /// Requirements: 4.8, 11.5 /// [Fact] public async Task LoadDetailAsync_WhenSelectedRunChanges_SecondLoadWins() { // Arrange var runId1 = Guid.NewGuid(); var runId2 = Guid.NewGuid(); var detail1 = CreateMockDetail(runId1, "Program1"); var detail2 = CreateMockDetail(runId2, "Program2"); var firstDetailStarted = new SemaphoreSlim(0, 1); var firstDetailCanProceed = new SemaphoreSlim(0, 1); var detailCallCount = 0; var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ReturnsAsync(new List { detail1.Run, detail2.Run }); mockStore .Setup(s => s.GetRunDetailAsync(It.IsAny())) .Returns(async id => { var n = Interlocked.Increment(ref detailCallCount); if (n == 1) { firstDetailStarted.Release(); await firstDetailCanProceed.WaitAsync(); return detail1; } return detail2; }); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Start first detail load var firstDetailLoad = vm.LoadDetailAsync(runId1); // Wait for first detail load to start await firstDetailStarted.WaitAsync(TimeSpan.FromSeconds(2)); // Act - start second detail load while first is still blocked var secondDetailLoad = vm.LoadDetailAsync(runId2); // Allow first detail call to return (result will be discarded due to cancellation check) firstDetailCanProceed.Release(); await Task.WhenAll(firstDetailLoad, secondDetailLoad); // Assert - second detail should be loaded, not first Assert.Equal("Program2", vm.DetailRun?.ProgramName); Assert.Equal(2, vm.DetailNodes.Count); Assert.False(vm.HasDetailError); } /// /// Test: Cancellation of LoadDetailAsync is silent (no error displayed). /// Requirements: 11.5 /// Note: This test verifies that when a load is cancelled (by starting a new load), /// the cancellation is handled silently without setting error state. /// [Fact] public async Task LoadDetailAsync_WhenCancelled_IsSilent() { // Arrange var runId1 = Guid.NewGuid(); var runId2 = Guid.NewGuid(); var detail1 = CreateMockDetail(runId1); var detail2 = CreateMockDetail(runId2); var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ReturnsAsync(new List { detail1.Run, detail2.Run }); // First call returns detail1, second call returns detail2 mockStore .Setup(s => s.GetRunDetailAsync(runId1)) .ReturnsAsync(detail1); mockStore .Setup(s => s.GetRunDetailAsync(runId2)) .ReturnsAsync(detail2); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Act // Start first load, then immediately start second load (cancels first) var firstLoad = vm.LoadDetailAsync(runId1); var secondLoad = vm.LoadDetailAsync(runId2); // Wait for both to complete await Task.WhenAll(firstLoad, secondLoad); // Assert // The key assertion: cancellation should be silent (no error state) Assert.False(vm.HasDetailError, "HasDetailError should be false - cancellation should be silent"); Assert.Null(vm.DetailError); Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after completion"); // The second load should have succeeded Assert.NotNull(vm.DetailRun); Assert.Equal(runId2, vm.DetailRun.RunId); } /// /// Test: Error sets HasDetailError. /// Requirements: 4.8 /// [Fact] public async Task LoadDetailAsync_WhenErrorOccurs_SetsHasDetailError() { // Arrange var runId = Guid.NewGuid(); var expectedErrorMessage = "Database connection failed"; var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ReturnsAsync(new List()); mockStore .Setup(s => s.GetRunDetailAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException(expectedErrorMessage)); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Act await vm.LoadDetailAsync(runId); // Assert Assert.True(vm.HasDetailError, "HasDetailError should be true after error"); Assert.NotNull(vm.DetailError); Assert.Contains(expectedErrorMessage, vm.DetailError); Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after error"); } /// /// Test: Successful load clears error state and populates detail. /// Requirements: 4.8 /// [Fact] public async Task LoadDetailAsync_WhenSuccessful_PopulatesDetailAndClearsErrors() { // Arrange var runId = Guid.NewGuid(); var detail = CreateMockDetail(runId, "SuccessProgram"); var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ReturnsAsync(new List { detail.Run }); mockStore .Setup(s => s.GetRunDetailAsync(runId)) .ReturnsAsync(detail); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Act await vm.LoadDetailAsync(runId); // Assert Assert.False(vm.HasDetailError, "HasDetailError should be false after successful load"); Assert.Null(vm.DetailError); Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after load completes"); Assert.NotNull(vm.DetailRun); Assert.Equal("SuccessProgram", vm.DetailRun.ProgramName); Assert.Equal(2, vm.DetailNodes.Count); Assert.Equal("Node_0", vm.DetailNodes[0].NodeName); Assert.Equal("Node_1", vm.DetailNodes[1].NodeName); } /// /// Test: LoadDetailAsync clears previous detail state before loading. /// Requirements: 4.8 /// [Fact] public async Task LoadDetailAsync_ClearsPreviousDetailState() { // Arrange var runId1 = Guid.NewGuid(); var runId2 = Guid.NewGuid(); var detail1 = CreateMockDetail(runId1, "Program1"); var detail2 = CreateMockDetail(runId2, "Program2"); var mockStore = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath()); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny())) .ReturnsAsync(new List { detail1.Run, detail2.Run }); mockStore .Setup(s => s.GetRunDetailAsync(runId1)) .ReturnsAsync(detail1); mockStore .Setup(s => s.GetRunDetailAsync(runId2)) .ReturnsAsync(detail2); var vm = new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); // Load first detail await vm.LoadDetailAsync(runId1); Assert.Equal("Program1", vm.DetailRun?.ProgramName); // Act - Load second detail await vm.LoadDetailAsync(runId2); // Assert - second detail should replace first Assert.Equal("Program2", vm.DetailRun?.ProgramName); Assert.Equal(2, vm.DetailNodes.Count); Assert.All(vm.DetailNodes, node => Assert.Contains("Node_", node.NodeName)); } } }