using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; [Trait("Category", "Unit")] public sealed class AbLegacyCapabilityTests { // ---- ITagDiscovery ---- /// Verifies that DiscoverAsync emits pre-declared tags under the device folder. [Fact] public async Task DiscoverAsync_emits_pre_declared_tags_under_device_folder() { var builder = new RecordingBuilder(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Press-SLC-1")], RawTags = AbLegacyRawTags.Entries( [ new AbLegacyTagDefinition("Speed", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("Temperature", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, Writable: false), ]), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy"); builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1"); builder.Variables.Count.ShouldBe(2); builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate); builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } // ---- ISubscribable ---- /// Verifies that Subscribe initial poll raises OnDataChange. [Fact] public async Task Subscribe_initial_poll_raises_OnDataChange() { var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 42 }, }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)]), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None); await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2)); events.First().Snapshot.Value.ShouldBe(42); await drv.UnsubscribeAsync(handle, CancellationToken.None); } /// Verifies that Unsubscribe halts polling. [Fact] public async Task Unsubscribe_halts_polling() { var tagRef = new FakeAbLegacyTag( new AbLegacyTagCreateParams("10.0.0.5", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2))) { Value = 1 }; var control = new FakeAbLegacyTag( new AbLegacyTagCreateParams("10.0.0.5", 44818, "1,0", "slc500", "N7:1", TimeSpan.FromSeconds(2))) { Value = 1 }; var factory = new FakeAbLegacyTagFactory { Customise = p => p.TagName == "N7:1" ? control : tagRef }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], RawTags = AbLegacyRawTags.Entries( [ new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("Y", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int), ]), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); var controlHandle = await drv.SubscribeAsync(["Y"], TimeSpan.FromMilliseconds(100), CancellationToken.None); await WaitForAsync(() => events.Count(e => e.FullReference == "X") >= 1, TimeSpan.FromSeconds(1)); await drv.UnsubscribeAsync(handle, CancellationToken.None); var afterUnsub = events.Count(e => e.FullReference == "X"); var readsAfterUnsub = tagRef.ReadCount; tagRef.Value = 999; // Positive control (#583): the still-subscribed sibling, on the same interval, polls three more times — // so X's loop, had it survived, has had three chances to read the change and raise it. var controlReads = control.ReadCount; await WaitForAsync(() => control.ReadCount >= controlReads + 3, TimeSpan.FromSeconds(3)); control.ReadCount.ShouldBeGreaterThanOrEqualTo(controlReads + 3); events.Count(e => e.FullReference == "X").ShouldBe(afterUnsub); tagRef.ReadCount.ShouldBe(readsAfterUnsub); await drv.UnsubscribeAsync(controlHandle, CancellationToken.None); } // ---- IHostConnectivityProbe ---- /// Verifies that GetHostStatuses returns one status per device. [Fact] public async Task GetHostStatuses_returns_one_per_device() { var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [ new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"), ], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetHostStatuses().Count.ShouldBe(2); } /// Verifies that Probe transitions to Running on successful read. [Fact] public async Task Probe_transitions_to_Running_on_successful_read() { var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) }; var transitions = new ConcurrentQueue(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(100), Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0", }, }, "drv-1", factory); drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2)); drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); await drv.ShutdownAsync(CancellationToken.None); } /// Verifies that Probe transitions to Stopped on read failure. [Fact] public async Task Probe_transitions_to_Stopped_on_read_failure() { var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true } }; var transitions = new ConcurrentQueue(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(100), Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0", }, }, "drv-1", factory); drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2)); drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped); await drv.ShutdownAsync(CancellationToken.None); } /// Verifies that Probe is disabled when ProbeAddress is null. [Fact] public async Task Probe_disabled_when_ProbeAddress_is_null() { var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); // #583: structural, not a fixed wait — no probe loop was ever started, so no transition can follow. drv.GetDeviceState("ab://10.0.0.5/1,0")!.ProbeCts.ShouldBeNull(); drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown); await drv.ShutdownAsync(CancellationToken.None); // Positive control: the same observation on a driver whose probe IS configured sees the loop, and that loop // produces a transition — so a null ProbeCts really means "no loop". var transitions = new ConcurrentQueue(); var control = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(100), Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0", }, }, "drv-control", new FakeAbLegacyTagFactory()); control.OnHostStatusChanged += (_, e) => transitions.Enqueue(e); await control.InitializeAsync("{}", CancellationToken.None); control.GetDeviceState("ab://10.0.0.5/1,0")!.ProbeCts.ShouldNotBeNull(); await WaitForAsync(() => !transitions.IsEmpty, TimeSpan.FromSeconds(2)); transitions.ShouldNotBeEmpty(); await control.ShutdownAsync(CancellationToken.None); } // ---- IPerCallHostResolver ---- /// Verifies that ResolveHost returns declared device for known tag. [Fact] public async Task ResolveHost_returns_declared_device_for_known_tag() { var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [ new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"), ], RawTags = AbLegacyRawTags.Entries( [ new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("B", "ab://10.0.0.6/1,0", "N7:0", AbLegacyDataType.Int), ]), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0"); drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0"); } /// Verifies that ResolveHost falls back to first device for unknown tags. [Fact] public async Task ResolveHost_falls_back_to_first_device_for_unknown() { var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.ResolveHost("missing").ShouldBe("ab://10.0.0.5/1,0"); } /// Verifies that ResolveHost falls back to DriverInstanceId when no devices exist. [Fact] public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices() { var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.ResolveHost("anything").ShouldBe("drv-1"); } // ---- helpers ---- private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } private sealed class RecordingBuilder : IAddressSpaceBuilder { /// Gets list of folders created during discovery. public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); /// Gets list of variables created during discovery. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); /// Records folder creation. /// The browse name of the folder. /// The display name of the folder. public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// Records variable creation. /// The browse name of the variable. /// The display name of the variable. /// The driver attribute information. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } /// Records property addition (stub implementation). /// The property name (unused). /// The data type (unused). /// The property value (unused). public void AddProperty(string _, DriverDataType __, object? ___) { } private sealed class Handle(string fullRef) : IVariableHandle { /// Gets the full reference of the variable. public string FullReference => fullRef; /// Marks the variable as an alarm condition. /// The alarm condition information. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } /// Null sink for alarm condition transitions. private sealed class NullSink : IAlarmConditionSink { /// public void OnTransition(AlarmEventArgs args) { } } } }