393 lines
15 KiB
C#
393 lines
15 KiB
C#
// 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<IInspectionResultStore> mockStore = null,
|
|
Mock<ILoggerService> mockLogger = null,
|
|
Mock<IXpDataPathService> mockDataPathService = null)
|
|
{
|
|
mockStore ??= new Mock<IInspectionResultStore>();
|
|
mockLogger ??= new Mock<ILoggerService>();
|
|
mockDataPathService ??= new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).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<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord>());
|
|
|
|
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<InspectionNodeResult>
|
|
{
|
|
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<InspectionMetricResult>(),
|
|
Assets = new List<InspectionAssetRecord>(),
|
|
PipelineSnapshots = new List<PipelineExecutionSnapshot>(),
|
|
Events = new List<InspectionRunEvent>()
|
|
};
|
|
}
|
|
|
|
// ── Task 4.2: LoadDetailAsync selection-change and cancellation paths ────────
|
|
|
|
/// <summary>
|
|
/// Test: Switching SelectedRun cancels prior load
|
|
/// Requirements: 4.8, 11.5
|
|
/// </summary>
|
|
[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<InspectionRunDetail>();
|
|
var tcs2 = new TaskCompletionSource<InspectionRunDetail>();
|
|
|
|
var mockStore = new Mock<IInspectionResultStore>();
|
|
|
|
// Setup QueryRunsAsync to return two runs
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord>
|
|
{
|
|
detail1.Run,
|
|
detail2.Run
|
|
});
|
|
|
|
var callCount = 0;
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
|
.Returns<Guid, CancellationToken>((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);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: Cancellation is silent (no error displayed)
|
|
/// Requirements: 11.5
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task LoadDetailAsync_WhenCancelled_IsSilent()
|
|
{
|
|
// Arrange
|
|
var runId = Guid.NewGuid();
|
|
var detail = CreateMockDetail(runId);
|
|
|
|
var tcs = new TaskCompletionSource<InspectionRunDetail>();
|
|
var mockStore = new Mock<IInspectionResultStore>();
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
|
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
|
.Returns<Guid, CancellationToken>((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");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: Error sets HasDetailError
|
|
/// Requirements: 4.8
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
|
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
|
.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");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: Successful load clears error state and populates detail
|
|
/// Requirements: 4.8
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task LoadDetailAsync_WhenSuccessful_PopulatesDetailAndClearsErrors()
|
|
{
|
|
// Arrange
|
|
var runId = Guid.NewGuid();
|
|
var detail = CreateMockDetail(runId, "SuccessProgram");
|
|
|
|
var mockStore = new Mock<IInspectionResultStore>();
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
|
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(runId, It.IsAny<CancellationToken>()))
|
|
.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);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: LoadDetailAsync clears previous detail state before loading
|
|
/// Requirements: 4.8
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
|
|
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(runId1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(detail1);
|
|
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(runId2, It.IsAny<CancellationToken>()))
|
|
.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));
|
|
});
|
|
}
|
|
}
|
|
}
|