using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Review 02 U1: GetDiagnostics answered a placeholder — Guid.Empty, "Spawned", zero devices and /// "now" for every child, in every behaviour — so the snapshot looked live and said nothing. It now carries /// each child's row id, instance id, last-published health state, device counts and last change time. /// public sealed class DriverHostActorDiagnosticsTests : RuntimeActorTestBase { private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly NodeId TestNode = NodeId.Parse("node-1:4053"); private const string DriverId = "DI-1"; [Fact] public async Task A_connected_child_reports_its_row_id_name_health_state_and_device_count() { var db = NewInMemoryDbFactory(); var rowId = Guid.NewGuid(); var before = DateTime.UtcNow.AddSeconds(-1); var factory = new RecordingSubscribableDriverFactory("Modbus"); var deploymentId = V3ArtifactSeeder.SeedV3Deployment( db, RevA, new[] { (DriverId, "Line1-Modbus", "dev1", "speed", "Float", false) }, Array.Empty<(string, string, string, string, string?)>(), enabled: true, driverRowIds: new Dictionary { [DriverId] = rowId }); var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory); var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Any(d => d.State == "Healthy")); snapshot.NodeId.ShouldBe(TestNode); snapshot.CurrentRevision.ShouldBe(RevA); snapshot.RunningFromCache.ShouldBeFalse(); var driver = snapshot.Drivers.ShouldHaveSingleItem(); driver.DriverInstanceId.ShouldBe(rowId, "the real row id, not Guid.Empty"); driver.Name.ShouldBe(DriverId, "the string instance id every other surface keys on"); driver.State.ShouldBe("Healthy"); driver.ConnectedDevices.ShouldBe(1); driver.FaultedDevices.ShouldBe(0); driver.StoppedDevices.ShouldBe(0); driver.LastChangeUtc.ShouldBeGreaterThan(before); driver.LastChangeUtc.ShouldBeLessThanOrEqualTo(DateTime.UtcNow); } [Fact] public async Task A_stubbed_child_reports_Stubbed_with_no_devices() { var db = NewInMemoryDbFactory(); // The factory answers for a type the artifact does not use, so the host stubs the child. var factory = new RecordingSubscribableDriverFactory("NotModbus"); var deploymentId = V3ArtifactSeeder.SeedV3Deployment( db, RevA, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }, Array.Empty<(string, string, string, string, string?)>(), enabled: true); var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory); var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Count == 1); var driver = snapshot.Drivers.Single(); driver.State.ShouldBe("Stubbed"); driver.ConnectedDevices.ShouldBe(0); driver.FaultedDevices.ShouldBe(0); } [Fact] public void Faulted_counts_only_Faulted_hosts_and_Stopped_is_reported_separately() { // #786: a Stopped host used to be counted as faulted. var now = DateTime.UtcNow; var health = new ChildHealthRecord( DriverState.Degraded, now, null, new[] { new HostConnectivityStatus("plc-a:502", HostState.Running, now), new HostConnectivityStatus("plc-b:502", HostState.Faulted, now), new HostConnectivityStatus("plc-c:502", HostState.Stopped, now), new HostConnectivityStatus("plc-d:502", HostState.Stopped, now), new HostConnectivityStatus("plc-e:502", HostState.Unknown, now), }, now); var row = DriverHostActor.DiagnosticsFromHealth(Guid.NewGuid(), DriverId, health); row.ConnectedDevices.ShouldBe(1); row.FaultedDevices.ShouldBe(1); row.StoppedDevices.ShouldBe(2); row.FaultedHostNames.ShouldBe(new[] { "plc-b:502" }); } [Theory] [InlineData(DriverState.Faulted, 0, 1)] [InlineData(DriverState.Healthy, 1, 0)] [InlineData(DriverState.Degraded, 1, 0)] public void A_single_host_driver_is_one_device_with_no_host_name(DriverState state, int connected, int faulted) { var row = DriverHostActor.DiagnosticsFromHealth( Guid.NewGuid(), DriverId, new ChildHealthRecord(state, null, null, null, DateTime.UtcNow)); row.ConnectedDevices.ShouldBe(connected); row.FaultedDevices.ShouldBe(faulted); row.StoppedDevices.ShouldBe(0); row.FaultedHostNames.ShouldNotBeNull().ShouldBeEmpty(); } private async Task AwaitDiagnosticsAsync(IActorRef host, Func ready) { NodeDiagnosticsSnapshot? snapshot = null; await AwaitAssertAsync(async () => { snapshot = await host.Ask(new GetDiagnostics(CorrelationId.NewId()), PresenceBudget); ready(snapshot).ShouldBeTrue(); }, PresenceBudget); return snapshot!; } }