ccda27d220
GatewayHistorianDataSource.ReadEventsAsync sized its result list to maxEvents, which is int.MaxValue for NumValuesPerNode = 0: every uncapped Gateway events read (forward on master, and the new walked reverse one) threw 'Array dimensions exceeded' and counted as a backend failure. Capacity is bounded at 1024. The events walk delegate moved to ReverseEventsReader so it is tested over the real Gateway source (forward uncapped, reverse uncapped, reverse capped).
280 lines
12 KiB
C#
280 lines
12 KiB
C#
using System.Runtime.CompilerServices;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
|
|
/// <summary>
|
|
/// Reusable in-memory test double for <see cref="IHistorianGatewayClient"/>. Every method returns
|
|
/// from a public settable result field and records its call arguments into public fields, so the
|
|
/// later driver tasks (T7/T8/T11/T12/T14) can drive behaviour and assert on what the driver sent
|
|
/// without a live gateway. Throw fields let a test simulate transport faults (e.g. an
|
|
/// <c>RpcException</c>) per operation; reads share <see cref="ThrowOnRead"/>.
|
|
/// </summary>
|
|
public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient
|
|
{
|
|
// ---- ReadRaw -------------------------------------------------------------------------------
|
|
public IReadOnlyList<HistorianSample> RawSamples = Array.Empty<HistorianSample>();
|
|
public string? LastReadRawTag;
|
|
public DateTime LastReadRawStartUtc;
|
|
public DateTime LastReadRawEndUtc;
|
|
public int LastReadRawMaxValues;
|
|
public int ReadRawCallCount;
|
|
/// <summary>
|
|
/// When set, ReadRaw serves <c>RawSource(start, end, maxValues)</c> instead of <see cref="RawSamples"/> (a
|
|
/// store-like fake honouring the window, #681).
|
|
/// </summary>
|
|
public Func<DateTime, DateTime, int, IEnumerable<HistorianSample>>? RawSource;
|
|
/// <summary>Raw samples actually pulled by the consumer across every ReadRaw stream.</summary>
|
|
public int RawYieldedCount;
|
|
/// <summary>ReadRaw streams the consumer enumerated to the END (never broke out of — #681 review).</summary>
|
|
public int RawStreamsCompleted;
|
|
/// <summary>Every ReadRaw call's (start, end, maxValues), in order.</summary>
|
|
public readonly List<(DateTime Start, DateTime End, int MaxValues)> RawCalls = new();
|
|
|
|
// ---- ReadAggregate -------------------------------------------------------------------------
|
|
public IReadOnlyList<HistorianAggregateSample> AggregateSamples = Array.Empty<HistorianAggregateSample>();
|
|
public string? LastAggregateTag;
|
|
public DateTime LastAggregateStartUtc;
|
|
public DateTime LastAggregateEndUtc;
|
|
public RetrievalMode LastAggregateMode;
|
|
public TimeSpan LastAggregateInterval;
|
|
public int ReadAggregateCallCount;
|
|
|
|
// ---- ReadAtTime ----------------------------------------------------------------------------
|
|
public IReadOnlyList<HistorianSample> AtTimeSamples = Array.Empty<HistorianSample>();
|
|
public string? LastReadAtTimeTag;
|
|
public IReadOnlyList<DateTime>? LastReadAtTimeTimestamps;
|
|
public int ReadAtTimeCallCount;
|
|
|
|
// ---- ReadEvents ----------------------------------------------------------------------------
|
|
public IReadOnlyList<HistorianEvent> Events = Array.Empty<HistorianEvent>();
|
|
public string? LastReadEventsSourceName;
|
|
public DateTime LastReadEventsStartUtc;
|
|
public DateTime LastReadEventsEndUtc;
|
|
public int LastReadEventsMaxEvents;
|
|
public int ReadEventsCallCount;
|
|
|
|
/// <summary>When set, ReadEvents serves <c>EventsSource(start, end)</c> instead of <see cref="Events"/>.</summary>
|
|
public Func<DateTime, DateTime, IEnumerable<HistorianEvent>>? EventsSource;
|
|
|
|
/// <summary>Thrown (deferred to first enumeration) by every read method when set.</summary>
|
|
public Exception? ThrowOnRead;
|
|
|
|
// ---- WriteLiveValues -----------------------------------------------------------------------
|
|
public WriteAck WriteLiveValuesResult = new() { Success = true };
|
|
public string? LastWriteLiveTag;
|
|
public IReadOnlyList<HistorianLiveValue>? LastWriteLiveValues;
|
|
public int WriteLiveValuesCallCount;
|
|
public Exception? WriteLiveValuesThrows;
|
|
|
|
// ---- SendEvent -----------------------------------------------------------------------------
|
|
public WriteAck SendEventResult = new() { Success = true };
|
|
public HistorianEvent? LastSendEvent;
|
|
public int SendEventCallCount;
|
|
public Exception? SendEventThrows;
|
|
|
|
// ---- EnsureTags ----------------------------------------------------------------------------
|
|
public TagOperationResults EnsureTagsResult = new();
|
|
public IReadOnlyList<HistorianTagDefinition>? LastEnsureDefinitions;
|
|
public int EnsureTagsCallCount;
|
|
public Exception? EnsureTagsThrows;
|
|
|
|
// ---- Probe ---------------------------------------------------------------------------------
|
|
public bool ProbeResult = true;
|
|
public int ProbeCallCount;
|
|
public Exception? ProbeThrows;
|
|
|
|
// ---- GetConnectionStatus -------------------------------------------------------------------
|
|
public ConnectionStatus ConnectionStatus = new();
|
|
public int GetConnectionStatusCallCount;
|
|
public Exception? GetConnectionStatusThrows;
|
|
|
|
// ---- Dispose -------------------------------------------------------------------------------
|
|
public int DisposeCallCount;
|
|
|
|
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
|
string tag,
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
int maxValues,
|
|
CancellationToken ct)
|
|
{
|
|
LastReadRawTag = tag;
|
|
LastReadRawStartUtc = startUtc;
|
|
LastReadRawEndUtc = endUtc;
|
|
LastReadRawMaxValues = maxValues;
|
|
lock (RawCalls)
|
|
{
|
|
ReadRawCallCount++;
|
|
RawCalls.Add((startUtc, endUtc, maxValues));
|
|
}
|
|
if (MaxValuesCeiling is { } ceiling && maxValues > ceiling)
|
|
{
|
|
// The real gateway's refusal (HistorianReadService.ReadRaw), raised on the first MoveNext before any row, as
|
|
// the client package surfaces it: a HistorianGatewayException wrapping the RpcException.
|
|
var rpc = new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
|
|
CeilingRejectionDetail ?? $"max_values exceeds the server ceiling of {ceiling}; read large ranges by time-slicing " +
|
|
"(split the range and read the slices sequentially), or enable " +
|
|
"Historian:ReadLimits:AllowStreamingBeyondCeiling to page through server-side."));
|
|
return ToAsyncStream(Array.Empty<HistorianSample>(),
|
|
new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct);
|
|
}
|
|
var items = RawSource is null ? RawSamples : RawSource(startUtc, endUtc, maxValues).ToList();
|
|
return CountingStream(ToAsyncStream(items, ThrowOnRead, ct));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A strict gateway (<c>Historian:ReadLimits:AllowStreamingBeyondCeiling=false</c>) with this
|
|
/// <c>MaxValuesCeiling</c>: a ReadRaw asking for more is refused <c>InvalidArgument</c> (#691.3).
|
|
/// </summary>
|
|
public int? MaxValuesCeiling;
|
|
|
|
/// <summary>Overrides the refusal's detail text (an older/other gateway wording).</summary>
|
|
public string? CeilingRejectionDetail;
|
|
|
|
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
|
|
string tag,
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
RetrievalMode mode,
|
|
TimeSpan interval,
|
|
CancellationToken ct)
|
|
{
|
|
LastAggregateTag = tag;
|
|
LastAggregateStartUtc = startUtc;
|
|
LastAggregateEndUtc = endUtc;
|
|
LastAggregateMode = mode;
|
|
LastAggregateInterval = interval;
|
|
ReadAggregateCallCount++;
|
|
AggregateCalls.Add((startUtc, endUtc, mode, interval));
|
|
if (AggregateSource is null) return ToAsyncStream(AggregateSamples, ThrowOnRead, ct);
|
|
if (startUtc > endUtc || interval <= TimeSpan.Zero)
|
|
{
|
|
// The real gateway's request validation (ValidateRange; interval must be > 0), raised before any row.
|
|
var rpc = new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
|
|
startUtc > endUtc ? "start_time must be less than or equal to end_time." : "interval must be greater than zero."));
|
|
return ToAsyncStream(Array.Empty<HistorianAggregateSample>(),
|
|
new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct);
|
|
}
|
|
return ToAsyncStream(AggregateSource(startUtc, endUtc, mode, interval).ToList(), ThrowOnRead, ct);
|
|
}
|
|
|
|
/// <summary>When set, ReadAggregate serves <c>AggregateSource(start, end, mode, interval)</c> (and refuses start > end).</summary>
|
|
public Func<DateTime, DateTime, RetrievalMode, TimeSpan, IEnumerable<HistorianAggregateSample>>? AggregateSource;
|
|
|
|
/// <summary>Every ReadAggregate call's (start, end, mode, interval), in order.</summary>
|
|
public readonly List<(DateTime Start, DateTime End, RetrievalMode Mode, TimeSpan Interval)> AggregateCalls = new();
|
|
|
|
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
|
|
string tag,
|
|
IReadOnlyList<DateTime> timestampsUtc,
|
|
CancellationToken ct)
|
|
{
|
|
LastReadAtTimeTag = tag;
|
|
LastReadAtTimeTimestamps = timestampsUtc;
|
|
ReadAtTimeCallCount++;
|
|
return ThrowOnRead is not null
|
|
? Task.FromException<IReadOnlyList<HistorianSample>>(ThrowOnRead)
|
|
: Task.FromResult(AtTimeSamples);
|
|
}
|
|
|
|
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
|
string? sourceName,
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
int maxEvents,
|
|
CancellationToken ct)
|
|
{
|
|
LastReadEventsSourceName = sourceName;
|
|
LastReadEventsStartUtc = startUtc;
|
|
LastReadEventsEndUtc = endUtc;
|
|
LastReadEventsMaxEvents = maxEvents;
|
|
ReadEventsCallCount++;
|
|
return ToAsyncStream(EventsSource is null ? Events : EventsSource(startUtc, endUtc).ToList(), ThrowOnRead, ct);
|
|
}
|
|
|
|
public Task<WriteAck> WriteLiveValuesAsync(
|
|
string tag,
|
|
IReadOnlyList<HistorianLiveValue> values,
|
|
CancellationToken ct)
|
|
{
|
|
LastWriteLiveTag = tag;
|
|
LastWriteLiveValues = values;
|
|
WriteLiveValuesCallCount++;
|
|
return WriteLiveValuesThrows is not null
|
|
? Task.FromException<WriteAck>(WriteLiveValuesThrows)
|
|
: Task.FromResult(WriteLiveValuesResult);
|
|
}
|
|
|
|
public Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct)
|
|
{
|
|
LastSendEvent = evt;
|
|
SendEventCallCount++;
|
|
return SendEventThrows is not null
|
|
? Task.FromException<WriteAck>(SendEventThrows)
|
|
: Task.FromResult(SendEventResult);
|
|
}
|
|
|
|
public Task<TagOperationResults> EnsureTagsAsync(
|
|
IReadOnlyList<HistorianTagDefinition> definitions,
|
|
CancellationToken ct)
|
|
{
|
|
LastEnsureDefinitions = definitions;
|
|
EnsureTagsCallCount++;
|
|
return EnsureTagsThrows is not null
|
|
? Task.FromException<TagOperationResults>(EnsureTagsThrows)
|
|
: Task.FromResult(EnsureTagsResult);
|
|
}
|
|
|
|
public Task<bool> ProbeAsync(CancellationToken ct)
|
|
{
|
|
ProbeCallCount++;
|
|
return ProbeThrows is not null
|
|
? Task.FromException<bool>(ProbeThrows)
|
|
: Task.FromResult(ProbeResult);
|
|
}
|
|
|
|
public Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct)
|
|
{
|
|
GetConnectionStatusCallCount++;
|
|
return GetConnectionStatusThrows is not null
|
|
? Task.FromException<ConnectionStatus>(GetConnectionStatusThrows)
|
|
: Task.FromResult(ConnectionStatus);
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
DisposeCallCount++;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private async IAsyncEnumerable<HistorianSample> CountingStream(IAsyncEnumerable<HistorianSample> inner)
|
|
{
|
|
await foreach (var item in inner.ConfigureAwait(false))
|
|
{
|
|
RawYieldedCount++;
|
|
yield return item;
|
|
}
|
|
RawStreamsCompleted++;
|
|
}
|
|
|
|
private static async IAsyncEnumerable<T> ToAsyncStream<T>(
|
|
IReadOnlyList<T> items,
|
|
Exception? error,
|
|
[EnumeratorCancellation] CancellationToken ct)
|
|
{
|
|
if (error is not null)
|
|
{
|
|
throw error;
|
|
}
|
|
|
|
foreach (var item in items)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
yield return item;
|
|
}
|
|
|
|
await ValueTask.CompletedTask.ConfigureAwait(false);
|
|
}
|
|
}
|