// Feature: cnc-inspection-report-viewer // 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; using XplorePlane.Services.InspectionResults; using XplorePlane.ViewModels.Cnc; using Xunit; namespace XplorePlane.Tests.ViewModels { public class InspectionReportViewerViewModelTests { // ── Helpers ────────────────────────────────────────────────────────────────── private static InspectionReportViewerViewModel 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()); // Setup default QueryRunsAsync to return empty list mockStore .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); return new InspectionReportViewerViewModel( mockStore.Object, mockLogger.Object, mockDataPathService.Object); } 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.Completed, DurationMs = 100 }, new InspectionNodeResult { NodeId = Guid.NewGuid(), NodeIndex = 1, NodeName = "Node_1", PipelineName = "Pipeline_1", NodePass = true, Status = InspectionNodeStatus.Completed, DurationMs = 150 } }, Metrics = new List(), Assets = new List(), PipelineSnapshots = new List(), Events = new List() }; } // ── Task 4.2: LoadDetailAsync selection-change and cancellation paths ──────── /// /// Test: Switching SelectedRun cancels prior load /// Requirements: 4.8, 11.5 /// [Fact] public async Task LoadDetailAsync_WhenSelectedRunChanges_CancelsPriorLoad() { // Arrange var runId1 = Guid.NewGuid(); var runId2 = Guid.NewGuid(); var detail1 = CreateMockDetail(runId1, "Program1"); var detail2 = CreateMockDetail(runId2, "Program2"); var tcs1 = new TaskCompletionSource(); var tcs2 = new TaskCompletionSource(); var mockStore = new Mock(); // Setup QueryRunsAsync to return two runs mockStore .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { detail1.Run, detail2.Run }); var callCount = 0; mockStore .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny())) .Returns((id, ct) => { callCount++; if (callCount == 1) { // First call - register cancellation callback ct.Register(() => tcs1.TrySetCanceled()); return tcs1.Task; } else { // Second call - complete immediately tcs2.SetResult(detail2); return tcs2.Task; } }); var vm = CreateVm(mockStore); // Wait for initial load to complete await Task.Delay(100); // Act // Select first run (starts loading) await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run); }); // Give the first load a moment to start await Task.Delay(50); // Select second run (should cancel first load) await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run); }); // Wait for second load to complete await Task.Delay(200); // Assert Assert.True(tcs1.Task.IsCanceled, "First load should have been cancelled"); Assert.True(tcs2.Task.IsCompletedSuccessfully, "Second load should have completed"); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { Assert.Equal("Program2", vm.DetailRun?.ProgramName); Assert.Equal(2, vm.DetailNodes.Count); }); } /// /// Test: Cancellation is silent (no error displayed) /// Requirements: 11.5 /// [Fact] public async Task LoadDetailAsync_WhenCancelled_IsSilent() { // Arrange var runId = Guid.NewGuid(); var detail = CreateMockDetail(runId); var tcs = new TaskCompletionSource(); var mockStore = new Mock(); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { detail.Run }); mockStore .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny())) .Returns((id, ct) => { ct.Register(() => tcs.TrySetCanceled()); return tcs.Task; }); var vm = CreateVm(mockStore); // Wait for initial load await Task.Delay(100); // Act await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = new InspectionRunRowViewModel(detail.Run); }); // Give it a moment to start loading await Task.Delay(50); // Cancel by selecting null await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = null; }); // Wait for cancellation to propagate await Task.Delay(200); // Assert - cancellation should be silent (no error state) await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { Assert.False(vm.HasDetailError, "HasDetailError should be false after cancellation"); Assert.Null(vm.DetailError); Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after cancellation"); }); } /// /// Test: Error sets HasDetailError /// Requirements: 4.8 /// [Fact] public async Task LoadDetailAsync_WhenErrorOccurs_SetsHasDetailError() { // Arrange var runId = Guid.NewGuid(); var detail = CreateMockDetail(runId); var expectedErrorMessage = "Database connection failed"; var mockStore = new Mock(); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { detail.Run }); mockStore .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException(expectedErrorMessage)); var vm = CreateVm(mockStore); // Wait for initial load await Task.Delay(100); // Act await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = new InspectionRunRowViewModel(detail.Run); }); // Wait for error to be processed await Task.Delay(200); // Assert await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { 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(); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { detail.Run }); mockStore .Setup(s => s.GetRunDetailAsync(runId, It.IsAny())) .ReturnsAsync(detail); var vm = CreateVm(mockStore); // Wait for initial load await Task.Delay(100); // Act await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = new InspectionRunRowViewModel(detail.Run); }); // Wait for load to complete await Task.Delay(200); // Assert await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { 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(); mockStore .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { detail1.Run, detail2.Run }); mockStore .Setup(s => s.GetRunDetailAsync(runId1, It.IsAny())) .ReturnsAsync(detail1); mockStore .Setup(s => s.GetRunDetailAsync(runId2, It.IsAny())) .ReturnsAsync(detail2); var vm = CreateVm(mockStore); // Wait for initial load await Task.Delay(100); // Act - Load first detail await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run); }); await Task.Delay(200); // Verify first detail is loaded await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { Assert.Equal("Program1", vm.DetailRun?.ProgramName); }); // Load second detail await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run); }); await Task.Delay(200); // Assert - second detail should replace first await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { Assert.Equal("Program2", vm.DetailRun?.ProgramName); Assert.Equal(2, vm.DetailNodes.Count); Assert.All(vm.DetailNodes, node => Assert.Contains("Node_", node.NodeName)); }); } } }