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