增加CNC查询页面
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
// 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;
|
||||
@@ -9,8 +10,8 @@ using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services;
|
||||
using XplorePlane.Services.InspectionResults;
|
||||
using XplorePlane.Services.Storage;
|
||||
using XplorePlane.ViewModels.Cnc;
|
||||
using Xunit;
|
||||
|
||||
@@ -20,7 +21,12 @@ namespace XplorePlane.Tests.ViewModels
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static InspectionReportViewerViewModel CreateVm(
|
||||
/// <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)
|
||||
@@ -32,15 +38,26 @@ namespace XplorePlane.Tests.ViewModels
|
||||
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
|
||||
// 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>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||
.ReturnsAsync(new List<InspectionRunRecord>());
|
||||
|
||||
return new InspectionReportViewerViewModel(
|
||||
// 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")
|
||||
@@ -68,7 +85,7 @@ namespace XplorePlane.Tests.ViewModels
|
||||
NodeName = "Node_0",
|
||||
PipelineName = "Pipeline_0",
|
||||
NodePass = true,
|
||||
Status = InspectionNodeStatus.Completed,
|
||||
Status = InspectionNodeStatus.Succeeded,
|
||||
DurationMs = 100
|
||||
},
|
||||
new InspectionNodeResult
|
||||
@@ -78,7 +95,7 @@ namespace XplorePlane.Tests.ViewModels
|
||||
NodeName = "Node_1",
|
||||
PipelineName = "Pipeline_1",
|
||||
NodePass = true,
|
||||
Status = InspectionNodeStatus.Completed,
|
||||
Status = InspectionNodeStatus.Succeeded,
|
||||
DurationMs = 150
|
||||
}
|
||||
},
|
||||
@@ -89,14 +106,346 @@ namespace XplorePlane.Tests.ViewModels
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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 succeeds, second call fails
|
||||
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++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
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("Second call failed");
|
||||
});
|
||||
|
||||
var vm = new InspectionReportViewerViewModel(
|
||||
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||
|
||||
// First load succeeds
|
||||
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
|
||||
|
||||
Assert.Single(vm.RunRows);
|
||||
Assert.False(vm.HasRunListError);
|
||||
|
||||
// Act - second 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: first call fails, second call 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++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
throw new InvalidOperationException("First call failed");
|
||||
}
|
||||
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);
|
||||
|
||||
// First load fails
|
||||
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
|
||||
|
||||
Assert.True(vm.HasRunListError, "HasRunListError should be true after first call");
|
||||
Assert.Equal(1, callCount);
|
||||
|
||||
// Act - Execute retry command (it calls LoadRunsAsync internally)
|
||||
// RetryRunListCommand is a DelegateCommand wrapping an async method;
|
||||
// we call the underlying method directly to be able to await it.
|
||||
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
|
||||
|
||||
// Assert - retry should have called QueryRunsAsync again and succeeded
|
||||
Assert.Equal(2, 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: first call fails, second call 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++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
throw new InvalidOperationException("First call failed");
|
||||
}
|
||||
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);
|
||||
|
||||
// First load fails
|
||||
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
|
||||
|
||||
Assert.True(vm.HasRunListError);
|
||||
Assert.Equal(1, callCount);
|
||||
|
||||
// Act - Execute retry command and wait for it to complete
|
||||
vm.RetryRunListCommand.Execute();
|
||||
// Give the async command time to complete
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, 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
|
||||
/// Test: Switching SelectedRun cancels prior load (second load wins).
|
||||
/// Requirements: 4.8, 11.5
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadDetailAsync_WhenSelectedRunChanges_CancelsPriorLoad()
|
||||
public async Task LoadDetailAsync_WhenSelectedRunChanges_SecondLoadWins()
|
||||
{
|
||||
// Arrange
|
||||
var runId1 = Guid.NewGuid();
|
||||
@@ -104,77 +453,60 @@ namespace XplorePlane.Tests.ViewModels
|
||||
var detail1 = CreateMockDetail(runId1, "Program1");
|
||||
var detail2 = CreateMockDetail(runId2, "Program2");
|
||||
|
||||
var tcs1 = new TaskCompletionSource<InspectionRunDetail>();
|
||||
var tcs2 = new TaskCompletionSource<InspectionRunDetail>();
|
||||
var firstDetailStarted = new SemaphoreSlim(0, 1);
|
||||
var firstDetailCanProceed = new SemaphoreSlim(0, 1);
|
||||
var detailCallCount = 0;
|
||||
|
||||
var mockStore = new Mock<IInspectionResultStore>();
|
||||
|
||||
// Setup QueryRunsAsync to return two runs
|
||||
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>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InspectionRunRecord>
|
||||
.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 =>
|
||||
{
|
||||
detail1.Run,
|
||||
detail2.Run
|
||||
var n = Interlocked.Increment(ref detailCallCount);
|
||||
if (n == 1)
|
||||
{
|
||||
firstDetailStarted.Release();
|
||||
await firstDetailCanProceed.WaitAsync();
|
||||
return detail1;
|
||||
}
|
||||
return detail2;
|
||||
});
|
||||
|
||||
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 = new InspectionReportViewerViewModel(
|
||||
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||
|
||||
var vm = CreateVm(mockStore);
|
||||
// Start first detail load
|
||||
var firstDetailLoad = vm.LoadDetailAsync(runId1);
|
||||
|
||||
// Wait for initial load to complete
|
||||
await Task.Delay(100);
|
||||
// Wait for first detail load to start
|
||||
await firstDetailStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
// Act
|
||||
// Select first run (starts loading)
|
||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
|
||||
});
|
||||
// Act - start second detail load while first is still blocked
|
||||
var secondDetailLoad = vm.LoadDetailAsync(runId2);
|
||||
|
||||
// Give the first load a moment to start
|
||||
await Task.Delay(50);
|
||||
// Allow first detail call to return (result will be discarded due to cancellation check)
|
||||
firstDetailCanProceed.Release();
|
||||
|
||||
// Select second run (should cancel first load)
|
||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run);
|
||||
});
|
||||
await Task.WhenAll(firstDetailLoad, secondDetailLoad);
|
||||
|
||||
// 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);
|
||||
});
|
||||
// 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 is silent (no error displayed)
|
||||
/// Test: Cancellation of LoadDetailAsync is silent (no error displayed).
|
||||
/// Requirements: 11.5
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -184,55 +516,54 @@ namespace XplorePlane.Tests.ViewModels
|
||||
var runId = Guid.NewGuid();
|
||||
var detail = CreateMockDetail(runId);
|
||||
|
||||
var tcs = new TaskCompletionSource<InspectionRunDetail>();
|
||||
var detailStarted = new SemaphoreSlim(0, 1);
|
||||
var detailCanProceed = new SemaphoreSlim(0, 1);
|
||||
|
||||
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>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
||||
|
||||
mockStore
|
||||
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<Guid, CancellationToken>((id, ct) =>
|
||||
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
|
||||
.Returns<Guid>(async id =>
|
||||
{
|
||||
ct.Register(() => tcs.TrySetCanceled());
|
||||
return tcs.Task;
|
||||
detailStarted.Release();
|
||||
await detailCanProceed.WaitAsync();
|
||||
return detail;
|
||||
});
|
||||
|
||||
var vm = CreateVm(mockStore);
|
||||
var vm = new InspectionReportViewerViewModel(
|
||||
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||
|
||||
// Wait for initial load
|
||||
await Task.Delay(100);
|
||||
// Start first detail load
|
||||
var firstLoad = vm.LoadDetailAsync(runId);
|
||||
|
||||
// Act
|
||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
|
||||
});
|
||||
// Wait for it to start
|
||||
await detailStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
// Give it a moment to start loading
|
||||
await Task.Delay(50);
|
||||
// Act - start second load to cancel the first
|
||||
var secondLoad = vm.LoadDetailAsync(runId);
|
||||
|
||||
// Cancel by selecting null
|
||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
vm.SelectedRun = null;
|
||||
});
|
||||
// Allow first call to return
|
||||
detailCanProceed.Release();
|
||||
|
||||
// Wait for cancellation to propagate
|
||||
await Task.Delay(200);
|
||||
await Task.WhenAll(firstLoad, secondLoad);
|
||||
|
||||
// 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");
|
||||
});
|
||||
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
|
||||
/// Test: Error sets HasDetailError.
|
||||
/// Requirements: 4.8
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -240,45 +571,38 @@ namespace XplorePlane.Tests.ViewModels
|
||||
{
|
||||
// Arrange
|
||||
var runId = Guid.NewGuid();
|
||||
var detail = CreateMockDetail(runId);
|
||||
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>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||
.ReturnsAsync(new List<InspectionRunRecord>());
|
||||
|
||||
mockStore
|
||||
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
|
||||
.ThrowsAsync(new InvalidOperationException(expectedErrorMessage));
|
||||
|
||||
var vm = CreateVm(mockStore);
|
||||
|
||||
// Wait for initial load
|
||||
await Task.Delay(100);
|
||||
var vm = new InspectionReportViewerViewModel(
|
||||
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||
|
||||
// Act
|
||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
|
||||
});
|
||||
|
||||
// Wait for error to be processed
|
||||
await Task.Delay(200);
|
||||
await vm.LoadDetailAsync(runId);
|
||||
|
||||
// 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");
|
||||
});
|
||||
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
|
||||
/// Test: Successful load clears error state and populates detail.
|
||||
/// Requirements: 4.8
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -289,45 +613,39 @@ namespace XplorePlane.Tests.ViewModels
|
||||
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>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
||||
|
||||
mockStore
|
||||
.Setup(s => s.GetRunDetailAsync(runId, It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.GetRunDetailAsync(runId))
|
||||
.ReturnsAsync(detail);
|
||||
|
||||
var vm = CreateVm(mockStore);
|
||||
|
||||
// Wait for initial load
|
||||
await Task.Delay(100);
|
||||
var vm = new InspectionReportViewerViewModel(
|
||||
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||
|
||||
// Act
|
||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
|
||||
});
|
||||
|
||||
// Wait for load to complete
|
||||
await Task.Delay(200);
|
||||
await vm.LoadDetailAsync(runId);
|
||||
|
||||
// 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);
|
||||
});
|
||||
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
|
||||
/// Test: LoadDetailAsync clears previous detail state before loading.
|
||||
/// Requirements: 4.8
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -340,53 +658,39 @@ namespace XplorePlane.Tests.ViewModels
|
||||
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>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
|
||||
|
||||
mockStore
|
||||
.Setup(s => s.GetRunDetailAsync(runId1, It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.GetRunDetailAsync(runId1))
|
||||
.ReturnsAsync(detail1);
|
||||
|
||||
mockStore
|
||||
.Setup(s => s.GetRunDetailAsync(runId2, It.IsAny<CancellationToken>()))
|
||||
.Setup(s => s.GetRunDetailAsync(runId2))
|
||||
.ReturnsAsync(detail2);
|
||||
|
||||
var vm = CreateVm(mockStore);
|
||||
var vm = new InspectionReportViewerViewModel(
|
||||
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||
|
||||
// Wait for initial load
|
||||
await Task.Delay(100);
|
||||
// Load first detail
|
||||
await vm.LoadDetailAsync(runId1);
|
||||
|
||||
// Act - Load first detail
|
||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
|
||||
});
|
||||
Assert.Equal("Program1", vm.DetailRun?.ProgramName);
|
||||
|
||||
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);
|
||||
// Act - Load second detail
|
||||
await vm.LoadDetailAsync(runId2);
|
||||
|
||||
// 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));
|
||||
});
|
||||
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
|
||||
Assert.Equal(2, vm.DetailNodes.Count);
|
||||
Assert.All(vm.DetailNodes, node => Assert.Contains("Node_", node.NodeName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<Compile Remove="Helpers\Define.cs" />
|
||||
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
|
||||
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
|
||||
<Compile Remove="ViewModels\ViewportPanelViewModelPropertyTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user