Migrate historian from SQL to aahClientManaged SDK and resolve all OPC UA Part 11 gaps
Replace direct SQL queries against Historian Runtime database with the Wonderware Historian managed SDK (ArchestrA.HistorianAccess). Add HistoryServerCapabilities node, AggregateFunctions folder, continuation points, ReadAtTime interpolation, ReturnBounds, ReadModified rejection, HistoricalDataConfiguration per node, historical event access, and client-side StandardDeviation aggregate support. Remove screenshot tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -130,7 +130,7 @@ public class HistoryViewModelTests
|
||||
public void AggregateTypes_ContainsNullForRaw()
|
||||
{
|
||||
_vm.AggregateTypes.ShouldContain((AggregateType?)null);
|
||||
_vm.AggregateTypes.Count.ShouldBe(7); // null + 6 enum values
|
||||
_vm.AggregateTypes.Count.ShouldBe(8); // null + 7 enum values
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user