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