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

@@ -14,6 +14,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public ConnectionInfo? ConnectResult { get; set; }
public Exception? ConnectException { get; set; }
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = [];
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; set; } = new();
public Exception? BrowseException { get; set; }
public DataValue ReadResult { get; set; } =
@@ -100,6 +101,10 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
BrowseCallCount++;
LastBrowseParentNodeId = parentNodeId;
if (BrowseException != null) throw BrowseException;
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.ToString(), out var perParent))
return Task.FromResult(perParent);
return Task.FromResult(BrowseResults);
}
@@ -136,6 +141,18 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
public StatusCode AcknowledgeResult { get; set; } = StatusCodes.Good;
public Exception? AcknowledgeException { get; set; }
public int AcknowledgeCallCount { get; private set; }
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
AcknowledgeCallCount++;
if (AcknowledgeException != null) throw AcknowledgeException;
return Task.FromResult(AcknowledgeResult);
}
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default)
{

View File

@@ -0,0 +1,23 @@
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
public sealed class FakeSettingsService : ISettingsService
{
public UserSettings Settings { get; set; } = new();
public int LoadCallCount { get; private set; }
public int SaveCallCount { get; private set; }
public UserSettings? LastSaved { get; private set; }
public UserSettings Load()
{
LoadCallCount++;
return Settings;
}
public void Save(UserSettings settings)
{
SaveCallCount++;
LastSaved = settings;
}
}

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

View File

@@ -0,0 +1,170 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media;
using Avalonia.Threading;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Controls;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Screenshots;
public class DateTimeRangePickerScreenshot
{
private static readonly object Lock = new();
private static bool _initialized;
private static void EnsureInitialized()
{
lock (Lock)
{
if (_initialized) return;
_initialized = true;
AppBuilder.Configure<ScreenshotTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions
{
UseHeadlessDrawing = false
})
.SetupWithoutStarting();
}
}
[Fact]
public void TextBoxes_ShowValues_WhenSetBeforeLoad()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker
{
StartDateTime = new DateTimeOffset(2026, 3, 31, 8, 0, 0, TimeSpan.Zero),
EndDateTime = new DateTimeOffset(2026, 3, 31, 14, 0, 0, TimeSpan.Zero)
};
var window = new Window
{
Content = picker,
Width = 700, Height = 70
};
window.Show();
Dispatcher.UIThread.RunJobs();
var startInput = picker.FindControl<TextBox>("StartInput");
var endInput = picker.FindControl<TextBox>("EndInput");
startInput.ShouldNotBeNull();
endInput.ShouldNotBeNull();
startInput!.Text.ShouldBe("2026-03-31 08:00:00");
endInput!.Text.ShouldBe("2026-03-31 14:00:00");
window.Close();
});
}
[Fact]
public void TextBoxes_ShowValues_WhenSetAfterLoad()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker();
var window = new Window
{
Content = picker,
Width = 700, Height = 70
};
window.Show();
Dispatcher.UIThread.RunJobs();
// Set values after the control is loaded
picker.StartDateTime = new DateTimeOffset(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
picker.EndDateTime = new DateTimeOffset(2026, 1, 15, 18, 45, 0, TimeSpan.Zero);
Dispatcher.UIThread.RunJobs();
var startInput = picker.FindControl<TextBox>("StartInput");
var endInput = picker.FindControl<TextBox>("EndInput");
startInput!.Text.ShouldBe("2026-01-15 10:30:00");
endInput!.Text.ShouldBe("2026-01-15 18:45:00");
window.Close();
});
}
[Fact]
public void PresetButtons_SetCorrectRange()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker();
var window = new Window
{
Content = picker,
Width = 700, Height = 70
};
window.Show();
Dispatcher.UIThread.RunJobs();
// Click the 1h preset
var lastHourBtn = picker.FindControl<Button>("LastHourBtn");
lastHourBtn.ShouldNotBeNull();
lastHourBtn!.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
Dispatcher.UIThread.RunJobs();
picker.StartDateTime.ShouldNotBeNull();
picker.EndDateTime.ShouldNotBeNull();
var span = picker.EndDateTime!.Value - picker.StartDateTime!.Value;
span.TotalMinutes.ShouldBe(60, 1); // ~1 hour
// Verify text boxes are populated
var startInput = picker.FindControl<TextBox>("StartInput");
var endInput = picker.FindControl<TextBox>("EndInput");
startInput!.Text.ShouldNotBeNullOrEmpty();
endInput!.Text.ShouldNotBeNullOrEmpty();
window.Close();
});
}
[Fact]
public void Capture_DateTimeRangePicker_Screenshot()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker
{
StartDateTime = new DateTimeOffset(2026, 3, 31, 8, 0, 0, TimeSpan.Zero),
EndDateTime = new DateTimeOffset(2026, 3, 31, 14, 0, 0, TimeSpan.Zero)
};
var window = new Window
{
Content = new Border { Padding = new Thickness(16), Background = Brushes.White, Child = picker },
Width = 700, Height = 70, Background = Brushes.White
};
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
Assert.NotNull(bitmap);
var outputDir = Path.Combine(
Path.GetDirectoryName(typeof(DateTimeRangePickerScreenshot).Assembly.Location)!, "screenshots");
Directory.CreateDirectory(outputDir);
bitmap!.Save(Path.Combine(outputDir, "datetimerangepicker.png"));
window.Close();
});
}
}

View File

@@ -0,0 +1,199 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media;
using Avalonia.Threading;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Controls;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Screenshots;
public class DocumentationScreenshots
{
private static readonly object Lock = new();
private static bool _initialized;
private static void EnsureInitialized()
{
lock (Lock)
{
if (_initialized) return;
_initialized = true;
AppBuilder.Configure<ScreenshotTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.SetupWithoutStarting();
}
}
private static string OutputDir
{
get
{
var dir = Path.Combine(
Path.GetDirectoryName(typeof(DocumentationScreenshots).Assembly.Location)!, "screenshots", "docs");
Directory.CreateDirectory(dir);
return dir;
}
}
[Fact]
public void Capture_ConnectionPanel()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var vm = CreateConnectedViewModel();
var panel = new StackPanel
{
Spacing = 4,
Children =
{
new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 8,
Children =
{
new StackPanel { Spacing = 2, Children = { new TextBlock { Text = "Endpoint URL", FontSize = 11, Foreground = Brushes.Gray }, new TextBox { Text = "opc.tcp://localhost:4840/LmxOpcUa", Width = 400 } } },
new Button { Content = "Connect", Padding = new Thickness(16, 6) },
new Button { Content = "Disconnect", Padding = new Thickness(16, 6) }
}
}
}
};
var window = new Window
{
Content = new Border { Padding = new Thickness(12), Background = new SolidColorBrush(Color.Parse("#F0F0F0")), Child = panel },
Width = 700, Height = 60, Background = Brushes.White
};
window.Show();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "connection-panel.png"));
window.Close();
});
}
[Fact]
public void Capture_SubscriptionsTab()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var service = new FakeOpcUaClientService();
var dispatcher = new SynchronousUiDispatcher();
var vm = new SubscriptionsViewModel(service, dispatcher) { IsConnected = true };
vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=3;s=DEV.ScanState", 1000) { Value = "True", Status = "0x00000000 (Good)", Timestamp = "2026-03-31T14:30:00Z" });
vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=3;s=TestMachine_001.TestHistoryValue", 1000) { Value = "3", Status = "0x00000000 (Good)", Timestamp = "2026-03-31T14:30:01Z" });
vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=3;s=DevPlatform.CPULoad", 1000) { Value = "42.5", Status = "0x00000000 (Good)", Timestamp = "2026-03-31T14:30:02Z" });
var view = new SubscriptionsView { DataContext = vm };
var window = new Window { Content = view, Width = 750, Height = 250, Background = Brushes.White };
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "subscriptions-tab.png"));
window.Close();
});
}
[Fact]
public void Capture_AlarmsTab()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var service = new FakeOpcUaClientService();
var dispatcher = new SynchronousUiDispatcher();
var vm = new AlarmsViewModel(service, dispatcher)
{
IsConnected = true, IsSubscribed = true, MonitoredNodeIdText = "ns=3;s=TestArea"
};
vm.AlarmEvents.Add(new AlarmEventViewModel("TestMachine_001.TestAlarm002", "TestAlarm002", 500, "Test alarm #2", true, true, true, new DateTime(2026, 3, 26, 17, 38, 22)));
vm.AlarmEvents.Add(new AlarmEventViewModel("TestMachine_001.TestAlarm003", "TestAlarm003", 500, "Test alarm #3", true, true, false, new DateTime(2026, 3, 26, 17, 38, 22)));
vm.AlarmEvents.Add(new AlarmEventViewModel("TestMachine_001.TestAlarm001", "TestAlarm001", 500, "Alarm cleared", true, false, false, DateTime.MinValue));
var view = new AlarmsView { DataContext = vm };
var window = new Window { Content = view, Width = 900, Height = 250, Background = Brushes.White };
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "alarms-tab.png"));
window.Close();
});
}
[Fact]
public void Capture_HistoryTab()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var service = new FakeOpcUaClientService();
var dispatcher = new SynchronousUiDispatcher();
var vm = new HistoryViewModel(service, dispatcher)
{
IsConnected = true,
SelectedNodeId = "ns=3;s=TestMachine_001.TestHistoryValue",
StartTime = new DateTimeOffset(2026, 3, 26, 0, 0, 0, TimeSpan.Zero),
EndTime = new DateTimeOffset(2026, 3, 26, 18, 0, 0, TimeSpan.Zero)
};
vm.Results.Add(new HistoryValueViewModel("0", "0x00000000 (Good)", "2026-03-26T04:02:59Z", "2026-03-26T04:02:59Z"));
vm.Results.Add(new HistoryValueViewModel("7", "0x00000000 (Good)", "2026-03-26T04:22:13Z", "2026-03-26T04:22:13Z"));
vm.Results.Add(new HistoryValueViewModel("4", "0x00000000 (Good)", "2026-03-26T04:22:16Z", "2026-03-26T04:22:16Z"));
vm.Results.Add(new HistoryValueViewModel("9", "0x00000000 (Good)", "2026-03-26T05:08:46Z", "2026-03-26T05:08:46Z"));
var view = new HistoryView { DataContext = vm };
var window = new Window { Content = view, Width = 800, Height = 350, Background = Brushes.White };
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "history-tab.png"));
window.Close();
});
}
[Fact]
public void Capture_DateTimeRangePicker()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker
{
StartDateTime = new DateTimeOffset(2026, 3, 26, 0, 0, 0, TimeSpan.Zero),
EndDateTime = new DateTimeOffset(2026, 3, 31, 18, 0, 0, TimeSpan.Zero)
};
var window = new Window
{
Content = new Border { Padding = new Thickness(12), Background = Brushes.White, Child = picker },
Width = 650, Height = 65, Background = Brushes.White
};
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "datetimerangepicker.png"));
window.Close();
});
}
private static MainWindowViewModel CreateConnectedViewModel()
{
var service = new FakeOpcUaClientService
{
ConnectResult = new ConnectionInfo("opc.tcp://localhost:4840/LmxOpcUa", "LmxOpcUa", "None",
"http://opcfoundation.org/UA/SecurityPolicy#None", "session-1", "TestSession"),
BrowseResults = new[] { new BrowseResult("ns=3;s=ZB", "ZB", "Object", true) },
RedundancyResult = new RedundancyInfo("Warm", 200, new[] { "urn:localhost:LmxOpcUa:instance1" }, "urn:localhost:LmxOpcUa:instance1")
};
var factory = new FakeOpcUaClientServiceFactory(service);
var settings = new FakeSettingsService();
return new MainWindowViewModel(factory, new SynchronousUiDispatcher(), settings);
}
}

View File

@@ -0,0 +1,20 @@
using Avalonia;
using Avalonia.Headless;
using Avalonia.Themes.Fluent;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Screenshots;
public class ScreenshotTestApp : Application
{
public override void Initialize()
{
Styles.Add(new FluentTheme());
}
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<ScreenshotTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions
{
UseHeadlessDrawing = false
});
}

View File

@@ -5,6 +5,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
@@ -168,4 +169,131 @@ public class SubscriptionsViewModelTests
_vm.ActiveSubscriptions.ShouldBeEmpty();
_service.SubscribeCallCount.ShouldBe(0);
}
[Fact]
public async Task GetSubscribedNodeIds_ReturnsActiveNodeIds()
{
_vm.IsConnected = true;
await _vm.AddSubscriptionForNodeAsync("ns=2;s=Node1");
await _vm.AddSubscriptionForNodeAsync("ns=2;s=Node2");
var ids = _vm.GetSubscribedNodeIds();
ids.Count.ShouldBe(2);
ids.ShouldContain("ns=2;s=Node1");
ids.ShouldContain("ns=2;s=Node2");
}
[Fact]
public async Task RestoreSubscriptionsAsync_SubscribesAllNodes()
{
_vm.IsConnected = true;
await _vm.RestoreSubscriptionsAsync(["ns=2;s=A", "ns=2;s=B"]);
_vm.ActiveSubscriptions.Count.ShouldBe(2);
_service.SubscribeCallCount.ShouldBe(2);
}
[Fact]
public async Task ValidateAndWriteAsync_SuccessReturnsTrue()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant(42), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
_service.WriteResult = Opc.Ua.StatusCodes.Good;
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "99");
success.ShouldBeTrue();
message.ShouldContain("Good");
_service.WriteCallCount.ShouldBe(1);
}
[Fact]
public async Task ValidateAndWriteAsync_ParseFailureReturnsFalse()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant(42), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "not-a-number");
success.ShouldBeFalse();
message.ShouldContain("Cannot parse");
message.ShouldContain("Int32");
_service.WriteCallCount.ShouldBe(0);
}
[Fact]
public async Task ValidateAndWriteAsync_WriteFailureReturnsFalse()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant("hello"), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
_service.WriteException = new Exception("Access denied");
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "world");
success.ShouldBeFalse();
message.ShouldContain("Access denied");
}
[Fact]
public async Task ValidateAndWriteAsync_BadStatusReturnsFalse()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant("hello"), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
_service.WriteResult = Opc.Ua.StatusCodes.BadNotWritable;
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "world");
success.ShouldBeFalse();
message.ShouldContain("Write failed");
}
[Fact]
public async Task AddSubscriptionRecursiveAsync_SubscribesVariableDirectly()
{
_vm.IsConnected = true;
await _vm.AddSubscriptionRecursiveAsync("ns=2;s=Var1", "Variable");
_vm.ActiveSubscriptions.Count.ShouldBe(1);
_vm.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=Var1");
}
[Fact]
public async Task AddSubscriptionRecursiveAsync_BrowsesObjectAndSubscribesVariableChildren()
{
_vm.IsConnected = true;
_service.BrowseResultsByParent["ns=2;s=Folder"] =
[
new BrowseResult("ns=2;s=Child1", "Child1", "Variable", false),
new BrowseResult("ns=2;s=Child2", "Child2", "Variable", false)
];
await _vm.AddSubscriptionRecursiveAsync("ns=2;s=Folder", "Object");
_vm.ActiveSubscriptions.Count.ShouldBe(2);
_service.SubscribeCallCount.ShouldBe(2);
}
[Fact]
public async Task AddSubscriptionRecursiveAsync_RecursesNestedObjects()
{
_vm.IsConnected = true;
_service.BrowseResultsByParent["ns=2;s=Root"] =
[
new BrowseResult("ns=2;s=SubFolder", "SubFolder", "Object", true),
new BrowseResult("ns=2;s=RootVar", "RootVar", "Variable", false)
];
_service.BrowseResultsByParent["ns=2;s=SubFolder"] =
[
new BrowseResult("ns=2;s=DeepVar", "DeepVar", "Variable", false)
];
await _vm.AddSubscriptionRecursiveAsync("ns=2;s=Root", "Object");
_vm.ActiveSubscriptions.Count.ShouldBe(2);
_vm.ActiveSubscriptions.ShouldContain(s => s.NodeId == "ns=2;s=RootVar");
_vm.ActiveSubscriptions.ShouldContain(s => s.NodeId == "ns=2;s=DeepVar");
}
}

View File

@@ -10,6 +10,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Headless" Version="11.2.7"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.7"/>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>