Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDiagnosticsTests.cs
2026-04-26 03:50:47 -04:00

338 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) { } }
}
}