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; /// /// PR ablegacy-10 / #253 — verifies the per-device diagnostic-counter surface that /// auto-emits under each device's _Diagnostics/ 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 / /// _Diagnostics/ addresses, /// - read-time short-circuit returning the live snapshot via ReadAsync, /// - independent counters across two devices. /// [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 devices, IReadOnlyList 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( () => 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( () => 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) { } } } }