diff --git a/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs b/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs
index 7608d8a..a38ea63 100644
--- a/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs
+++ b/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs
@@ -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(
+ ///
+ /// 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.
+ ///
+ private static (InspectionReportViewerViewModel vm, Task initialLoad) CreateVm(
Mock mockStore = null,
Mock mockLogger = null,
Mock mockDataPathService = null)
@@ -32,15 +38,26 @@ namespace XplorePlane.Tests.ViewModels
mockLogger.Setup(l => l.ForModule()).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(), It.IsAny()))
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
.ReturnsAsync(new List());
- return new InspectionReportViewerViewModel(
+ // Capture the initial load task by intercepting the first QueryRunsAsync call
+ var initialLoadTcs = new TaskCompletionSource();
+ 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 ─────────────────────
+
+ ///
+ /// Test: When QueryRunsAsync throws an exception, HasRunListError becomes true and RunListError is set.
+ /// Requirements: 2.6
+ ///
+ [Fact]
+ public async Task LoadRunsAsync_WhenErrorOccurs_SetsHasRunListError()
+ {
+ // Arrange
+ var expectedErrorMessage = "Database connection failed";
+ var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
+
+ mockStore
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .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);
+ }
+
+ ///
+ /// Test: When QueryRunsAsync throws an exception, RunRows is cleared.
+ /// Requirements: 2.6
+ ///
+ [Fact]
+ public async Task LoadRunsAsync_WhenErrorOccurs_ClearsRunRows()
+ {
+ // Arrange: first call succeeds, second call fails
+ var callCount = 0;
+ var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
+
+ mockStore
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .Returns(query =>
+ {
+ callCount++;
+ if (callCount == 1)
+ {
+ return Task.FromResult>(new List
+ {
+ 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);
+ }
+
+ ///
+ /// Test: When a second LoadRunsAsync call is made before the first completes,
+ /// the first is cancelled silently (no error shown).
+ /// Requirements: 11.5
+ ///
+ [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();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
+
+ mockStore
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .Returns(async query =>
+ {
+ var n = Interlocked.Increment(ref callCount);
+ if (n == 1)
+ {
+ firstCallStarted.Release();
+ await firstCallCanProceed.WaitAsync();
+ }
+ return new List();
+ });
+
+ 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");
+ }
+
+ ///
+ /// Test: When RetryRunListCommand is executed, QueryRunsAsync is called again.
+ /// Requirements: 2.6
+ ///
+ [Fact]
+ public async Task RetryRunListCommand_ReInvokesQueryRunsAsync()
+ {
+ // Arrange: first call fails, second call succeeds
+ var callCount = 0;
+ var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
+
+ mockStore
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .Returns(query =>
+ {
+ callCount++;
+ if (callCount == 1)
+ {
+ throw new InvalidOperationException("First call failed");
+ }
+ return Task.FromResult>(new List
+ {
+ 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);
+ }
+
+ ///
+ /// Test: RetryRunListCommand re-invokes the query (command-level test).
+ /// Requirements: 2.6
+ ///
+ [Fact]
+ public async Task RetryRunListCommand_CommandExecution_CallsQueryRunsAsync()
+ {
+ // Arrange: first call fails, second call succeeds
+ var callCount = 0;
+ var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
+
+ mockStore
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .Returns(query =>
+ {
+ callCount++;
+ if (callCount == 1)
+ {
+ throw new InvalidOperationException("First call failed");
+ }
+ return Task.FromResult>(new List
+ {
+ 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);
+ }
+
+ ///
+ /// Test: Successful load clears error state and populates RunRows.
+ /// Requirements: 2.6
+ ///
+ [Fact]
+ public async Task LoadRunsAsync_WhenSuccessful_PopulatesRunRowsAndClearsErrors()
+ {
+ // Arrange
+ var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
+
+ mockStore
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ 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 ────────
///
- /// Test: Switching SelectedRun cancels prior load
+ /// Test: Switching SelectedRun cancels prior load (second load wins).
/// Requirements: 4.8, 11.5
///
[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();
- var tcs2 = new TaskCompletionSource();
+ var firstDetailStarted = new SemaphoreSlim(0, 1);
+ var firstDetailCanProceed = new SemaphoreSlim(0, 1);
+ var detailCallCount = 0;
var mockStore = new Mock();
-
- // Setup QueryRunsAsync to return two runs
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
+
mockStore
- .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new List
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .ReturnsAsync(new List { detail1.Run, detail2.Run });
+
+ mockStore
+ .Setup(s => s.GetRunDetailAsync(It.IsAny()))
+ .Returns(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(), It.IsAny()))
- .Returns((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);
}
///
- /// Test: Cancellation is silent (no error displayed)
+ /// Test: Cancellation of LoadDetailAsync is silent (no error displayed).
/// Requirements: 11.5
///
[Fact]
@@ -184,55 +516,54 @@ namespace XplorePlane.Tests.ViewModels
var runId = Guid.NewGuid();
var detail = CreateMockDetail(runId);
- var tcs = new TaskCompletionSource();
+ var detailStarted = new SemaphoreSlim(0, 1);
+ var detailCanProceed = new SemaphoreSlim(0, 1);
+
var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
- .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny()))
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
.ReturnsAsync(new List { detail.Run });
mockStore
- .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny()))
- .Returns((id, ct) =>
+ .Setup(s => s.GetRunDetailAsync(It.IsAny()))
+ .Returns(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");
}
///
- /// Test: Error sets HasDetailError
+ /// Test: Error sets HasDetailError.
/// Requirements: 4.8
///
[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();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
- .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new List { detail.Run });
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
+ .ReturnsAsync(new List());
mockStore
- .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny()))
+ .Setup(s => s.GetRunDetailAsync(It.IsAny()))
.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");
}
///
- /// Test: Successful load clears error state and populates detail
+ /// Test: Successful load clears error state and populates detail.
/// Requirements: 4.8
///
[Fact]
@@ -289,45 +613,39 @@ namespace XplorePlane.Tests.ViewModels
var detail = CreateMockDetail(runId, "SuccessProgram");
var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
- .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny()))
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
.ReturnsAsync(new List { detail.Run });
mockStore
- .Setup(s => s.GetRunDetailAsync(runId, It.IsAny()))
+ .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);
}
///
- /// Test: LoadDetailAsync clears previous detail state before loading
+ /// Test: LoadDetailAsync clears previous detail state before loading.
/// Requirements: 4.8
///
[Fact]
@@ -340,53 +658,39 @@ namespace XplorePlane.Tests.ViewModels
var detail2 = CreateMockDetail(runId2, "Program2");
var mockStore = new Mock();
+ var mockLogger = new Mock();
+ var mockDataPathService = new Mock();
+
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
- .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny()))
+ .Setup(s => s.QueryRunsAsync(It.IsAny()))
.ReturnsAsync(new List { detail1.Run, detail2.Run });
mockStore
- .Setup(s => s.GetRunDetailAsync(runId1, It.IsAny()))
+ .Setup(s => s.GetRunDetailAsync(runId1))
.ReturnsAsync(detail1);
mockStore
- .Setup(s => s.GetRunDetailAsync(runId2, It.IsAny()))
+ .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));
}
}
}
diff --git a/XplorePlane.Tests/XplorePlane.Tests.csproj b/XplorePlane.Tests/XplorePlane.Tests.csproj
index c84fe89..7e104f6 100644
--- a/XplorePlane.Tests/XplorePlane.Tests.csproj
+++ b/XplorePlane.Tests/XplorePlane.Tests.csproj
@@ -12,6 +12,7 @@
+
diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs
index cb1d149..045ac38 100644
--- a/XplorePlane/App.xaml.cs
+++ b/XplorePlane/App.xaml.cs
@@ -433,10 +433,12 @@ namespace XplorePlane
containerRegistry.Register();
containerRegistry.Register();
containerRegistry.Register();
+ containerRegistry.Register();
// ── CNC / 矩阵导航视图 ──
containerRegistry.RegisterForNavigation();
containerRegistry.RegisterForNavigation();
+ containerRegistry.Register();
// ── 导航相机服务(单例)──
containerRegistry.RegisterSingleton();
diff --git a/XplorePlane/Controls/ZoomableImageViewer.cs b/XplorePlane/Controls/ZoomableImageViewer.cs
new file mode 100644
index 0000000..8e920ee
--- /dev/null
+++ b/XplorePlane/Controls/ZoomableImageViewer.cs
@@ -0,0 +1,296 @@
+// Feature: cnc-inspection-report-viewer
+// Task 6: Implement ZoomableImageViewer custom control
+
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using Prism.Commands;
+
+namespace XplorePlane.Controls
+{
+ ///
+ /// 可缩放、平移的图像查看器控件。
+ /// 支持鼠标滚轮缩放(25%-400%)、拖拽平移、双击全屏。
+ ///
+ public class ZoomableImageViewer : Control
+ {
+ private const double MinScale = 0.25;
+ private const double MaxScale = 4.0;
+ private const double ScaleStep = 0.1;
+
+ private ScaleTransform _scaleTransform;
+ private TranslateTransform _translateTransform;
+ private Point _lastMousePosition;
+ private bool _isDragging;
+ private Image _imageElement;
+ private Border _containerBorder;
+
+ static ZoomableImageViewer()
+ {
+ DefaultStyleKeyProperty.OverrideMetadata(
+ typeof(ZoomableImageViewer),
+ new FrameworkPropertyMetadata(typeof(ZoomableImageViewer)));
+ }
+
+ public ZoomableImageViewer()
+ {
+ _scaleTransform = new ScaleTransform(1.0, 1.0);
+ _translateTransform = new TranslateTransform(0, 0);
+
+ FitToViewCommand = new DelegateCommand(ExecuteFitToView);
+
+ Loaded += OnLoaded;
+ }
+
+ #region Dependency Properties
+
+ public static readonly DependencyProperty SourceProperty =
+ DependencyProperty.Register(
+ nameof(Source),
+ typeof(BitmapSource),
+ typeof(ZoomableImageViewer),
+ new PropertyMetadata(null, OnSourceChanged));
+
+ public BitmapSource Source
+ {
+ get => (BitmapSource)GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public static readonly DependencyProperty FallbackTextProperty =
+ DependencyProperty.Register(
+ nameof(FallbackText),
+ typeof(string),
+ typeof(ZoomableImageViewer),
+ new PropertyMetadata("图像不可用"));
+
+ public string FallbackText
+ {
+ get => (string)GetValue(FallbackTextProperty);
+ set => SetValue(FallbackTextProperty, value);
+ }
+
+ #endregion
+
+ #region Routed Events
+
+ public static readonly RoutedEvent FullScreenRequestedEvent =
+ EventManager.RegisterRoutedEvent(
+ nameof(FullScreenRequested),
+ RoutingStrategy.Bubble,
+ typeof(RoutedEventHandler),
+ typeof(ZoomableImageViewer));
+
+ public event RoutedEventHandler FullScreenRequested
+ {
+ add => AddHandler(FullScreenRequestedEvent, value);
+ remove => RemoveHandler(FullScreenRequestedEvent, value);
+ }
+
+ #endregion
+
+ #region Commands
+
+ public ICommand FitToViewCommand { get; }
+
+ #endregion
+
+ #region Overrides
+
+ public override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ _imageElement = GetTemplateChild("PART_Image") as Image;
+ _containerBorder = GetTemplateChild("PART_Container") as Border;
+
+ if (_imageElement != null)
+ {
+ var transformGroup = new TransformGroup();
+ transformGroup.Children.Add(_scaleTransform);
+ transformGroup.Children.Add(_translateTransform);
+ _imageElement.RenderTransform = transformGroup;
+ _imageElement.RenderTransformOrigin = new Point(0.5, 0.5);
+ }
+ }
+
+ protected override void OnMouseWheel(MouseWheelEventArgs e)
+ {
+ base.OnMouseWheel(e);
+
+ if (_imageElement == null || Source == null)
+ return;
+
+ // Calculate new scale
+ double delta = e.Delta > 0 ? ScaleStep : -ScaleStep;
+ double newScale = _scaleTransform.ScaleX + delta;
+
+ // Clamp to boundaries
+ if (newScale < MinScale || newScale > MaxScale)
+ {
+ // At boundary, no effect
+ e.Handled = true;
+ return;
+ }
+
+ newScale = Math.Max(MinScale, Math.Min(MaxScale, newScale));
+
+ // Get mouse position relative to image
+ Point mousePos = e.GetPosition(_imageElement);
+
+ // Calculate zoom center offset
+ double offsetX = mousePos.X * (_scaleTransform.ScaleX - newScale);
+ double offsetY = mousePos.Y * (_scaleTransform.ScaleY - newScale);
+
+ // Apply new scale
+ _scaleTransform.ScaleX = newScale;
+ _scaleTransform.ScaleY = newScale;
+
+ // Adjust translation to zoom at mouse position
+ _translateTransform.X += offsetX;
+ _translateTransform.Y += offsetY;
+
+ // Clamp translation
+ ClampTranslation();
+
+ e.Handled = true;
+ }
+
+ protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
+ {
+ base.OnMouseLeftButtonDown(e);
+
+ if (_scaleTransform.ScaleX > 1.0 && Source != null)
+ {
+ _lastMousePosition = e.GetPosition(this);
+ _isDragging = true;
+ CaptureMouse();
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnMouseMove(MouseEventArgs e)
+ {
+ base.OnMouseMove(e);
+
+ if (_isDragging && e.LeftButton == MouseButtonState.Pressed)
+ {
+ Point currentPosition = e.GetPosition(this);
+ Vector delta = currentPosition - _lastMousePosition;
+
+ _translateTransform.X += delta.X;
+ _translateTransform.Y += delta.Y;
+
+ ClampTranslation();
+
+ _lastMousePosition = currentPosition;
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
+ {
+ base.OnMouseLeftButtonUp(e);
+
+ if (_isDragging)
+ {
+ _isDragging = false;
+ ReleaseMouseCapture();
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnMouseDoubleClick(MouseButtonEventArgs e)
+ {
+ base.OnMouseDoubleClick(e);
+
+ if (Source != null)
+ {
+ RaiseEvent(new RoutedEventArgs(FullScreenRequestedEvent, this));
+ e.Handled = true;
+ }
+ }
+
+ #endregion
+
+ #region Private Methods
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (Source != null)
+ {
+ ExecuteFitToView();
+ }
+ }
+
+ private static void OnSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var viewer = (ZoomableImageViewer)d;
+ if (viewer.IsLoaded && e.NewValue != null)
+ {
+ viewer.ExecuteFitToView();
+ }
+ }
+
+ private void ExecuteFitToView()
+ {
+ if (_imageElement == null || _containerBorder == null || Source == null)
+ return;
+
+ double containerWidth = _containerBorder.ActualWidth;
+ double containerHeight = _containerBorder.ActualHeight;
+
+ if (containerWidth <= 0 || containerHeight <= 0)
+ return;
+
+ double imageWidth = Source.PixelWidth;
+ double imageHeight = Source.PixelHeight;
+
+ if (imageWidth <= 0 || imageHeight <= 0)
+ return;
+
+ // Calculate scale to fit
+ double scaleX = containerWidth / imageWidth;
+ double scaleY = containerHeight / imageHeight;
+ double scale = Math.Min(scaleX, scaleY);
+
+ // Clamp to min/max
+ scale = Math.Max(MinScale, Math.Min(MaxScale, scale));
+
+ _scaleTransform.ScaleX = scale;
+ _scaleTransform.ScaleY = scale;
+
+ // Center the image
+ _translateTransform.X = 0;
+ _translateTransform.Y = 0;
+ }
+
+ private void ClampTranslation()
+ {
+ if (_imageElement == null || _containerBorder == null || Source == null)
+ return;
+
+ double containerWidth = _containerBorder.ActualWidth;
+ double containerHeight = _containerBorder.ActualHeight;
+
+ if (containerWidth <= 0 || containerHeight <= 0)
+ return;
+
+ double imageWidth = Source.PixelWidth * _scaleTransform.ScaleX;
+ double imageHeight = Source.PixelHeight * _scaleTransform.ScaleY;
+
+ // Calculate bounds
+ double maxOffsetX = Math.Max(0, (imageWidth - containerWidth) / 2);
+ double maxOffsetY = Math.Max(0, (imageHeight - containerHeight) / 2);
+
+ // Clamp translation so image edges don't exceed control bounds
+ _translateTransform.X = Math.Max(-maxOffsetX, Math.Min(maxOffsetX, _translateTransform.X));
+ _translateTransform.Y = Math.Max(-maxOffsetY, Math.Min(maxOffsetY, _translateTransform.Y));
+ }
+
+ #endregion
+ }
+}
diff --git a/XplorePlane/Themes/Generic.xaml b/XplorePlane/Themes/Generic.xaml
index 055be1d..547a088 100644
--- a/XplorePlane/Themes/Generic.xaml
+++ b/XplorePlane/Themes/Generic.xaml
@@ -122,4 +122,49 @@
+
+
+
diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs
index a45c2e8..f9006f3 100644
--- a/XplorePlane/ViewModels/Main/MainViewModel.cs
+++ b/XplorePlane/ViewModels/Main/MainViewModel.cs
@@ -98,6 +98,7 @@ namespace XplorePlane.ViewModels
public DelegateCommand OpenPipelineEditorCommand { get; }
public DelegateCommand OpenCncEditorCommand { get; }
public DelegateCommand OpenMatrixEditorCommand { get; }
+ public DelegateCommand OpenInspectionReportViewerCommand { get; }
public DelegateCommand OpenToolboxCommand { get; }
public DelegateCommand OpenLibraryVersionsCommand { get; }
public DelegateCommand OpenUserManualCommand { get; }
@@ -224,6 +225,7 @@ namespace XplorePlane.ViewModels
private Window _settingsWindow;
private Window _toolboxWindow;
private Window _raySourceConfigWindow;
+ private Window _inspectionReportViewerWindow;
private object _imagePanelContent;
private GridLength _viewportPanelWidth = new(1, GridUnitType.Star);
private GridLength _imagePanelWidth = new(320);
@@ -293,6 +295,7 @@ namespace XplorePlane.ViewModels
OpenCncEditorCommand = new DelegateCommand(ExecuteOpenCncEditor);
OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编辑"));
+ OpenInspectionReportViewerCommand = new DelegateCommand(ExecuteOpenInspectionReportViewer);
OpenToolboxCommand = new DelegateCommand(ExecuteOpenToolbox);
OpenLibraryVersionsCommand = new DelegateCommand(() => ShowWindow(new Views.LibraryVersionsWindow(), "关于"));
OpenUserManualCommand = new DelegateCommand(ExecuteOpenUserManual);
@@ -396,6 +399,33 @@ namespace XplorePlane.ViewModels
ShowOrActivate(_toolboxWindow, w => _toolboxWindow = w, () => new Views.OperatorToolboxWindow(), "Operator Toolbox");
}
+ private void ExecuteOpenInspectionReportViewer()
+ {
+ try
+ {
+ ShowOrActivate(
+ _inspectionReportViewerWindow,
+ w => _inspectionReportViewerWindow = w,
+ () =>
+ {
+ var viewModel = _containerProvider.Resolve();
+ var window = _containerProvider.Resolve();
+ window.DataContext = viewModel;
+ return window;
+ },
+ "检测记录查看器");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to open Inspection Report Viewer window");
+ MessageBox.Show(
+ $"无法打开检测记录查看器: {ex.Message}",
+ "错误",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
private void ExecuteOpenCncEditor()
{
_ = ExecuteOpenCncEditorAsync();
diff --git a/XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml b/XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml
new file mode 100644
index 0000000..718192a
--- /dev/null
+++ b/XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml
@@ -0,0 +1,695 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml.cs b/XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml.cs
new file mode 100644
index 0000000..8cdcb86
--- /dev/null
+++ b/XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml.cs
@@ -0,0 +1,87 @@
+// Feature: cnc-inspection-report-viewer
+// Task 8.6: Implement code-behind for full-screen image overlay and window closing
+
+using System;
+using System.Windows;
+using System.Windows.Media.Imaging;
+using XplorePlane.Controls;
+using XplorePlane.ViewModels.Cnc;
+
+namespace XplorePlane.Views.Cnc
+{
+ ///
+ /// CNC 检测记录查看器窗口
+ ///
+ public partial class InspectionReportViewerWindow : Window
+ {
+ public InspectionReportViewerWindow(InspectionReportViewerViewModel viewModel)
+ {
+ InitializeComponent();
+ DataContext = viewModel;
+ Closing += Window_Closing;
+ }
+
+ private void ZoomableImageViewer_FullScreenRequested(object sender, RoutedEventArgs e)
+ {
+ var viewer = sender as ZoomableImageViewer;
+ if (viewer?.Source == null)
+ return;
+
+ // Create full-screen overlay window
+ var overlayWindow = new Window
+ {
+ WindowStyle = WindowStyle.None,
+ WindowState = WindowState.Maximized,
+ Background = System.Windows.Media.Brushes.Black,
+ Owner = this,
+ ShowInTaskbar = false
+ };
+
+ var grid = new System.Windows.Controls.Grid();
+
+ // Add image viewer
+ var fullScreenViewer = new ZoomableImageViewer
+ {
+ Source = viewer.Source,
+ Margin = new Thickness(20)
+ };
+ grid.Children.Add(fullScreenViewer);
+
+ // Add close button
+ var closeButton = new System.Windows.Controls.Button
+ {
+ Content = "✕",
+ Width = 50,
+ Height = 50,
+ FontSize = 24,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(20),
+ Background = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(128, 255, 255, 255)),
+ BorderThickness = new Thickness(0)
+ };
+ closeButton.Click += (s, args) => overlayWindow.Close();
+ grid.Children.Add(closeButton);
+
+ // ESC key to close
+ overlayWindow.KeyDown += (s, args) =>
+ {
+ if (args.Key == System.Windows.Input.Key.Escape)
+ overlayWindow.Close();
+ };
+
+ overlayWindow.Content = grid;
+ overlayWindow.ShowDialog();
+ }
+
+ private async void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
+ {
+ var viewModel = DataContext as InspectionReportViewerViewModel;
+ if (viewModel != null)
+ {
+ await viewModel.OnWindowClosingAsync();
+ }
+ }
+ }
+}
diff --git a/XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml b/XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml
new file mode 100644
index 0000000..8e00642
--- /dev/null
+++ b/XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml.cs b/XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml.cs
new file mode 100644
index 0000000..13f4be9
--- /dev/null
+++ b/XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml.cs
@@ -0,0 +1,89 @@
+// Feature: cnc-inspection-report-viewer
+// Task 7: Implement SnapshotViewerWindow
+
+using System;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Threading;
+
+namespace XplorePlane.Views.Cnc
+{
+ ///
+ /// 配方快照查看器窗口
+ /// Pipeline snapshot viewer window
+ ///
+ public partial class SnapshotViewerWindow : Window
+ {
+ private readonly string _jsonContent;
+ private readonly DispatcherTimer _resetButtonTimer;
+
+ public SnapshotViewerWindow(string pipelineDefinitionJson, string pipelineName)
+ {
+ InitializeComponent();
+
+ Title = $"配方快照 - {pipelineName}";
+
+ // Format and display JSON
+ if (string.IsNullOrWhiteSpace(pipelineDefinitionJson))
+ {
+ JsonTextBox.Text = "配方快照数据不可用";
+ CopyButton.Visibility = Visibility.Collapsed;
+ _jsonContent = null;
+ }
+ else
+ {
+ try
+ {
+ // Pretty-print JSON with 2-space indentation
+ var jsonDoc = JsonDocument.Parse(pipelineDefinitionJson);
+ _jsonContent = JsonSerializer.Serialize(
+ jsonDoc.RootElement,
+ new JsonSerializerOptions { WriteIndented = true });
+ JsonTextBox.Text = _jsonContent;
+ }
+ catch
+ {
+ JsonTextBox.Text = "配方快照数据不可用";
+ CopyButton.Visibility = Visibility.Collapsed;
+ _jsonContent = null;
+ }
+ }
+
+ // Initialize timer for button text reset
+ _resetButtonTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(2)
+ };
+ _resetButtonTimer.Tick += ResetButtonTimer_Tick;
+ }
+
+ private void CopyButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrEmpty(_jsonContent))
+ return;
+
+ try
+ {
+ Clipboard.SetText(_jsonContent);
+
+ // Change button text to "已复制"
+ CopyButton.Content = "已复制";
+ CopyButton.IsEnabled = false;
+
+ // Start timer to reset after 2 seconds
+ _resetButtonTimer.Start();
+ }
+ catch
+ {
+ // Silently fail if clipboard access fails
+ }
+ }
+
+ private void ResetButtonTimer_Tick(object sender, EventArgs e)
+ {
+ _resetButtonTimer.Stop();
+ CopyButton.Content = "复制到剪贴板";
+ CopyButton.IsEnabled = true;
+ }
+ }
+}
diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml
index a86b998..394b969 100644
--- a/XplorePlane/Views/Main/MainWindow.xaml
+++ b/XplorePlane/Views/Main/MainWindow.xaml
@@ -326,6 +326,13 @@
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
+