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() { }
}
}
}