using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Phase C Task 2 — node-manager materialisation honours the historize intent. Boot a real /// through (the same harness /// uses), drive /// with / without a historian tagname, and assert the created 's /// Historizing flag + HistoryRead access bit + the NodeId→tagname registration. /// public sealed class NodeManagerHistorizeTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-historize-{Guid.NewGuid():N}"); /// A historized variable is created Historizing, gains the HistoryRead access bit in BOTH /// AccessLevel and UserAccessLevel, and its NodeId→tagname registration is queryable. [Fact] public async Task EnsureVariable_with_historian_tagname_sets_historizing_and_history_read_bit() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", writable: false, historianTagname: "WW.Tag"); var variable = nm.TryGetVariable("eq-1/temp"); variable.ShouldNotBeNull(); variable!.Historizing.ShouldBeTrue(); (variable.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0); (variable.UserAccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0); // Read-only historized node still grants CurrentRead. (variable.AccessLevel & AccessLevels.CurrentRead).ShouldNotBe((byte)0); // The NodeId→resolved-tagname registration is queryable for the (later) HistoryRead override. nm.TryGetHistorizedTagname("eq-1/temp", out var tagname).ShouldBeTrue(); tagname.ShouldBe("WW.Tag"); await host.DisposeAsync(); } /// A non-historized variable (tagname null / omitted) stays Historizing=false, has no /// HistoryRead bit, and is NOT registered in the NodeId→tagname map. [Fact] public async Task EnsureVariable_without_historian_tagname_is_not_historized() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; // Explicit null and the defaulted-param form both mean "not historized". nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32", writable: false, historianTagname: null); nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32", writable: false); foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" }) { var variable = nm.TryGetVariable(nodeId); variable.ShouldNotBeNull(); variable!.Historizing.ShouldBeFalse(); (variable.AccessLevel & AccessLevels.HistoryRead).ShouldBe((byte)0); (variable.UserAccessLevel & AccessLevels.HistoryRead).ShouldBe((byte)0); nm.TryGetHistorizedTagname(nodeId, out var tagname).ShouldBeFalse(); tagname.ShouldBeNull(); } await host.DisposeAsync(); } /// A historized AND writable node keeps the CurrentRead|CurrentWrite composite AND gains the /// HistoryRead bit (the three bits are OR-ed, not replaced). [Fact] public async Task EnsureVariable_historized_and_writable_keeps_read_write_and_adds_history_read() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float", writable: true, historianTagname: "WW.Setpoint"); var variable = nm.TryGetVariable("eq-1/setpoint"); variable.ShouldNotBeNull(); variable!.Historizing.ShouldBeTrue(); (variable.AccessLevel & AccessLevels.CurrentRead).ShouldNotBe((byte)0); (variable.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0); (variable.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0); // The composite is exactly CurrentRead | CurrentWrite | HistoryRead. variable.AccessLevel.ShouldBe((byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite | AccessLevels.HistoryRead)); variable.UserAccessLevel.ShouldBe(variable.AccessLevel); nm.TryGetHistorizedTagname("eq-1/setpoint", out var tagname).ShouldBeTrue(); tagname.ShouldBe("WW.Setpoint"); await host.DisposeAsync(); } /// RebuildAddressSpace drops the NodeId→tagname registrations alongside the variables. [Fact] public async Task RebuildAddressSpace_clears_historized_tagname_registrations() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", writable: false, historianTagname: "WW.Tag"); nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue(); nm.RebuildAddressSpace(); nm.TryGetHistorizedTagname("eq-1/temp", out var tagname).ShouldBeFalse(); tagname.ShouldBeNull(); await host.DisposeAsync(); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.HistorizeTest", ApplicationUri = $"urn:OtOpcUa.HistorizeTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); return (host, server); } private static int AllocateFreePort() { using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }