diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs index d9a38d49..a9ed7eca 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs @@ -5,10 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// Each value corresponds to one method (or tightly-related method group) on the /// Core.Abstractions capability interfaces (, , /// , , , -/// , ). +/// ). /// /// -/// Per the per-capability retry policy in docs/v2/plan.md: Read / HistoryRead / +/// Per the per-capability retry policy in docs/v2/plan.md: Read / /// Discover / Probe / AlarmSubscribe auto-retry; does NOT retry unless the /// tag-definition carries . Alarm-acknowledge is treated /// as a write for retry semantics (an alarm-ack is not idempotent at the plant-floor acknowledgement @@ -37,9 +37,6 @@ public enum DriverCapability /// . Does NOT retry — ack is a write-shaped operation. AlarmAcknowledge, - /// reads (Raw/Processed/AtTime/Events). Retries by default. - HistoryRead, - /// /// IDriver.InitializeAsync — connection establishment (R2-10 T10 closure: without this, /// a driver that cannot connect contributes zero breaker input and the resilience panel stays diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs new file mode 100644 index 00000000..81096ec3 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs @@ -0,0 +1,92 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +// Shared history result contracts used by IHistorianDataSource and the historian gateway. +// Drivers carry no history capability (Gitea #655): HistoryRead is served server-side. + + +/// Result of a HistoryRead call. +/// Returned samples in chronological order. +/// Opaque token for the next call when more samples are available; null when complete. +public sealed record HistoryReadResult( + IReadOnlyList Samples, + byte[]? ContinuationPoint); + +/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates. +/// +/// The first five members are the original set every historian backend serves. The remainder (#285) are +/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that +/// supports them (a gateway backend maps them to its own aggregate modes). A backend that cannot serve a +/// member must throw — the server maps that to +/// BadAggregateNotSupported rather than a generic failure. Append only: the numeric values +/// are not persisted, but every switch over this enum must keep a total default. +/// +public enum HistoryAggregateType +{ + Average, + Minimum, + Maximum, + Total, + Count, + Interpolative, + TimeAverage, + Start, + End, + Delta, + Range, + MinimumActualTime, + MaximumActualTime, + StartBound, + EndBound, + DeltaBounds, + DurationGood, + DurationBad, + PercentGood, + PercentBad, + WorstQuality, + NumberOfTransitions, + AnnotationCount, + StandardDeviationPopulation, + VariancePopulation, +} + +/// +/// Thrown by a history backend when it cannot serve the requested . +/// The Server HistoryRead path translates it to BadAggregateNotSupported for that node only. +/// +public sealed class HistoryAggregateNotSupportedException : NotSupportedException +{ + /// The aggregate the backend could not serve. + public HistoryAggregateType Aggregate { get; } + + /// Creates the exception for . + /// The unsupported aggregate. + public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate) + : base($"The historian backend does not support the {aggregate} aggregate.") + => Aggregate = aggregate; +} + +/// +/// One row returned by — a historical +/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the +/// Server needs to populate a HistoryEventFieldList for HistoryReadEvents responses. +/// +/// Stable unique id for the event — driver-specific format. +/// Source object that emitted the event. May differ from the sourceName filter the caller passed (fuzzy matches). +/// Process-side timestamp — when the event actually occurred. +/// Historian-side timestamp — when the historian persisted the row; may lag by the historian's buffer flush cadence. +/// Human-readable message text. +/// OPC UA severity (1-1000). Backends map their native priority scale onto this range. +public sealed record HistoricalEvent( + string EventId, + string? SourceName, + DateTime EventTimeUtc, + DateTime ReceivedTimeUtc, + string? Message, + ushort Severity); + +/// Result of a call. +/// Events in chronological order by EventTimeUtc. +/// Opaque token for the next call when more events are available; null when complete. +public sealed record HistoricalEventsResult( + IReadOnlyList Events, + byte[]? ContinuationPoint); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs index 7443f6be..9942954e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs @@ -5,15 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// and resolved per OPC UA namespace, independent of any driver's lifecycle. /// /// -/// Distinct from : -/// -/// is a *driver capability* — the server -/// dispatches to it via the driver instance. -/// is a *server registration* — the -/// server resolves it via namespace and calls it directly, so a single -/// historian (e.g. Wonderware) can serve many drivers' nodes, and drivers can -/// restart without dropping history availability. -/// +/// This is a *server registration*, not a driver capability: drivers carry no history +/// surface (Gitea #655). The server calls it directly, so a single historian can serve +/// many drivers' nodes, and drivers can restart without dropping history availability. /// All values returned use the shared / /// shapes; backend-specific quality / type encodings /// are translated to OPC UA StatusCode uints inside the data source. @@ -86,9 +80,7 @@ public interface IHistorianDataSource : IDisposable /// equivalent "use default" sentinel. /// /// This surface declares and - /// as required members — a server-side historian owns the full read surface, unlike - /// where the same two methods are optional default-impl - /// methods so legacy drivers can stay raw-only. The asymmetry is intentional. + /// as required members — a server-side historian owns the full read surface. /// /// The source name to filter events, or null to return events from all sources. /// The start of the time range in UTC. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs index 02c353fc..f735d0f4 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// Required capability for every driver instance. Owns lifecycle, metadata, health. /// Other capabilities (, , /// , , , -/// , , +/// , /// ) are composable — a driver implements only what its /// backend actually supports. /// diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs deleted file mode 100644 index 310ac626..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs +++ /dev/null @@ -1,237 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -/// -/// Driver capability for historical-data reads (OPC UA HistoryRead). Optional — -/// only drivers whose backends carry historian data implement this. Currently: -/// Galaxy (Wonderware Historian via the optional plugin), OPC UA Client (forward -/// to upstream server). -/// -/// -/// and are C# default interface -/// methods that throw — drivers opt in by overriding so -/// a raw-only driver compiles without forcing it to provide at-time / event surfaces it -/// has no backend for. The sibling server-side surface, , -/// declares both methods as required because a registered historian owns the full read -/// surface; the asymmetry is intentional. -/// -public interface IHistoryProvider -{ - /// - /// Read raw historical samples for a single attribute over a time range. - /// The Core wraps this with continuation-point handling. - /// - /// The full reference path to the attribute. - /// Inclusive lower bound on the time range (UTC). - /// Exclusive upper bound on the time range (UTC). - /// Maximum number of values to return per node. - /// A cancellation token to stop the operation. - /// A task that returns the history read result containing samples and optional continuation point. - Task ReadRawAsync( - string fullReference, - DateTime startUtc, - DateTime endUtc, - uint maxValuesPerNode, - CancellationToken cancellationToken); - - /// - /// Read processed (aggregated) samples — interval-bucketed average / min / max / etc. - /// Optional — drivers that only support raw history can throw . - /// - /// The full reference path to the attribute. - /// Inclusive lower bound on the time range (UTC). - /// Exclusive upper bound on the time range (UTC). - /// The time interval for bucketing samples. - /// The aggregate function to apply to each bucket. - /// A cancellation token to stop the operation. - /// A task that returns the history read result containing aggregated samples and optional continuation point. - Task ReadProcessedAsync( - string fullReference, - DateTime startUtc, - DateTime endUtc, - TimeSpan interval, - HistoryAggregateType aggregate, - CancellationToken cancellationToken); - - /// - /// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The - /// driver interpolates (or returns the prior-boundary sample) when no exact match - /// exists. Optional; drivers that can't interpolate throw . - /// - /// - /// Default implementation throws. Drivers opt in by overriding; keeps existing - /// IHistoryProvider implementations compiling without forcing a ReadAtTime path - /// they may not have a backend for. - /// - /// The full reference path to the attribute. - /// The list of timestamps at which to read values. - /// A cancellation token to stop the operation. - /// A task that returns the history read result containing samples at the requested times. - Task ReadAtTimeAsync( - string fullReference, - IReadOnlyList timestampsUtc, - CancellationToken cancellationToken) - => throw new NotSupportedException( - $"{GetType().Name} does not implement ReadAtTimeAsync. " + - "Drivers whose backends support at-time reads override this method."); - - /// - /// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct - /// from the live event stream — historical rows come from an event historian (Galaxy's - /// Alarm Provider history log, etc.) rather than the driver's active subscription. - /// - /// - /// Optional filter: null means "all sources", otherwise restrict to events from that - /// source-object name. Drivers may ignore the filter if the backend doesn't support it. - /// - /// Inclusive lower bound on EventTimeUtc. - /// Exclusive upper bound on EventTimeUtc. - /// - /// Upper cap on returned events — the driver's backend enforces this. The type is - /// rather than (which the sibling raw / processed - /// reads use for maxValuesPerNode) because callers and downstream historian - /// adapters historically treat maxEvents <= 0 as a sentinel meaning - /// "use the backend's default cap" (see WonderwareHistorianClient / - /// HistorianDataSource). The asymmetry is intentional. - /// - /// Continuation contract when using the sentinel: When maxEvents <= 0 - /// the backend applies its own cap. If the backend's cap truncates the result - /// (i.e. more matching events exist beyond the returned set), implementations MUST set - /// to a non-null token so - /// callers can detect truncation and page. A null ContinuationPoint means all - /// matching events were returned — callers rely on this to decide whether to page. - /// - /// Request cancellation. - /// A task that returns the historical events result containing matching event records and optional continuation point. - /// - /// Default implementation throws. Drivers whose backend can serve historical events - /// override: Galaxy (Wonderware Alarm & Events log) and the OPC UA Client driver - /// (forwarding HistoryReadEvents to its upstream server). Raw-only drivers such as Modbus - /// stay with the default and let callers see BadHistoryOperationUnsupported. - /// - /// Note the type asymmetry with / - /// (both use uint maxValuesPerNode): event - /// readers accept a signed int maxEvents so callers can pass 0 / negative as a - /// "use default cap" sentinel without an extra parameter or overload. - /// - Task ReadEventsAsync( - string? sourceName, - DateTime startUtc, - DateTime endUtc, - int maxEvents, - CancellationToken cancellationToken) - => throw new NotSupportedException( - $"{GetType().Name} does not implement ReadEventsAsync. " + - "Drivers whose backends have an event historian override this method."); -} - -/// Result of a HistoryRead call. -/// Returned samples in chronological order. -/// Opaque token for the next call when more samples are available; null when complete. -public sealed record HistoryReadResult( - IReadOnlyList Samples, - byte[]? ContinuationPoint); - -/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates. -/// -/// The first five members are the original set every historian backend serves. The remainder (#285) are -/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that -/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a -/// member must throw — the server maps that to -/// BadAggregateNotSupported rather than a generic failure. Append only: the numeric values -/// are not persisted, but every switch over this enum must keep a total default. -/// -public enum HistoryAggregateType -{ - Average, - Minimum, - Maximum, - Total, - Count, - Interpolative, - TimeAverage, - Start, - End, - Delta, - Range, - MinimumActualTime, - MaximumActualTime, - StartBound, - EndBound, - DeltaBounds, - DurationGood, - DurationBad, - PercentGood, - PercentBad, - WorstQuality, - NumberOfTransitions, - AnnotationCount, - StandardDeviationPopulation, - VariancePopulation, -} - -/// -/// Thrown by a history backend when it cannot serve the requested . -/// The Server HistoryRead path translates it to BadAggregateNotSupported for that node only. -/// -public sealed class HistoryAggregateNotSupportedException : NotSupportedException -{ - /// The aggregate the backend could not serve. - public HistoryAggregateType Aggregate { get; } - - /// Creates the exception for . - /// The unsupported aggregate. - public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate) - : base($"The historian backend does not support the {aggregate} aggregate.") - => Aggregate = aggregate; -} - -/// -/// Thrown by a history backend that is a passthrough to another OPC UA server when that server answered -/// a node with a Bad per-node HistoryReadResult.StatusCode other than -/// BadAggregateNotSupported (which has its own ). -/// The Server HistoryRead path surfaces verbatim as that node's result status -/// instead of the generic BadHistoryOperationUnsupported, so a refused read is never mistaken for -/// an empty Good one. -/// -public sealed class HistoryReadStatusException : Exception -{ - /// The raw OPC UA status code the upstream server returned for the node. - public uint StatusCode { get; } - - /// Creates the exception for the upstream . - /// The Bad OPC UA status code to surface for the node. - /// is not a Bad code (severity bits 10/11). - public HistoryReadStatusException(uint statusCode) - : base(IsBad(statusCode) - ? $"The upstream server refused the history read with status 0x{statusCode:X8}." - : throw new ArgumentException($"0x{statusCode:X8} is not a Bad status code.", nameof(statusCode))) - => StatusCode = statusCode; - - private static bool IsBad(uint statusCode) => (statusCode & 0x80000000u) != 0; -} - -/// -/// One row returned by — a historical -/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the -/// Server needs to populate a HistoryEventFieldList for HistoryReadEvents responses. -/// -/// Stable unique id for the event — driver-specific format. -/// Source object that emitted the event. May differ from the sourceName filter the caller passed (fuzzy matches). -/// Process-side timestamp — when the event actually occurred. -/// Historian-side timestamp — when the historian persisted the row; may lag by the historian's buffer flush cadence. -/// Human-readable message text. -/// OPC UA severity (1-1000). Drivers map their native priority scale onto this range. -public sealed record HistoricalEvent( - string EventId, - string? SourceName, - DateTime EventTimeUtc, - DateTime ReceivedTimeUtc, - string? Message, - ushort Severity); - -/// Result of a call. -/// Events in chronological order by EventTimeUtc. -/// Opaque token for the next call when more events are available; null when complete. -public sealed record HistoricalEventsResult( - IReadOnlyList Events, - byte[]? ContinuationPoint); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs index 4481bb66..aca30418 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs @@ -81,7 +81,6 @@ public sealed record DriverResilienceOptions [DriverCapability.Probe] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5), [DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 5, RetryCount: 3, BreakerFailureThreshold: 5), [DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 5), - [DriverCapability.HistoryRead] = new(TimeoutSeconds: 30, RetryCount: 2, BreakerFailureThreshold: 5), // Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a // threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect // timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5 @@ -97,7 +96,6 @@ public sealed record DriverResilienceOptions [DriverCapability.Probe] = new(TimeoutSeconds: 4, RetryCount: 3, BreakerFailureThreshold: 5), [DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 8, RetryCount: 3, BreakerFailureThreshold: 5), [DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 5), - [DriverCapability.HistoryRead] = new(TimeoutSeconds: 60, RetryCount: 2, BreakerFailureThreshold: 5), // See the Tier A Initialize note: threshold 2 is cadence-reachability, not a tuning choice. [DriverCapability.Initialize] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 2), }, @@ -110,7 +108,6 @@ public sealed record DriverResilienceOptions [DriverCapability.Probe] = new(TimeoutSeconds: 10, RetryCount: 1, BreakerFailureThreshold: 0), [DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 15, RetryCount: 1, BreakerFailureThreshold: 0), [DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0), - [DriverCapability.HistoryRead] = new(TimeoutSeconds: 120, RetryCount: 1, BreakerFailureThreshold: 0), // Tier C stays breaker-less like every other Tier C capability (process-level supervision). [DriverCapability.Initialize] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0), }, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 764b9a37..cf34709b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// monitored-item handles. That mechanic lands in PR 69. /// /// -public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, ISkippedTagReporter, IDisposable, IAsyncDisposable +public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags whose TagConfig carried no usable nodeId, so there is no upstream node /// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: NamespaceMap @@ -2258,335 +2258,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit public string DiagnosticId => $"opcua-alarm-sub-{Id}"; } - // ---- IHistoryProvider (passthrough to upstream server) ---- - - /// - public async Task ReadRawAsync( - string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, - CancellationToken cancellationToken) - { - var details = new ReadRawModifiedDetails - { - IsReadModified = false, - StartTime = startUtc, - EndTime = endUtc, - NumValuesPerNode = maxValuesPerNode, - ReturnBounds = false, - }; - return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken) - .ConfigureAwait(false); - } - - /// - public async Task ReadProcessedAsync( - string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, - HistoryAggregateType aggregate, CancellationToken cancellationToken) - { - var aggregateId = MapAggregateToNodeId(aggregate); - var details = new ReadProcessedDetails - { - StartTime = startUtc, - EndTime = endUtc, - ProcessingInterval = interval.TotalMilliseconds, - AggregateType = [aggregateId], - }; - return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken, aggregate) - .ConfigureAwait(false); - } - - /// - public async Task ReadAtTimeAsync( - string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) - { - var reqTimes = new DateTimeCollection(timestampsUtc); - var details = new ReadAtTimeDetails - { - ReqTimes = reqTimes, - UseSimpleBounds = true, - }; - return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken) - .ConfigureAwait(false); - } - - /// - /// Shared HistoryRead wire path — used by Raw/Processed/AtTime. Handles NodeId parse, - /// Session.HistoryReadAsync call, per-sample StatusCode passthrough (no translation per §8 - /// cascading-quality rule), and HistoryData unwrap into . A Bad - /// per-NODE HistoryReadResult.StatusCode is never read as empty data: BadAggregateNotSupported - /// throws , any other Bad throws - /// (Gitea #649). Good / GoodNoData / GoodMoreData / Uncertain are - /// unchanged. - /// - private async Task ExecuteHistoryReadAsync( - string fullReference, ExtensionObject historyReadDetails, CancellationToken ct, - HistoryAggregateType? aggregate = null) - { - // Make sure a session exists before queuing on the gate, but do NOT bind the wire call - // (or the namespace-relative NodeId parse) to this reference — a reconnect can swap - // Session while we wait on _gate. The session actually used is re-read inside the gate. - _ = RequireSession(); - - await _gate.WaitAsync(ct).ConfigureAwait(false); - try - { - // Re-read Session inside the critical section: if a reconnect completed while we - // were blocked on _gate, OnReconnectComplete has already swapped in the new session. - // NodeId parsing is namespace-relative, so it must also use the current session's - // namespace table. - var session = Session; - if (session is null || !TryResolveTagNode(session, fullReference, out var nodeId)) - { - return new Core.Abstractions.HistoryReadResult([], null); - } - - var nodesToRead = new HistoryReadValueIdCollection - { - new HistoryReadValueId { NodeId = nodeId }, - }; - - var resp = await session.HistoryReadAsync( - requestHeader: null, - historyReadDetails: historyReadDetails, - timestampsToReturn: TimestampsToReturn.Both, - releaseContinuationPoints: false, - nodesToRead: nodesToRead, - ct: ct).ConfigureAwait(false); - - if (resp.Results.Count == 0) return new Core.Abstractions.HistoryReadResult([], null); - var r = resp.Results[0]; - - // The per-node status decides whether the payload means anything. BadNoData is the one Bad code - // that legitimately means "nothing in the window", so it stays an empty result. - // CodeBits (not Code) so an info/overflow sub-status bit on the upstream code does not dodge the match. - if (StatusCode.IsBad(r.StatusCode) && r.StatusCode.CodeBits != StatusCodes.BadNoData) - { - if (r.StatusCode.CodeBits == StatusCodes.BadAggregateNotSupported && aggregate is { } agg) - throw new HistoryAggregateNotSupportedException(agg); - throw new HistoryReadStatusException(r.StatusCode.Code); - } - - // Unwrap HistoryData from the ExtensionObject-encoded payload the SDK returns. - // Samples stay in chronological order per OPC UA Part 11; cascading-quality - // rule: preserve each DataValue's upstream StatusCode + timestamps verbatim. - var samples = new List(); - if (r.HistoryData?.Body is HistoryData hd) - { - var now = DateTime.UtcNow; - foreach (var dv in hd.DataValues) - { - samples.Add(new DataValueSnapshot( - Value: dv.Value, - StatusCode: dv.StatusCode.Code, - SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp, - ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp)); - } - } - - var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null; - return new Core.Abstractions.HistoryReadResult(samples, contPt); - } - finally { _gate.Release(); } - } - - /// Map to the OPC UA Part 13 standard aggregate NodeId. - /// The aggregation function type to map. - /// The standard aggregate function NodeId. - internal static NodeId MapAggregateToNodeId(HistoryAggregateType aggregate) => aggregate switch - { - HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average, - HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum, - HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum, - HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total, - HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count, - HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative, - HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage, - HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start, - HistoryAggregateType.End => ObjectIds.AggregateFunction_End, - HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta, - HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range, - HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime, - HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime, - HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound, - HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound, - HistoryAggregateType.DeltaBounds => ObjectIds.AggregateFunction_DeltaBounds, - HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood, - HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad, - HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood, - HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad, - HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality, - HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions, - HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount, - HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation, - HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation, - _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null), - }; - - // Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices. - private static readonly string[] EventFieldBrowseNames = - [ - BrowseNames.EventId, // 0 - BrowseNames.SourceName, // 1 - BrowseNames.Time, // 2 - BrowseNames.ReceiveTime, // 3 - BrowseNames.Message, // 4 - BrowseNames.Severity, // 5 - ]; - - /// - /// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents — - /// the six BaseEventType fields the OtOpcUa server projects (). - /// The clause order is load-bearing: reads results by index. - /// - /// An EventFilter with six SimpleAttributeOperand value clauses. - internal static EventFilter BuildBaseEventFilter() - { - var filter = new EventFilter(); - foreach (var browseName in EventFieldBrowseNames) - { - filter.SelectClauses.Add(new SimpleAttributeOperand - { - TypeDefinitionId = ObjectTypeIds.BaseEventType, - BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) }, - AttributeId = Attributes.Value, - }); - } - return filter; - } - - /// - /// Maps an upstream (field arrays ordered to match - /// ) onto records. Defensive: - /// short / null / wrong-typed fields degrade to null/default rather than throwing. - /// - /// The upstream history-event payload. - /// The mapped historical events in upstream order. - internal static IReadOnlyList MapHistoryEvents(HistoryEvent historyEvent) - { - if (historyEvent?.Events is not { Count: > 0 } rows) return []; - var result = new List(rows.Count); - foreach (var row in rows) - { - var fields = row?.EventFields; - result.Add(new Core.Abstractions.HistoricalEvent( - EventId: CoerceEventId(FieldAt(fields, 0)), - SourceName: CoerceString(FieldAt(fields, 1)), - EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)), - ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)), - Message: CoerceString(FieldAt(fields, 4)), - Severity: CoerceSeverity(FieldAt(fields, 5)))); - } - return result; - } - - private static object? FieldAt(VariantCollection? fields, int index) - => fields is not null && index < fields.Count ? fields[index].Value : null; - - private static string CoerceEventId(object? value) => value switch - { - byte[] bytes => Convert.ToBase64String(bytes), - string s => s, - null => string.Empty, - _ => value.ToString() ?? string.Empty, - }; - - private static string? CoerceString(object? value) => value switch - { - LocalizedText lt => lt.Text, - string s => s, - null => null, - _ => value.ToString(), - }; - - // Missing-field sentinel is UTC-kinded so a downstream .ToUniversalTime() can't shift it. - private static readonly DateTime MissingTimeSentinel = - DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - - private static DateTime CoerceDateTime(object? value) - => value is DateTime dt ? dt : MissingTimeSentinel; - - private static ushort CoerceSeverity(object? value) - { - try { return value is null ? (ushort)0 : Convert.ToUInt16(value); } - catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; } - } - - /// - // Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical - // BuildBaseEventFilter and maps the result onto HistoricalEvent (the OtOpcUa server projects - // only those six BaseEventType fields, so a richer client filter would be discarded - // server-side — the driver supplies the canonical set itself). sourceName is the upstream - // event-notifier NodeId to read from (mirrors how fullReference is the upstream NodeId for - // raw reads); null/empty resolves to the upstream Server object (i=2253), the standard - // server-wide event notifier, and an unparseable id short-circuits to an empty result, - // matching the raw path's malformed-NodeId behavior. - public async Task ReadEventsAsync( - string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, - CancellationToken cancellationToken) - { - // Confirm a session exists before queuing; the session actually used — and the - // namespace-relative source-NodeId parse — are re-read inside the gate so a reconnect - // mid-wait can't leave us reading from a closed session. - _ = RequireSession(); - - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Re-read Session inside the critical section: OnReconnectComplete may have swapped - // in a new session while we were blocked on _gate, and the source-NodeId parse is - // namespace-relative so it must bind against the current session's namespace table. - var session = Session; - if (session is null) return new Core.Abstractions.HistoricalEventsResult([], null); - - NodeId notifierNodeId; - if (string.IsNullOrEmpty(sourceName)) - { - notifierNodeId = ObjectIds.Server; - } - // sourceName is an upstream event-notifier node id (not a tag RawPath), so it - // re-binds directly against the session via the static resolve. - else if (!NamespaceMap.TryResolve(session, sourceName, out var parsed)) - { - return new Core.Abstractions.HistoricalEventsResult([], null); - } - else - { - notifierNodeId = parsed; - } - - var details = new ReadEventDetails - { - StartTime = startUtc, - EndTime = endUtc, - NumValuesPerNode = maxEvents <= 0 ? 0u : (uint)maxEvents, - Filter = BuildBaseEventFilter(), - }; - - var nodesToRead = new HistoryReadValueIdCollection - { - new HistoryReadValueId { NodeId = notifierNodeId }, - }; - - var resp = await session.HistoryReadAsync( - requestHeader: null, - historyReadDetails: new ExtensionObject(details), - timestampsToReturn: TimestampsToReturn.Both, - releaseContinuationPoints: false, - nodesToRead: nodesToRead, - ct: cancellationToken).ConfigureAwait(false); - - if (resp.Results.Count == 0) return new Core.Abstractions.HistoricalEventsResult([], null); - var r = resp.Results[0]; - - var events = r.HistoryData?.Body is HistoryEvent he - ? MapHistoryEvents(he) - : []; - - var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null; - return new Core.Abstractions.HistoricalEventsResult(events, contPt); - } - finally { _gate.Release(); } - } - // ---- IHostConnectivityProbe ---- /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs index e0f64f0a..83b72980 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs @@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; public sealed class ResilienceFormModel { public static readonly string[] Capabilities = - ["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead", "Initialize"]; + ["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "Initialize"]; // RecycleIntervalSeconds is GONE (Gitea #522) — the scheduled-recycle machinery it authored was // deleted, so the field configured nothing. It is deliberately NOT explicitly stripped from stored diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs index effb0e99..61008a9d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs @@ -193,7 +193,7 @@ internal static class HistoryPaging /// The single SourceTimestamp every tie in the cluster shares. /// The (inclusive) upper bound of the read window; unchanged across pages. /// OpcUaServer-003, accepted by design: the backend contract - /// (IHistoryProvider.ReadRawAsync) treats endUtc as EXCLUSIVE, so the + /// (IHistorianDataSource.ReadRawAsync) treats endUtc as EXCLUSIVE, so the /// next <= endUtc test here is deliberately the conservative side — at worst one extra /// empty resume page ([endUtc, endUtc) → GoodNoData, no continuation point), never a /// dropped or duplicated sample. Tightening to < would be correct only if the live diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 01f81a8f..3584a5f3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -5419,14 +5419,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 errors[handle.Index] = StatusCodes.BadAggregateNotSupported; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported }; } - catch (HistoryReadStatusException ex) - { - // A passthrough backend (OpcUaClient) relayed the upstream server's Bad per-node status - surface - // it verbatim for THIS node rather than collapsing it to BadHistoryOperationUnsupported. - _logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused history read for node {NodeId}", handle.NodeId); - errors[handle.Index] = new StatusCode(ex.StatusCode); - results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) }; - } catch (Exception ex) { // One node's backend failure (throw / timeout) must not throw out of the batch — surface a @@ -5628,13 +5620,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 errors[handle.Index] = StatusCodes.BadTimeout; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout }; } - catch (HistoryReadStatusException ex) - { - // Upstream Bad per-node status relayed by a passthrough backend - surface it verbatim (see ServeNodeAsync). - _logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused HistoryReadRaw for node {NodeId}", handle.NodeId); - errors[handle.Index] = new StatusCode(ex.StatusCode); - results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) }; - } catch (Exception ex) { // One node's backend failure must not throw out of the batch — Bad for THIS node only. diff --git a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs index 5c5bdc21..10506ad4 100644 --- a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs +++ b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs @@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.Analyzers; /// IReadable.ReadAsync still trips the rule. /// /// -/// Default-interface-method handling: IHistoryProvider.ReadAtTimeAsync and -/// IHistoryProvider.ReadEventsAsync ship as DIM bodies. When a driver inherits the +/// Default-interface-method handling: a guarded interface member may ship as a DIM +/// body. When a driver inherits the /// DIM (no override), FindImplementationForInterfaceMember returns the interface's /// own method symbol, which still equals for an interface-typed /// receiver. When a driver overrides the DIM, the override symbol equals @@ -75,7 +75,6 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer "ZB.MOM.WW.OtOpcUa.Core.Abstractions.ISubscribable", "ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHostConnectivityProbe", "ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmSource", - "ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider", ]; /// @@ -106,7 +105,7 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer category: "OtOpcUa.Resilience", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource/IHistoryProvider call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project."); + description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project."); /// public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistoryReadStatusExceptionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistoryReadStatusExceptionTests.cs deleted file mode 100644 index c943a8c1..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistoryReadStatusExceptionTests.cs +++ /dev/null @@ -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(() => new HistoryReadStatusException(code)); -} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs index 562dab11..458e6796 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs @@ -153,22 +153,6 @@ public sealed class IHistorianDataSourceContractTests parameter!.ParameterType.ShouldBe(expectedType); } - /// Verifies that history provider max parameters have the expected type. - /// The name of the method to test. - /// The name of the parameter to verify. - /// The expected parameter type. - [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); - } - /// /// Core.Abstractions-009 (OpcUaServer-002): when maxEvents <= 0 the contract /// requires implementations to set @@ -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"); } - - /// - /// Core.Abstractions-008: and - /// are C# default interface methods - /// (drivers opt in), whereas and - /// 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. - /// - /// The name of the optional method to check. - [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."); - } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs index 23971b1a..cd58029a 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs @@ -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."); } + + /// + /// Gitea #655: HistoryRead is served server-side by IHistorianDataSource; no driver-side history + /// capability exists. Re-adding one without also adding a dispatch path would recreate dead surface. + /// + [Fact] + public void NoDriverSideHistoryCapability_Exists() + { + Assembly.GetType("ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider").ShouldBeNull(); + Enum.GetNames().ShouldNotContain("HistoryRead"); + } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs index bdf0e519..89a5a4d4 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs @@ -138,6 +138,33 @@ public sealed class DriverResilienceOptionsParserTests DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]); } + /// + /// Gitea #655: the retired HistoryRead capability may still sit in stored + /// DriverInstance.ResilienceConfig rows. It must parse (diagnostic only, never a throw), be skipped, + /// and leave every other override in the same blob applied. + /// + [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]); + } + /// Verifies that top-level property names are case insensitive. [Fact] public void PropertyNames_AreCaseInsensitive() diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs index 1bf1a56c..94403874 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs @@ -116,7 +116,6 @@ public sealed class DriverResilienceOptionsTests /// The driver capability to test. [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)] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs index 037343c1..400abfd3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs @@ -16,9 +16,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; /// /// Why opc-plc over loopback against our own server — (1) independent /// cert chain + user-token handling catches interop bugs loopback can't; -/// (2) built-in alarm ConditionType + history simulation gives -/// + -/// coverage without a custom +/// (2) built-in alarm ConditionType simulation gives +/// 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 open62541/open62541 as a /// second image once this lands, for fully-independent-stack interop. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs index 96deeaf1..34276308 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs @@ -92,32 +92,4 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim) await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } - - /// - /// 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. - /// - [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(); - } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs deleted file mode 100644 index 7fe81eca..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs +++ /dev/null @@ -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 -{ - /// Every member (#285: the full Part 13 set, not just five). - public static IEnumerable AllAggregates() => - Enum.GetValues().Select(a => new object[] { a }); - - /// Every member maps to a DISTINCT aggregate NodeId (a copy/paste mapping would collide). - [Fact] - public void MapAggregateToNodeId_is_injective_over_the_whole_enum() - { - var ids = Enum.GetValues().Select(OpcUaClientDriver.MapAggregateToNodeId).ToList(); - ids.Distinct().Count().ShouldBe(ids.Count); - Enum.GetValues().Length.ShouldBeGreaterThan(5); - } - - /// Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum. - /// The history aggregate type to test. - [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); - } - - /// Verifies MapAggregateToNodeId rejects invalid enum values. - [Fact] - public void MapAggregateToNodeId_rejects_invalid_enum_value() - { - // Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through. - Should.Throw(() => - OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99)); - } - - /// Verifies ReadRawAsync throws without initialization. - [Fact] - public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException() - { - using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit"); - await Should.ThrowAsync(async () => - await drv.ReadRawAsync("ns=2;s=Counter", - DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000, - TestContext.Current.CancellationToken)); - } - - /// Verifies ReadRawAsync with malformed NodeId returns empty result. - [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; - } - - /// Verifies ReadProcessedAsync throws without initialization. - [Fact] - public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException() - { - using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit"); - await Should.ThrowAsync(async () => - await drv.ReadProcessedAsync("ns=2;s=Counter", - DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, - TimeSpan.FromSeconds(10), HistoryAggregateType.Average, - TestContext.Current.CancellationToken)); - } - - /// Verifies ReadAtTimeAsync throws without initialization. - [Fact] - public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException() - { - using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit"); - await Should.ThrowAsync(async () => - await drv.ReadAtTimeAsync("ns=2;s=Counter", - [DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow], - TestContext.Current.CancellationToken)); - } - - /// ReadEventsAsync requires an initialized session like the sibling history reads. - [Fact] - public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException() - { - using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit"); - await Should.ThrowAsync(async () => - await drv.ReadEventsAsync( - sourceName: null, - startUtc: DateTime.UtcNow.AddMinutes(-5), - endUtc: DateTime.UtcNow, - maxEvents: 100, - cancellationToken: TestContext.Current.CancellationToken)); - } - - /// BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order. - [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 - } - } - - /// MapHistoryEvents maps every field by its canonical select-clause index. - [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); - } - - /// MapHistoryEvents returns empty for a HistoryEvent with no rows. - [Fact] - public void MapHistoryEvents_with_no_events_returns_empty() - { - OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty(); - } - - /// MapHistoryEvents tolerates a field list shorter than six without throwing. - [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); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs index 07a76e7d..93010f2b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs @@ -8,13 +8,13 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// -/// Regression coverage for the stale-session concurrency race in the history/read wire -/// paths. A background reconnect handler can swap +/// Regression coverage for the stale-session concurrency race in the alarm-acknowledge wire +/// path. A background reconnect handler can swap /// while a caller is parked on _gate.WaitAsync. If a method captures the session /// reference before 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 Session -/// inside the gate. These tests prove the history/read funnels follow it. +/// inside the gate. This test proves the acknowledge path follows it. /// /// /// The swap is staged so the race is exercised, not raced against: @@ -41,13 +41,6 @@ public sealed class OpcUaClientStaleSessionRaceTests { private static Mock NewSessionMock(TaskCompletionSource? prologueReached = null) { - var resp = new HistoryReadResponse - { - ResponseHeader = new ResponseHeader(), - Results = new HistoryReadResultCollection(), - DiagnosticInfos = new DiagnosticInfoCollection(), - }; - var mock = new Mock(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(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .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 newSession, Mock oldSession) - { - newSession.Verify(s => s.HistoryReadAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny()), - Times.Once); - oldSession.Verify(s => s.HistoryReadAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - private static void VerifyAckWireHitNewSessionNotOld(Mock newSession, Mock oldSession) { newSession.Verify(s => s.CallAsync( @@ -101,82 +74,6 @@ public sealed class OpcUaClientStaleSessionRaceTests Times.Never); } - /// - /// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the - /// HistoryRead against the session current after the gate, not a reference - /// captured before WaitAsync. - /// - [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); - } - - /// - /// 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. - /// - [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); - } - /// /// AcknowledgeAsync issues its Acknowledge method CallAsync against the session /// current after the gate — and parses the namespace-relative ConditionId against that diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverHistoryWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverHistoryWireTests.cs deleted file mode 100644 index b1afc571..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverHistoryWireTests.cs +++ /dev/null @@ -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; - -/// -/// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real -/// issues Raw / Processed / AtTime reads over loopback to the in-process -/// OtOpcUa server, whose node manager dispatches to an injected deterministic -/// () behind a historized raw variable. -/// -[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); - - /// Five samples, one per second, value = 1..5; the third is Uncertain. - 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 Calls { get; } = []; - - public Task 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 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(); - 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 ReadAtTimeAsync( - string fullReference, IReadOnlyList 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 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(() => - 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(() => - 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(() => - 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"); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs index afee581a..93324ec0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -312,61 +312,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable await host.DisposeAsync(); } - /// 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. - [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 - { - new() { NodeId = badId, ContinuationPoint = cp1 }, - new() { NodeId = okId }, - }; - var results = new List { null!, null! }; - var errors = new List { 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 -------------------------------------------------------------------------------- /// 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; - /// When set, a Raw read of this tagname throws (#649). - public volatile string? FailTagname; public void ResetReadCount() => _readCount = 0; public Task 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) diff --git a/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs b/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs index c13a25bb..252f27ee 100644 --- a/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs +++ b/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs @@ -50,16 +50,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions { public interface IHostConnectivityProbe { IReadOnlyList GetHostStatuses(); } - public class HistoryReadResult { } - public class HistoricalEventsResult { } - public interface IHistoryProvider { - Task ReadRawAsync(string fullRef, DateTime start, DateTime end, uint max, CancellationToken ct); - Task ReadProcessedAsync(string fullRef, DateTime start, DateTime end, TimeSpan interval, CancellationToken ct); - Task ReadAtTimeAsync(string fullRef, IReadOnlyList timestamps, CancellationToken ct) - => throw new NotSupportedException(); - Task 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 ExecuteAsync(DriverCapability c, string host, Func> call, CancellationToken ct); @@ -346,141 +336,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server { diags.ShouldBeEmpty(); } - // ----------------------------------------------------------------------- - // IHistoryProvider — interface-typed receiver - // ----------------------------------------------------------------------- - - /// Verifies that a direct ReadRawAsync call on an interface trips the diagnostic. - [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"); - } - - /// Verifies that a direct ReadProcessedAsync call on an interface trips the diagnostic. - [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"); - } - - /// Verifies that a direct ReadAtTimeAsync call on an interface trips the diagnostic. - [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(), CancellationToken.None); - } - } -} -"""; - var diags = await Compile(userSrc); - diags.Length.ShouldBe(1); - diags[0].GetMessage().ShouldContain("ReadAtTimeAsync"); - } - - /// Verifies that a wrapped ReadRawAsync call inside a CapabilityInvoker lambda passes cleanly. - [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) - // ----------------------------------------------------------------------- - - /// Verifies that a direct HistoryRead call on a concrete driver trips the diagnostic. - [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 ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!; - public Task 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 - // ======================================================================= - - /// Verifies that a direct ReadAtTimeAsync call on a concrete driver inheriting the DIM trips the diagnostic. - [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 ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!; - public Task 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(), CancellationToken.None); - } - } -} -"""; - var diags = await Compile(userSrc); - diags.Length.ShouldBe(1); - diags[0].GetMessage().ShouldContain("ReadAtTimeAsync"); - } - - /// Verifies that a direct ReadEventsAsync call on a concrete driver inheriting the DIM trips the diagnostic. - [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 ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!; - public Task 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"); - } - - /// Verifies that a direct ReadAtTimeAsync call on a concrete driver overriding the DIM trips the diagnostic. - [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 ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!; - public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!; - public Task ReadAtTimeAsync(string r, IReadOnlyList ts, CancellationToken ct) => throw null!; - } - public sealed class BadFullHistoryCaller { - public async Task DoIt(FullHistoryDriver driver) { - _ = await driver.ReadAtTimeAsync("tag", new List(), CancellationToken.None); - } - } -} -"""; - var diags = await Compile(userSrc); - diags.Length.ShouldBe(1); - diags[0].GetMessage().ShouldContain("ReadAtTimeAsync"); - } - - /// Verifies that a wrapped ReadAtTimeAsync DIM call inside a CapabilityInvoker lambda passes cleanly. - [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(), 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. // ======================================================================= /// Verifies that a direct UnsubscribeAsync call trips the diagnostic. @@ -965,32 +690,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server { diags[0].GetMessage().ShouldContain("AcknowledgeAsync"); } - /// Verifies that a wrapped ReadEventsAsync DIM call inside a CapabilityInvoker lambda passes cleanly. - [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> CompileWithoutStubs(string userSource) { var syntaxTrees = new[] { CSharpSyntaxTree.ParseText(userSource) };