340 lines
14 KiB
C#
340 lines
14 KiB
C#
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();
|
|
// PR ablegacy-12 / #255 — DemoteCount + LastDemotedUtc bring the canonical
|
|
// count to 9 names per device (was 7 in PR ablegacy-10).
|
|
diagVars.Count.ShouldBe(AbLegacyDiagnosticTags.DiagnosticTagNames.Count * 2);
|
|
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) { } }
|
|
}
|
|
}
|