新增调试页面

This commit is contained in:
zhengxuan.zhang
2026-05-16 13:44:36 +08:00
parent 0648c601d7
commit 0ccf9c529e
10 changed files with 1195 additions and 1 deletions
@@ -0,0 +1,338 @@
using Moq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows;
using Xunit;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.Debug;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// 测试 DebugPanelConfigService(任务 1.2
/// Tests DebugPanelConfigService (Task 1.2)
///
/// **Validates: Requirements 12.1-12.7**
/// </summary>
public class DebugPanelConfigServiceTests : IDisposable
{
private readonly string _tempDir;
private readonly string _configPath;
private readonly Mock<ILoggerService> _mockLogger;
private readonly Mock<ILoggerService> _mockModuleLogger;
private readonly DebugPanelConfigService _service;
public DebugPanelConfigServiceTests()
{
// 创建临时测试目录
_tempDir = Path.Combine(Path.GetTempPath(), "XplorePlaneTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_tempDir);
_configPath = Path.Combine(_tempDir, "DebugPanel.config");
// 设置 mock logger
_mockModuleLogger = new Mock<ILoggerService>();
_mockLogger = new Mock<ILoggerService>();
_mockLogger.Setup(l => l.ForModule<DebugPanelConfigService>())
.Returns(_mockModuleLogger.Object);
// 使用反射创建服务实例并设置配置路径
_service = new DebugPanelConfigService(_mockLogger.Object);
// 使用反射修改私有字段 _configPath
var field = typeof(DebugPanelConfigService).GetField("_configPath",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(_service, _configPath);
}
public void Dispose()
{
// 清理临时目录
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, true);
}
}
/// <summary>
/// 测试:当配置文件不存在时,LoadConfig 应返回默认配置
/// **Validates: Requirement 12.7**
/// </summary>
[Fact]
public void LoadConfig_WhenFileDoesNotExist_ReturnsDefaultConfig()
{
// Act
var config = _service.LoadConfig();
// Assert
Assert.NotNull(config);
Assert.NotNull(config.Window);
Assert.Equal(1200, config.Window.Width);
Assert.Equal(800, config.Window.Height);
Assert.Equal(WindowState.Normal, config.Window.State);
Assert.NotNull(config.EventFilters);
Assert.Equal(8, config.EventFilters.Count);
Assert.True(config.EventFilters["MotionState"]);
Assert.True(config.EventFilters["RaySourceState"]);
Assert.True(config.EventFilters["DetectorState"]);
Assert.True(config.EventFilters["SystemState"]);
Assert.True(config.EventFilters["CameraState"]);
Assert.True(config.EventFilters["LinkedViewState"]);
Assert.True(config.EventFilters["RecipeExecutionState"]);
Assert.True(config.EventFilters["CalibrationMatrix"]);
}
/// <summary>
/// 测试:SaveConfig 应成功保存配置到 JSON 文件
/// **Validates: Requirements 12.1-12.5**
/// </summary>
[Fact]
public void SaveConfig_WithValidConfig_SavesSuccessfully()
{
// Arrange
var config = new DebugPanelConfig
{
Window = new WindowConfig
{
Left = 150,
Top = 200,
Width = 1400,
Height = 900,
State = WindowState.Maximized
},
EventFilters = new Dictionary<string, bool>
{
["MotionState"] = false,
["RaySourceState"] = true
},
DockingLayout = "<Layout>Test</Layout>"
};
// Act
_service.SaveConfig(config);
// Assert
Assert.True(File.Exists(_configPath));
var json = File.ReadAllText(_configPath);
Assert.Contains("\"Width\": 1400", json);
Assert.Contains("\"Height\": 900", json);
Assert.Contains("\"State\": 2", json); // WindowState.Maximized = 2
Assert.Contains("\"MotionState\": false", json);
Assert.Contains("\"RaySourceState\": true", json);
Assert.Contains("\"DockingLayout\": \"<Layout>Test</Layout>\"", json);
}
/// <summary>
/// 测试:LoadConfig 应成功从 JSON 文件加载配置
/// **Validates: Requirement 12.6**
/// </summary>
[Fact]
public void LoadConfig_WithExistingFile_LoadsSuccessfully()
{
// Arrange
var originalConfig = new DebugPanelConfig
{
Window = new WindowConfig
{
Left = 250,
Top = 300,
Width = 1600,
Height = 1000,
State = WindowState.Normal
},
EventFilters = new Dictionary<string, bool>
{
["MotionState"] = true,
["RaySourceState"] = false,
["DetectorState"] = true
},
DockingLayout = "<Layout>Custom</Layout>"
};
_service.SaveConfig(originalConfig);
// Act
var loadedConfig = _service.LoadConfig();
// Assert
Assert.NotNull(loadedConfig);
Assert.NotNull(loadedConfig.Window);
Assert.Equal(250, loadedConfig.Window.Left);
Assert.Equal(300, loadedConfig.Window.Top);
Assert.Equal(1600, loadedConfig.Window.Width);
Assert.Equal(1000, loadedConfig.Window.Height);
Assert.Equal(WindowState.Normal, loadedConfig.Window.State);
Assert.NotNull(loadedConfig.EventFilters);
Assert.True(loadedConfig.EventFilters["MotionState"]);
Assert.False(loadedConfig.EventFilters["RaySourceState"]);
Assert.True(loadedConfig.EventFilters["DetectorState"]);
Assert.Equal("<Layout>Custom</Layout>", loadedConfig.DockingLayout);
}
/// <summary>
/// 测试:SaveConfig 应创建不存在的目录
/// **Validates: Requirement 12.4**
/// </summary>
[Fact]
public void SaveConfig_WhenDirectoryDoesNotExist_CreatesDirectory()
{
// Arrange
var nestedPath = Path.Combine(_tempDir, "nested", "path", "DebugPanel.config");
var field = typeof(DebugPanelConfigService).GetField("_configPath",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(_service, nestedPath);
var config = new DebugPanelConfig
{
Window = new WindowConfig { Width = 1200, Height = 800, State = WindowState.Normal },
EventFilters = new Dictionary<string, bool>()
};
// Act
_service.SaveConfig(config);
// Assert
Assert.True(File.Exists(nestedPath));
Assert.True(Directory.Exists(Path.GetDirectoryName(nestedPath)));
}
/// <summary>
/// 测试:SaveConfig 使用 null 配置应记录警告并不抛出异常
/// **Validates: Requirement 12.7**
/// </summary>
[Fact]
public void SaveConfig_WithNullConfig_LogsWarningAndDoesNotThrow()
{
// Act & Assert
var exception = Record.Exception(() => _service.SaveConfig(null));
Assert.Null(exception);
// 验证记录了警告日志
_mockModuleLogger.Verify(
l => l.Warn(It.IsAny<string>(), It.IsAny<object[]>()),
Times.Once);
}
/// <summary>
/// 测试:LoadConfig 遇到损坏的 JSON 文件应返回默认配置并记录警告
/// **Validates: Requirement 12.7**
/// </summary>
[Fact]
public void LoadConfig_WithCorruptedFile_ReturnsDefaultConfigAndLogsWarning()
{
// Arrange
File.WriteAllText(_configPath, "{ invalid json content }");
// Act
var config = _service.LoadConfig();
// Assert
Assert.NotNull(config);
Assert.Equal(1200, config.Window.Width);
Assert.Equal(800, config.Window.Height);
// 验证记录了错误日志
_mockModuleLogger.Verify(
l => l.Error(It.IsAny<Exception>(), It.IsAny<string>(), It.IsAny<object[]>()),
Times.Once);
}
/// <summary>
/// 测试:配置文件路径应使用 %AppData%\XplorePlane\DebugPanel.config
/// **Validates: Requirement 12.4**
/// </summary>
[Fact]
public void Constructor_SetsCorrectConfigPath()
{
// Arrange
var freshService = new DebugPanelConfigService(_mockLogger.Object);
var field = typeof(DebugPanelConfigService).GetField("_configPath",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
// Act
var actualPath = field?.GetValue(freshService) as string;
// Assert
Assert.NotNull(actualPath);
var expectedPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"XplorePlane",
"DebugPanel.config");
Assert.Equal(expectedPath, actualPath);
}
/// <summary>
/// 测试:配置文件应使用 JSON 格式
/// **Validates: Requirement 12.5**
/// </summary>
[Fact]
public void SaveConfig_UsesJsonFormat()
{
// Arrange
var config = new DebugPanelConfig
{
Window = new WindowConfig { Width = 1200, Height = 800, State = WindowState.Normal },
EventFilters = new Dictionary<string, bool> { ["Test"] = true }
};
// Act
_service.SaveConfig(config);
// Assert
var json = File.ReadAllText(_configPath);
Assert.StartsWith("{", json.Trim());
Assert.EndsWith("}", json.Trim());
Assert.Contains("\"Window\":", json);
Assert.Contains("\"EventFilters\":", json);
}
/// <summary>
/// 测试:默认配置应包含所有 8 种状态类型的过滤器
/// **Validates: Requirement 12.3**
/// </summary>
[Fact]
public void GetDefaultConfig_ContainsAllStateTypeFilters()
{
// Act
var config = _service.LoadConfig();
// Assert
Assert.NotNull(config.EventFilters);
Assert.Equal(8, config.EventFilters.Count);
Assert.Contains("MotionState", config.EventFilters.Keys);
Assert.Contains("RaySourceState", config.EventFilters.Keys);
Assert.Contains("DetectorState", config.EventFilters.Keys);
Assert.Contains("SystemState", config.EventFilters.Keys);
Assert.Contains("CameraState", config.EventFilters.Keys);
Assert.Contains("LinkedViewState", config.EventFilters.Keys);
Assert.Contains("RecipeExecutionState", config.EventFilters.Keys);
Assert.Contains("CalibrationMatrix", config.EventFilters.Keys);
}
/// <summary>
/// 测试:默认窗口配置应为 1200×800 像素
/// **Validates: Requirements 12.1**
/// </summary>
[Fact]
public void GetDefaultConfig_HasCorrectWindowSize()
{
// Act
var config = _service.LoadConfig();
// Assert
Assert.Equal(1200, config.Window.Width);
Assert.Equal(800, config.Window.Height);
Assert.Equal(WindowState.Normal, config.Window.State);
}
/// <summary>
/// 测试:构造函数应拒绝 null logger
/// </summary>
[Fact]
public void Constructor_WithNullLogger_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new DebugPanelConfigService(null));
}
}
}
@@ -0,0 +1,212 @@
using Moq;
using System;
using System.Linq;
using System.Windows.Threading;
using Xunit;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.ViewModels.Debug;
namespace XplorePlane.Tests.ViewModels
{
/// <summary>
/// EventLogViewModel 单元测试
/// Unit tests for EventLogViewModel
/// </summary>
public class EventLogViewModelTests
{
private readonly Mock<IAppStateService> _mockAppStateService;
private readonly Mock<ILoggerService> _mockLoggerService;
private readonly Dispatcher _dispatcher;
public EventLogViewModelTests()
{
_mockAppStateService = new Mock<IAppStateService>();
_mockLoggerService = new Mock<ILoggerService>();
// 创建 Dispatcher(需要在 STA 线程上)
// Create Dispatcher (needs to be on STA thread)
_dispatcher = Dispatcher.CurrentDispatcher;
// 设置 logger mock 返回自身(链式调用)
// Setup logger mock to return itself (method chaining)
_mockLoggerService.Setup(l => l.ForModule<EventLogViewModel>())
.Returns(_mockLoggerService.Object);
}
[Fact]
public void Constructor_InitializesFilterOptionsWithAllStatesEnabled()
{
// Arrange & Act
var viewModel = new EventLogViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// Assert
Assert.Equal(8, viewModel.FilterOptions.Count);
Assert.True(viewModel.FilterOptions["MotionState"]);
Assert.True(viewModel.FilterOptions["RaySourceState"]);
Assert.True(viewModel.FilterOptions["DetectorState"]);
Assert.True(viewModel.FilterOptions["SystemState"]);
Assert.True(viewModel.FilterOptions["CameraState"]);
Assert.True(viewModel.FilterOptions["LinkedViewState"]);
Assert.True(viewModel.FilterOptions["RecipeExecutionState"]);
Assert.True(viewModel.FilterOptions["CalibrationMatrix"]);
}
[Fact]
public void ToggleFilterCommand_TogglesFilterState()
{
// Arrange
var viewModel = new EventLogViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
var initialState = viewModel.FilterOptions["MotionState"];
// Act
viewModel.ToggleFilterCommand.Execute("MotionState");
// Assert
Assert.Equal(!initialState, viewModel.FilterOptions["MotionState"]);
}
[Fact]
public void SelectAllFiltersCommand_EnablesAllFilters()
{
// Arrange
var viewModel = new EventLogViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// 先禁用所有过滤器
// First disable all filters
viewModel.ClearAllFiltersCommand.Execute();
// Act
viewModel.SelectAllFiltersCommand.Execute();
// Assert
Assert.All(viewModel.FilterOptions.Values, value => Assert.True(value));
}
[Fact]
public void ClearAllFiltersCommand_DisablesAllFilters()
{
// Arrange
var viewModel = new EventLogViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// Act
viewModel.ClearAllFiltersCommand.Execute();
// Assert
Assert.All(viewModel.FilterOptions.Values, value => Assert.False(value));
}
[Fact]
public void ApplyFilter_FiltersEventLogCorrectly()
{
// Arrange
var viewModel = new EventLogViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// 添加测试数据
// Add test data
viewModel.EventLog.Add(new EventLogEntry
{
Timestamp = DateTime.Now,
EventType = "MotionStateChanged",
FieldName = "StageX",
OldValue = "0",
NewValue = "100",
Category = "MotionState"
});
viewModel.EventLog.Add(new EventLogEntry
{
Timestamp = DateTime.Now,
EventType = "RaySourceStateChanged",
FieldName = "IsOn",
OldValue = "False",
NewValue = "True",
Category = "RaySourceState"
});
// 禁用 RaySourceState 过滤器
// Disable RaySourceState filter
viewModel.FilterOptions["RaySourceState"] = false;
// Act
viewModel.ToggleFilterCommand.Execute("MotionState"); // 触发过滤器应用
viewModel.ToggleFilterCommand.Execute("MotionState"); // 恢复状态
// Assert
Assert.Single(viewModel.FilteredEventLog);
Assert.Equal("MotionState", viewModel.FilteredEventLog[0].Category);
}
[Fact]
public void EventLog_LimitsTo1000Records()
{
// Arrange
var viewModel = new EventLogViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// Act - 添加 1001 条记录
// Act - Add 1001 records
for (int i = 0; i < 1001; i++)
{
viewModel.EventLog.Add(new EventLogEntry
{
Timestamp = DateTime.Now,
EventType = "MotionStateChanged",
FieldName = "StageX",
OldValue = i.ToString(),
NewValue = (i + 1).ToString(),
Category = "MotionState"
});
}
// Assert
Assert.Equal(1000, viewModel.EventLog.Count);
}
[Fact]
public void Dispose_ClearsCollections()
{
// Arrange
var viewModel = new EventLogViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
viewModel.EventLog.Add(new EventLogEntry
{
Timestamp = DateTime.Now,
EventType = "MotionStateChanged",
FieldName = "StageX",
OldValue = "0",
NewValue = "100",
Category = "MotionState"
});
// Act
viewModel.Dispose();
// Assert
Assert.Empty(viewModel.EventLog);
Assert.Empty(viewModel.FilteredEventLog);
}
}
}
@@ -0,0 +1,320 @@
using Moq;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
using Xunit;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.ViewModels.Debug;
namespace XplorePlane.Tests.ViewModels
{
/// <summary>
/// 状态显示 ViewModel 单元测试
/// StateDisplayViewModel unit tests
/// </summary>
public class StateDisplayViewModelTests
{
private readonly Mock<IAppStateService> _mockAppStateService;
private readonly Mock<ILoggerService> _mockLoggerService;
private readonly Mock<ILoggerService> _mockModuleLogger;
private readonly Dispatcher _dispatcher;
public StateDisplayViewModelTests()
{
_mockAppStateService = new Mock<IAppStateService>();
_mockLoggerService = new Mock<ILoggerService>();
_mockModuleLogger = new Mock<ILoggerService>();
// Setup logger to return module logger
_mockLoggerService
.Setup(l => l.ForModule<StateDisplayViewModel>())
.Returns(_mockModuleLogger.Object);
// Create a dispatcher for the current thread
_dispatcher = Dispatcher.CurrentDispatcher;
}
[Fact]
public void Constructor_InitializesStateTree_WithEightRootNodes()
{
// Arrange & Act
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// Assert
Assert.Equal(8, viewModel.StateTree.Count);
Assert.Contains(viewModel.StateTree, n => n.Name == "MotionState");
Assert.Contains(viewModel.StateTree, n => n.Name == "RaySourceState");
Assert.Contains(viewModel.StateTree, n => n.Name == "DetectorState");
Assert.Contains(viewModel.StateTree, n => n.Name == "SystemState");
Assert.Contains(viewModel.StateTree, n => n.Name == "CameraState");
Assert.Contains(viewModel.StateTree, n => n.Name == "LinkedViewState");
Assert.Contains(viewModel.StateTree, n => n.Name == "RecipeExecutionState");
Assert.Contains(viewModel.StateTree, n => n.Name == "CalibrationMatrix");
}
[Fact]
public void Constructor_InitializesMotionStateFields_WithCorrectCount()
{
// Arrange & Act
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// Assert
var motionStateNode = viewModel.StateTree.First(n => n.Name == "MotionState");
Assert.Equal(18, motionStateNode.Children.Count);
}
[Fact]
public void Initialize_SubscribesToAllStateChangeEvents()
{
// Arrange
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
// Act
viewModel.Initialize();
// Assert
_mockAppStateService.VerifyAdd(s => s.MotionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<MotionState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.RaySourceStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<RaySourceState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.DetectorStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<DetectorState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.SystemStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<SystemState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.CameraStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<CameraState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.LinkedViewStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<LinkedViewState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.RecipeExecutionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<RecipeExecutionState>>>(), Times.Once);
}
[Fact]
public void Dispose_UnsubscribesFromAllStateChangeEvents()
{
// Arrange
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
viewModel.Initialize();
// Act
viewModel.Dispose();
// Assert
_mockAppStateService.VerifyRemove(s => s.MotionStateChanged -= It.IsAny<EventHandler<StateChangedEventArgs<MotionState>>>(), Times.Once);
_mockAppStateService.VerifyRemove(s => s.RaySourceStateChanged -= It.IsAny<EventHandler<StateChangedEventArgs<RaySourceState>>>(), Times.Once);
_mockAppStateService.VerifyRemove(s => s.DetectorStateChanged -= It.IsAny<EventHandler<StateChangedEventArgs<DetectorState>>>(), Times.Once);
_mockAppStateService.VerifyRemove(s => s.SystemStateChanged -= It.IsAny<EventHandler<StateChangedEventArgs<SystemState>>>(), Times.Once);
_mockAppStateService.VerifyRemove(s => s.CameraStateChanged -= It.IsAny<EventHandler<StateChangedEventArgs<CameraState>>>(), Times.Once);
_mockAppStateService.VerifyRemove(s => s.LinkedViewStateChanged -= It.IsAny<EventHandler<StateChangedEventArgs<LinkedViewState>>>(), Times.Once);
_mockAppStateService.VerifyRemove(s => s.RecipeExecutionStateChanged -= It.IsAny<EventHandler<StateChangedEventArgs<RecipeExecutionState>>>(), Times.Once);
}
[Fact]
public void StateChange_UpdatesNodeValue_AndSetsHighlight()
{
// Arrange
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
viewModel.Initialize();
var oldState = new RaySourceState(false, 100.0, 5.0);
var newState = new RaySourceState(true, 160.0, 8.0);
// Act
_mockAppStateService.Raise(
s => s.RaySourceStateChanged += null,
_mockAppStateService.Object,
new StateChangedEventArgs<RaySourceState>(oldState, newState));
// Process dispatcher queue
DoEvents();
// Assert
var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState");
var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn");
var voltageNode = raySourceNode.Children.First(n => n.Name == "Voltage");
var powerNode = raySourceNode.Children.First(n => n.Name == "Power");
Assert.Equal("True", isOnNode.Value);
Assert.True(isOnNode.IsHighlighted);
Assert.Equal("Green", isOnNode.HighlightColor); // Boolean true -> Green
Assert.Equal("160.00", voltageNode.Value);
Assert.True(voltageNode.IsHighlighted);
Assert.Equal("Green", voltageNode.HighlightColor); // Increase -> Green
Assert.Equal("8.00", powerNode.Value);
Assert.True(powerNode.IsHighlighted);
Assert.Equal("Green", powerNode.HighlightColor); // Increase -> Green
}
[Fact]
public void StateChange_BooleanFalse_SetsRedHighlight()
{
// Arrange
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
viewModel.Initialize();
var oldState = new RaySourceState(true, 160.0, 8.0);
var newState = new RaySourceState(false, 160.0, 8.0);
// Act
_mockAppStateService.Raise(
s => s.RaySourceStateChanged += null,
_mockAppStateService.Object,
new StateChangedEventArgs<RaySourceState>(oldState, newState));
// Process dispatcher queue
DoEvents();
// Assert
var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState");
var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn");
Assert.Equal("False", isOnNode.Value);
Assert.True(isOnNode.IsHighlighted);
Assert.Equal("Red", isOnNode.HighlightColor); // Boolean false -> Red
}
[Fact]
public void StateChange_NumericDecrease_SetsRedHighlight()
{
// Arrange
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
viewModel.Initialize();
var oldState = new RaySourceState(true, 160.0, 8.0);
var newState = new RaySourceState(true, 120.0, 5.0);
// Act
_mockAppStateService.Raise(
s => s.RaySourceStateChanged += null,
_mockAppStateService.Object,
new StateChangedEventArgs<RaySourceState>(oldState, newState));
// Process dispatcher queue
DoEvents();
// Assert
var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState");
var voltageNode = raySourceNode.Children.First(n => n.Name == "Voltage");
var powerNode = raySourceNode.Children.First(n => n.Name == "Power");
Assert.Equal("120.00", voltageNode.Value);
Assert.True(voltageNode.IsHighlighted);
Assert.Equal("Red", voltageNode.HighlightColor); // Decrease -> Red
Assert.Equal("5.00", powerNode.Value);
Assert.True(powerNode.IsHighlighted);
Assert.Equal("Red", powerNode.HighlightColor); // Decrease -> Red
}
[Fact]
public async Task StateChange_HighlightClearsAfterTwoSeconds()
{
// Arrange
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
viewModel.Initialize();
var oldState = new RaySourceState(false, 100.0, 5.0);
var newState = new RaySourceState(true, 160.0, 8.0);
// Act
_mockAppStateService.Raise(
s => s.RaySourceStateChanged += null,
_mockAppStateService.Object,
new StateChangedEventArgs<RaySourceState>(oldState, newState));
// Process dispatcher queue
DoEvents();
var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState");
var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn");
// Verify highlight is set
Assert.True(isOnNode.IsHighlighted);
// Wait for 2.5 seconds (2 seconds delay + buffer)
await Task.Delay(2500);
DoEvents();
// Assert - highlight should be cleared
Assert.False(isOnNode.IsHighlighted);
}
[Fact]
public void StateChange_NoChange_DoesNotSetHighlight()
{
// Arrange
var viewModel = new StateDisplayViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
viewModel.Initialize();
var oldState = new RaySourceState(true, 160.0, 8.0);
var newState = new RaySourceState(true, 160.0, 8.0);
// Act
_mockAppStateService.Raise(
s => s.RaySourceStateChanged += null,
_mockAppStateService.Object,
new StateChangedEventArgs<RaySourceState>(oldState, newState));
// Process dispatcher queue
DoEvents();
// Assert
var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState");
var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn");
var voltageNode = raySourceNode.Children.First(n => n.Name == "Voltage");
var powerNode = raySourceNode.Children.First(n => n.Name == "Power");
// Values should be updated but not highlighted
Assert.Equal("True", isOnNode.Value);
Assert.False(isOnNode.IsHighlighted);
Assert.Equal("160.00", voltageNode.Value);
Assert.False(voltageNode.IsHighlighted);
Assert.Equal("8.00", powerNode.Value);
Assert.False(powerNode.IsHighlighted);
}
/// <summary>
/// 处理 Dispatcher 队列中的所有待处理消息
/// Process all pending messages in the Dispatcher queue
/// </summary>
private void DoEvents()
{
var frame = new DispatcherFrame();
_dispatcher.BeginInvoke(DispatcherPriority.Background, new DispatcherOperationCallback(
delegate (object f)
{
((DispatcherFrame)f).Continue = false;
return null;
}), frame);
Dispatcher.PushFrame(frame);
}
}
}