Merge hw3-history (#655 delete dead driver-side history capability + client surfaces Bad HistoryRead)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 00:14:47 -04:00
47 changed files with 289 additions and 1709 deletions
@@ -0,0 +1,38 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
public class HistoryReadStatusTests
{
/// <summary>A refused read must surface, carrying the status code, rather than read as an empty result.</summary>
/// <param name="code">The Bad per-node status code.</param>
[Theory]
[InlineData(StatusCodes.BadHistoryOperationUnsupported)]
[InlineData(StatusCodes.BadUserAccessDenied)]
[InlineData(StatusCodes.BadNodeIdUnknown)]
[InlineData(StatusCodes.BadTimeout)]
public void Bad_status_throws_ServiceResultException_with_the_code(uint code)
{
var ex = Should.Throw<ServiceResultException>(
() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
ex.StatusCode.ShouldBe(code);
ex.Message.ShouldContain("ns=2;s=n");
}
/// <summary>Good, GoodNoData, Uncertain and BadNoData are all non-refusals (the window may simply be empty).</summary>
/// <param name="code">The non-refusal status code.</param>
[Theory]
[InlineData(StatusCodes.Good)]
[InlineData(StatusCodes.GoodNoData)]
[InlineData(StatusCodes.GoodMoreData)]
[InlineData(StatusCodes.UncertainNoCommunicationLastUsableValue)]
[InlineData(StatusCodes.BadNoData)]
public void Non_refusal_status_does_not_throw(uint code)
{
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
}
}
@@ -1,21 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
public sealed class HistoryReadStatusExceptionTests
{
[Theory]
[InlineData(0x801F0000u)] // BadUserAccessDenied
[InlineData(0xC0000000u)] // severity 11 (reserved Bad)
public void A_Bad_code_is_carried_verbatim(uint code) =>
new HistoryReadStatusException(code).StatusCode.ShouldBe(code);
[Theory]
[InlineData(0x00000000u)] // Good
[InlineData(0x00A70000u)] // GoodNoData
[InlineData(0x40000000u)] // Uncertain
public void A_non_Bad_code_is_rejected(uint code) =>
Should.Throw<ArgumentException>(() => new HistoryReadStatusException(code));
}
@@ -153,22 +153,6 @@ public sealed class IHistorianDataSourceContractTests
parameter!.ParameterType.ShouldBe(expectedType);
}
/// <summary>Verifies that history provider max parameters have the expected type.</summary>
/// <param name="methodName">The name of the method to test.</param>
/// <param name="parameterName">The name of the parameter to verify.</param>
/// <param name="expectedType">The expected parameter type.</param>
[Theory]
[InlineData("ReadRawAsync", "maxValuesPerNode", typeof(uint))]
[InlineData("ReadEventsAsync", "maxEvents", typeof(int))]
public void HistoryProvider_MaxParameter_TypePinned(string methodName, string parameterName, Type expectedType)
{
var method = typeof(IHistoryProvider).GetMethod(methodName);
method.ShouldNotBeNull();
var parameter = method!.GetParameters().FirstOrDefault(p => p.Name == parameterName);
parameter.ShouldNotBeNull($"Method {methodName} should expose a '{parameterName}' parameter.");
parameter!.ParameterType.ShouldBe(expectedType);
}
/// <summary>
/// Core.Abstractions-009 (OpcUaServer-002): when <c>maxEvents &lt;= 0</c> the contract
/// requires implementations to set <see cref="HistoricalEventsResult.ContinuationPoint"/>
@@ -195,30 +179,4 @@ public sealed class IHistorianDataSourceContractTests
continuationProp!.PropertyType.ShouldBe(typeof(byte[]),
"ContinuationPoint is byte[]? — null means no more pages, non-null means caller must page");
}
/// <summary>
/// Core.Abstractions-008: <see cref="IHistoryProvider.ReadAtTimeAsync"/> and
/// <see cref="IHistoryProvider.ReadEventsAsync"/> are C# default interface methods
/// (drivers opt in), whereas <see cref="IHistorianDataSource.ReadAtTimeAsync"/> and
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> are required (the server-side
/// historian must implement them). This test pins the asymmetry so an implementer cannot
/// accidentally collapse the two surfaces and so the documented rationale stays load-bearing.
/// </summary>
/// <param name="methodName">The name of the optional method to check.</param>
[Theory]
[InlineData("ReadAtTimeAsync")]
[InlineData("ReadEventsAsync")]
public void HistoryProvider_OptionalMethods_HaveDefaultImplementation(string methodName)
{
var providerMethod = typeof(IHistoryProvider).GetMethod(methodName);
providerMethod.ShouldNotBeNull();
// Default interface methods carry a method body — IsAbstract is false.
providerMethod!.IsAbstract.ShouldBeFalse(
$"IHistoryProvider.{methodName} must remain a default-impl-throws method so legacy drivers continue to compile.");
var dataSourceMethod = typeof(IHistorianDataSource).GetMethod(methodName);
dataSourceMethod.ShouldNotBeNull();
dataSourceMethod!.IsAbstract.ShouldBeTrue(
$"IHistorianDataSource.{methodName} must remain required — server-side historians own the full surface.");
}
}
@@ -62,7 +62,6 @@ public sealed class InterfaceIndependenceTests
[InlineData(typeof(IWritable))]
[InlineData(typeof(ISubscribable))]
[InlineData(typeof(IAlarmSource))]
[InlineData(typeof(IHistoryProvider))]
[InlineData(typeof(IRediscoverable))]
[InlineData(typeof(IHostConnectivityProbe))]
[InlineData(typeof(ISkippedTagReporter))]
@@ -73,4 +72,15 @@ public sealed class InterfaceIndependenceTests
type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it.");
type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class.");
}
/// <summary>
/// Gitea #655: HistoryRead is served server-side by <c>IHistorianDataSource</c>; no driver-side history
/// capability exists. Re-adding one without also adding a dispatch path would recreate dead surface.
/// </summary>
[Fact]
public void NoDriverSideHistoryCapability_Exists()
{
Assembly.GetType("ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider").ShouldBeNull();
Enum.GetNames<DriverCapability>().ShouldNotContain("HistoryRead");
}
}
@@ -138,6 +138,33 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
/// <summary>
/// Gitea #655: the retired <c>HistoryRead</c> capability may still sit in stored
/// <c>DriverInstance.ResilienceConfig</c> rows. It must parse (diagnostic only, never a throw), be skipped,
/// and leave every other override in the same blob applied.
/// </summary>
[Fact]
public void StoredConfig_WithRetiredHistoryReadKey_StillParses_AndOtherOverridesApply()
{
var json = """
{
"capabilityPolicies": {
"HistoryRead": { "timeoutSeconds": 45, "retryCount": 1, "breakerFailureThreshold": 3 },
"Read": { "timeoutSeconds": 7 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldNotBeNull();
diag.ShouldContain("HistoryRead");
diag.ShouldContain("skipped");
options.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
/// <summary>Verifies that top-level property names are case insensitive.</summary>
[Fact]
public void PropertyNames_AreCaseInsensitive()
@@ -116,7 +116,6 @@ public sealed class DriverResilienceOptionsTests
/// <param name="capability">The driver capability to test.</param>
[Theory]
[InlineData(DriverTier.A, DriverCapability.Read)]
[InlineData(DriverTier.A, DriverCapability.HistoryRead)]
[InlineData(DriverTier.B, DriverCapability.Discover)]
[InlineData(DriverTier.B, DriverCapability.Probe)]
[InlineData(DriverTier.C, DriverCapability.AlarmSubscribe)]
@@ -16,9 +16,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <para>
/// <b>Why opc-plc over loopback against our own server</b> — (1) independent
/// cert chain + user-token handling catches interop bugs loopback can't;
/// (2) built-in alarm ConditionType + history simulation gives
/// <see cref="Core.Abstractions.IAlarmSource"/> +
/// <see cref="Core.Abstractions.IHistoryProvider"/> coverage without a custom
/// (2) built-in alarm ConditionType simulation gives
/// <see cref="Core.Abstractions.IAlarmSource"/> coverage without a custom
/// driver fake; (3) pinned image tag fixes the test surface in a way our own
/// evolving server wouldn't. Follow-up: add <c>open62541/open62541</c> as a
/// second image once this lands, for fully-independent-stack interop.
@@ -92,32 +92,4 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
}
/// <summary>
/// Verifies HistoryReadEvents passthrough issues a well-formed request and returns a
/// result without throwing. opc-plc exposes live alarm conditions (--alm) but is NOT a
/// historian, so the upstream may return zero historical events or reject the service —
/// either way the driver must produce a HistoricalEventsResult, never throw. This proves
/// the wire request + unwrap path; a non-empty event list is infra-gated on an upstream
/// that historizes events.
/// </summary>
[Fact]
public async Task Client_reads_events_returns_result_without_throwing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-events");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.ReadEventsAsync(
sourceName: null, // null → upstream Server object (i=2253)
startUtc: DateTime.UtcNow.AddHours(-1),
endUtc: DateTime.UtcNow,
maxEvents: 100,
cancellationToken: TestContext.Current.CancellationToken);
result.ShouldNotBeNull();
result.Events.ShouldNotBeNull();
}
}
@@ -1,178 +0,0 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryTests
{
/// <summary>Every <see cref="HistoryAggregateType"/> member (#285: the full Part 13 set, not just five).</summary>
public static IEnumerable<object[]> AllAggregates() =>
Enum.GetValues<HistoryAggregateType>().Select(a => new object[] { a });
/// <summary>Every member maps to a DISTINCT aggregate NodeId (a copy/paste mapping would collide).</summary>
[Fact]
public void MapAggregateToNodeId_is_injective_over_the_whole_enum()
{
var ids = Enum.GetValues<HistoryAggregateType>().Select(OpcUaClientDriver.MapAggregateToNodeId).ToList();
ids.Distinct().Count().ShouldBe(ids.Count);
Enum.GetValues<HistoryAggregateType>().Length.ShouldBeGreaterThan(5);
}
/// <summary>Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum.</summary>
/// <param name="agg">The history aggregate type to test.</param>
[Theory]
[MemberData(nameof(AllAggregates))]
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
NodeId.IsNull(nodeId).ShouldBeFalse();
// Every mapping should resolve to an AggregateFunction_* NodeId (namespace 0, numeric id).
nodeId.NamespaceIndex.ShouldBe((ushort)0);
}
/// <summary>Verifies MapAggregateToNodeId rejects invalid enum values.</summary>
[Fact]
public void MapAggregateToNodeId_rejects_invalid_enum_value()
{
// Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through.
Should.Throw<ArgumentOutOfRangeException>(() =>
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99));
}
/// <summary>Verifies ReadRawAsync throws without initialization.</summary>
[Fact]
public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadRawAsync("ns=2;s=Counter",
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000,
TestContext.Current.CancellationToken));
}
/// <summary>Verifies ReadRawAsync with malformed NodeId returns empty result.</summary>
[Fact]
public async Task ReadRawAsync_with_malformed_NodeId_returns_empty_result_not_throw()
{
// Same defensive pattern as ReadAsync / WriteAsync — malformed NodeId short-circuits
// to an empty result rather than crashing a batch history call. Needs init via the
// throw path first, then we pass "" to trigger the parse-fail branch inside
// ExecuteHistoryReadAsync. The init itself fails against 127.0.0.1:1 so we stop there.
// Not runnable without init — keep as placeholder for when the in-process fixture
// PR lands.
await Task.CompletedTask;
}
/// <summary>Verifies ReadProcessedAsync throws without initialization.</summary>
[Fact]
public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadProcessedAsync("ns=2;s=Counter",
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow,
TimeSpan.FromSeconds(10), HistoryAggregateType.Average,
TestContext.Current.CancellationToken));
}
/// <summary>Verifies ReadAtTimeAsync throws without initialization.</summary>
[Fact]
public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadAtTimeAsync("ns=2;s=Counter",
[DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow],
TestContext.Current.CancellationToken));
}
/// <summary>ReadEventsAsync requires an initialized session like the sibling history reads.</summary>
[Fact]
public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadEventsAsync(
sourceName: null,
startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow,
maxEvents: 100,
cancellationToken: TestContext.Current.CancellationToken));
}
/// <summary>BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order.</summary>
[Fact]
public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses()
{
var filter = OpcUaClientDriver.BuildBaseEventFilter();
filter.SelectClauses.Count.ShouldBe(6);
string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"];
for (var i = 0; i < expected.Length; i++)
{
var clause = filter.SelectClauses[i];
clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType);
clause.AttributeId.ShouldBe(Attributes.Value);
clause.BrowsePath.Count.ShouldBe(1);
clause.BrowsePath[0].Name.ShouldBe(expected[i]);
clause.BrowsePath[0].NamespaceIndex.ShouldBe((ushort)0); // BaseEventType fields live in ns 0
}
}
/// <summary>MapHistoryEvents maps every field by its canonical select-clause index.</summary>
[Fact]
public void MapHistoryEvents_maps_all_six_fields_by_canonical_index()
{
var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc);
var recvTime = eventTime.AddSeconds(1);
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 })); // 0 EventId
fields.EventFields.Add(new Variant("Pump17")); // 1 SourceName
fields.EventFields.Add(new Variant(eventTime)); // 2 Time
fields.EventFields.Add(new Variant(recvTime)); // 3 ReceiveTime
fields.EventFields.Add(new Variant(new LocalizedText("High temp"))); // 4 Message
fields.EventFields.Add(new Variant((ushort)700)); // 5 Severity
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
var e = mapped[0];
e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 }));
e.SourceName.ShouldBe("Pump17");
e.EventTimeUtc.ShouldBe(eventTime);
e.ReceivedTimeUtc.ShouldBe(recvTime);
e.Message.ShouldBe("High temp");
e.Severity.ShouldBe((ushort)700);
}
/// <summary>MapHistoryEvents returns empty for a HistoryEvent with no rows.</summary>
[Fact]
public void MapHistoryEvents_with_no_events_returns_empty()
{
OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty();
}
/// <summary>MapHistoryEvents tolerates a field list shorter than six without throwing.</summary>
[Fact]
public void MapHistoryEvents_tolerates_short_field_list_without_throwing()
{
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 }));
mapped[0].SourceName.ShouldBeNull();
mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue);
mapped[0].EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); // sentinel is UTC-kinded, not Unspecified
mapped[0].Severity.ShouldBe((ushort)0);
}
}
@@ -8,13 +8,13 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Regression coverage for the stale-session concurrency race in the history/read wire
/// paths. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// Regression coverage for the stale-session concurrency race in the alarm-acknowledge wire
/// path. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// while a caller is parked on <c>_gate.WaitAsync</c>. If a method captures the session
/// reference <i>before</i> the gate and uses it afterwards, it issues the wire call against
/// the closed/stale session. The correct idiom — already used by ReadAsync/WriteAsync — is
/// a fast-fail guard outside the gate plus an authoritative re-read of <c>Session</c>
/// <i>inside</i> the gate. These tests prove the history/read funnels follow it.
/// <i>inside</i> the gate. This test proves the acknowledge path follows it.
/// </summary>
/// <remarks>
/// The swap is staged so the race is exercised, not raced against:
@@ -41,13 +41,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
{
private static Mock<ISession> NewSessionMock(TaskCompletionSource? prologueReached = null)
{
var resp = new HistoryReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = new HistoryReadResultCollection(),
DiagnosticInfos = new DiagnosticInfoCollection(),
};
var mock = new Mock<ISession>(MockBehavior.Loose);
// NodeId parsing inside the driver resolves the reference against the session's
// MessageContext. This getter is read in the driver's synchronous prologue (before the
@@ -56,14 +49,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
.Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context))
.Callback(() => prologueReached?.TrySetResult());
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
mock.Setup(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ExtensionObject>(),
It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(),
It.IsAny<HistoryReadValueIdCollection>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resp);
// Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to
// completion against whichever session it ends up binding to.
mock.Setup(s => s.CallAsync(
@@ -79,18 +64,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
return mock;
}
private static void VerifyWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Once);
oldSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Never);
}
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.CallAsync(
@@ -101,82 +74,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
Times.Never);
}
/// <summary>
/// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the
/// HistoryRead against the session current <i>after</i> the gate, not a reference
/// captured before WaitAsync.
/// </summary>
[Fact]
public async Task ReadRawAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
// v3: a HistoryRead over a variable is keyed by the tag's RawPath, resolved through the
// authored RawTags table to its upstream node id. Author "ns=2;s=Counter" as both the
// RawPath and the TagConfig.nodeId so the two-stage resolve reaches the wire.
var options = new OpcUaClientDriverOptions
{
RawTags = [new RawTagEntry("ns=2;s=Counter", """{"nodeId":"ns=2;s=Counter"}""", WriteIdempotent: false)],
};
using var drv = new OpcUaClientDriver(options, "opcua-stale-raw");
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), options.RawTags));
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
// Hold the gate so the method parks on WaitAsync after running its synchronous prologue.
await drv.Gate.WaitAsync(ct);
var call = drv.ReadRawAsync(
"ns=2;s=Counter", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000, ct);
// Wait until the buggy capture-before-gate has grabbed the OLD session (signalled via
// its MessageContext read), then stage the reconnect swap and release the gate. The
// fixed path reads no pre-gate session member, so the signal never fires and the
// bounded fallback elapses — harmless, because the fixed read happens only after the
// gate is released and therefore always sees the swapped session.
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// ReadEventsAsync is a separate HistoryRead site with the same hazard — it must also
/// re-read Session inside the gate rather than use the pre-gate capture.
/// </summary>
[Fact]
public async Task ReadEventsAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-stale-events");
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// ReadEventsAsync only parses the source NodeId when sourceName is non-empty, so pass a
// concrete source id to force the prologue's MessageContext read (and its signal).
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
await drv.Gate.WaitAsync(ct);
var call = drv.ReadEventsAsync(
sourceName: "ns=2;s=Pump17", startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow, maxEvents: 100, cancellationToken: ct);
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// AcknowledgeAsync issues its Acknowledge method <c>CallAsync</c> against the session
/// current after the gate — and parses the namespace-relative ConditionId against that
@@ -1,258 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real
/// <see cref="OpcUaClientDriver"/> issues Raw / Processed / AtTime reads over loopback to the in-process
/// OtOpcUa server, whose node manager dispatches to an injected deterministic
/// <see cref="IHistorianDataSource"/> (<see cref="FakeHistorian"/>) behind a historized raw variable.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaClientDriverHistoryWireTests
{
private const string HistRef = "Sim/dev1/Hist";
private const string HistTag = "Plant.Hist.Series";
private const uint Uncertain = 0x40000000u;
private const uint BadNoData = 0x809B0000u;
private const uint BadUserAccessDenied = 0x801F0000u;
private const uint BadHistoryOperationUnsupported = 0x80720000u;
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static DateTime At(int seconds) => T0.AddSeconds(seconds);
/// <summary>Five samples, one per second, value = 1..5; the third is Uncertain.</summary>
private static readonly DataValueSnapshot[] Series =
[
new(1.0, 0u, At(0), At(0)),
new(2.0, 0u, At(1), At(1)),
new(3.0, Uncertain, At(2), At(2)),
new(4.0, 0u, At(3), At(3)),
new(5.0, 0u, At(4), At(4)),
];
private sealed class FakeHistorian : IHistorianDataSource
{
public List<string> Calls { get; } = [];
public Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken ct)
{
lock (Calls) Calls.Add($"raw:{fullReference}");
var hits = Series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc < endUtc);
if (maxValuesPerNode > 0) hits = hits.Take((int)maxValuesPerNode);
return Task.FromResult(new HistoryReadResult([.. hits], null));
}
public Task<HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken ct)
{
lock (Calls) Calls.Add($"processed:{fullReference}:{aggregate}");
// Minimum stands in for "the backend refuses with some OTHER Bad status" (#649).
if (aggregate == HistoryAggregateType.Minimum)
throw new HistoryReadStatusException(BadUserAccessDenied);
// Maximum stands in for a node-level BadNoData ("nothing in the window"), which must stay empty.
if (aggregate == HistoryAggregateType.Maximum)
throw new HistoryReadStatusException(BadNoData);
if (aggregate != HistoryAggregateType.Average)
throw new HistoryAggregateNotSupportedException(aggregate);
var buckets = new List<DataValueSnapshot>();
for (var b = startUtc; b < endUtc; b += interval)
{
var inBucket = Series.Where(s => s.SourceTimestampUtc >= b && s.SourceTimestampUtc < b + interval).ToList();
if (inBucket.Count == 0) continue;
buckets.Add(new DataValueSnapshot(inBucket.Average(s => (double)s.Value!), 0u, b, b));
}
return Task.FromResult(new HistoryReadResult(buckets, null));
}
public Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct)
{
lock (Calls) Calls.Add($"attime:{fullReference}");
// Step semantics: the last sample at or before the requested instant; BadNoData before the first.
var result = timestampsUtc.Select(t =>
{
var prior = Series.LastOrDefault(s => s.SourceTimestampUtc <= t);
return prior is null
? new DataValueSnapshot(null, 0x809B0000u /* BadNoData */, t, t)
: new DataValueSnapshot(prior.Value, prior.StatusCode, t, t);
}).ToList();
return Task.FromResult(new HistoryReadResult(result, null));
}
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) =>
Task.FromResult(new HistoricalEventsResult([], null));
public HistorianHealthSnapshot GetHealthSnapshot() =>
new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
public void Dispose() { }
}
private static async Task<(ServerEventRig Rig, FakeHistorian Fake, OpcUaClientDriver Drv)> StartAsync(
string name, CancellationToken ct)
{
var rig = await OpcUaClientDriverWireTests.StartRigAsync(name, ct);
var fake = new FakeHistorian();
rig.Nm.HistorianDataSource = fake;
var id = rig.EnsureRawVariable("Hist", "Double", writable: false, historianTagname: HistTag);
OpcUaClientDriverWireTests.Publish(rig, id, 5.0);
var drv = OpcUaClientDriverWireTests.NewDriver(
rig, name,
extraTags: [OpcUaClientDriverWireTests.Tag(HistRef, ServerEventRig.RawNsu(id))]);
await drv.InitializeAsync("{}", ct);
return (rig, fake, drv);
}
[Fact]
public async Task ReadRaw_returns_the_historians_samples_with_values_timestamps_and_statuses()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-raw", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct);
result.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0, 4.0, 5.0]);
result.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(Series.Select(s => s.SourceTimestampUtc));
result.Samples.Select(s => s.StatusCode).ShouldBe([0u, 0u, Uncertain, 0u, 0u]);
result.ContinuationPoint.ShouldBeNull("the whole window fit in one page");
lock (fake.Calls) fake.Calls.ShouldContain($"raw:{HistTag}", "the read must reach the historian under the node's tagname");
}
[Fact]
public async Task ReadRaw_honours_the_window_and_pages_with_a_continuation_point_when_the_cap_is_hit()
{
var ct = TestContext.Current.CancellationToken;
var (rig, _, drv) = await StartAsync("hist-raw-page", ct);
await using var _r = rig;
await using var _d = drv;
// Window [1s, 4s) holds samples 2,3,4 (end exclusive).
var window = await drv.ReadRawAsync(HistRef, At(1), At(4), 0, ct);
window.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([2.0, 3.0, 4.0]);
// A cap of 3 against 5 available: first three samples and a continuation point.
var paged = await drv.ReadRawAsync(HistRef, At(0), At(10), 3, ct);
paged.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0]);
paged.ContinuationPoint.ShouldNotBeNull("a full page must carry a continuation point");
}
[Fact]
public async Task ReadAtTime_returns_one_sample_per_requested_instant_with_the_prior_value()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-attime", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadAtTimeAsync(HistRef, [At(-1), At(1), At(2), At(3).AddMilliseconds(500)], ct);
result.Samples.Count.ShouldBe(4);
result.Samples[0].StatusCode.ShouldNotBe(0u, "before the first sample there is no value");
Convert.ToDouble(result.Samples[1].Value).ShouldBe(2.0);
result.Samples[1].StatusCode.ShouldBe(0u);
Convert.ToDouble(result.Samples[2].Value).ShouldBe(3.0);
result.Samples[2].StatusCode.ShouldBe(Uncertain);
Convert.ToDouble(result.Samples[3].Value).ShouldBe(4.0);
result.Samples.Skip(1).Select(s => s.SourceTimestampUtc)
.ShouldBe([At(1), At(2), At(3).AddMilliseconds(500)]);
lock (fake.Calls) fake.Calls.ShouldContain($"attime:{HistTag}");
}
[Fact]
public async Task ReadProcessed_serves_a_supported_aggregate_and_surfaces_BadAggregateNotSupported_for_an_unsupported_one()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-processed", ct);
await using var _r = rig;
await using var _d = drv;
// Average over 2s buckets across [0s,6s): {1,2}=1.5, {3,4}=3.5, {5}=5.
var avg = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Average, ct);
avg.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.5, 3.5, 5.0]);
avg.Samples.Select(s => s.SourceTimestampUtc).ShouldBe([At(0), At(2), At(4)]);
// The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, and the
// driver must raise it (not return empty Good data) so its own server surfaces it per node (#649).
// The Average above is the positive control that the same path serves data, and the call must have
// REACHED the backend.
var ex = await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct));
ex.Aggregate.ShouldBe(HistoryAggregateType.Count);
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Count");
}
[Fact]
public async Task ReadProcessed_surfaces_any_other_Bad_node_status_instead_of_empty_data()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-badstatus", ct);
await using var _r = rig;
await using var _d = drv;
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Minimum, ct));
ex.StatusCode.ShouldBe(BadUserAccessDenied);
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Minimum");
}
[Fact]
public async Task A_node_level_BadNoData_stays_an_empty_result_not_an_error()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-badnodata", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadProcessedAsync(
HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Maximum, ct);
result.Samples.ShouldBeEmpty();
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Maximum", "the call must have reached the backend");
}
[Fact]
public async Task A_Good_read_with_no_samples_in_the_window_is_still_empty_data_not_an_error()
{
var ct = TestContext.Current.CancellationToken;
var (rig, _, drv) = await StartAsync("hist-goodnodata", ct);
await using var _r = rig;
await using var _d = drv;
// Historized node, window past the series: the server answers GoodNoData, which must stay a normal empty result.
var result = await drv.ReadRawAsync(HistRef, At(100), At(110), 0, ct);
result.Samples.ShouldBeEmpty();
result.ContinuationPoint.ShouldBeNull();
}
[Fact]
public async Task A_read_of_a_non_historized_node_surfaces_its_Bad_status_and_never_reaches_the_historian()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-none", ct);
await using var _r = rig;
await using var _d = drv;
// The server answers BadHistoryOperationUnsupported for a node with no historian tagname; that Bad
// node status is surfaced (#649), no longer read as empty Good data.
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct));
ex.StatusCode.ShouldBe(BadHistoryOperationUnsupported);
// Positive control: the historized sibling does reach the historian on the same session.
(await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct)).Samples.Count.ShouldBe(5);
lock (fake.Calls) fake.Calls.Count.ShouldBe(1, "only the historized read dispatches");
}
}
@@ -312,61 +312,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
await host.DisposeAsync();
}
/// <summary>A passthrough backend's Bad status on a RESUME read surfaces verbatim for that node only (#649):
/// no outbound continuation point, the cursor is consumed (no stray cursor), and a sibling node in the same
/// batch is unaffected.</summary>
[Fact]
public async Task Raw_resume_read_that_throws_HistoryReadStatusException_surfaces_the_code_and_isolates_the_sibling()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
var fake = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Double",
writable: false, historianTagname: "WW.Bad", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/ok", parentFolderNodeId: null, displayName: "Ok", dataType: "Double",
writable: false, historianTagname: "WW.Ok", realm: AddressSpaceRealm.Uns);
var badId = nm.TryGetVariable("eq-1/bad")!.NodeId;
var okId = nm.TryGetVariable("eq-1/ok")!.NodeId;
// Page 1 succeeds and emits a continuation point.
var (_, _, cp1) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
cp1.ShouldNotBeNull();
// The backend now refuses the bad node; one batch resumes it and freshly reads the sibling.
fake.FailTagname = "WW.Bad";
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var details = new ReadRawModifiedDetails
{
StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false,
};
var nodesToRead = new List<HistoryReadValueId>
{
new() { NodeId = badId, ContinuationPoint = cp1 },
new() { NodeId = okId },
};
var results = new List<SdkHistoryReadResult> { null!, null! };
var errors = new List<ServiceResult> { null!, null! };
nm.HistoryRead(context, details, TimestampsToReturn.Both,
releaseContinuationPoints: false, nodesToRead, results, errors);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
results[0].ContinuationPoint.ShouldBeNull();
errors[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
((HistoryData)ExtensionObject.ToEncodeable(results[1].HistoryData)).DataValues.Count.ShouldBe(100);
results[1].ContinuationPoint.ShouldNotBeNull("the sibling is full-page and pages normally");
// The consumed cursor is gone: nothing stray to resume.
fake.FailTagname = null;
var (_, e, _) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1);
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
await host.DisposeAsync();
}
// --- helpers --------------------------------------------------------------------------------
/// <summary>Issue a single-node Raw HistoryRead and return that node's decoded sample values, error,
@@ -468,8 +413,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
private int _readCount;
public int ReadCount => _readCount;
/// <summary>When set, a Raw read of this tagname throws <see cref="HistoryReadStatusException"/> (#649).</summary>
public volatile string? FailTagname;
public void ResetReadCount() => _readCount = 0;
public Task<HistorianRead> ReadRawAsync(
@@ -477,8 +420,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
CancellationToken cancellationToken)
{
Interlocked.Increment(ref _readCount);
if (FailTagname is { } bad && bad == fullReference)
throw new HistoryReadStatusException(StatusCodes.BadUserAccessDenied);
var window = series
.Where(s => (s.SourceTimestampUtc ?? DateTime.MinValue) >= startUtc
&& (s.SourceTimestampUtc ?? DateTime.MinValue) <= endUtc)
@@ -50,16 +50,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions {
public interface IHostConnectivityProbe {
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
}
public class HistoryReadResult { }
public class HistoricalEventsResult { }
public interface IHistoryProvider {
Task<HistoryReadResult> ReadRawAsync(string fullRef, DateTime start, DateTime end, uint max, CancellationToken ct);
Task<HistoryReadResult> ReadProcessedAsync(string fullRef, DateTime start, DateTime end, TimeSpan interval, CancellationToken ct);
Task<HistoryReadResult> ReadAtTimeAsync(string fullRef, IReadOnlyList<DateTime> timestamps, CancellationToken ct)
=> throw new NotSupportedException();
Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime start, DateTime end, int maxEvents, CancellationToken ct)
=> throw new NotSupportedException();
}
public enum DriverCapability { Read, Write, Discover, AlarmSubscribe, AlarmAcknowledge }
public interface IDriverCapabilityInvoker {
ValueTask<T> ExecuteAsync<T>(DriverCapability c, string host, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct);
@@ -346,141 +336,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
diags.ShouldBeEmpty();
}
// -----------------------------------------------------------------------
// IHistoryProvider — interface-typed receiver
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct ReadRawAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadRawAsync_OnInterface_TripsDiagnostic()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryRead {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadRawAsync");
}
/// <summary>Verifies that a direct ReadProcessedAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadProcessedAsync_OnInterface_TripsDiagnostic()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryProcessed {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadProcessedAsync("tag", DateTime.MinValue, DateTime.MaxValue, TimeSpan.FromMinutes(1), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadProcessedAsync");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnInterface_TripsDiagnostic()
{
// ReadAtTimeAsync is a default interface method — the analyzer must still flag it when
// called through an interface-typed receiver.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryAtTime {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a wrapped ReadRawAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadRawAsync_InsideCapabilityInvokerLambda_PassesCleanly()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryRead {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// -----------------------------------------------------------------------
// IHistoryProvider — concrete driver receiver (FindImplementationForInterfaceMember branch)
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct HistoryRead call on a concrete driver trips the diagnostic.</summary>
[Fact]
public async Task Direct_HistoryRead_OnConcreteDriver_TripsDiagnostic()
{
// ConcreteHistoryDriver implements IHistoryProvider with explicitly-named methods
// (same names here, but the test exercises the FindImplementationForInterfaceMember
// branch because the receiver type is the concrete class, not the interface).
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class ConcreteHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
}
public sealed class BadConcreteHistoryRead {
public async Task DoIt(ConcreteHistoryDriver driver) {
_ = await driver.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadRawAsync");
}
// -----------------------------------------------------------------------
// IHostConnectivityProbe — synchronous member must NOT be flagged
// -----------------------------------------------------------------------
@@ -739,139 +594,9 @@ namespace SomeOther {
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-005 — IHistoryProvider default-interface-method asymmetry
// =======================================================================
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// ConcreteHistoryDriver does NOT override ReadAtTimeAsync (it inherits the DIM).
// The call site has a concrete-receiver type — the analyzer must still flag the call
// because the bound method (ReadAtTimeAsync) is the interface's own default impl.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class PartialHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
// ReadAtTimeAsync + ReadEventsAsync NOT overridden — driver inherits the DIM throws.
}
public sealed class BadPartialHistoryCaller {
public async Task DoIt(PartialHistoryDriver driver) {
// Cast through the interface so the binder resolves to IHistoryProvider.ReadAtTimeAsync
// (the DIM). FindImplementationForInterfaceMember returns the interface method itself
// when nothing overrides it.
var iface = (IHistoryProvider)driver;
_ = await iface.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a direct ReadEventsAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadEventsAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// Same as above but for ReadEventsAsync — confirms both DIMs are caught when the
// driver doesn't override them.
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class PartialHistoryDriver2 : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
}
public sealed class BadPartialHistoryEventsCaller {
public async Task DoIt(PartialHistoryDriver2 driver) {
var iface = (IHistoryProvider)driver;
_ = await iface.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadEventsAsync");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver overriding the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverOverridingDIM_TripsDiagnostic()
{
// Driver explicitly overrides ReadAtTimeAsync — the concrete-receiver call goes through
// the override; FindImplementationForInterfaceMember returns the override method, which
// SymbolEqualityComparer matches against method.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class FullHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> ts, CancellationToken ct) => throw null!;
}
public sealed class BadFullHistoryCaller {
public async Task DoIt(FullHistoryDriver driver) {
_ = await driver.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a wrapped ReadAtTimeAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadAtTimeAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// DIM wrapped properly — must not trip.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryAtTime {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadAtTimeAsync("tag", new List<DateTime>(), ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-008 — missing trip + pass-clean tests for ISubscribable.UnsubscribeAsync,
// IAlarmSource.UnsubscribeAlarmsAsync, IAlarmSource.AcknowledgeAsync, and the wrapped
// (pass-clean) path for IHistoryProvider.ReadEventsAsync (DIM).
// IAlarmSource.UnsubscribeAlarmsAsync, IAlarmSource.AcknowledgeAsync.
// =======================================================================
/// <summary>Verifies that a direct UnsubscribeAsync call trips the diagnostic.</summary>
@@ -965,32 +690,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
diags[0].GetMessage().ShouldContain("AcknowledgeAsync");
}
/// <summary>Verifies that a wrapped ReadEventsAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadEventsAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// ReadEventsAsync is a DIM — the wrapped path must not trip the diagnostic.
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryEvents {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 0, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
private static async Task<ImmutableArray<Diagnostic>> CompileWithoutStubs(string userSource)
{
var syntaxTrees = new[] { CSharpSyntaxTree.ParseText(userSource) };