Auto: ablegacy-10 — diagnostic counters as tags

Closes #253
This commit is contained in:
Joseph Doherty
2026-04-26 03:50:47 -04:00
parent 14876ea210
commit 42472b5549
10 changed files with 1000 additions and 5 deletions

View File

@@ -0,0 +1,56 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// PR ablegacy-10 / #253 — wire-level smoke against ab_server PCCC: after N reads
/// against the live runtime, the auto-emitted <c>_Diagnostics/&lt;host&gt;/RequestCount</c>
/// short-circuit must round-trip the same N value through <c>ReadAsync</c>. Skipped
/// when ab_server isn't reachable; otherwise builds the same way the existing read
/// smoke tests do.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyDiagnosticsIntegrationTests(AbLegacyServerFixture sim)
{
[AbLegacyFact]
public async Task RequestCount_diagnostic_matches_read_invocations()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
Tags =
[
new AbLegacyTagDefinition(
Name: "IntCounter",
DeviceHostAddress: deviceUri,
Address: "N7:0",
DataType: AbLegacyDataType.Int),
],
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, driverInstanceId: "ablegacy-smoke-diagnostics");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
const int N = 5;
for (var i = 0; i < N; i++)
await drv.ReadAsync(["IntCounter"], TestContext.Current.CancellationToken);
// Diagnostic short-circuit returns the live counter through ReadAsync without a
// libplctag round-trip. Verifies both that the discovery path emitted the
// address + that the read path serves it locally with Good status.
var diagRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{deviceUri}/RequestCount";
var snapshots = await drv.ReadAsync([diagRef], TestContext.Current.CancellationToken);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
Convert.ToInt64(snapshots.Single().Value).ShouldBe(N);
}
}

View File

@@ -31,9 +31,15 @@ public sealed class AbLegacyCapabilityTests
builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy");
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1");
builder.Variables.Count.ShouldBe(2);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
// PR ablegacy-10 / #253 — discovery now also emits a `_Diagnostics` folder + 7
// diagnostic-counter variables per device. Filter the recording so this older
// assertion still focuses on the user-declared variables.
var userVars = builder.Variables
.Where(v => !v.Info.FullName.StartsWith(AbLegacyDiagnosticTags.DiagnosticsFolderPrefix))
.ToList();
userVars.Count.ShouldBe(2);
userVars.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
userVars.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- ISubscribable ----

View File

@@ -0,0 +1,337 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// PR ablegacy-10 / #253 — verifies the per-device diagnostic-counter surface that
/// auto-emits under each device's <c>_Diagnostics/</c> folder. Tests cover:
/// - counter increments for success / fail / retry sequences,
/// - LastErrorCode / LastErrorMessage capture on failed reads,
/// - reset on ReinitializeAsync,
/// - 7-variable discovery emission per device,
/// - InitializeAsync collision rejection for user tags shadowing reserved names /
/// <c>_Diagnostics/</c> addresses,
/// - read-time short-circuit returning the live snapshot via <c>ReadAsync</c>,
/// - independent counters across two devices.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDiagnosticsTests
{
private const string DeviceA = "ab://10.0.0.5/1,0";
private const string DeviceB = "ab://10.0.0.6/1,0";
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
params AbLegacyTagDefinition[] tags)
=> NewDriver(devices: [new AbLegacyDeviceOptions(DeviceA)], tags: tags);
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
IReadOnlyList<AbLegacyDeviceOptions> devices,
IReadOnlyList<AbLegacyTagDefinition> tags,
int? retries = null)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = devices,
Tags = tags,
Retries = retries,
}, "drv-1", factory);
return (drv, factory);
}
// ---- counter increments ----
[Fact]
public async Task Five_reads_three_ok_two_fail_record_correct_counters()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed the runtime once — each ReadAsync flips Status before the call so we drive
// success / failure deterministically. Status -14 maps to BadNodeIdUnknown (terminal,
// not retried) so each failure is exactly one Request + one Error with no retries.
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 };
// 3 OK reads.
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
// 2 failed reads — flip the fake to BadNodeIdUnknown (terminal, no retries).
factory.Tags["N7:0"].Status = -14;
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
var snapshot = drv.DiagnosticTags.Snapshot(DeviceA);
snapshot.Request.ShouldBe(5);
snapshot.Response.ShouldBe(3);
snapshot.Error.ShouldBe(2);
snapshot.Retry.ShouldBe(0); // terminal failures don't retry
}
[Fact]
public async Task LastErrorCode_reflects_most_recent_failed_read()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 };
await drv.ReadAsync(["X"], CancellationToken.None); // success — clears nothing
factory.Tags["N7:0"].Status = -14;
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["N7:0"].Status = -16; // BadNotWritable maps but still terminal
await drv.ReadAsync(["X"], CancellationToken.None);
var snapshot = drv.DiagnosticTags.Snapshot(DeviceA);
snapshot.LastErrorCode.ShouldBe(-16);
snapshot.LastErrorMessage.ShouldContain("libplctag status -16");
}
[Fact]
public async Task RetryCount_increments_per_retry_attempt()
{
// Driver-wide Retries = 2 — one bad-comm read becomes 1 original + 2 retries = 3 attempts.
// Each retry beyond the first bumps the RetryCount counter exactly once.
var (drv, factory) = NewDriver(
devices: [new AbLegacyDeviceOptions(DeviceA)],
tags: [new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int)],
retries: 2);
await drv.InitializeAsync("{}", CancellationToken.None);
// -7 maps to BadCommunicationError → eligible for retry. The fake's GetStatus returns
// the seeded Status on every attempt; all three attempts fail and exhaust retries.
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = -7 };
await drv.ReadAsync(["X"], CancellationToken.None);
var snapshot = drv.DiagnosticTags.Snapshot(DeviceA);
snapshot.Request.ShouldBe(1);
snapshot.Retry.ShouldBe(2);
snapshot.Error.ShouldBe(1);
snapshot.CommFailures.ShouldBe(1); // BadCommunicationError counts as a comm failure
}
[Fact]
public async Task ReinitializeAsync_resets_counters()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 };
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
drv.DiagnosticTags.Snapshot(DeviceA).Request.ShouldBe(2);
await drv.ReinitializeAsync("{}", CancellationToken.None);
var snapshot = drv.DiagnosticTags.Snapshot(DeviceA);
snapshot.Request.ShouldBe(0);
snapshot.Response.ShouldBe(0);
snapshot.Error.ShouldBe(0);
snapshot.Retry.ShouldBe(0);
snapshot.LastErrorCode.ShouldBe(0);
snapshot.LastErrorMessage.ShouldBeEmpty();
snapshot.CommFailures.ShouldBe(0);
}
// ---- discovery emission ----
[Fact]
public async Task DiscoverAsync_emits_seven_diagnostic_variables_per_device()
{
var (drv, _) = NewDriver(
devices:
[
new AbLegacyDeviceOptions(DeviceA),
new AbLegacyDeviceOptions(DeviceB),
],
tags: []);
await drv.InitializeAsync("{}", CancellationToken.None);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
// Both devices emit a _Diagnostics folder.
builder.Folders.Count(f => f.BrowseName == "_Diagnostics").ShouldBe(2);
// Each device emits the seven canonical names; FullName carries the device host.
foreach (var host in new[] { DeviceA, DeviceB })
{
foreach (var name in AbLegacyDiagnosticTags.DiagnosticTagNames)
{
var fullName = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{host}/{name}";
builder.Variables.Any(v => v.Info.FullName == fullName)
.ShouldBeTrue($"expected variable for {fullName}");
}
}
// Diagnostic vars are read-only.
var diagVars = builder.Variables
.Where(v => v.Info.FullName.StartsWith(AbLegacyDiagnosticTags.DiagnosticsFolderPrefix))
.ToList();
diagVars.Count.ShouldBe(14); // 7 names × 2 devices
diagVars.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
}
// ---- collision rejection ----
[Fact]
public async Task InitializeAsync_rejects_user_tag_with_reserved_name()
{
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(DeviceA)],
// RequestCount is one of the seven reserved diagnostic names.
Tags = [new AbLegacyTagDefinition("RequestCount", DeviceA, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", new FakeAbLegacyTagFactory());
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
ex.Message.ShouldContain("RequestCount");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task InitializeAsync_rejects_user_tag_with_diagnostics_address()
{
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(DeviceA)],
Tags =
[
new AbLegacyTagDefinition("RogueTag", DeviceA,
$"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}whatever", AbLegacyDataType.Int),
],
}, "drv-1", new FakeAbLegacyTagFactory());
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
ex.Message.ShouldContain("_Diagnostics/");
}
// ---- read short-circuit ----
[Fact]
public async Task ReadAsync_short_circuits_for_diagnostic_address_returning_snapshot()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 };
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
var diagRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{DeviceA}/RequestCount";
var diagResponseRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{DeviceA}/ResponseCount";
var snapshots = await drv.ReadAsync([diagRef, diagResponseRef], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snapshots[0].Value.ShouldBe(3L);
snapshots[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snapshots[1].Value.ShouldBe(3L);
}
[Fact]
public async Task Diagnostic_reads_do_not_increment_RequestCount()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 };
await drv.ReadAsync(["X"], CancellationToken.None);
// Fire a bunch of diagnostic reads — the counter must stay at 1 because the
// diagnostics short-circuit is driver-local observability, not field traffic.
var diagRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{DeviceA}/RequestCount";
for (var i = 0; i < 10; i++)
await drv.ReadAsync([diagRef], CancellationToken.None);
drv.DiagnosticTags.Snapshot(DeviceA).Request.ShouldBe(1);
}
// ---- multi-device isolation ----
[Fact]
public async Task Two_devices_have_independent_counters()
{
var (drv, factory) = NewDriver(
devices:
[
new AbLegacyDeviceOptions(DeviceA),
new AbLegacyDeviceOptions(DeviceB),
],
tags:
[
new AbLegacyTagDefinition("A", DeviceA, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("B", DeviceB, "N7:0", AbLegacyDataType.Int),
]);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 };
await drv.ReadAsync(["A"], CancellationToken.None);
await drv.ReadAsync(["A"], CancellationToken.None);
await drv.ReadAsync(["A"], CancellationToken.None);
await drv.ReadAsync(["B"], CancellationToken.None);
drv.DiagnosticTags.Snapshot(DeviceA).Request.ShouldBe(3);
drv.DiagnosticTags.Snapshot(DeviceB).Request.ShouldBe(1);
}
// ---- TryRead / IsDiagnosticAddress / IsReservedName plumbing ----
[Fact]
public void IsDiagnosticAddress_recognises_prefix()
{
AbLegacyDiagnosticTags.IsDiagnosticAddress("_Diagnostics/foo/RequestCount").ShouldBeTrue();
AbLegacyDiagnosticTags.IsDiagnosticAddress("AbLegacy/foo/RequestCount").ShouldBeFalse();
AbLegacyDiagnosticTags.IsDiagnosticAddress(null).ShouldBeFalse();
AbLegacyDiagnosticTags.IsDiagnosticAddress("").ShouldBeFalse();
}
[Fact]
public void IsReservedName_covers_all_seven_canonical_names()
{
foreach (var n in AbLegacyDiagnosticTags.DiagnosticTagNames)
AbLegacyDiagnosticTags.IsReservedName(n).ShouldBeTrue();
AbLegacyDiagnosticTags.IsReservedName("RandomTag").ShouldBeFalse();
AbLegacyDiagnosticTags.IsReservedName(null).ShouldBeFalse();
}
[Fact]
public void TryRead_returns_false_for_unrecognised_shape()
{
var d = new AbLegacyDiagnosticTags();
d.TryRead("AbLegacy/foo", out _).ShouldBeFalse();
d.TryRead("_Diagnostics/host/UnknownName", out _).ShouldBeFalse();
d.TryRead("_Diagnostics/no-name-segment", out _).ShouldBeFalse();
}
// ---- helpers ----
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) { } }
}
}