using System.Runtime.CompilerServices; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// PR abcip-4.4 — coverage for the writeable _RefreshTagDb system tag. Discovery /// emits the entry as Operate, reads always return false, and writes of any /// truthy value dispatch to while bumping the /// AbCip.RefreshTriggers diagnostic counter. Falsy / unparseable writes are /// no-ops so a SCADA template that pulses the trigger off after firing it doesn't see /// a phantom error. /// [Trait("Category", "Unit")] public sealed class AbCipRefreshTagDbTests { private const string Host = "ab://10.0.0.5/1,0"; private static string RefreshRef(string host = Host) => $"_System/{host}/{AbCipSystemTagSource.RefreshTagDbName}"; [Fact] public async Task Discovery_emits_RefreshTagDb_as_writeable() { var builder = new RecordingBuilder(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var refreshVar = builder.Variables.Single(v => v.Info.FullName.EndsWith("/_RefreshTagDb")); refreshVar.Info.SecurityClass.ShouldBe(SecurityClassification.Operate); refreshVar.Info.DriverDataType.ShouldBe(DriverDataType.Boolean); refreshVar.Info.FullName.ShouldBe($"_System/{Host}/_RefreshTagDb"); } [Fact] public async Task Read_RefreshTagDb_returns_false() { await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); var snaps = await drv.ReadAsync([RefreshRef()], CancellationToken.None); snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); snaps[0].Value.ShouldBe(false); } [Fact] public async Task Read_RefreshTagDb_returns_false_after_truthy_write() { // Writing the trigger doesn't change the read shape — it's always false the next // time a client reads it (Kepware-style "latches back to idle" semantics). var factory = new CountingEnumeratorFactory(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); await drv.WriteAsync([new WriteRequest(RefreshRef(), true)], CancellationToken.None); var snaps = await drv.ReadAsync([RefreshRef()], CancellationToken.None); snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); snaps[0].Value.ShouldBe(false); } [Fact] public async Task Truthy_write_dispatches_to_RebrowseAsync() { var factory = new CountingEnumeratorFactory( new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false)); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); // First DiscoverAsync caches the builder + bumps the enumerator once. await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); factory.EnumerationCount.ShouldBe(1); var results = await drv.WriteAsync( [new WriteRequest(RefreshRef(), true)], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); // RebrowseAsync re-runs the enumerator → count bumps. factory.EnumerationCount.ShouldBe(2); } [Theory] [InlineData(true)] [InlineData(1)] [InlineData(1.5)] [InlineData("true")] [InlineData("True")] [InlineData("1")] public async Task Various_truthy_shapes_all_trigger_a_refresh(object value) { var factory = new CountingEnumeratorFactory(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); var baseline = factory.EnumerationCount; var results = await drv.WriteAsync( [new WriteRequest(RefreshRef(), value)], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.EnumerationCount.ShouldBe(baseline + 1); } [Theory] [InlineData(false)] [InlineData(0)] [InlineData(0.0)] [InlineData("false")] [InlineData("False")] [InlineData("0")] [InlineData("")] [InlineData("not-a-bool")] public async Task Falsy_or_unparseable_write_is_a_noop(object value) { var factory = new CountingEnumeratorFactory(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); var baseline = factory.EnumerationCount; var results = await drv.WriteAsync( [new WriteRequest(RefreshRef(), value)], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.EnumerationCount.ShouldBe(baseline); drv.SystemTagSource.GetRefreshTriggerCount(Host).ShouldBe(0); } [Fact] public async Task Null_write_is_a_noop() { var factory = new CountingEnumeratorFactory(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); var baseline = factory.EnumerationCount; var results = await drv.WriteAsync( [new WriteRequest(RefreshRef(), null)], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.EnumerationCount.ShouldBe(baseline); } [Fact] public async Task RefreshTriggers_counter_bumps_per_truthy_write() { var factory = new CountingEnumeratorFactory(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); await drv.WriteAsync([new WriteRequest(RefreshRef(), true)], CancellationToken.None); await drv.WriteAsync([new WriteRequest(RefreshRef(), true)], CancellationToken.None); await drv.WriteAsync([new WriteRequest(RefreshRef(), false)], CancellationToken.None); drv.GetHealth().DiagnosticsOrEmpty["AbCip.RefreshTriggers"].ShouldBe(2); drv.SystemTagSource.GetRefreshTriggerCount(Host).ShouldBe(2); drv.SystemTagSource.TotalRefreshTriggers.ShouldBe(2); } [Fact] public async Task Two_devices_keep_independent_refresh_counters() { const string a = "ab://10.0.0.5/1,0"; const string b = "ab://10.0.0.6/1,0"; var factory = new CountingEnumeratorFactory(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); await drv.WriteAsync( [new WriteRequest($"_System/{a}/_RefreshTagDb", true)], CancellationToken.None); await drv.WriteAsync( [new WriteRequest($"_System/{b}/_RefreshTagDb", true)], CancellationToken.None); await drv.WriteAsync( [new WriteRequest($"_System/{a}/_RefreshTagDb", true)], CancellationToken.None); drv.SystemTagSource.GetRefreshTriggerCount(a).ShouldBe(2); drv.SystemTagSource.GetRefreshTriggerCount(b).ShouldBe(1); drv.SystemTagSource.TotalRefreshTriggers.ShouldBe(3); } [Fact] public async Task Write_to_unknown_System_name_returns_BadNotWritable() { await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest($"_System/{Host}/_ConnectionStatus", "Running")], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); } [Fact] public async Task Write_to_unknown_System_device_returns_BadNodeIdUnknown() { await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("_System/ab://10.99.99.99/1,0/_RefreshTagDb", true)], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Refresh_write_before_discovery_is_a_noop_Good() { // _cachedBuilder is null before DiscoverAsync runs — the write should still report // Good (Kepware-style trigger semantics never bubble "no address space yet" up to // the OPC UA client) but not invoke RebrowseAsync. var factory = new CountingEnumeratorFactory(); await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest(RefreshRef(), true)], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.EnumerationCount.ShouldBe(0); } [Fact] public async Task System_writes_do_not_block_genuine_tag_writes_in_the_same_batch() { // Mixed batch: one _RefreshTagDb + one ordinary tag write. The system entry is // intercepted before the planner runs, the ordinary entry still flows through // multi-write packing untouched, and both results land at their original indices. var factory = new CountingEnumeratorFactory(); var tagFactory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 }, }; await using var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Host)], EnableControllerBrowse = true, Tags = [new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", tagFactory: tagFactory, enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); var results = await drv.WriteAsync( [ new WriteRequest(RefreshRef(), true), new WriteRequest("Speed", 42), ], CancellationToken.None); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); drv.SystemTagSource.GetRefreshTriggerCount(Host).ShouldBe(1); } // ---- helpers (mirror AbCipRebrowseTests) ---- private sealed class RecordingBuilder : IAddressSpaceBuilder { public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } public void AddProperty(string _, DriverDataType __, object? ___) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } private sealed class CountingEnumeratorFactory : IAbCipTagEnumeratorFactory { private readonly AbCipDiscoveredTag[] _tags; public int CreateCount { get; private set; } public int EnumerationCount { get; private set; } public CountingEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags; public IAbCipTagEnumerator Create() { CreateCount++; return new CountingEnumerator(this); } private sealed class CountingEnumerator(CountingEnumeratorFactory outer) : IAbCipTagEnumerator { public async IAsyncEnumerable EnumerateAsync( AbCipTagCreateParams deviceParams, [EnumeratorCancellation] CancellationToken cancellationToken) { outer.EnumerationCount++; await Task.CompletedTask; foreach (var t in outer._tags) yield return t; } public void Dispose() { } } } }