Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiagnosticsTests.cs
Joseph Doherty bb1ab47b68 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>
2026-04-25 15:53:57 -04:00

164 lines
6.2 KiB
C#

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();
}
}