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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user