using System.Linq; using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Unit coverage for the static helpers exposes to bridge /// driver-side history data ( + ) /// to the OPC UA on-wire shape (HistoryData / HistoryEvent wrapped in an /// ). Fast, framework-only — no server fixture. /// [Trait("Category", "Unit")] public sealed class DriverNodeManagerHistoryMappingTests { [Theory] [InlineData(nameof(HistoryAggregateType.Average), HistoryAggregateType.Average)] [InlineData(nameof(HistoryAggregateType.Minimum), HistoryAggregateType.Minimum)] [InlineData(nameof(HistoryAggregateType.Maximum), HistoryAggregateType.Maximum)] [InlineData(nameof(HistoryAggregateType.Total), HistoryAggregateType.Total)] [InlineData(nameof(HistoryAggregateType.Count), HistoryAggregateType.Count)] public void MapAggregate_translates_each_supported_OPC_UA_aggregate_NodeId( string name, HistoryAggregateType expected) { // Resolve the ObjectIds.AggregateFunction_ constant via reflection so the test // keeps working if the stack ever renames them — failure means the stack broke its // naming convention, worth surfacing loudly. var field = typeof(ObjectIds).GetField("AggregateFunction_" + name); field.ShouldNotBeNull(); var nodeId = (NodeId)field!.GetValue(null)!; DriverNodeManager.MapAggregate(nodeId).ShouldBe(expected); } [Fact] public void MapAggregate_returns_null_for_unknown_aggregate() { // AggregateFunction_TimeAverage is a valid OPC UA aggregate but not one the driver // surfaces. Null here means the service handler will translate to BadAggregateNotSupported // — the right behavior per Part 13 when the requested aggregate isn't implemented. DriverNodeManager.MapAggregate(ObjectIds.AggregateFunction_TimeAverage).ShouldBeNull(); } [Fact] public void MapAggregate_returns_null_for_null_input() { // Processed requests that omit the aggregate list (or pass a single null) must not crash. DriverNodeManager.MapAggregate(null).ShouldBeNull(); } [Fact] public void BuildHistoryData_wraps_samples_as_HistoryData_extension_object() { var samples = new[] { new DataValueSnapshot(Value: 42, StatusCode: StatusCodes.Good, SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 1, DateTimeKind.Utc)), new DataValueSnapshot(Value: 99, StatusCode: StatusCodes.Good, SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 5, DateTimeKind.Utc), ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 6, DateTimeKind.Utc)), }; var ext = DriverNodeManager.BuildHistoryData(samples); ext.Body.ShouldBeOfType(); var hd = (HistoryData)ext.Body; hd.DataValues.Count.ShouldBe(2); hd.DataValues[0].Value.ShouldBe(42); hd.DataValues[1].Value.ShouldBe(99); hd.DataValues[0].SourceTimestamp.ShouldBe(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } [Fact] public void BuildHistoryEvent_wraps_events_with_BaseEventType_field_ordering() { // BuildHistoryEvent populates a fixed field set in BaseEventType's conventional order: // EventId, SourceName, Message, Severity, Time, ReceiveTime. Pinning this so a later // "respect the client's SelectClauses" change can't silently break older clients that // rely on the default layout. var events = new[] { new HistoricalEvent( EventId: "e-1", SourceName: "Tank1.HiAlarm", EventTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), ReceivedTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc), Message: "High level reached", Severity: 750), }; var ext = DriverNodeManager.BuildHistoryEvent(events); ext.Body.ShouldBeOfType(); var he = (HistoryEvent)ext.Body; he.Events.Count.ShouldBe(1); var fields = he.Events[0].EventFields; fields.Count.ShouldBe(6); fields[0].Value.ShouldBe("e-1"); // EventId fields[1].Value.ShouldBe("Tank1.HiAlarm"); // SourceName ((LocalizedText)fields[2].Value).Text.ShouldBe("High level reached"); // Message fields[3].Value.ShouldBe((ushort)750); // Severity ((DateTime)fields[4].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc)); ((DateTime)fields[5].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc)); } [Fact] public void BuildHistoryEvent_substitutes_empty_string_for_null_SourceName_and_Message() { // Driver-side nulls are preserved through the wire contract by design (distinguishes // "system event with no source" from "source unknown"), but OPC UA Variants of type // String must not carry null — the stack serializes null-string as empty. This test // pins the choice so a nullable-Variant refactor doesn't break clients that display // the field without a null check. var events = new[] { new HistoricalEvent("sys", null, DateTime.UtcNow, DateTime.UtcNow, null, 1), }; var ext = DriverNodeManager.BuildHistoryEvent(events); var fields = ((HistoryEvent)ext.Body).Events[0].EventFields; fields[1].Value.ShouldBe(string.Empty); ((LocalizedText)fields[2].Value).Text.ShouldBe(string.Empty); } [Fact] public void ToDataValue_preserves_status_code_and_timestamps() { var snap = new DataValueSnapshot( Value: 123.45, StatusCode: StatusCodes.UncertainSubstituteValue, SourceTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc), ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc)); var dv = DriverNodeManager.ToDataValue(snap); dv.Value.ShouldBe(123.45); dv.StatusCode.Code.ShouldBe(StatusCodes.UncertainSubstituteValue); dv.SourceTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc)); dv.ServerTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc)); } [Fact] public void ToDataValue_leaves_SourceTimestamp_default_when_snapshot_has_no_source_time() { // Galaxy's raw-history rows often carry only a ServerTimestamp (the historian knows // when it wrote the row, not when the process sampled it). The mapping must not // synthesize a bogus SourceTimestamp from ServerTimestamp — that would lie to the // client about the measurement's actual time. var snap = new DataValueSnapshot(Value: 1, StatusCode: 0, SourceTimestampUtc: null, ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc)); var dv = DriverNodeManager.ToDataValue(snap); dv.SourceTimestamp.ShouldBe(default); } }