Auto: opcuaclient-13 — Part 13 aggregate catalog mapping

Closes #285
This commit is contained in:
Joseph Doherty
2026-04-26 09:46:33 -04:00
parent 7cbc566db9
commit 0adc5adb59
7 changed files with 537 additions and 8 deletions

View File

@@ -0,0 +1,115 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Sweep coverage for the full <see cref="HistoryAggregateType"/> catalog over a real
/// <c>opc-plc</c> upstream. Loops every enum value, calls <c>ReadProcessedAsync</c> with a
/// 1-second processing interval, and asserts the wire path doesn't crash even when the
/// simulator declines to honour a particular aggregate (it returns
/// <c>BadAggregateNotSupported</c> on the per-row HistoryRead result rather than a
/// thrown exception).
/// </summary>
/// <remarks>
/// <para>
/// <b>Build-only scaffold for now.</b> opc-plc's default profile doesn't enable
/// history simulation on the well-known nodes — <c>ns=3;s=StepUp</c> isn't
/// historized out of the box. This test therefore <see cref="Assert.Skip(string)"/>
/// until the fixture image is upgraded to one of the opc-plc history-sim profiles
/// (e.g. <c>--useslowtypes</c> + <c>--ut=10</c>) AND a known-good historized
/// NodeId is wired into <see cref="OpcPlcProfile"/>.
/// </para>
/// <para>
/// <b>Why it sweeps every enum.</b> The unit-test sweep
/// (<c>OpcUaClientAggregateMappingTests</c>) covers the enum-to-NodeId mapping.
/// This integration test catches any wire-side regression where the SDK rejects
/// a NodeId we thought was well-known — e.g. a future SDK version retires a
/// constant. Aggregates the simulator doesn't honour come back as
/// <c>BadAggregateNotSupported</c>; we count and log them rather than failing,
/// since server-side support is a runtime capability advertisement, not a
/// driver-side bug.
/// </para>
/// </remarks>
[Collection(OpcPlcCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "opc-plc")]
public sealed class OpcUaClientAggregateSweepTests(OpcPlcFixture sim)
{
/// <summary>
/// Iterates the entire <see cref="HistoryAggregateType"/> enum against opc-plc.
/// Each call must return a result object (possibly with empty samples and/or a
/// bad-status row inside) without throwing; aggregates the simulator declines are
/// surfaced as <c>BadAggregateNotSupported</c> on the data rows rather than failing
/// the test.
/// </summary>
[Fact]
public async Task ReadProcessedAsync_sweeps_every_HistoryAggregateType_without_crashing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
Assert.Skip(
"opc-plc default profile does not enable HistoryRead on well-known nodes. " +
"Re-enable when OpcPlcFixture is upgraded to a history-sim profile and a known " +
"historized NodeId is added to OpcPlcProfile (e.g. --useslowtypes --ut=10).");
#pragma warning disable CS0162 // unreachable scaffold below — kept for the post-fixture-upgrade flip
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-aggregate-sweep");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var end = DateTime.UtcNow;
var start = end.AddMinutes(-1);
var interval = TimeSpan.FromSeconds(1);
// Placeholder NodeId — swap to OpcPlcProfile.HistorizedNode once the fixture is upgraded.
const string historizedNode = OpcPlcProfile.StepUp;
var unsupported = new List<HistoryAggregateType>();
var supported = new List<HistoryAggregateType>();
foreach (var aggregate in Enum.GetValues<HistoryAggregateType>())
{
HistoryReadResult? result = null;
try
{
result = await drv.ReadProcessedAsync(
historizedNode, start, end, interval, aggregate,
TestContext.Current.CancellationToken);
}
catch (Exception ex)
{
Assert.Fail(
$"ReadProcessedAsync({aggregate}) threw {ex.GetType().Name}: {ex.Message}. " +
"Wire path should never throw — unsupported aggregates surface as BadAggregateNotSupported.");
}
result.ShouldNotBeNull();
// BadAggregateNotSupported = 0x80330000 per OPC UA Part 4 status codes. Detect by
// inspecting the per-row StatusCode — opc-plc returns the bad code on the (single)
// sample row when it can't honour the aggregate.
const uint BadAggregateNotSupported = 0x80330000;
const uint BadHistoryOperationUnsupported = 0x80710000;
var anyBadAggregate = result.Samples.Any(s =>
s.StatusCode == BadAggregateNotSupported ||
s.StatusCode == BadHistoryOperationUnsupported);
if (anyBadAggregate || result.Samples.Count == 0)
{
unsupported.Add(aggregate);
}
else
{
supported.Add(aggregate);
}
}
// Sanity: at least one aggregate should round-trip cleanly. If none do, the upstream
// is wholly history-disabled and the fixture-upgrade gate above didn't kick in.
supported.Count.ShouldBeGreaterThan(0,
"at least one Part 13 aggregate should round-trip against opc-plc; " +
$"all {Enum.GetValues<HistoryAggregateType>().Length} returned BadAggregateNotSupported. " +
$"Unsupported set: {string.Join(", ", unsupported)}");
#pragma warning restore CS0162
}
}

View File

@@ -0,0 +1,156 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Sweep coverage for <see cref="OpcUaClientDriver.MapAggregateToNodeId"/> over the full
/// <see cref="HistoryAggregateType"/> catalog. PR-13 (issue #285) extended the enum from 5
/// to ~30 values matching OPC UA Part 13 §5; these tests guard the mapping table so a
/// future addition either gets a switch arm or trips the
/// <see cref="ArgumentOutOfRangeException"/> default — never silently returns
/// <c>NodeId.Null</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this is unit-only.</b> The OPC UA Part 13 aggregate NodeIds are well-known —
/// the SDK exposes them as static readonly fields on <c>Opc.Ua.ObjectIds</c>. Round-trip
/// testing against a live upstream is the integration suite's job (see
/// <c>OpcUaClientAggregateSweepTests</c>); the wire path doesn't add anything to the
/// enum-to-NodeId mapping itself.
/// </para>
/// <para>
/// <b>Cascading-quality rule.</b> Aggregates the upstream server doesn't honour come
/// back with <c>BadAggregateNotSupported</c> on the per-row HistoryRead result, not as
/// a thrown exception — the driver's mapping is best-effort by design.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class OpcUaClientAggregateMappingTests
{
/// <summary>
/// Every declared <see cref="HistoryAggregateType"/> value resolves to a non-null
/// namespace-0 <see cref="NodeId"/>. Sweeps the full enum so a new value can't land
/// without a switch arm — the default branch throws
/// <see cref="ArgumentOutOfRangeException"/> which the test would surface immediately.
/// </summary>
[Theory]
[MemberData(nameof(AllHistoryAggregateTypes))]
public void MapAggregateToNodeId_resolves_every_enum_value_to_a_namespace0_NodeId(HistoryAggregateType aggregate)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(aggregate);
NodeId.IsNull(nodeId).ShouldBeFalse(
$"HistoryAggregateType.{aggregate} must map to a Part 13 AggregateFunction_* NodeId");
nodeId.NamespaceIndex.ShouldBe((ushort)0,
$"HistoryAggregateType.{aggregate} maps to a standard NodeId — namespace must be 0");
nodeId.IdType.ShouldBe(IdType.Numeric,
$"HistoryAggregateType.{aggregate} maps to a numeric Part 13 NodeId");
}
/// <summary>
/// Regression cover for the original 5 ordinals — Average/Minimum/Maximum/Total/Count
/// stay pinned to their existing SDK NodeIds. Guards against an accidental swap when
/// the switch table grew to ~30 arms.
/// </summary>
[Theory]
[InlineData(HistoryAggregateType.Average, "AggregateFunction_Average")]
[InlineData(HistoryAggregateType.Minimum, "AggregateFunction_Minimum")]
[InlineData(HistoryAggregateType.Maximum, "AggregateFunction_Maximum")]
[InlineData(HistoryAggregateType.Total, "AggregateFunction_Total")]
[InlineData(HistoryAggregateType.Count, "AggregateFunction_Count")]
public void MapAggregateToNodeId_original_five_aggregates_stay_pinned_to_their_SDK_NodeIds(
HistoryAggregateType aggregate, string expectedSdkFieldName)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(aggregate);
var expected = GetSdkAggregateNodeId(expectedSdkFieldName);
nodeId.ShouldBe(expected);
}
/// <summary>
/// The new Part 13 aggregates added in PR-13 each resolve to the SDK constant whose
/// name matches the enum value. Guards against a transposition bug on any of the 25
/// new arms.
/// </summary>
[Theory]
[InlineData(HistoryAggregateType.TimeAverage, "AggregateFunction_TimeAverage")]
[InlineData(HistoryAggregateType.TimeAverage2, "AggregateFunction_TimeAverage2")]
[InlineData(HistoryAggregateType.Interpolative, "AggregateFunction_Interpolative")]
[InlineData(HistoryAggregateType.MinimumActualTime, "AggregateFunction_MinimumActualTime")]
[InlineData(HistoryAggregateType.MaximumActualTime, "AggregateFunction_MaximumActualTime")]
[InlineData(HistoryAggregateType.Range, "AggregateFunction_Range")]
[InlineData(HistoryAggregateType.Range2, "AggregateFunction_Range2")]
[InlineData(HistoryAggregateType.AnnotationCount, "AggregateFunction_AnnotationCount")]
[InlineData(HistoryAggregateType.DurationGood, "AggregateFunction_DurationGood")]
[InlineData(HistoryAggregateType.DurationBad, "AggregateFunction_DurationBad")]
[InlineData(HistoryAggregateType.PercentGood, "AggregateFunction_PercentGood")]
[InlineData(HistoryAggregateType.PercentBad, "AggregateFunction_PercentBad")]
[InlineData(HistoryAggregateType.WorstQuality, "AggregateFunction_WorstQuality")]
[InlineData(HistoryAggregateType.WorstQuality2, "AggregateFunction_WorstQuality2")]
[InlineData(HistoryAggregateType.StandardDeviationSample, "AggregateFunction_StandardDeviationSample")]
[InlineData(HistoryAggregateType.StandardDeviationPopulation, "AggregateFunction_StandardDeviationPopulation")]
[InlineData(HistoryAggregateType.VarianceSample, "AggregateFunction_VarianceSample")]
[InlineData(HistoryAggregateType.VariancePopulation, "AggregateFunction_VariancePopulation")]
[InlineData(HistoryAggregateType.NumberOfTransitions, "AggregateFunction_NumberOfTransitions")]
[InlineData(HistoryAggregateType.DurationInStateZero, "AggregateFunction_DurationInStateZero")]
[InlineData(HistoryAggregateType.DurationInStateNonZero, "AggregateFunction_DurationInStateNonZero")]
[InlineData(HistoryAggregateType.Start, "AggregateFunction_Start")]
[InlineData(HistoryAggregateType.End, "AggregateFunction_End")]
[InlineData(HistoryAggregateType.Delta, "AggregateFunction_Delta")]
[InlineData(HistoryAggregateType.StartBound, "AggregateFunction_StartBound")]
[InlineData(HistoryAggregateType.EndBound, "AggregateFunction_EndBound")]
public void MapAggregateToNodeId_new_Part13_aggregates_resolve_to_matching_SDK_NodeIds(
HistoryAggregateType aggregate, string expectedSdkFieldName)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(aggregate);
var expected = GetSdkAggregateNodeId(expectedSdkFieldName);
nodeId.ShouldBe(expected);
}
/// <summary>
/// Out-of-range enum values trip the default arm with
/// <see cref="ArgumentOutOfRangeException"/>. <c>int.MaxValue</c> is a future-proof
/// sentinel that is guaranteed never to collide with a real enum ordinal.
/// </summary>
[Fact]
public void MapAggregateToNodeId_rejects_out_of_range_enum_value()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)int.MaxValue));
}
/// <summary>
/// The enum sweep is the source of truth — every value declared in
/// <see cref="HistoryAggregateType"/> participates in
/// <see cref="MapAggregateToNodeId_resolves_every_enum_value_to_a_namespace0_NodeId"/>.
/// Adding a new enum value automatically adds a new test row.
/// </summary>
public static TheoryData<HistoryAggregateType> AllHistoryAggregateTypes()
{
var data = new TheoryData<HistoryAggregateType>();
foreach (var v in Enum.GetValues<HistoryAggregateType>())
{
data.Add(v);
}
return data;
}
/// <summary>
/// Reflect the requested static field off <see cref="ObjectIds"/>. Used so the test
/// table stays declarative — adding a new aggregate is one [InlineData] row plus an
/// enum value, with no SDK-version-specific casting.
/// </summary>
private static NodeId GetSdkAggregateNodeId(string fieldName)
{
var field = typeof(ObjectIds).GetField(fieldName)
?? throw new InvalidOperationException(
$"OPC UA SDK does not expose ObjectIds.{fieldName}. " +
"If the SDK was upgraded and the field was renamed, update the mapping table.");
var value = field.GetValue(null);
return (NodeId)value!;
}
}