using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry.Pre847; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry; /// /// Mixed-version wire tests for DriverHealth.reporting_node_id (proto field 15, added by lmxopcua#847; /// tests lmxopcua#851). A rolling upgrade runs old and new nodes against old and new centrals, so the field /// must cross the wire in all three directions: new→new round-trips, old→new parses as ABSENT (not an empty /// node id), and new→old is ignored as an unknown field without disturbing the rest of the message. "Old" is /// the frozen pre-#847 schema in Protos/telemetry_pre847.proto — a real generated parser/serializer, not /// a hand-built byte string. /// [Trait("Category", "Unit")] public sealed class DriverHealthReportingNodeWireTests { private static readonly DateTime PublishedUtc = new(2026, 11, 11, 12, 0, 0, DateTimeKind.Utc); private static readonly DateTime LastReadUtc = new(2026, 11, 11, 11, 59, 30, DateTimeKind.Utc); private static DriverHealth NewHealth(string? reportingNodeId) { var msg = new DriverHealth { ClusterId = "MAIN", DriverInstanceId = "drv-1", State = "Faulted", LastSuccessfulReadUtc = Timestamp.FromDateTime(LastReadUtc), LastError = "timeout", ErrorCount5Min = 4, PublishedUtc = Timestamp.FromDateTime(PublishedUtc), HasDiagnostics = true, }; msg.Diagnostics["pdu"] = "480"; if (reportingNodeId is not null) msg.ReportingNodeId = reportingNodeId; return msg; } [Fact] public void New_sender_to_new_receiver_round_trips_the_reporting_node_id() { var bytes = NewHealth("central-1").ToByteArray(); var parsed = DriverHealth.Parser.ParseFrom(bytes); parsed.HasReportingNodeId.ShouldBeTrue(); var e = TelemetryProtoMapCentral.ToHealth(parsed); e.ReportingNodeId.ShouldBe("central-1"); e.DriverInstanceId.ShouldBe("drv-1"); e.State.ShouldBe("Faulted"); e.PublishedUtc.ShouldBe(PublishedUtc); } [Fact] public void New_sender_without_a_node_id_omits_field_15_and_maps_to_null() { // A harness / node with no role info leaves the field unset: it must not appear on the wire at all, so an // explicit "" would not be confused with "absent" either way. var msg = NewHealth(reportingNodeId: null); msg.HasReportingNodeId.ShouldBeFalse(); var e = TelemetryProtoMapCentral.ToHealth(DriverHealth.Parser.ParseFrom(msg.ToByteArray())); e.ReportingNodeId.ShouldBeNull(); } [Fact] public void Old_sender_to_new_receiver_parses_as_absent_node_id_and_keeps_every_other_field() { var old = new DriverHealthPre847 { ClusterId = "MAIN", DriverInstanceId = "drv-1", State = "Healthy", LastSuccessfulReadUtc = Timestamp.FromDateTime(LastReadUtc), ErrorCount5Min = 0, PublishedUtc = Timestamp.FromDateTime(PublishedUtc), RediscoveryReason = "schema changed", HasDiagnostics = true, }; old.Diagnostics["pdu"] = "240"; var parsed = DriverHealth.Parser.ParseFrom(old.ToByteArray()); // Explicit presence: absent, NOT the proto3 default "" — central keys the snapshot under the // driver-only (unlabelled) node and the AdminUI renders it as a pre-#847 row. parsed.HasReportingNodeId.ShouldBeFalse(); var e = TelemetryProtoMapCentral.ToHealth(parsed); e.ReportingNodeId.ShouldBeNull(); e.ClusterId.ShouldBe("MAIN"); e.DriverInstanceId.ShouldBe("drv-1"); e.State.ShouldBe("Healthy"); e.LastSuccessfulReadUtc.ShouldBe(LastReadUtc); e.LastError.ShouldBeNull(); e.PublishedUtc.ShouldBe(PublishedUtc); e.RediscoveryReason.ShouldBe("schema changed"); e.Diagnostics.ShouldNotBeNull(); e.Diagnostics!["pdu"].ShouldBe("240"); } [Fact] public void New_sender_to_old_receiver_ignores_field_15_and_keeps_every_other_field() { var bytes = NewHealth("central-2").ToByteArray(); // An old central's parser: field 15 is unknown to it. It must neither throw nor shift any known field. var old = DriverHealthPre847.Parser.ParseFrom(bytes); old.ClusterId.ShouldBe("MAIN"); old.DriverInstanceId.ShouldBe("drv-1"); old.State.ShouldBe("Faulted"); old.LastSuccessfulReadUtc.ToDateTime().ShouldBe(LastReadUtc); old.LastError.ShouldBe("timeout"); old.ErrorCount5Min.ShouldBe(4); old.PublishedUtc.ToDateTime().ShouldBe(PublishedUtc); old.HasDiagnostics.ShouldBeTrue(); old.Diagnostics["pdu"].ShouldBe("480"); // The old parser keeps field 15 as an unknown field rather than corrupting it: re-serialised by an old // process, a new reader still recovers the node id. DriverHealth.Parser.ParseFrom(old.ToByteArray()).ReportingNodeId.ShouldBe("central-2"); } }