using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; [Trait("Category", "Unit")] public sealed class FocasCapabilityTests { // ---- ITagDiscovery ---- /// Verifies that DiscoverAsync emits pre-declared tags. [Fact] public async Task DiscoverAsync_emits_pre_declared_tags() { var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")], Tags = [ new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false), ], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS"); builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1"); // FOCAS is read-only by design — all user tags are ViewOnly regardless of the // Writable field, because WireFocasClient.WriteAsync always returns BadNotWritable. builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } // ---- ISubscribable ---- /// Verifies that the initial subscription poll raises an OnDataChange event. [Fact] public async Task Subscribe_initial_poll_raises_OnDataChange() { var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } }, }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], Probe = new FocasProbeOptions { 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((sbyte)42); await drv.UnsubscribeAsync(handle, CancellationToken.None); } /// Verifies that ShutdownAsync cancels active subscriptions. [Fact] public async Task ShutdownAsync_cancels_active_subscriptions() { var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } }, }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1)); await drv.ShutdownAsync(CancellationToken.None); var afterShutdown = events.Count; await Task.Delay(200); events.Count.ShouldBe(afterShutdown); } // ---- IHostConnectivityProbe ---- /// Verifies that GetHostStatuses returns one entry per device. [Fact] public async Task GetHostStatuses_returns_entry_per_device() { var drv = new FocasDriver(new FocasDriverOptions { Devices = [ new FocasDeviceOptions("focas://10.0.0.5:8193"), new FocasDeviceOptions("focas://10.0.0.6:8193"), ], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetHostStatuses().Count.ShouldBe(2); } /// Verifies that the probe transitions to Running on successful connection. [Fact] public async Task Probe_transitions_to_Running_on_success() { var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { ProbeResult = true }, }; var transitions = new ConcurrentQueue(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(100), Timeout = TimeSpan.FromMilliseconds(50), }, }, "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 the probe transitions to Stopped on connection failure. [Fact] public async Task Probe_transitions_to_Stopped_on_failure() { var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { ProbeResult = false }, }; var transitions = new ConcurrentQueue(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(100), Timeout = TimeSpan.FromMilliseconds(50), }, }, "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); } // ---- IPerCallHostResolver ---- /// Verifies that ResolveHost returns the declared device for a known tag. [Fact] public async Task ResolveHost_returns_declared_device_for_known_tag() { var drv = new FocasDriver(new FocasDriverOptions { Devices = [ new FocasDeviceOptions("focas://10.0.0.5:8193"), new FocasDeviceOptions("focas://10.0.0.6:8193"), ], Tags = [ new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte), ], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); drv.ResolveHost("A").ShouldBe("focas://10.0.0.5:8193"); drv.ResolveHost("B").ShouldBe("focas://10.0.0.6:8193"); } /// Verifies that ResolveHost falls back to the first device for unknown tags. [Fact] public async Task ResolveHost_falls_back_to_first_device_for_unknown() { var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); drv.ResolveHost("missing").ShouldBe("focas://10.0.0.5:8193"); } /// Verifies that ResolveHost falls back to the driver instance ID when no devices are configured. [Fact] public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices() { var drv = new FocasDriver(new FocasDriverOptions(), "drv-1", new FakeFocasClientFactory()); 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); } /// Test double that records IAddressSpaceBuilder calls. private sealed class RecordingBuilder : IAddressSpaceBuilder { /// Gets the list of recorded folder calls. public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); /// Gets the list of recorded variable calls. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); /// Records a folder call. /// The browse name of the folder. /// The display name of the folder. /// This builder for chaining. public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// Records a variable call. /// The browse name of the variable. /// The display name of the variable. /// The driver attribute information. /// A variable handle for the recorded variable. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } /// Records a property call (no-op). /// The property name (unused). /// The property data type (unused). /// The property value (unused). public void AddProperty(string _, DriverDataType __, object? ___) { } private sealed class Handle(string fullRef) : IVariableHandle { /// Gets the full reference. public string FullReference => fullRef; /// Marks as alarm condition. /// The alarm condition information. /// An alarm condition sink. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } /// Null alarm condition sink. private sealed class NullSink : IAlarmConditionSink { /// Handles transition (no-op). /// The alarm event arguments (unused). public void OnTransition(AlarmEventArgs args) { } } } }