Auto: opcuaclient-4 — diagnostics counters
Per-driver counters surfaced via DriverHealth.Diagnostics for the driver-diagnostics RPC. New OpcUaClientDiagnostics tracks PublishRequestCount, NotificationCount, NotificationsPerSecond (5s-half-life EWMA), MissingPublishRequestCount, DroppedNotificationCount, SessionResetCount and LastReconnectUtcTicks via Interlocked on the hot path. DriverHealth gains an optional IReadOnlyDictionary<string,double>? Diagnostics parameter (defaulted null for back-compat with the seven other drivers' constructors). OpcUaClientDriver wires Session.Notification + Session.PublishError on connect and on reconnect-complete (recording a session-reset there); GetHealth snapshots the counters on every poll so the RPC sees fresh values without a tick source. Tests: 11 new OpcUaClientDiagnosticsTests cover counter increments, EWMA convergence, snapshot shape, GetHealth integration, and DriverHealth back-compat. Full OpcUaClient.Tests 115/115 green. Closes #276 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the per-driver diagnostic counters surfaced via
|
||||
/// <see cref="DriverHealth.Diagnostics"/> for the <c>driver-diagnostics</c> RPC
|
||||
/// (task #276). Counters are exercised directly through the internal helper rather
|
||||
/// than via a live SDK <c>ISession</c> because the SDK requires a connected upstream
|
||||
/// to publish events and we want unit-level coverage of the math + the snapshot shape.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Counters_default_to_zero()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.PublishRequestCount.ShouldBe(0);
|
||||
d.NotificationCount.ShouldBe(0);
|
||||
d.NotificationsPerSecond.ShouldBe(0);
|
||||
d.MissingPublishRequestCount.ShouldBe(0);
|
||||
d.DroppedNotificationCount.ShouldBe(0);
|
||||
d.SessionResetCount.ShouldBe(0);
|
||||
d.LastReconnectUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementPublishRequest_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementPublishRequest();
|
||||
d.IncrementPublishRequest();
|
||||
d.IncrementPublishRequest();
|
||||
d.PublishRequestCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementMissingPublishRequest_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.MissingPublishRequestCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementDroppedNotification_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementDroppedNotification();
|
||||
d.DroppedNotificationCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordNotification_grows_count_and_then_rate()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
// First sample seeds the EWMA — rate stays 0 until we have a delta.
|
||||
d.RecordNotification(t0);
|
||||
d.NotificationCount.ShouldBe(1);
|
||||
d.NotificationsPerSecond.ShouldBe(0);
|
||||
|
||||
// 1 Hz steady state: 30 samples spaced 1s apart converge toward 1/s. With 5s half-life
|
||||
// and alpha=0.5^(1/5)≈0.871, the EWMA approaches 1 - alpha^N — after 30 samples that's
|
||||
// 1 - 0.871^30 ≈ 0.984.
|
||||
for (var i = 1; i <= 30; i++)
|
||||
d.RecordNotification(t0.AddSeconds(i));
|
||||
|
||||
d.NotificationCount.ShouldBe(31);
|
||||
d.NotificationsPerSecond.ShouldBeInRange(0.95, 1.05, "EWMA at 5s half-life converges to ~1Hz after 30 samples");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSessionReset_bumps_count_and_sets_last_reconnect()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
var t = new DateTime(2026, 4, 25, 12, 34, 56, DateTimeKind.Utc);
|
||||
d.RecordSessionReset(t);
|
||||
d.SessionResetCount.ShouldBe(1);
|
||||
d.LastReconnectUtc.ShouldBe(t);
|
||||
|
||||
// Second reset overwrites timestamp + bumps count.
|
||||
var t2 = t.AddMinutes(5);
|
||||
d.RecordSessionReset(t2);
|
||||
d.SessionResetCount.ShouldBe(2);
|
||||
d.LastReconnectUtc.ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_emits_well_known_keys()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementPublishRequest();
|
||||
d.RecordNotification(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.IncrementDroppedNotification();
|
||||
d.RecordSessionReset(new DateTime(2026, 4, 25, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var snap = d.Snapshot();
|
||||
|
||||
snap.ShouldContainKey("PublishRequestCount");
|
||||
snap["PublishRequestCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("NotificationCount");
|
||||
snap["NotificationCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("NotificationsPerSecond");
|
||||
snap.ShouldContainKey("MissingPublishRequestCount");
|
||||
snap["MissingPublishRequestCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("DroppedNotificationCount");
|
||||
snap["DroppedNotificationCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("SessionResetCount");
|
||||
snap["SessionResetCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("LastReconnectUtcTicks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_omits_LastReconnectUtcTicks_when_no_reset_recorded()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.Snapshot().ShouldNotContainKey("LastReconnectUtcTicks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_GetHealth_includes_diagnostics_dictionary()
|
||||
{
|
||||
// GetHealth must expose the snapshot to the RPC consumer even before any session
|
||||
// has been opened — operators call it during startup to check counters baseline.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test");
|
||||
var health = drv.GetHealth();
|
||||
health.Diagnostics.ShouldNotBeNull();
|
||||
health.Diagnostics!.ShouldContainKey("PublishRequestCount");
|
||||
health.Diagnostics["PublishRequestCount"].ShouldBe(0);
|
||||
health.Diagnostics.ShouldContainKey("NotificationCount");
|
||||
health.Diagnostics.ShouldContainKey("SessionResetCount");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_health_diagnostics_reflect_internal_counters_after_increment()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test-2");
|
||||
|
||||
// Drive a counter through the test seam to prove the GetHealth snapshot is live,
|
||||
// not a one-shot at construction.
|
||||
drv.DiagnosticsForTest.IncrementPublishRequest();
|
||||
drv.DiagnosticsForTest.IncrementPublishRequest();
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.Diagnostics!["PublishRequestCount"].ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverHealth_default_diagnostics_is_null_but_DiagnosticsOrEmpty_is_empty()
|
||||
{
|
||||
// Back-compat: pre-existing call sites that construct DriverHealth with the
|
||||
// 3-arg overload must keep working — the 4th param defaults to null.
|
||||
var h = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
h.Diagnostics.ShouldBeNull();
|
||||
h.DiagnosticsOrEmpty.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user