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:
Joseph Doherty
2026-03-31 20:46:45 -04:00
parent 8fae2cb790
commit 188cbf7d24
53 changed files with 2652 additions and 189 deletions

View File

@@ -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);
}
}