Files
XplorePlane/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs
T
2026-05-12 20:09:13 +08:00

697 lines
30 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 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 (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
/// </summary>
[Fact]
public async Task LoadDetailAsync_WhenCancelled_IsSilent()
{
// Arrange
var runId = Guid.NewGuid();
var detail = CreateMockDetail(runId);
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>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
.Returns<Guid>(async id =>
{
detailStarted.Release();
await detailCanProceed.WaitAsync();
return detail;
});
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Start first detail load
var firstLoad = vm.LoadDetailAsync(runId);
// Wait for it to start
await detailStarted.WaitAsync(TimeSpan.FromSeconds(2));
// Act - start second load to cancel the first
var secondLoad = vm.LoadDetailAsync(runId);
// Allow first call to return
detailCanProceed.Release();
await Task.WhenAll(firstLoad, secondLoad);
// Assert - cancellation should be silent (no error state)
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 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));
}
}
}