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

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));
}
}
}