using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a /// or /// assigned after the first apply left existing folders without event history (and existing nodes /// without HistoryUpdate) until a full rebuild, and a source reset to Null never took the bit back. /// Both setters now re-evaluate the bits on every existing node. /// public sealed class NodeManagerCapabilityRefreshTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-caprefresh-{Guid.NewGuid():N}"); [Fact] public async Task A_late_historian_source_grants_event_history_to_already_promoted_folders_and_a_reset_takes_it_back() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, AddressSpaceRealm.Uns); var folder = nm.TryGetFolder("eq-1")!; // Promoted while the source is Null: live events only (non-vacuity for the refresh). (folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0); (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0); nm.IsEventHistorySource("eq-1").ShouldBeFalse(); nm.HistorianDataSource = new StubSource(); (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldNotBe((byte)0, "a late source must light the bit on the existing folder"); nm.IsEventHistorySource("eq-1").ShouldBeTrue(); nm.HistorianDataSource = null!; // the setter maps null to the Null source (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0, "a reset source must take the bit back"); (folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0, "live-event subscription is untouched"); nm.IsEventHistorySource("eq-1").ShouldBeFalse(); } finally { await host.DisposeAsync(); } } [Fact] public async Task A_late_updater_grants_history_write_to_historized_nodes_only_and_a_reset_takes_it_back() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureVariable("Plant/dev1/hist", null, "hist", "Float", writable: true, realm: AddressSpaceRealm.Raw, historianTagname: "Hist.A"); nm.EnsureVariable("Plant/dev1/plain", null, "plain", "Float", writable: false, realm: AddressSpaceRealm.Raw); var hist = nm.TryGetVariable("Plant/dev1/hist", AddressSpaceRealm.Raw)!; var plain = nm.TryGetVariable("Plant/dev1/plain", AddressSpaceRealm.Raw)!; (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "no updater at materialise time"); nm.HistorianDataUpdater = new StubUpdater(); (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0); (hist.UserAccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0); (hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0, "the authored Writable survives the refresh"); (hist.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0); (plain.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "a non-historized node never gets HistoryWrite"); nm.HistorianDataUpdater = null; (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0); (hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0); } finally { await host.DisposeAsync(); } } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.CapRefreshTest", ApplicationUri = $"urn:OtOpcUa.CapRefreshTest:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); public void Dispose() { if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); } private sealed class StubSource : IHistorianDataSource { public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } private sealed class StubUpdater : IHistorianDataUpdater { public Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) => throw new NotSupportedException(); public Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) => throw new NotSupportedException(); public Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct) => throw new NotSupportedException(); } }