377 lines
16 KiB
C#
377 lines
16 KiB
C#
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() { }
|
|
}
|
|
}
|
|
}
|