Auto: abcip-4.4 — _RefreshTagDb writeable system tag

Closes #241
This commit is contained in:
Joseph Doherty
2026-04-26 03:16:28 -04:00
parent e46e4de31f
commit e0e5e04e48
8 changed files with 877 additions and 45 deletions

View File

@@ -0,0 +1,91 @@
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.IntegrationTests;
/// <summary>
/// PR abcip-4.4 — end-to-end coverage that writing <c>_RefreshTagDb</c> on the
/// synthetic system folder dispatches to <see cref="AbCipDriver.RebrowseAsync"/>
/// against a live <c>ab_server</c>. Mirrors <see cref="AbCipSystemTagDiscoveryTests"/>
/// but exercises the write entry point so the same outcome (template cache cleared,
/// enumerator re-walked) is observable through the OPC UA write surface.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipRefreshTagDbTests
{
[AbServerFact]
public async Task RefreshTagDb_write_invokes_rebrowse_and_bumps_counter()
{
var profile = KnownProfiles.ControlLogix;
var fixture = new AbServerFixture(profile);
await fixture.InitializeAsync();
try
{
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
EnableControllerBrowse = true,
Timeout = TimeSpan.FromSeconds(5),
}, "drv-refresh-tagdb");
await drv.InitializeAsync("{}", CancellationToken.None);
// Discovery primes the cached builder so the subsequent _RefreshTagDb write
// has a target to dispatch to. The same fixture pattern from AbCipRebrowseTests
// is exercised here through the write surface instead of a direct
// RebrowseAsync call.
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
// Seed the template cache so we can assert RebrowseAsync clears it — same
// behavioural contract as the unit test, validated against a live walker.
drv.TemplateCache.Put(deviceUri, 42, new AbCipUdtShape("T", 4, []));
drv.TemplateCache.Count.ShouldBe(1);
var refreshRef = $"_System/{deviceUri}/{AbCipSystemTagSource.RefreshTagDbName}";
var results = await drv.WriteAsync(
[new WriteRequest(refreshRef, true)], CancellationToken.None);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
// RebrowseAsync drops the template cache + the diagnostics counter advances.
drv.TemplateCache.Count.ShouldBe(0);
drv.SystemTagSource.GetRefreshTriggerCount(deviceUri).ShouldBe(1);
drv.GetHealth().DiagnosticsOrEmpty["AbCip.RefreshTriggers"].ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
finally
{
await fixture.DisposeAsync();
}
}
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) { }
}
}
}

View File

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

View File

@@ -104,7 +104,7 @@ public sealed class AbCipSystemTagSourceTests
}
[Fact]
public async Task Discovery_emits_five_system_nodes_per_device()
public async Task Discovery_emits_six_system_nodes_per_device()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
@@ -122,19 +122,26 @@ public sealed class AbCipSystemTagSourceTests
.Select(v => v.BrowseName)
.OrderBy(s => s)
.ToList();
// PR abcip-4.4 — _RefreshTagDb joins the original five.
systemVars.ShouldBe(new[]
{
"_ConnectionStatus", "_DeviceError", "_LastScanTimeMs",
"_ScanRate", "_TagCount",
"_RefreshTagDb", "_ScanRate", "_TagCount",
});
// All five carry the device host inside the FullName.
// All six carry the device host inside the FullName.
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.ShouldAllBe(v => v.Info.FullName.StartsWith("_System/ab://10.0.0.5/1,0/"));
// PR 4.4 will flip _RefreshTagDb to writeable; today every system var is ViewOnly.
// PR abcip-4.4 — _RefreshTagDb is the sole writeable entry (Operate); the rest
// remain ViewOnly so a SCADA template can't accidentally clobber the diagnostic
// surface.
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Where(v => v.Info.FullName.StartsWith("_System/")
&& !v.Info.FullName.EndsWith("/_RefreshTagDb"))
.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
builder.Variables
.Single(v => v.Info.FullName.EndsWith("/_RefreshTagDb"))
.Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
}
[Fact]
@@ -155,7 +162,8 @@ public sealed class AbCipSystemTagSourceTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.Count(f => f.BrowseName == "_System").ShouldBe(2);
builder.Variables.Count(v => v.Info.FullName.StartsWith("_System/")).ShouldBe(10);
// PR abcip-4.4 — six system variables per device (added _RefreshTagDb).
builder.Variables.Count(v => v.Info.FullName.StartsWith("_System/")).ShouldBe(12);
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.Info.FullName)