Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs
Joseph Doherty f9bc301c33 Client rename residuals: lmxopcua-cli → otopcua-cli + LmxOpcUaClient → OtOpcUaClient with migration shim. Closes task #208 (the executable-name + LocalAppData-folder slice that was called out in Client.CLI.md / Client.UI.md as a deliberately-deferred residual of the Phase 0 rename). Six source references flipped to the canonical OtOpcUaClient spelling: Program.cs CliFx executable name + description (lmxopcua-cli → otopcua-cli), DefaultApplicationConfigurationFactory.cs ApplicationName + ApplicationUri (LmxOpcUaClient + urn:localhost:LmxOpcUaClient → OtOpcUaClient + urn:localhost:OtOpcUaClient), OpcUaClientService.CreateSessionAsync session-name arg, ConnectionSettings.CertificateStorePath default, MainWindowViewModel.CertificateStorePath default, JsonSettingsService.SettingsDir. Two consuming tests (ConnectionSettingsTests + MainWindowViewModelTests) updated to assert the new canonical name. New ClientStoragePaths static helper at src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs is the migration shim — single entry point for the PKI root + pki subpath, runs a one-shot legacy-folder probe on first resolution: if {LocalAppData}/LmxOpcUaClient/ exists + {LocalAppData}/OtOpcUaClient/ does not, Directory.Move renames it in place (atomic on NTFS within the same volume) so trusted server certs + saved connection settings persist across the rename without operator action. Idempotent per-process via a Lock-guarded _migrationChecked flag so repeated CertificateStorePath getter calls on the hot path pay no IO cost beyond the first. Fresh-install path (neither folder exists) + already-migrated path (only canonical exists) + manual-override path (both exist — developer has set up something explicit) are all no-ops that leave state alone. IOException on the Directory.Move is swallowed + logged as a false return so a concurrent peer process losing the race doesn't crash the consumer; the losing process falls back to whatever state exists. Five new ClientStoragePathsTests assert: GetRoot ends with canonical name under LocalAppData, GetPkiPath nests pki under root, CanonicalFolderName is OtOpcUaClient, LegacyFolderName is LmxOpcUaClient (the migration contract — a typo here would leak the legacy folder past the shim), repeat invocation returns false after first-touch arms the in-process guard. Doc-side residual-explanation notes in docs/Client.CLI.md + docs/Client.UI.md are dropped now that the rename is real; replaced with a short "pre-#208 dev boxes migrate automatically on first launch" note that points at ClientStoragePaths. Sample CLI invocations in Client.CLI.md updated via sed from lmxopcua-cli to otopcua-cli across every command block (14 replacements). Pre-existing staleness in SubscribeCommandTests.Execute_PrintsSubscriptionMessage surfaced during the test run — the CLI's subscribe command has long since switched to an aggregate "Subscribed to {count}/{total} nodes (interval: ...)" output format but the test still asserted the original single-node form. Updated the assertion to match current output + added a comment explaining the change; this is unrelated to the rename but was blocking a green Client.CLI.Tests run. Full solution build 0 errors; Client.Shared.Tests 136/136 + 5 new shim tests passing; Client.UI.Tests 98/98; Client.CLI.Tests 52/52 (was 51/52 before the subscribe-test fix). No Admin/Core/Server changes — this touches only the client layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:50:40 -04:00

543 lines
19 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
using ZB.MOM.WW.OtOpcUa.Client.UI.Tests.Fakes;
using ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult;
using ConnectionState = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.ConnectionState;
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Tests;
/// <summary>
/// Verifies the main UI shell behavior for connection state, settings persistence, browsing, subscriptions, and history navigation.
/// </summary>
public class MainWindowViewModelTests
{
private readonly FakeOpcUaClientService _service;
private readonly FakeSettingsService _settingsService;
private readonly MainWindowViewModel _vm;
public MainWindowViewModelTests()
{
_service = new FakeOpcUaClientService
{
ConnectResult = new ConnectionInfo(
"opc.tcp://localhost:4840",
"TestServer",
"None",
"http://opcfoundation.org/UA/SecurityPolicy#None",
"session-1",
"TestSession"),
BrowseResults =
[
new BrowseResult("ns=2;s=Root", "Root", "Object", true)
],
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, _settingsService);
}
/// <summary>
/// Verifies that the shell starts disconnected with the default endpoint and status text.
/// </summary>
[Fact]
public void DefaultState_IsDisconnected()
{
_vm.ConnectionState.ShouldBe(ConnectionState.Disconnected);
_vm.IsConnected.ShouldBeFalse();
_vm.EndpointUrl.ShouldBe("opc.tcp://localhost:4840");
_vm.StatusMessage.ShouldBe("Disconnected");
}
/// <summary>
/// Verifies that the connect command is available before a session is established.
/// </summary>
[Fact]
public void ConnectCommand_CanExecute_WhenDisconnected()
{
_vm.ConnectCommand.CanExecute(null).ShouldBeTrue();
}
/// <summary>
/// Verifies that disconnect is disabled until a server session is active.
/// </summary>
[Fact]
public void DisconnectCommand_CannotExecute_WhenDisconnected()
{
_vm.DisconnectCommand.CanExecute(null).ShouldBeFalse();
}
/// <summary>
/// Verifies that a successful connect command updates the shell into the connected state.
/// </summary>
[Fact]
public async Task ConnectCommand_TransitionsToConnected()
{
await _vm.ConnectCommand.ExecuteAsync(null);
_vm.ConnectionState.ShouldBe(ConnectionState.Connected);
_vm.IsConnected.ShouldBeTrue();
_service.ConnectCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that the initial browse tree is loaded after a successful connect.
/// </summary>
[Fact]
public async Task ConnectCommand_LoadsRootNodes()
{
await _vm.ConnectCommand.ExecuteAsync(null);
_vm.BrowseTree.RootNodes.Count.ShouldBe(1);
_vm.BrowseTree.RootNodes[0].DisplayName.ShouldBe("Root");
}
/// <summary>
/// Verifies that redundancy details are fetched and exposed after connecting.
/// </summary>
[Fact]
public async Task ConnectCommand_FetchesRedundancyInfo()
{
await _vm.ConnectCommand.ExecuteAsync(null);
_vm.RedundancyInfo.ShouldNotBeNull();
_vm.RedundancyInfo!.Mode.ShouldBe("None");
_vm.RedundancyInfo.ServiceLevel.ShouldBe((byte)200);
}
/// <summary>
/// Verifies that the session label shows the connected server and session identity.
/// </summary>
[Fact]
public async Task ConnectCommand_SetsSessionLabel()
{
await _vm.ConnectCommand.ExecuteAsync(null);
_vm.SessionLabel.ShouldContain("TestServer");
_vm.SessionLabel.ShouldContain("TestSession");
}
/// <summary>
/// Verifies that disconnect returns the shell to the disconnected state.
/// </summary>
[Fact]
public async Task DisconnectCommand_TransitionsToDisconnected()
{
await _vm.ConnectCommand.ExecuteAsync(null);
await _vm.DisconnectCommand.ExecuteAsync(null);
_vm.ConnectionState.ShouldBe(ConnectionState.Disconnected);
_vm.IsConnected.ShouldBeFalse();
_service.DisconnectCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that disconnect clears session-specific UI state such as browse data and redundancy details.
/// </summary>
[Fact]
public async Task Disconnect_ClearsStateAndChildren()
{
await _vm.ConnectCommand.ExecuteAsync(null);
await _vm.DisconnectCommand.ExecuteAsync(null);
_vm.SessionLabel.ShouldBe(string.Empty);
_vm.RedundancyInfo.ShouldBeNull();
_vm.BrowseTree.RootNodes.ShouldBeEmpty();
_vm.SubscriptionCount.ShouldBe(0);
}
/// <summary>
/// Verifies that connection-state events from the client update the shell status text and state.
/// </summary>
[Fact]
public async Task ConnectionStateChangedEvent_UpdatesState()
{
await _vm.ConnectCommand.ExecuteAsync(null);
_service.RaiseConnectionStateChanged(
new ConnectionStateChangedEventArgs(ConnectionState.Connected, ConnectionState.Reconnecting,
"opc.tcp://localhost:4840"));
_vm.ConnectionState.ShouldBe(ConnectionState.Reconnecting);
_vm.StatusMessage.ShouldBe("Reconnecting...");
}
/// <summary>
/// Verifies that selecting a tree node updates the dependent read/write and history panels.
/// </summary>
[Fact]
public async Task SelectedTreeNode_PropagatesToChildViewModels()
{
await _vm.ConnectCommand.ExecuteAsync(null);
var node = _vm.BrowseTree.RootNodes[0];
_vm.SelectedTreeNode = node;
_vm.ReadWrite.SelectedNodeId.ShouldBe(node.NodeId);
_vm.History.SelectedNodeId.ShouldBe(node.NodeId);
}
/// <summary>
/// Verifies that a successful connect propagates connected state into the child tabs.
/// </summary>
[Fact]
public async Task ConnectCommand_PropagatesIsConnectedToChildViewModels()
{
await _vm.ConnectCommand.ExecuteAsync(null);
_vm.ReadWrite.IsConnected.ShouldBeTrue();
_vm.Subscriptions.IsConnected.ShouldBeTrue();
_vm.Alarms.IsConnected.ShouldBeTrue();
_vm.History.IsConnected.ShouldBeTrue();
}
/// <summary>
/// Verifies that disconnect propagates disconnected state into the child tabs.
/// </summary>
[Fact]
public async Task DisconnectCommand_PropagatesIsConnectedFalseToChildViewModels()
{
await _vm.ConnectCommand.ExecuteAsync(null);
await _vm.DisconnectCommand.ExecuteAsync(null);
_vm.ReadWrite.IsConnected.ShouldBeFalse();
_vm.Subscriptions.IsConnected.ShouldBeFalse();
_vm.Alarms.IsConnected.ShouldBeFalse();
_vm.History.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Verifies that failed connection attempts restore the disconnected shell state and surface the error text.
/// </summary>
[Fact]
public async Task ConnectFailure_RevertsToDisconnected()
{
_service.ConnectException = new Exception("Connection refused");
await _vm.ConnectCommand.ExecuteAsync(null);
_vm.ConnectionState.ShouldBe(ConnectionState.Disconnected);
_vm.StatusMessage.ShouldContain("Connection refused");
}
/// <summary>
/// Verifies that connection-state transitions raise property-changed notifications for UI binding updates.
/// </summary>
[Fact]
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.Connected, ConnectionState.Reconnecting,
"opc.tcp://localhost:4840"));
changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState));
}
/// <summary>
/// Verifies that the shell initializes advanced connection settings with the expected defaults.
/// </summary>
[Fact]
public void DefaultState_HasCorrectAdvancedSettings()
{
_vm.FailoverUrls.ShouldBeNull();
_vm.SessionTimeoutSeconds.ShouldBe(60);
_vm.AutoAcceptCertificates.ShouldBeTrue();
_vm.CertificateStorePath.ShouldContain("OtOpcUaClient");
_vm.CertificateStorePath.ShouldContain("pki");
}
/// <summary>
/// Verifies that failover endpoint text is parsed into connection settings on connect.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsFailoverUrlsToSettings()
{
_vm.FailoverUrls = "opc.tcp://backup1:4840, opc.tcp://backup2:4840";
await _vm.ConnectCommand.ExecuteAsync(null);
_service.LastConnectionSettings.ShouldNotBeNull();
_service.LastConnectionSettings!.FailoverUrls.ShouldNotBeNull();
_service.LastConnectionSettings.FailoverUrls!.Length.ShouldBe(2);
_service.LastConnectionSettings.FailoverUrls[0].ShouldBe("opc.tcp://backup1:4840");
_service.LastConnectionSettings.FailoverUrls[1].ShouldBe("opc.tcp://backup2:4840");
}
/// <summary>
/// Verifies that empty failover text is normalized to no configured failover endpoints.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsEmptyFailoverUrlsToNull()
{
_vm.FailoverUrls = "";
await _vm.ConnectCommand.ExecuteAsync(null);
_service.LastConnectionSettings.ShouldNotBeNull();
_service.LastConnectionSettings!.FailoverUrls.ShouldBeNull();
}
/// <summary>
/// Verifies that the configured session timeout is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsSessionTimeoutToSettings()
{
_vm.SessionTimeoutSeconds = 120;
await _vm.ConnectCommand.ExecuteAsync(null);
_service.LastConnectionSettings.ShouldNotBeNull();
_service.LastConnectionSettings!.SessionTimeoutSeconds.ShouldBe(120);
}
/// <summary>
/// Verifies that the auto-accept certificate toggle is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsAutoAcceptCertificatesToSettings()
{
_vm.AutoAcceptCertificates = false;
await _vm.ConnectCommand.ExecuteAsync(null);
_service.LastConnectionSettings.ShouldNotBeNull();
_service.LastConnectionSettings!.AutoAcceptCertificates.ShouldBeFalse();
}
/// <summary>
/// Verifies that a custom certificate store path is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsCertificateStorePathToSettings()
{
_vm.CertificateStorePath = "/custom/pki/path";
await _vm.ConnectCommand.ExecuteAsync(null);
_service.LastConnectionSettings.ShouldNotBeNull();
_service.LastConnectionSettings!.CertificateStorePath.ShouldBe("/custom/pki/path");
}
/// <summary>
/// Verifies that subscribing selected nodes adds subscriptions and switches the shell to the subscriptions tab.
/// </summary>
[Fact]
public async Task SubscribeSelectedNodesCommand_SubscribesAndSwitchesToTab()
{
await _vm.ConnectCommand.ExecuteAsync(null);
// 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("ns=2;s=TestVar");
_vm.SelectedTabIndex.ShouldBe(1);
}
/// <summary>
/// Verifies that subscribing selected nodes is a no-op when nothing is selected.
/// </summary>
[Fact]
public async Task SubscribeSelectedNodesCommand_DoesNothing_WhenNoSelection()
{
await _vm.ConnectCommand.ExecuteAsync(null);
await _vm.SubscribeSelectedNodesCommand.ExecuteAsync(null);
_vm.Subscriptions.ActiveSubscriptions.ShouldBeEmpty();
}
/// <summary>
/// Verifies that the history command targets the selected node and switches the shell to the history tab.
/// </summary>
[Fact]
public async Task ViewHistoryForSelectedNodeCommand_SetsNodeAndSwitchesToTab()
{
await _vm.ConnectCommand.ExecuteAsync(null);
var node = _vm.BrowseTree.RootNodes[0];
_vm.SelectedTreeNodes.Add(node);
_vm.ViewHistoryForSelectedNodeCommand.Execute(null);
_vm.History.SelectedNodeId.ShouldBe(node.NodeId);
_vm.SelectedTabIndex.ShouldBe(3);
}
/// <summary>
/// Verifies that history actions are enabled when a variable node is selected.
/// </summary>
[Fact]
public async Task UpdateHistoryEnabledForSelection_TrueForVariableNode()
{
await _vm.ConnectCommand.ExecuteAsync(null);
var variableNode = new TreeNodeViewModel(
"ns=2;s=Var1", "Var1", "Variable", false, _service, new SynchronousUiDispatcher());
_vm.SelectedTreeNodes.Add(variableNode);
_vm.UpdateHistoryEnabledForSelection();
_vm.IsHistoryEnabledForSelection.ShouldBeTrue();
}
/// <summary>
/// Verifies that history actions stay disabled when an object node rather than a variable is selected.
/// </summary>
[Fact]
public async Task UpdateHistoryEnabledForSelection_FalseForObjectNode()
{
await _vm.ConnectCommand.ExecuteAsync(null);
var objectNode = new TreeNodeViewModel(
"ns=2;s=Obj1", "Obj1", "Object", true, _service, new SynchronousUiDispatcher());
_vm.SelectedTreeNodes.Add(objectNode);
_vm.UpdateHistoryEnabledForSelection();
_vm.IsHistoryEnabledForSelection.ShouldBeFalse();
}
/// <summary>
/// Verifies that history actions stay disabled when no server connection is active.
/// </summary>
[Fact]
public void UpdateHistoryEnabledForSelection_FalseWhenDisconnected()
{
_vm.UpdateHistoryEnabledForSelection();
_vm.IsHistoryEnabledForSelection.ShouldBeFalse();
}
/// <summary>
/// Verifies that saved user settings are loaded during shell construction.
/// </summary>
[Fact]
public void Constructor_LoadsSettingsFromService()
{
_settingsService.LoadCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that persisted connection and security settings are applied to the shell on startup.
/// </summary>
[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");
}
/// <summary>
/// Verifies that successful connections persist the current connection settings.
/// </summary>
[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");
}
/// <summary>
/// Verifies that failed connection attempts do not overwrite saved settings.
/// </summary>
[Fact]
public async Task ConnectCommand_DoesNotSaveOnFailure()
{
_service.ConnectException = new Exception("Connection refused");
await _vm.ConnectCommand.ExecuteAsync(null);
_settingsService.SaveCallCount.ShouldBe(0);
}
/// <summary>
/// Verifies that active subscriptions are persisted when the shell disconnects.
/// </summary>
[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");
}
/// <summary>
/// Verifies that saved subscriptions are restored after reconnecting the shell.
/// </summary>
[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);
}
}