Add UI features, alarm ack, historian UTC fix, and Client.UI documentation
Major changes across the client stack: - Settings persistence (connection, subscriptions, alarm source) - Deferred OPC UA SDK init for instant startup - Array/status code formatting, write value popup, alarm acknowledgment - Severity-colored alarm rows, condition dedup on server side - DateTimeRangePicker control with preset buttons and UTC text input - Historian queries use wwTimezone=UTC and OPCQuality column - Recursive subscribe from tree, multi-select remove - Connection panel with expander, folder chooser for cert path - Dynamic tab headers showing subscription/alarm counts - Client.UI.md documentation with headless-rendered screenshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
|
||||
public class MainWindowViewModelTests
|
||||
{
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
private readonly FakeSettingsService _settingsService;
|
||||
private readonly MainWindowViewModel _vm;
|
||||
|
||||
public MainWindowViewModelTests()
|
||||
@@ -32,9 +33,10 @@ public class MainWindowViewModelTests
|
||||
RedundancyResult = new RedundancyInfo("None", 200, ["urn:test"], "urn:test")
|
||||
};
|
||||
|
||||
_settingsService = new FakeSettingsService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(_service);
|
||||
var dispatcher = new SynchronousUiDispatcher();
|
||||
_vm = new MainWindowViewModel(factory, dispatcher);
|
||||
_vm = new MainWindowViewModel(factory, dispatcher, _settingsService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,10 +122,12 @@ public class MainWindowViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionStateChangedEvent_UpdatesState()
|
||||
public async Task ConnectionStateChangedEvent_UpdatesState()
|
||||
{
|
||||
await _vm.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
_service.RaiseConnectionStateChanged(
|
||||
new ConnectionStateChangedEventArgs(ConnectionState.Disconnected, ConnectionState.Reconnecting,
|
||||
new ConnectionStateChangedEventArgs(ConnectionState.Connected, ConnectionState.Reconnecting,
|
||||
"opc.tcp://localhost:4840"));
|
||||
|
||||
_vm.ConnectionState.ShouldBe(ConnectionState.Reconnecting);
|
||||
@@ -177,17 +181,18 @@ public class MainWindowViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertyChanged_FiredForConnectionState()
|
||||
public async Task PropertyChanged_FiredForConnectionState()
|
||||
{
|
||||
await _vm.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var changed = new List<string>();
|
||||
_vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);
|
||||
|
||||
_service.RaiseConnectionStateChanged(
|
||||
new ConnectionStateChangedEventArgs(ConnectionState.Disconnected, ConnectionState.Connected,
|
||||
new ConnectionStateChangedEventArgs(ConnectionState.Connected, ConnectionState.Reconnecting,
|
||||
"opc.tcp://localhost:4840"));
|
||||
|
||||
changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState));
|
||||
changed.ShouldContain(nameof(MainWindowViewModel.IsConnected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -258,13 +263,15 @@ public class MainWindowViewModelTests
|
||||
{
|
||||
await _vm.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var node1 = _vm.BrowseTree.RootNodes[0];
|
||||
_vm.SelectedTreeNodes.Add(node1);
|
||||
// Use a Variable node so recursive subscribe subscribes it directly
|
||||
var varNode = new TreeNodeViewModel(
|
||||
"ns=2;s=TestVar", "TestVar", "Variable", false, _service, new SynchronousUiDispatcher());
|
||||
_vm.SelectedTreeNodes.Add(varNode);
|
||||
|
||||
await _vm.SubscribeSelectedNodesCommand.ExecuteAsync(null);
|
||||
|
||||
_vm.Subscriptions.ActiveSubscriptions.Count.ShouldBe(1);
|
||||
_vm.Subscriptions.ActiveSubscriptions[0].NodeId.ShouldBe(node1.NodeId);
|
||||
_vm.Subscriptions!.ActiveSubscriptions.Count.ShouldBe(1);
|
||||
_vm.Subscriptions.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=TestVar");
|
||||
_vm.SelectedTabIndex.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -327,4 +334,107 @@ public class MainWindowViewModelTests
|
||||
|
||||
_vm.IsHistoryEnabledForSelection.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_LoadsSettingsFromService()
|
||||
{
|
||||
_settingsService.LoadCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AppliesSavedSettings()
|
||||
{
|
||||
var saved = new UserSettings
|
||||
{
|
||||
EndpointUrl = "opc.tcp://saved:5555",
|
||||
Username = "savedUser",
|
||||
Password = "savedPass",
|
||||
SecurityMode = SecurityMode.Sign,
|
||||
FailoverUrls = "opc.tcp://backup:5555",
|
||||
SessionTimeoutSeconds = 120,
|
||||
AutoAcceptCertificates = false,
|
||||
CertificateStorePath = "/custom/path"
|
||||
};
|
||||
|
||||
var settingsService = new FakeSettingsService { Settings = saved };
|
||||
var service = new FakeOpcUaClientService
|
||||
{
|
||||
ConnectResult = _service.ConnectResult,
|
||||
BrowseResults = _service.BrowseResults,
|
||||
RedundancyResult = _service.RedundancyResult
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(service);
|
||||
var vm = new MainWindowViewModel(factory, new SynchronousUiDispatcher(), settingsService);
|
||||
|
||||
vm.EndpointUrl.ShouldBe("opc.tcp://saved:5555");
|
||||
vm.Username.ShouldBe("savedUser");
|
||||
vm.Password.ShouldBe("savedPass");
|
||||
vm.SelectedSecurityMode.ShouldBe(SecurityMode.Sign);
|
||||
vm.FailoverUrls.ShouldBe("opc.tcp://backup:5555");
|
||||
vm.SessionTimeoutSeconds.ShouldBe(120);
|
||||
vm.AutoAcceptCertificates.ShouldBeFalse();
|
||||
vm.CertificateStorePath.ShouldBe("/custom/path");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectCommand_SavesSettingsOnSuccess()
|
||||
{
|
||||
_vm.EndpointUrl = "opc.tcp://myserver:4840";
|
||||
_vm.Username = "admin";
|
||||
|
||||
await _vm.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
_settingsService.SaveCallCount.ShouldBe(1);
|
||||
_settingsService.LastSaved.ShouldNotBeNull();
|
||||
_settingsService.LastSaved!.EndpointUrl.ShouldBe("opc.tcp://myserver:4840");
|
||||
_settingsService.LastSaved.Username.ShouldBe("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectCommand_DoesNotSaveOnFailure()
|
||||
{
|
||||
_service.ConnectException = new Exception("Connection refused");
|
||||
|
||||
await _vm.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
_settingsService.SaveCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectCommand_SavesSubscribedNodes()
|
||||
{
|
||||
await _vm.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// Add a subscription
|
||||
_vm.Subscriptions.IsConnected = true;
|
||||
await _vm.Subscriptions.AddSubscriptionForNodeAsync("ns=2;s=TestSub");
|
||||
|
||||
// Disconnect saves settings including subscriptions
|
||||
await _vm.DisconnectCommand.ExecuteAsync(null);
|
||||
|
||||
_settingsService.LastSaved.ShouldNotBeNull();
|
||||
_settingsService.LastSaved!.SubscribedNodes.ShouldContain("ns=2;s=TestSub");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectCommand_RestoresSavedSubscriptions()
|
||||
{
|
||||
_settingsService.Settings.SubscribedNodes = ["ns=2;s=Restored1", "ns=2;s=Restored2"];
|
||||
|
||||
var service = new FakeOpcUaClientService
|
||||
{
|
||||
ConnectResult = _service.ConnectResult,
|
||||
BrowseResults = _service.BrowseResults,
|
||||
RedundancyResult = _service.RedundancyResult
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(service);
|
||||
var vm = new MainWindowViewModel(factory, new SynchronousUiDispatcher(), _settingsService);
|
||||
|
||||
await vm.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vm.Subscriptions.ActiveSubscriptions.Count.ShouldBe(2);
|
||||
vm.Subscriptions.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=Restored1");
|
||||
vm.Subscriptions.ActiveSubscriptions[1].NodeId.ShouldBe("ns=2;s=Restored2");
|
||||
vm.SubscriptionCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user