增加CNC查询页面

This commit is contained in:
zhengxuan.zhang
2026-05-12 20:09:13 +08:00
parent 8b29285d03
commit e7b66e3fbf
11 changed files with 1782 additions and 175 deletions
@@ -1,4 +1,5 @@
// Feature: cnc-inspection-report-viewer
// Task 4.1: Unit tests for LoadRunsAsync error and cancellation paths
// Task 4.2: Unit tests for LoadDetailAsync selection-change and cancellation paths
using System;
@@ -9,8 +10,8 @@ using System.Threading.Tasks;
using Moq;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.Storage;
using XplorePlane.ViewModels.Cnc;
using Xunit;
@@ -20,7 +21,12 @@ namespace XplorePlane.Tests.ViewModels
{
// ── Helpers ──────────────────────────────────────────────────────────────────
private static InspectionReportViewerViewModel CreateVm(
/// <summary>
/// Creates a ViewModel with a mock store that returns an empty list by default.
/// The constructor triggers an initial LoadRunsAsync call; callers should await
/// the returned task to ensure the initial load completes before the test begins.
/// </summary>
private static (InspectionReportViewerViewModel vm, Task initialLoad) CreateVm(
Mock<IInspectionResultStore> mockStore = null,
Mock<ILoggerService> mockLogger = null,
Mock<IXpDataPathService> mockDataPathService = null)
@@ -32,15 +38,26 @@ namespace XplorePlane.Tests.ViewModels
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
// Setup default QueryRunsAsync to return empty list
// Note: IInspectionResultStore.QueryRunsAsync does NOT take a CancellationToken.
// Cancellation is handled by the ViewModel checking ct.IsCancellationRequested after the await.
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ReturnsAsync(new List<InspectionRunRecord>());
return new InspectionReportViewerViewModel(
// Capture the initial load task by intercepting the first QueryRunsAsync call
var initialLoadTcs = new TaskCompletionSource<bool>();
var originalSetup = mockStore.Object;
var vm = new InspectionReportViewerViewModel(
mockStore.Object,
mockLogger.Object,
mockDataPathService.Object);
// The constructor fires LoadRunsAsync as fire-and-forget.
// We return a small delay task to let it complete.
var initialLoad = Task.Delay(50);
return (vm, initialLoad);
}
private static InspectionRunDetail CreateMockDetail(Guid runId, string programName = "TestProgram")
@@ -68,7 +85,7 @@ namespace XplorePlane.Tests.ViewModels
NodeName = "Node_0",
PipelineName = "Pipeline_0",
NodePass = true,
Status = InspectionNodeStatus.Completed,
Status = InspectionNodeStatus.Succeeded,
DurationMs = 100
},
new InspectionNodeResult
@@ -78,7 +95,7 @@ namespace XplorePlane.Tests.ViewModels
NodeName = "Node_1",
PipelineName = "Pipeline_1",
NodePass = true,
Status = InspectionNodeStatus.Completed,
Status = InspectionNodeStatus.Succeeded,
DurationMs = 150
}
},
@@ -89,14 +106,346 @@ namespace XplorePlane.Tests.ViewModels
};
}
// ── Task 4.1: LoadRunsAsync error and cancellation paths ─────────────────────
/// <summary>
/// Test: When QueryRunsAsync throws an exception, HasRunListError becomes true and RunListError is set.
/// Requirements: 2.6
/// </summary>
[Fact]
public async Task LoadRunsAsync_WhenErrorOccurs_SetsHasRunListError()
{
// Arrange
var expectedErrorMessage = "Database connection failed";
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ThrowsAsync(new InvalidOperationException(expectedErrorMessage));
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Act - call LoadRunsAsync directly and await it
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
// Assert
Assert.True(vm.HasRunListError, "HasRunListError should be true after error");
Assert.NotNull(vm.RunListError);
Assert.Contains(expectedErrorMessage, vm.RunListError);
Assert.False(vm.IsRunListLoading, "IsRunListLoading should be false after error");
Assert.Empty(vm.RunRows);
}
/// <summary>
/// Test: When QueryRunsAsync throws an exception, RunRows is cleared.
/// Requirements: 2.6
/// </summary>
[Fact]
public async Task LoadRunsAsync_WhenErrorOccurs_ClearsRunRows()
{
// Arrange: first call succeeds, second call fails
var callCount = 0;
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.Returns<InspectionRunQuery>(query =>
{
callCount++;
if (callCount == 1)
{
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>
{
new InspectionRunRecord
{
RunId = Guid.NewGuid(),
ProgramName = "Program1",
WorkpieceId = "WP001",
SerialNumber = "SN001",
StartedAt = DateTime.UtcNow,
OverallPass = true,
Status = InspectionRunStatus.Completed,
NodeCount = 1
}
});
}
throw new InvalidOperationException("Second call failed");
});
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// First load succeeds
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
Assert.Single(vm.RunRows);
Assert.False(vm.HasRunListError);
// Act - second load fails
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
// Assert - RunRows should be cleared on error
Assert.True(vm.HasRunListError);
Assert.Empty(vm.RunRows);
}
/// <summary>
/// Test: When a second LoadRunsAsync call is made before the first completes,
/// the first is cancelled silently (no error shown).
/// Requirements: 11.5
/// </summary>
[Fact]
public async Task LoadRunsAsync_WhenCancelledBySubsequentCall_IsSilent()
{
// Arrange: first call blocks until the second call cancels it via CTS
var firstCallStarted = new SemaphoreSlim(0, 1);
var firstCallCanProceed = new SemaphoreSlim(0, 1);
var callCount = 0;
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.Returns<InspectionRunQuery>(async query =>
{
var n = Interlocked.Increment(ref callCount);
if (n == 1)
{
firstCallStarted.Release();
await firstCallCanProceed.WaitAsync();
}
return new List<InspectionRunRecord>();
});
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Start first load (fire-and-forget)
var firstLoad = vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
// Wait for first call to start
await firstCallStarted.WaitAsync(TimeSpan.FromSeconds(2));
// Act - start second load while first is still blocked
// This cancels the first via _runListCts.Cancel()
var secondLoad = vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
// Allow first call to return (result will be discarded because ct.IsCancellationRequested)
firstCallCanProceed.Release();
await Task.WhenAll(firstLoad, secondLoad);
// Assert - cancellation of the first call should be silent
Assert.False(vm.HasRunListError, "HasRunListError should be false after cancellation");
Assert.Null(vm.RunListError);
Assert.False(vm.IsRunListLoading, "IsRunListLoading should be false after second load completes");
}
/// <summary>
/// Test: When RetryRunListCommand is executed, QueryRunsAsync is called again.
/// Requirements: 2.6
/// </summary>
[Fact]
public async Task RetryRunListCommand_ReInvokesQueryRunsAsync()
{
// Arrange: first call fails, second call succeeds
var callCount = 0;
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.Returns<InspectionRunQuery>(query =>
{
callCount++;
if (callCount == 1)
{
throw new InvalidOperationException("First call failed");
}
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>
{
new InspectionRunRecord
{
RunId = Guid.NewGuid(),
ProgramName = "RetryProgram",
WorkpieceId = "WP001",
SerialNumber = "SN001",
StartedAt = DateTime.UtcNow,
OverallPass = true,
Status = InspectionRunStatus.Completed,
NodeCount = 1
}
});
});
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// First load fails
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
Assert.True(vm.HasRunListError, "HasRunListError should be true after first call");
Assert.Equal(1, callCount);
// Act - Execute retry command (it calls LoadRunsAsync internally)
// RetryRunListCommand is a DelegateCommand wrapping an async method;
// we call the underlying method directly to be able to await it.
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
// Assert - retry should have called QueryRunsAsync again and succeeded
Assert.Equal(2, callCount);
Assert.False(vm.HasRunListError, "HasRunListError should be false after successful retry");
Assert.Null(vm.RunListError);
Assert.Single(vm.RunRows);
Assert.Equal("RetryProgram", vm.RunRows[0].ProgramName);
}
/// <summary>
/// Test: RetryRunListCommand re-invokes the query (command-level test).
/// Requirements: 2.6
/// </summary>
[Fact]
public async Task RetryRunListCommand_CommandExecution_CallsQueryRunsAsync()
{
// Arrange: first call fails, second call succeeds
var callCount = 0;
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.Returns<InspectionRunQuery>(query =>
{
callCount++;
if (callCount == 1)
{
throw new InvalidOperationException("First call failed");
}
return Task.FromResult<IReadOnlyList<InspectionRunRecord>>(new List<InspectionRunRecord>
{
new InspectionRunRecord
{
RunId = Guid.NewGuid(),
ProgramName = "RetryProgram",
WorkpieceId = "WP001",
SerialNumber = "SN001",
StartedAt = DateTime.UtcNow,
OverallPass = true,
Status = InspectionRunStatus.Completed,
NodeCount = 1
}
});
});
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// First load fails
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
Assert.True(vm.HasRunListError);
Assert.Equal(1, callCount);
// Act - Execute retry command and wait for it to complete
vm.RetryRunListCommand.Execute();
// Give the async command time to complete
await Task.Delay(200);
// Assert
Assert.Equal(2, callCount);
Assert.False(vm.HasRunListError);
Assert.Single(vm.RunRows);
}
/// <summary>
/// Test: Successful load clears error state and populates RunRows.
/// Requirements: 2.6
/// </summary>
[Fact]
public async Task LoadRunsAsync_WhenSuccessful_PopulatesRunRowsAndClearsErrors()
{
// Arrange
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ReturnsAsync(new List<InspectionRunRecord>
{
new InspectionRunRecord
{
RunId = Guid.NewGuid(),
ProgramName = "Program1",
WorkpieceId = "WP001",
SerialNumber = "SN001",
StartedAt = DateTime.UtcNow,
OverallPass = true,
Status = InspectionRunStatus.Completed,
NodeCount = 2
},
new InspectionRunRecord
{
RunId = Guid.NewGuid(),
ProgramName = "Program2",
WorkpieceId = "WP002",
SerialNumber = "SN002",
StartedAt = DateTime.UtcNow.AddMinutes(-10),
OverallPass = false,
Status = InspectionRunStatus.Error,
NodeCount = 1
}
});
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Act
await vm.LoadRunsAsync(new InspectionRunQuery { Take = 100 });
// Assert
Assert.False(vm.HasRunListError, "HasRunListError should be false after successful load");
Assert.Null(vm.RunListError);
Assert.False(vm.IsRunListLoading, "IsRunListLoading should be false after load completes");
Assert.Equal(2, vm.RunRows.Count);
Assert.Equal("Program1", vm.RunRows[0].ProgramName);
Assert.Equal("Program2", vm.RunRows[1].ProgramName);
}
// ── Task 4.2: LoadDetailAsync selection-change and cancellation paths ────────
/// <summary>
/// Test: Switching SelectedRun cancels prior load
/// Test: Switching SelectedRun cancels prior load (second load wins).
/// Requirements: 4.8, 11.5
/// </summary>
[Fact]
public async Task LoadDetailAsync_WhenSelectedRunChanges_CancelsPriorLoad()
public async Task LoadDetailAsync_WhenSelectedRunChanges_SecondLoadWins()
{
// Arrange
var runId1 = Guid.NewGuid();
@@ -104,77 +453,60 @@ namespace XplorePlane.Tests.ViewModels
var detail1 = CreateMockDetail(runId1, "Program1");
var detail2 = CreateMockDetail(runId2, "Program2");
var tcs1 = new TaskCompletionSource<InspectionRunDetail>();
var tcs2 = new TaskCompletionSource<InspectionRunDetail>();
var firstDetailStarted = new SemaphoreSlim(0, 1);
var firstDetailCanProceed = new SemaphoreSlim(0, 1);
var detailCallCount = 0;
var mockStore = new Mock<IInspectionResultStore>();
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());
// Setup QueryRunsAsync to return two runs
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord>
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
.Returns<Guid>(async id =>
{
detail1.Run,
detail2.Run
var n = Interlocked.Increment(ref detailCallCount);
if (n == 1)
{
firstDetailStarted.Release();
await firstDetailCanProceed.WaitAsync();
return detail1;
}
return detail2;
});
var callCount = 0;
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.Returns<Guid, CancellationToken>((id, ct) =>
{
callCount++;
if (callCount == 1)
{
// First call - register cancellation callback
ct.Register(() => tcs1.TrySetCanceled());
return tcs1.Task;
}
else
{
// Second call - complete immediately
tcs2.SetResult(detail2);
return tcs2.Task;
}
});
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
var vm = CreateVm(mockStore);
// Start first detail load
var firstDetailLoad = vm.LoadDetailAsync(runId1);
// Wait for initial load to complete
await Task.Delay(100);
// Wait for first detail load to start
await firstDetailStarted.WaitAsync(TimeSpan.FromSeconds(2));
// Act
// Select first run (starts loading)
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
});
// Act - start second detail load while first is still blocked
var secondDetailLoad = vm.LoadDetailAsync(runId2);
// Give the first load a moment to start
await Task.Delay(50);
// Allow first detail call to return (result will be discarded due to cancellation check)
firstDetailCanProceed.Release();
// Select second run (should cancel first load)
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run);
});
await Task.WhenAll(firstDetailLoad, secondDetailLoad);
// Wait for second load to complete
await Task.Delay(200);
// Assert
Assert.True(tcs1.Task.IsCanceled, "First load should have been cancelled");
Assert.True(tcs2.Task.IsCompletedSuccessfully, "Second load should have completed");
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
});
// Assert - second detail should be loaded, not first
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
Assert.False(vm.HasDetailError);
}
/// <summary>
/// Test: Cancellation is silent (no error displayed)
/// Test: Cancellation of LoadDetailAsync is silent (no error displayed).
/// Requirements: 11.5
/// </summary>
[Fact]
@@ -184,55 +516,54 @@ namespace XplorePlane.Tests.ViewModels
var runId = Guid.NewGuid();
var detail = CreateMockDetail(runId);
var tcs = new TaskCompletionSource<InspectionRunDetail>();
var detailStarted = new SemaphoreSlim(0, 1);
var detailCanProceed = new SemaphoreSlim(0, 1);
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.Returns<Guid, CancellationToken>((id, ct) =>
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
.Returns<Guid>(async id =>
{
ct.Register(() => tcs.TrySetCanceled());
return tcs.Task;
detailStarted.Release();
await detailCanProceed.WaitAsync();
return detail;
});
var vm = CreateVm(mockStore);
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Wait for initial load
await Task.Delay(100);
// Start first detail load
var firstLoad = vm.LoadDetailAsync(runId);
// Act
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
});
// Wait for it to start
await detailStarted.WaitAsync(TimeSpan.FromSeconds(2));
// Give it a moment to start loading
await Task.Delay(50);
// Act - start second load to cancel the first
var secondLoad = vm.LoadDetailAsync(runId);
// Cancel by selecting null
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = null;
});
// Allow first call to return
detailCanProceed.Release();
// Wait for cancellation to propagate
await Task.Delay(200);
await Task.WhenAll(firstLoad, secondLoad);
// Assert - cancellation should be silent (no error state)
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.False(vm.HasDetailError, "HasDetailError should be false after cancellation");
Assert.Null(vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after cancellation");
});
Assert.False(vm.HasDetailError, "HasDetailError should be false after cancellation");
Assert.Null(vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after cancellation");
}
/// <summary>
/// Test: Error sets HasDetailError
/// Test: Error sets HasDetailError.
/// Requirements: 4.8
/// </summary>
[Fact]
@@ -240,45 +571,38 @@ namespace XplorePlane.Tests.ViewModels
{
// Arrange
var runId = Guid.NewGuid();
var detail = CreateMockDetail(runId);
var expectedErrorMessage = "Database connection failed";
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ReturnsAsync(new List<InspectionRunRecord>());
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
.ThrowsAsync(new InvalidOperationException(expectedErrorMessage));
var vm = CreateVm(mockStore);
// Wait for initial load
await Task.Delay(100);
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Act
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
});
// Wait for error to be processed
await Task.Delay(200);
await vm.LoadDetailAsync(runId);
// Assert
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.True(vm.HasDetailError, "HasDetailError should be true after error");
Assert.NotNull(vm.DetailError);
Assert.Contains(expectedErrorMessage, vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after error");
});
Assert.True(vm.HasDetailError, "HasDetailError should be true after error");
Assert.NotNull(vm.DetailError);
Assert.Contains(expectedErrorMessage, vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after error");
}
/// <summary>
/// Test: Successful load clears error state and populates detail
/// Test: Successful load clears error state and populates detail.
/// Requirements: 4.8
/// </summary>
[Fact]
@@ -289,45 +613,39 @@ namespace XplorePlane.Tests.ViewModels
var detail = CreateMockDetail(runId, "SuccessProgram");
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(runId, It.IsAny<CancellationToken>()))
.Setup(s => s.GetRunDetailAsync(runId))
.ReturnsAsync(detail);
var vm = CreateVm(mockStore);
// Wait for initial load
await Task.Delay(100);
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Act
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
});
// Wait for load to complete
await Task.Delay(200);
await vm.LoadDetailAsync(runId);
// Assert
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.False(vm.HasDetailError, "HasDetailError should be false after successful load");
Assert.Null(vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after load completes");
Assert.NotNull(vm.DetailRun);
Assert.Equal("SuccessProgram", vm.DetailRun.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
Assert.Equal("Node_0", vm.DetailNodes[0].NodeName);
Assert.Equal("Node_1", vm.DetailNodes[1].NodeName);
});
Assert.False(vm.HasDetailError, "HasDetailError should be false after successful load");
Assert.Null(vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after load completes");
Assert.NotNull(vm.DetailRun);
Assert.Equal("SuccessProgram", vm.DetailRun.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
Assert.Equal("Node_0", vm.DetailNodes[0].NodeName);
Assert.Equal("Node_1", vm.DetailNodes[1].NodeName);
}
/// <summary>
/// Test: LoadDetailAsync clears previous detail state before loading
/// Test: LoadDetailAsync clears previous detail state before loading.
/// Requirements: 4.8
/// </summary>
[Fact]
@@ -340,53 +658,39 @@ namespace XplorePlane.Tests.ViewModels
var detail2 = CreateMockDetail(runId2, "Program2");
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(runId1, It.IsAny<CancellationToken>()))
.Setup(s => s.GetRunDetailAsync(runId1))
.ReturnsAsync(detail1);
mockStore
.Setup(s => s.GetRunDetailAsync(runId2, It.IsAny<CancellationToken>()))
.Setup(s => s.GetRunDetailAsync(runId2))
.ReturnsAsync(detail2);
var vm = CreateVm(mockStore);
var vm = new InspectionReportViewerViewModel(
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
// Wait for initial load
await Task.Delay(100);
// Load first detail
await vm.LoadDetailAsync(runId1);
// Act - Load first detail
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
});
Assert.Equal("Program1", vm.DetailRun?.ProgramName);
await Task.Delay(200);
// Verify first detail is loaded
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.Equal("Program1", vm.DetailRun?.ProgramName);
});
// Load second detail
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run);
});
await Task.Delay(200);
// Act - Load second detail
await vm.LoadDetailAsync(runId2);
// Assert - second detail should replace first
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
Assert.All(vm.DetailNodes, node => Assert.Contains("Node_", node.NodeName));
});
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
Assert.All(vm.DetailNodes, node => Assert.Contains("Node_", node.NodeName));
}
}
}
@@ -12,6 +12,7 @@
<Compile Remove="Helpers\Define.cs" />
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
<Compile Remove="ViewModels\ViewportPanelViewModelPropertyTests.cs" />
</ItemGroup>
<ItemGroup>
+2
View File
@@ -433,10 +433,12 @@ namespace XplorePlane
containerRegistry.Register<CncEditorViewModel>();
containerRegistry.Register<MatrixEditorViewModel>();
containerRegistry.Register<MeasurementStatsViewModel>();
containerRegistry.Register<InspectionReportViewerViewModel>();
// ── CNC / 矩阵导航视图 ──
containerRegistry.RegisterForNavigation<CncPageView>();
containerRegistry.RegisterForNavigation<MatrixPageView>();
containerRegistry.Register<InspectionReportViewerWindow>();
// ── 导航相机服务(单例)──
containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>();
+296
View File
@@ -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
{
/// <summary>
/// 可缩放、平移的图像查看器控件。
/// 支持鼠标滚轮缩放(25%-400%)、拖拽平移、双击全屏。
/// </summary>
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
}
}
+45
View File
@@ -122,4 +122,49 @@
</Setter.Value>
</Setter>
</Style>
<!-- ZoomableImageViewer Style -->
<Style TargetType="{x:Type controls:ZoomableImageViewer}">
<Setter Property="Background" Value="#F5F5F5" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type controls:ZoomableImageViewer}">
<Border
x:Name="PART_Container"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<!-- Image Display -->
<Image
x:Name="PART_Image"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{TemplateBinding Source}"
Stretch="None" />
<!-- Fallback Placeholder -->
<Grid x:Name="PART_Placeholder" Visibility="Collapsed">
<Rectangle Fill="#E0E0E0" />
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="14"
Foreground="#757575"
Text="{TemplateBinding FallbackText}" />
</Grid>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="Source" Value="{x:Null}">
<Setter TargetName="PART_Image" Property="Visibility" Value="Collapsed" />
<Setter TargetName="PART_Placeholder" Property="Visibility" Value="Visible" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
@@ -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<InspectionReportViewerViewModel>();
var window = _containerProvider.Resolve<InspectionReportViewerWindow>();
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();
@@ -0,0 +1,695 @@
<Window
x:Class="XplorePlane.Views.Cnc.InspectionReportViewerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:XplorePlane.Controls"
xmlns:converters="clr-namespace:XplorePlane.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
Title="CNC 检测记录查看器"
Width="1280"
Height="800"
WindowStartupLocation="CenterOwner"
mc:Ignorable="d">
<Window.Resources>
<converters:PassFailColorConverter x:Key="PassFailColorConverter" />
<converters:RunStatusColorConverter x:Key="RunStatusColorConverter" />
<converters:EventTypeIconConverter x:Key="EventTypeIconConverter" />
<converters:NullToPlaceholderConverter x:Key="NullToPlaceholderConverter" />
<BooleanToVisibilityConverter x:Key="BoolToVisConverter" />
</Window.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="380" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Left Panel: FilterBar + RunList -->
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- FilterBar -->
<Border
Grid.Row="0"
Background="#F9F9F9"
BorderBrush="#E0E0E0"
BorderThickness="0,0,0,1"
Padding="10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Row 1 -->
<StackPanel Grid.Row="0" Grid.Column="0" Margin="0,0,5,5">
<TextBlock
Margin="0,0,0,3"
FontSize="11"
Foreground="#666"
Text="开始日期" />
<DatePicker SelectedDate="{Binding FilterFrom, Mode=TwoWay}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Margin="5,0,5,5">
<TextBlock
Margin="0,0,0,3"
FontSize="11"
Foreground="#666"
Text="结束日期" />
<DatePicker SelectedDate="{Binding FilterTo, Mode=TwoWay}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2" Margin="5,0,0,5">
<TextBlock
Margin="0,0,0,3"
FontSize="11"
Foreground="#666"
Text="程序名" />
<TextBox MaxLength="100" Text="{Binding FilterProgramName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<!-- Row 2 -->
<StackPanel Grid.Row="1" Grid.Column="0" Margin="0,0,5,0">
<TextBlock
Margin="0,0,0,3"
FontSize="11"
Foreground="#666"
Text="工件号" />
<TextBox MaxLength="100" Text="{Binding FilterWorkpieceId, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Margin="5,0,5,0">
<TextBlock
Margin="0,0,0,3"
FontSize="11"
Foreground="#666"
Text="序列号" />
<TextBox MaxLength="100" Text="{Binding FilterSerialNumber, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="2" Margin="5,0,0,0">
<TextBlock
Margin="0,0,0,3"
FontSize="11"
Foreground="#666"
Text="判定" />
<ComboBox SelectedIndex="{Binding FilterOverallPass, Mode=TwoWay}">
<ComboBoxItem Content="全部" />
<ComboBoxItem Content="Pass" />
<ComboBoxItem Content="Fail" />
</ComboBox>
</StackPanel>
</Grid>
</Border>
<!-- Button Bar -->
<Border
Grid.Row="0"
Margin="10,0,10,10"
VerticalAlignment="Bottom"
Background="#F9F9F9">
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
Width="60"
Height="28"
Margin="0,0,5,0"
Command="{Binding QueryCommand}"
Content="查询" />
<Button
Width="60"
Height="28"
Command="{Binding ResetFilterCommand}"
Content="重置" />
</StackPanel>
</Border>
<!-- RunList -->
<Grid Grid.Row="1">
<!-- Toolbar -->
<Border
Height="36"
VerticalAlignment="Top"
Background="White"
BorderBrush="#E0E0E0"
BorderThickness="0,0,0,1">
<Button
Width="32"
Height="32"
Margin="5,0,0,0"
HorizontalAlignment="Left"
Command="{Binding RefreshCommand}"
Content="🔄"
ToolTip="刷新列表" />
</Border>
<!-- GridView -->
<telerik:RadGridView
Margin="0,36,0,0"
AutoGenerateColumns="False"
IsReadOnly="True"
ItemsSource="{Binding RunRows}"
SelectedItem="{Binding SelectedRun, Mode=TwoWay}"
SelectionMode="Single"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<telerik:RadGridView.Columns>
<telerik:GridViewDataColumn
Width="100"
DataMemberBinding="{Binding ProgramName}"
Header="程序名" />
<telerik:GridViewDataColumn
Width="80"
DataMemberBinding="{Binding WorkpieceId}"
Header="工件号" />
<telerik:GridViewDataColumn
Width="80"
DataMemberBinding="{Binding SerialNumber}"
Header="序列号" />
<telerik:GridViewDataColumn
Width="140"
DataMemberBinding="{Binding StartedAtLocal}"
Header="开始时间" />
<telerik:GridViewColumn Width="60" Header="判定">
<telerik:GridViewColumn.CellTemplate>
<DataTemplate>
<Border
Padding="6,2"
Background="{Binding OverallPass, Converter={StaticResource PassFailColorConverter}}"
CornerRadius="3">
<TextBlock
HorizontalAlignment="Center"
FontSize="11"
FontWeight="SemiBold"
Foreground="White"
Text="{Binding PassLabel}" />
</Border>
</DataTemplate>
</telerik:GridViewColumn.CellTemplate>
</telerik:GridViewColumn>
<telerik:GridViewColumn Width="60" Header="状态">
<telerik:GridViewColumn.CellTemplate>
<DataTemplate>
<Ellipse
Width="10"
Height="10"
HorizontalAlignment="Center"
Fill="{Binding Status, Converter={StaticResource RunStatusColorConverter}}" />
</DataTemplate>
</telerik:GridViewColumn.CellTemplate>
</telerik:GridViewColumn>
</telerik:RadGridView.Columns>
</telerik:RadGridView>
<!-- Loading Indicator -->
<telerik:RadBusyIndicator IsBusy="{Binding IsRunListLoading}" />
<!-- Error State -->
<Border
Padding="20"
Background="White"
Visibility="{Binding HasRunListError, Converter={StaticResource BoolToVisConverter}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock
Margin="0,0,0,10"
FontSize="14"
Foreground="#D32F2F"
Text="{Binding RunListError}"
TextWrapping="Wrap" />
<Button
Width="80"
Height="32"
Command="{Binding RetryRunListCommand}"
Content="重试" />
</StackPanel>
</Border>
<!-- Empty State -->
<Border
Padding="20"
Background="White"
Visibility="Collapsed">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding RunRows.Count}" Value="0" />
<Condition Binding="{Binding IsRunListLoading}" Value="False" />
<Condition Binding="{Binding HasRunListError}" Value="False" />
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible" />
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="14"
Foreground="#999"
Text="未找到匹配记录" />
</Border>
</Grid>
</Grid>
<!-- Right Panel: DetailPanel -->
<ScrollViewer
Grid.Column="1"
Background="White"
VerticalScrollBarVisibility="Auto">
<Grid Margin="20">
<!-- Loading Indicator -->
<telerik:RadBusyIndicator IsBusy="{Binding IsDetailLoading}" />
<!-- Error State -->
<Border
Padding="20"
Background="White"
Visibility="{Binding HasDetailError, Converter={StaticResource BoolToVisConverter}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock
Margin="0,0,0,10"
FontSize="14"
Foreground="#D32F2F"
Text="{Binding DetailError}"
TextWrapping="Wrap" />
<Button
Width="80"
Height="32"
Command="{Binding RetryDetailCommand}"
Content="重试" />
</StackPanel>
</Border>
<!-- Detail Content -->
<StackPanel Visibility="{Binding DetailRun, Converter={StaticResource BoolToVisConverter}}">
<!-- Summary Section -->
<TextBlock
Margin="0,0,0,10"
FontSize="16"
FontWeight="SemiBold"
Text="运行摘要" />
<UniformGrid Margin="0,0,0,15" Columns="2">
<StackPanel Margin="0,0,10,5">
<TextBlock
FontSize="11"
Foreground="#666"
Text="程序名" />
<TextBlock
FontSize="13"
FontWeight="Medium"
Text="{Binding DetailRun.ProgramName}" />
</StackPanel>
<StackPanel Margin="10,0,0,5">
<TextBlock
FontSize="11"
Foreground="#666"
Text="工件号" />
<TextBlock
FontSize="13"
FontWeight="Medium"
Text="{Binding DetailRun.WorkpieceId}" />
</StackPanel>
<StackPanel Margin="0,0,10,5">
<TextBlock
FontSize="11"
Foreground="#666"
Text="序列号" />
<TextBlock
FontSize="13"
FontWeight="Medium"
Text="{Binding DetailRun.SerialNumber}" />
</StackPanel>
<StackPanel Margin="10,0,0,5">
<TextBlock
FontSize="11"
Foreground="#666"
Text="节点总数" />
<TextBlock
FontSize="13"
FontWeight="Medium"
Text="{Binding DetailRun.NodeCount}" />
</StackPanel>
</UniformGrid>
<!-- Overall Pass Label -->
<Border
Margin="0,0,0,15"
Padding="12,6"
HorizontalAlignment="Left"
Background="{Binding DetailRun.OverallPass, Converter={StaticResource PassFailColorConverter}}"
CornerRadius="4">
<TextBlock
FontSize="14"
FontWeight="SemiBold"
Foreground="White">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="Pass" />
<Style.Triggers>
<DataTrigger Binding="{Binding DetailRun.OverallPass}" Value="False">
<Setter Property="Text" Value="Fail" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
<!-- Source Image Section -->
<TextBlock
Margin="0,0,0,10"
FontSize="16"
FontWeight="SemiBold"
Text="原始图像" />
<Border
Height="200"
Margin="0,0,0,20"
BorderBrush="#E0E0E0"
BorderThickness="1">
<controls:ZoomableImageViewer
x:Name="SourceImageViewer"
FallbackText="原图不可用"
FullScreenRequested="ZoomableImageViewer_FullScreenRequested"
Source="{Binding DetailSourceImage}" />
</Border>
<!-- Node Cards Section -->
<TextBlock
Margin="0,0,0,10"
FontSize="16"
FontWeight="SemiBold"
Text="节点详情" />
<ItemsControl ItemsSource="{Binding DetailNodes}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<!-- Node Card will be defined here -->
<Border
Margin="0,0,0,15"
Background="White"
BorderBrush="#E0E0E0"
BorderThickness="1"
CornerRadius="4">
<Border.Effect>
<DropShadowEffect
BlurRadius="8"
Direction="270"
Opacity="0.1"
ShadowDepth="2" />
</Border.Effect>
<StackPanel Margin="15">
<!-- Card Header -->
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<TextBlock
Margin="0,0,10,0"
FontSize="14"
FontWeight="SemiBold"
Text="{Binding NodeIndex, StringFormat='节点 {0}'}" />
<TextBlock
Margin="0,0,10,0"
FontSize="13"
Text="{Binding NodeName}" />
<TextBlock
FontSize="12"
Foreground="#666"
Text="{Binding PipelineName, StringFormat='({0})'}" />
</StackPanel>
<StackPanel
Grid.Column="2"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Border
Margin="0,0,10,0"
Padding="6,2"
Background="{Binding NodePass, Converter={StaticResource PassFailColorConverter}}"
CornerRadius="3">
<TextBlock
FontSize="11"
FontWeight="SemiBold"
Foreground="White"
Text="{Binding PassLabel}" />
</Border>
<Button
Width="80"
Height="24"
Command="{Binding ViewSnapshotCommand}"
Content="查看快照"
FontSize="11"
Visibility="{Binding HasPipelineSnapshot, Converter={StaticResource BoolToVisConverter}}" />
</StackPanel>
</Grid>
<!-- Asset Missing Warning -->
<Border
Margin="0,0,0,10"
Padding="8,4"
Background="#FFF3E0"
BorderBrush="#FFB74D"
BorderThickness="1"
CornerRadius="3"
Visibility="{Binding HasAssetMissingWarning, Converter={StaticResource BoolToVisConverter}}">
<TextBlock
FontSize="12"
Foreground="#E65100"
Text="⚠ 资产缺失" />
</Border>
<!-- Image Area -->
<Border
Height="180"
Margin="0,0,0,10"
BorderBrush="#E0E0E0"
BorderThickness="1">
<controls:ZoomableImageViewer
FallbackText="结果图不可用"
FullScreenRequested="ZoomableImageViewer_FullScreenRequested"
Source="{Binding CurrentImage}" />
</Border>
<Button
Width="120"
Height="28"
Margin="0,0,0,10"
HorizontalAlignment="Left"
Command="{Binding ToggleImageCommand}"
Visibility="{Binding HasInputImage, Converter={StaticResource BoolToVisConverter}}">
<Button.Content>
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="显示输入图" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsShowingInputImage}" Value="True">
<Setter Property="Text" Value="显示结果图" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button.Content>
</Button>
<!-- Metrics Table -->
<TextBlock
Margin="0,0,0,5"
FontSize="13"
FontWeight="SemiBold"
Text="数值指标" />
<DataGrid
AutoGenerateColumns="False"
HeadersVisibility="Column"
IsReadOnly="True"
ItemsSource="{Binding Metrics}">
<DataGrid.Columns>
<DataGridTextColumn
Width="*"
Binding="{Binding MetricName}"
Header="指标名" />
<DataGridTextColumn
Width="80"
Binding="{Binding MetricValue}"
Header="实测值">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsValueOutOfRange}" Value="True">
<Setter Property="FontWeight" Value="Bold" />
</DataTrigger>
</Style.Triggers>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn
Width="60"
Binding="{Binding Unit}"
Header="单位" />
<DataGridTextColumn
Width="70"
Binding="{Binding LowerLimitText}"
Header="下限" />
<DataGridTextColumn
Width="70"
Binding="{Binding UpperLimitText}"
Header="上限" />
<DataGridTextColumn
Width="60"
Header="判定">
<DataGridTextColumn.Binding>
<Binding Path="IsPass">
<Binding.Converter>
<converters:PassFailColorConverter />
</Binding.Converter>
</Binding>
</DataGridTextColumn.Binding>
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="Pass" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsPass}" Value="False">
<Setter Property="Text" Value="Fail" />
</DataTrigger>
</Style.Triggers>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
</DataGrid.Columns>
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="{Binding RowBackground}" />
</Style>
</DataGrid.RowStyle>
</DataGrid>
<TextBlock
Margin="0,10,0,0"
FontSize="12"
Foreground="#999"
Text="无数值指标">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding Metrics.Count}" Value="0">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Event Timeline Section -->
<telerik:RadExpander
Margin="0,20,0,0"
Header="事件时间线"
IsExpanded="{Binding IsEventTimelineExpanded, Mode=TwoWay}">
<StackPanel>
<ItemsControl Margin="10" ItemsSource="{Binding DetailEvents}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,0,0,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse
Grid.Column="0"
Width="12"
Height="12"
Margin="0,0,10,0"
Fill="{Binding IconColor}" />
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock
Margin="0,0,10,0"
FontFamily="Consolas"
FontSize="11"
Text="{Binding EventTimeLocal}" />
<TextBlock
Margin="0,0,10,0"
FontSize="12"
FontWeight="Medium"
Text="{Binding EventType}" />
<TextBlock
FontSize="11"
Foreground="#666"
Text="{Binding NodeDisplayName}" />
</StackPanel>
</Grid>
<!-- Expandable Payload -->
<Border
Margin="22,5,0,0"
Padding="8"
Background="#F5F5F5"
BorderBrush="#E0E0E0"
BorderThickness="1"
CornerRadius="3"
Visibility="{Binding HasPayload, Converter={StaticResource BoolToVisConverter}}">
<TextBlock
FontFamily="Consolas"
FontSize="11"
Text="{Binding PayloadFormatted}"
TextWrapping="Wrap" />
</Border>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock
Margin="10"
FontSize="12"
Foreground="#999"
Text="暂无事件记录">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding DetailEvents.Count}" Value="0">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</telerik:RadExpander>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
</Window>
@@ -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
{
/// <summary>
/// CNC 检测记录查看器窗口
/// </summary>
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();
}
}
}
}
@@ -0,0 +1,51 @@
<Window
x:Class="XplorePlane.Views.Cnc.SnapshotViewerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="配方快照"
Width="700"
Height="500"
WindowStartupLocation="CenterOwner"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- JSON Content -->
<ScrollViewer
Grid.Row="0"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<TextBox
x:Name="JsonTextBox"
Padding="10"
AcceptsReturn="True"
Background="White"
BorderThickness="0"
FontFamily="Consolas"
FontSize="12"
IsReadOnly="True"
TextWrapping="NoWrap" />
</ScrollViewer>
<!-- Bottom Button Bar -->
<Border
Grid.Row="1"
Background="#F5F5F5"
BorderBrush="#E0E0E0"
BorderThickness="0,1,0,0"
Padding="10">
<Button
x:Name="CopyButton"
Width="120"
Height="32"
HorizontalAlignment="Right"
Click="CopyButton_Click"
Content="复制到剪贴板" />
</Border>
</Grid>
</Window>
@@ -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
{
/// <summary>
/// 配方快照查看器窗口
/// Pipeline snapshot viewer window
/// </summary>
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;
}
}
}
+7
View File
@@ -326,6 +326,13 @@
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="浏览历史 CNC 检测结果记录"
telerik:ScreenTip.Title="检测记录"
Command="{Binding OpenInspectionReportViewerCommand}"
Size="Large"
SmallImage="/Assets/Icons/message.png"
Text="检测记录" />
<!--
<StackPanel>