Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
7.3 KiB
C#
161 lines
7.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit coverage for the static helpers <see cref="DriverNodeManager"/> exposes to bridge
|
|
/// driver-side history data (<see cref="HistoricalEvent"/> + <see cref="DataValueSnapshot"/>)
|
|
/// to the OPC UA on-wire shape (<c>HistoryData</c> / <c>HistoryEvent</c> wrapped in an
|
|
/// <see cref="ExtensionObject"/>). Fast, framework-only — no server fixture.
|
|
/// </summary>
|
|
[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_<name> 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<HistoryData>();
|
|
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<HistoryEvent>();
|
|
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);
|
|
}
|
|
}
|