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>
200 lines
8.6 KiB
C#
200 lines
8.6 KiB
C#
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);
|
|
}
|
|
}
|