@@ -0,0 +1,376 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.4 — coverage for the writeable <c>_RefreshTagDb</c> system tag. Discovery
|
||||
/// emits the entry as Operate, reads always return <c>false</c>, and writes of any
|
||||
/// truthy value dispatch to <see cref="AbCipDriver.RebrowseAsync"/> while bumping the
|
||||
/// <c>AbCip.RefreshTriggers</c> 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.
|
||||
/// </summary>
|
||||
[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<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
outer.EnumerationCount++;
|
||||
await Task.CompletedTask;
|
||||
foreach (var t in outer._tags) yield return t;
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user