338 lines
14 KiB
C#
338 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();
|
||
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) { } }
|
||
}
|
||
}
|