@@ -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 ) { } }
}
}