723 lines
31 KiB
C#
723 lines
31 KiB
C#
// 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 ──────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static (InspectionReportViewerViewModel vm, Task initialLoad) 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());
|
|
|
|
// 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<InspectionRunQuery>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord>());
|
|
|
|
// Capture the initial load task by intercepting the first QueryRunsAsync call
|
|
var initialLoadTcs = new TaskCompletionSource<bool>();
|
|
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<InspectionNodeResult>
|
|
{
|
|
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<InspectionMetricResult>(),
|
|
Assets = new List<InspectionAssetRecord>(),
|
|
PipelineSnapshots = new List<PipelineExecutionSnapshot>(),
|
|
Events = new List<InspectionRunEvent>()
|
|
};
|
|
}
|
|
|
|
// ── Task 4.1: LoadRunsAsync error and cancellation paths ─────────────────────
|
|
|
|
/// <summary>
|
|
/// Test: When QueryRunsAsync throws an exception, HasRunListError becomes true and RunListError is set.
|
|
/// Requirements: 2.6
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task LoadRunsAsync_WhenErrorOccurs_SetsHasRunListError()
|
|
{
|
|
// Arrange
|
|
var expectedErrorMessage = "Database connection failed";
|
|
var mockStore = new Mock<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: When QueryRunsAsync throws an exception, RunRows is cleared.
|
|
/// Requirements: 2.6
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.Returns<InspectionRunQuery>(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<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>());
|
|
}
|
|
if (callCount == 2)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>
|
|
{
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: When a second LoadRunsAsync call is made before the first completes,
|
|
/// the first is cancelled silently (no error shown).
|
|
/// Requirements: 11.5
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.Returns<InspectionRunQuery>(async query =>
|
|
{
|
|
var n = Interlocked.Increment(ref callCount);
|
|
if (n == 1)
|
|
{
|
|
firstCallStarted.Release();
|
|
await firstCallCanProceed.WaitAsync();
|
|
}
|
|
return new List<InspectionRunRecord>();
|
|
});
|
|
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: When RetryRunListCommand is executed, QueryRunsAsync is called again.
|
|
/// Requirements: 2.6
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.Returns<InspectionRunQuery>(query =>
|
|
{
|
|
callCount++;
|
|
// call 1: constructor → empty list (success)
|
|
if (callCount == 1)
|
|
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>());
|
|
// call 2: first explicit → fail
|
|
if (callCount == 2)
|
|
throw new InvalidOperationException("Second call failed");
|
|
// call 3+: retry → succeed
|
|
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>
|
|
{
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: RetryRunListCommand re-invokes the query (command-level test).
|
|
/// Requirements: 2.6
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.Returns<InspectionRunQuery>(query =>
|
|
{
|
|
callCount++;
|
|
// call 1: constructor → empty list (success)
|
|
if (callCount == 1)
|
|
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>());
|
|
// call 2: first explicit → fail
|
|
if (callCount == 2)
|
|
throw new InvalidOperationException("Second call failed");
|
|
// call 3+: retry → succeed
|
|
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>
|
|
{
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: Successful load clears error state and populates RunRows.
|
|
/// Requirements: 2.6
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task LoadRunsAsync_WhenSuccessful_PopulatesRunRowsAndClearsErrors()
|
|
{
|
|
// Arrange
|
|
var mockStore = new Mock<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord>
|
|
{
|
|
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 ────────
|
|
|
|
/// <summary>
|
|
/// Test: Switching SelectedRun cancels prior load (second load wins).
|
|
/// Requirements: 4.8, 11.5
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
|
|
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
|
|
.Returns<Guid>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test: Error sets HasDetailError.
|
|
/// Requirements: 4.8
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task LoadDetailAsync_WhenErrorOccurs_SetsHasDetailError()
|
|
{
|
|
// Arrange
|
|
var runId = Guid.NewGuid();
|
|
var expectedErrorMessage = "Database connection failed";
|
|
|
|
var mockStore = new Mock<IInspectionResultStore>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord>());
|
|
|
|
mockStore
|
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
|
|
.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");
|
|
}
|
|
|
|
/// <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>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { 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);
|
|
}
|
|
|
|
/// <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>();
|
|
var mockLogger = new Mock<ILoggerService>();
|
|
var mockDataPathService = new Mock<IXpDataPathService>();
|
|
|
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
|
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
|
|
|
mockStore
|
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
|
.ReturnsAsync(new List<InspectionRunRecord> { 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));
|
|
}
|
|
}
|
|
}
|