From e7b66e3fbff7b2b0338104c2aee17ab813d7716d Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Tue, 12 May 2026 20:09:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0CNC=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InspectionReportViewerViewModelTests.cs | 654 +++++++++++----- XplorePlane.Tests/XplorePlane.Tests.csproj | 1 + XplorePlane/App.xaml.cs | 2 + XplorePlane/Controls/ZoomableImageViewer.cs | 296 ++++++++ XplorePlane/Themes/Generic.xaml | 45 ++ XplorePlane/ViewModels/Main/MainViewModel.cs | 30 + .../Cnc/InspectionReportViewerWindow.xaml | 695 ++++++++++++++++++ .../Cnc/InspectionReportViewerWindow.xaml.cs | 87 +++ .../Views/Cnc/SnapshotViewerWindow.xaml | 51 ++ .../Views/Cnc/SnapshotViewerWindow.xaml.cs | 89 +++ XplorePlane/Views/Main/MainWindow.xaml | 7 + 11 files changed, 1782 insertions(+), 175 deletions(-) create mode 100644 XplorePlane/Controls/ZoomableImageViewer.cs create mode 100644 XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml create mode 100644 XplorePlane/Views/Cnc/InspectionReportViewerWindow.xaml.cs create mode 100644 XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml create mode 100644 XplorePlane/Views/Cnc/SnapshotViewerWindow.xaml.cs 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 @@ + + + + + + + + + + + + + + +