using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Unit tests for the per-driver diagnostic counters surfaced via /// for the driver-diagnostics RPC /// (task #276). Counters are exercised directly through the internal helper rather /// than via a live SDK ISession because the SDK requires a connected upstream /// to publish events and we want unit-level coverage of the math + the snapshot shape. /// [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(); } }