增加CNC查询页面
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
// Feature: cnc-inspection-report-viewer
|
// 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
|
// Task 4.2: Unit tests for LoadDetailAsync selection-change and cancellation paths
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@@ -9,8 +10,8 @@ using System.Threading.Tasks;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using XP.Common.Logging.Interfaces;
|
using XP.Common.Logging.Interfaces;
|
||||||
using XplorePlane.Models;
|
using XplorePlane.Models;
|
||||||
using XplorePlane.Services;
|
|
||||||
using XplorePlane.Services.InspectionResults;
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.Storage;
|
||||||
using XplorePlane.ViewModels.Cnc;
|
using XplorePlane.ViewModels.Cnc;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -20,7 +21,12 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
{
|
{
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── 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<IInspectionResultStore> mockStore = null,
|
||||||
Mock<ILoggerService> mockLogger = null,
|
Mock<ILoggerService> mockLogger = null,
|
||||||
Mock<IXpDataPathService> mockDataPathService = null)
|
Mock<IXpDataPathService> mockDataPathService = null)
|
||||||
@@ -32,15 +38,26 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
|
||||||
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
|
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
|
mockStore
|
||||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||||
.ReturnsAsync(new List<InspectionRunRecord>());
|
.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,
|
mockStore.Object,
|
||||||
mockLogger.Object,
|
mockLogger.Object,
|
||||||
mockDataPathService.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")
|
private static InspectionRunDetail CreateMockDetail(Guid runId, string programName = "TestProgram")
|
||||||
@@ -68,7 +85,7 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
NodeName = "Node_0",
|
NodeName = "Node_0",
|
||||||
PipelineName = "Pipeline_0",
|
PipelineName = "Pipeline_0",
|
||||||
NodePass = true,
|
NodePass = true,
|
||||||
Status = InspectionNodeStatus.Completed,
|
Status = InspectionNodeStatus.Succeeded,
|
||||||
DurationMs = 100
|
DurationMs = 100
|
||||||
},
|
},
|
||||||
new InspectionNodeResult
|
new InspectionNodeResult
|
||||||
@@ -78,7 +95,7 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
NodeName = "Node_1",
|
NodeName = "Node_1",
|
||||||
PipelineName = "Pipeline_1",
|
PipelineName = "Pipeline_1",
|
||||||
NodePass = true,
|
NodePass = true,
|
||||||
Status = InspectionNodeStatus.Completed,
|
Status = InspectionNodeStatus.Succeeded,
|
||||||
DurationMs = 150
|
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 ────────
|
// ── Task 4.2: LoadDetailAsync selection-change and cancellation paths ────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test: Switching SelectedRun cancels prior load
|
/// Test: Switching SelectedRun cancels prior load (second load wins).
|
||||||
/// Requirements: 4.8, 11.5
|
/// Requirements: 4.8, 11.5
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LoadDetailAsync_WhenSelectedRunChanges_CancelsPriorLoad()
|
public async Task LoadDetailAsync_WhenSelectedRunChanges_SecondLoadWins()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var runId1 = Guid.NewGuid();
|
var runId1 = Guid.NewGuid();
|
||||||
@@ -104,77 +453,60 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
var detail1 = CreateMockDetail(runId1, "Program1");
|
var detail1 = CreateMockDetail(runId1, "Program1");
|
||||||
var detail2 = CreateMockDetail(runId2, "Program2");
|
var detail2 = CreateMockDetail(runId2, "Program2");
|
||||||
|
|
||||||
var tcs1 = new TaskCompletionSource<InspectionRunDetail>();
|
var firstDetailStarted = new SemaphoreSlim(0, 1);
|
||||||
var tcs2 = new TaskCompletionSource<InspectionRunDetail>();
|
var firstDetailCanProceed = new SemaphoreSlim(0, 1);
|
||||||
|
var detailCallCount = 0;
|
||||||
|
|
||||||
var mockStore = new Mock<IInspectionResultStore>();
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
// Setup QueryRunsAsync to return two runs
|
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
|
mockStore
|
||||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||||
.ReturnsAsync(new List<InspectionRunRecord>
|
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
|
||||||
|
|
||||||
|
mockStore
|
||||||
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
|
||||||
|
.Returns<Guid>(async id =>
|
||||||
{
|
{
|
||||||
detail1.Run,
|
var n = Interlocked.Increment(ref detailCallCount);
|
||||||
detail2.Run
|
if (n == 1)
|
||||||
|
{
|
||||||
|
firstDetailStarted.Release();
|
||||||
|
await firstDetailCanProceed.WaitAsync();
|
||||||
|
return detail1;
|
||||||
|
}
|
||||||
|
return detail2;
|
||||||
});
|
});
|
||||||
|
|
||||||
var callCount = 0;
|
var vm = new InspectionReportViewerViewModel(
|
||||||
mockStore
|
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||||
.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 = CreateVm(mockStore);
|
// Start first detail load
|
||||||
|
var firstDetailLoad = vm.LoadDetailAsync(runId1);
|
||||||
|
|
||||||
// Wait for initial load to complete
|
// Wait for first detail load to start
|
||||||
await Task.Delay(100);
|
await firstDetailStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
// Act
|
// Act - start second detail load while first is still blocked
|
||||||
// Select first run (starts loading)
|
var secondDetailLoad = vm.LoadDetailAsync(runId2);
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Give the first load a moment to start
|
// Allow first detail call to return (result will be discarded due to cancellation check)
|
||||||
await Task.Delay(50);
|
firstDetailCanProceed.Release();
|
||||||
|
|
||||||
// Select second run (should cancel first load)
|
await Task.WhenAll(firstDetailLoad, secondDetailLoad);
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for second load to complete
|
// Assert - second detail should be loaded, not first
|
||||||
await Task.Delay(200);
|
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
|
||||||
|
Assert.Equal(2, vm.DetailNodes.Count);
|
||||||
// Assert
|
Assert.False(vm.HasDetailError);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test: Cancellation is silent (no error displayed)
|
/// Test: Cancellation of LoadDetailAsync is silent (no error displayed).
|
||||||
/// Requirements: 11.5
|
/// Requirements: 11.5
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -184,55 +516,54 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
var runId = Guid.NewGuid();
|
var runId = Guid.NewGuid();
|
||||||
var detail = CreateMockDetail(runId);
|
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 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
|
mockStore
|
||||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||||
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
||||||
|
|
||||||
mockStore
|
mockStore
|
||||||
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
|
||||||
.Returns<Guid, CancellationToken>((id, ct) =>
|
.Returns<Guid>(async id =>
|
||||||
{
|
{
|
||||||
ct.Register(() => tcs.TrySetCanceled());
|
detailStarted.Release();
|
||||||
return tcs.Task;
|
await detailCanProceed.WaitAsync();
|
||||||
|
return detail;
|
||||||
});
|
});
|
||||||
|
|
||||||
var vm = CreateVm(mockStore);
|
var vm = new InspectionReportViewerViewModel(
|
||||||
|
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||||
|
|
||||||
// Wait for initial load
|
// Start first detail load
|
||||||
await Task.Delay(100);
|
var firstLoad = vm.LoadDetailAsync(runId);
|
||||||
|
|
||||||
// Act
|
// Wait for it to start
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
await detailStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
{
|
|
||||||
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Give it a moment to start loading
|
// Act - start second load to cancel the first
|
||||||
await Task.Delay(50);
|
var secondLoad = vm.LoadDetailAsync(runId);
|
||||||
|
|
||||||
// Cancel by selecting null
|
// Allow first call to return
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
detailCanProceed.Release();
|
||||||
{
|
|
||||||
vm.SelectedRun = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for cancellation to propagate
|
await Task.WhenAll(firstLoad, secondLoad);
|
||||||
await Task.Delay(200);
|
|
||||||
|
|
||||||
// Assert - cancellation should be silent (no error state)
|
// 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.HasDetailError, "HasDetailError should be false after cancellation");
|
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after cancellation");
|
||||||
Assert.Null(vm.DetailError);
|
|
||||||
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after cancellation");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test: Error sets HasDetailError
|
/// Test: Error sets HasDetailError.
|
||||||
/// Requirements: 4.8
|
/// Requirements: 4.8
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -240,45 +571,38 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var runId = Guid.NewGuid();
|
var runId = Guid.NewGuid();
|
||||||
var detail = CreateMockDetail(runId);
|
|
||||||
var expectedErrorMessage = "Database connection failed";
|
var expectedErrorMessage = "Database connection failed";
|
||||||
|
|
||||||
var mockStore = new Mock<IInspectionResultStore>();
|
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
|
mockStore
|
||||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||||
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
.ReturnsAsync(new List<InspectionRunRecord>());
|
||||||
|
|
||||||
mockStore
|
mockStore
|
||||||
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>()))
|
||||||
.ThrowsAsync(new InvalidOperationException(expectedErrorMessage));
|
.ThrowsAsync(new InvalidOperationException(expectedErrorMessage));
|
||||||
|
|
||||||
var vm = CreateVm(mockStore);
|
var vm = new InspectionReportViewerViewModel(
|
||||||
|
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||||
// Wait for initial load
|
|
||||||
await Task.Delay(100);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
await vm.LoadDetailAsync(runId);
|
||||||
{
|
|
||||||
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for error to be processed
|
|
||||||
await Task.Delay(200);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
Assert.True(vm.HasDetailError, "HasDetailError should be true after error");
|
||||||
{
|
Assert.NotNull(vm.DetailError);
|
||||||
Assert.True(vm.HasDetailError, "HasDetailError should be true after error");
|
Assert.Contains(expectedErrorMessage, vm.DetailError);
|
||||||
Assert.NotNull(vm.DetailError);
|
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after error");
|
||||||
Assert.Contains(expectedErrorMessage, vm.DetailError);
|
|
||||||
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after error");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test: Successful load clears error state and populates detail
|
/// Test: Successful load clears error state and populates detail.
|
||||||
/// Requirements: 4.8
|
/// Requirements: 4.8
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -289,45 +613,39 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
var detail = CreateMockDetail(runId, "SuccessProgram");
|
var detail = CreateMockDetail(runId, "SuccessProgram");
|
||||||
|
|
||||||
var mockStore = new Mock<IInspectionResultStore>();
|
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
|
mockStore
|
||||||
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>()))
|
||||||
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
|
||||||
|
|
||||||
mockStore
|
mockStore
|
||||||
.Setup(s => s.GetRunDetailAsync(runId, It.IsAny<CancellationToken>()))
|
.Setup(s => s.GetRunDetailAsync(runId))
|
||||||
.ReturnsAsync(detail);
|
.ReturnsAsync(detail);
|
||||||
|
|
||||||
var vm = CreateVm(mockStore);
|
var vm = new InspectionReportViewerViewModel(
|
||||||
|
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||||
// Wait for initial load
|
|
||||||
await Task.Delay(100);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
await vm.LoadDetailAsync(runId);
|
||||||
{
|
|
||||||
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for load to complete
|
|
||||||
await Task.Delay(200);
|
|
||||||
|
|
||||||
// Assert
|
// 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.HasDetailError, "HasDetailError should be false after successful load");
|
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after load completes");
|
||||||
Assert.Null(vm.DetailError);
|
Assert.NotNull(vm.DetailRun);
|
||||||
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after load completes");
|
Assert.Equal("SuccessProgram", vm.DetailRun.ProgramName);
|
||||||
Assert.NotNull(vm.DetailRun);
|
Assert.Equal(2, vm.DetailNodes.Count);
|
||||||
Assert.Equal("SuccessProgram", vm.DetailRun.ProgramName);
|
Assert.Equal("Node_0", vm.DetailNodes[0].NodeName);
|
||||||
Assert.Equal(2, vm.DetailNodes.Count);
|
Assert.Equal("Node_1", vm.DetailNodes[1].NodeName);
|
||||||
Assert.Equal("Node_0", vm.DetailNodes[0].NodeName);
|
|
||||||
Assert.Equal("Node_1", vm.DetailNodes[1].NodeName);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test: LoadDetailAsync clears previous detail state before loading
|
/// Test: LoadDetailAsync clears previous detail state before loading.
|
||||||
/// Requirements: 4.8
|
/// Requirements: 4.8
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -340,53 +658,39 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
var detail2 = CreateMockDetail(runId2, "Program2");
|
var detail2 = CreateMockDetail(runId2, "Program2");
|
||||||
|
|
||||||
var mockStore = new Mock<IInspectionResultStore>();
|
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
|
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 });
|
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
|
||||||
|
|
||||||
mockStore
|
mockStore
|
||||||
.Setup(s => s.GetRunDetailAsync(runId1, It.IsAny<CancellationToken>()))
|
.Setup(s => s.GetRunDetailAsync(runId1))
|
||||||
.ReturnsAsync(detail1);
|
.ReturnsAsync(detail1);
|
||||||
|
|
||||||
mockStore
|
mockStore
|
||||||
.Setup(s => s.GetRunDetailAsync(runId2, It.IsAny<CancellationToken>()))
|
.Setup(s => s.GetRunDetailAsync(runId2))
|
||||||
.ReturnsAsync(detail2);
|
.ReturnsAsync(detail2);
|
||||||
|
|
||||||
var vm = CreateVm(mockStore);
|
var vm = new InspectionReportViewerViewModel(
|
||||||
|
mockStore.Object, mockLogger.Object, mockDataPathService.Object);
|
||||||
|
|
||||||
// Wait for initial load
|
// Load first detail
|
||||||
await Task.Delay(100);
|
await vm.LoadDetailAsync(runId1);
|
||||||
|
|
||||||
// Act - Load first detail
|
Assert.Equal("Program1", vm.DetailRun?.ProgramName);
|
||||||
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(200);
|
// Act - Load second detail
|
||||||
|
await vm.LoadDetailAsync(runId2);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Assert - second detail should replace first
|
// 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.Equal("Program2", vm.DetailRun?.ProgramName);
|
Assert.All(vm.DetailNodes, node => Assert.Contains("Node_", node.NodeName));
|
||||||
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="Helpers\Define.cs" />
|
||||||
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
|
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
|
||||||
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
|
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
|
||||||
|
<Compile Remove="ViewModels\ViewportPanelViewModelPropertyTests.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -433,10 +433,12 @@ namespace XplorePlane
|
|||||||
containerRegistry.Register<CncEditorViewModel>();
|
containerRegistry.Register<CncEditorViewModel>();
|
||||||
containerRegistry.Register<MatrixEditorViewModel>();
|
containerRegistry.Register<MatrixEditorViewModel>();
|
||||||
containerRegistry.Register<MeasurementStatsViewModel>();
|
containerRegistry.Register<MeasurementStatsViewModel>();
|
||||||
|
containerRegistry.Register<InspectionReportViewerViewModel>();
|
||||||
|
|
||||||
// ── CNC / 矩阵导航视图 ──
|
// ── CNC / 矩阵导航视图 ──
|
||||||
containerRegistry.RegisterForNavigation<CncPageView>();
|
containerRegistry.RegisterForNavigation<CncPageView>();
|
||||||
containerRegistry.RegisterForNavigation<MatrixPageView>();
|
containerRegistry.RegisterForNavigation<MatrixPageView>();
|
||||||
|
containerRegistry.Register<InspectionReportViewerWindow>();
|
||||||
|
|
||||||
// ── 导航相机服务(单例)──
|
// ── 导航相机服务(单例)──
|
||||||
containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>();
|
containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,4 +122,49 @@
|
|||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</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>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ namespace XplorePlane.ViewModels
|
|||||||
public DelegateCommand OpenPipelineEditorCommand { get; }
|
public DelegateCommand OpenPipelineEditorCommand { get; }
|
||||||
public DelegateCommand OpenCncEditorCommand { get; }
|
public DelegateCommand OpenCncEditorCommand { get; }
|
||||||
public DelegateCommand OpenMatrixEditorCommand { get; }
|
public DelegateCommand OpenMatrixEditorCommand { get; }
|
||||||
|
public DelegateCommand OpenInspectionReportViewerCommand { get; }
|
||||||
public DelegateCommand OpenToolboxCommand { get; }
|
public DelegateCommand OpenToolboxCommand { get; }
|
||||||
public DelegateCommand OpenLibraryVersionsCommand { get; }
|
public DelegateCommand OpenLibraryVersionsCommand { get; }
|
||||||
public DelegateCommand OpenUserManualCommand { get; }
|
public DelegateCommand OpenUserManualCommand { get; }
|
||||||
@@ -224,6 +225,7 @@ namespace XplorePlane.ViewModels
|
|||||||
private Window _settingsWindow;
|
private Window _settingsWindow;
|
||||||
private Window _toolboxWindow;
|
private Window _toolboxWindow;
|
||||||
private Window _raySourceConfigWindow;
|
private Window _raySourceConfigWindow;
|
||||||
|
private Window _inspectionReportViewerWindow;
|
||||||
private object _imagePanelContent;
|
private object _imagePanelContent;
|
||||||
private GridLength _viewportPanelWidth = new(1, GridUnitType.Star);
|
private GridLength _viewportPanelWidth = new(1, GridUnitType.Star);
|
||||||
private GridLength _imagePanelWidth = new(320);
|
private GridLength _imagePanelWidth = new(320);
|
||||||
@@ -293,6 +295,7 @@ namespace XplorePlane.ViewModels
|
|||||||
|
|
||||||
OpenCncEditorCommand = new DelegateCommand(ExecuteOpenCncEditor);
|
OpenCncEditorCommand = new DelegateCommand(ExecuteOpenCncEditor);
|
||||||
OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编辑"));
|
OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编辑"));
|
||||||
|
OpenInspectionReportViewerCommand = new DelegateCommand(ExecuteOpenInspectionReportViewer);
|
||||||
OpenToolboxCommand = new DelegateCommand(ExecuteOpenToolbox);
|
OpenToolboxCommand = new DelegateCommand(ExecuteOpenToolbox);
|
||||||
OpenLibraryVersionsCommand = new DelegateCommand(() => ShowWindow(new Views.LibraryVersionsWindow(), "关于"));
|
OpenLibraryVersionsCommand = new DelegateCommand(() => ShowWindow(new Views.LibraryVersionsWindow(), "关于"));
|
||||||
OpenUserManualCommand = new DelegateCommand(ExecuteOpenUserManual);
|
OpenUserManualCommand = new DelegateCommand(ExecuteOpenUserManual);
|
||||||
@@ -396,6 +399,33 @@ namespace XplorePlane.ViewModels
|
|||||||
ShowOrActivate(_toolboxWindow, w => _toolboxWindow = w, () => new Views.OperatorToolboxWindow(), "Operator Toolbox");
|
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()
|
private void ExecuteOpenCncEditor()
|
||||||
{
|
{
|
||||||
_ = ExecuteOpenCncEditorAsync();
|
_ = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -326,6 +326,13 @@
|
|||||||
Size="Large"
|
Size="Large"
|
||||||
SmallImage="/Assets/Icons/matrix.png"
|
SmallImage="/Assets/Icons/matrix.png"
|
||||||
Text="矩阵编排" />
|
Text="矩阵编排" />
|
||||||
|
<telerik:RadRibbonButton
|
||||||
|
telerik:ScreenTip.Description="浏览历史 CNC 检测结果记录"
|
||||||
|
telerik:ScreenTip.Title="检测记录"
|
||||||
|
Command="{Binding OpenInspectionReportViewerCommand}"
|
||||||
|
Size="Large"
|
||||||
|
SmallImage="/Assets/Icons/message.png"
|
||||||
|
Text="检测记录" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|||||||
Reference in New Issue
Block a user