using System.Runtime.CompilerServices; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; /// /// Reusable in-memory test double for . 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 /// RpcException) per operation; reads share . /// public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient { public bool TagExistsResult = true; public int TagExistsCallCount; public Exception? TagExistsThrows; /// Optional asynchronous catalog response for overlapping presence-cache tests. public Func>? TagExistsSource; public Task TagExistsAsync(string tag, CancellationToken ct) { ct.ThrowIfCancellationRequested(); Interlocked.Increment(ref TagExistsCallCount); if (TagExistsSource is { } source) return source(tag, ct); if (TagExistsThrows is { } error) throw error; return Task.FromResult(TagExistsResult); } // ---- ReadRaw ------------------------------------------------------------------------------- public IReadOnlyList RawSamples = Array.Empty(); public string? LastReadRawTag; public DateTime LastReadRawStartUtc; public DateTime LastReadRawEndUtc; public int LastReadRawMaxValues; public int ReadRawCallCount; /// /// When set, ReadRaw serves RawSource(start, end, maxValues) instead of (a /// store-like fake honouring the window, #681). /// public Func>? RawSource; /// Raw samples actually pulled by the consumer across every ReadRaw stream. public int RawYieldedCount; /// ReadRaw streams the consumer enumerated to the END (never broke out of — #681 review). public int RawStreamsCompleted; /// Every ReadRaw call's (start, end, maxValues), in order. public readonly List<(DateTime Start, DateTime End, int MaxValues)> RawCalls = new(); // ---- ReadAggregate ------------------------------------------------------------------------- public IReadOnlyList AggregateSamples = Array.Empty(); public string? LastAggregateTag; public DateTime LastAggregateStartUtc; public DateTime LastAggregateEndUtc; public RetrievalMode LastAggregateMode; public TimeSpan LastAggregateInterval; public int ReadAggregateCallCount; // ---- ReadAtTime ---------------------------------------------------------------------------- public IReadOnlyList AtTimeSamples = Array.Empty(); public string? LastReadAtTimeTag; public IReadOnlyList? LastReadAtTimeTimestamps; public int ReadAtTimeCallCount; // ---- ReadEvents ---------------------------------------------------------------------------- public IReadOnlyList Events = Array.Empty(); public string? LastReadEventsSourceName; public DateTime LastReadEventsStartUtc; public DateTime LastReadEventsEndUtc; public int LastReadEventsMaxEvents; public int ReadEventsCallCount; /// When set, ReadEvents serves EventsSource(start, end) instead of . public Func>? EventsSource; /// Thrown (deferred to first enumeration) by every read method when set. public Exception? ThrowOnRead; // ---- WriteLiveValues ----------------------------------------------------------------------- public WriteAck WriteLiveValuesResult = new() { Success = true }; public string? LastWriteLiveTag; public IReadOnlyList? 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? 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 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(), 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)); } /// /// A strict gateway (Historian:ReadLimits:AllowStreamingBeyondCeiling=false) with this /// MaxValuesCeiling: a ReadRaw asking for more is refused InvalidArgument (#691.3). /// public int? MaxValuesCeiling; /// Overrides the refusal's detail text (an older/other gateway wording). public string? CeilingRejectionDetail; public IAsyncEnumerable 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(), new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct); } return ToAsyncStream(AggregateSource(startUtc, endUtc, mode, interval).ToList(), ThrowOnRead, ct); } /// When set, ReadAggregate serves AggregateSource(start, end, mode, interval) (and refuses start > end). public Func>? AggregateSource; /// Every ReadAggregate call's (start, end, mode, interval), in order. public readonly List<(DateTime Start, DateTime End, RetrievalMode Mode, TimeSpan Interval)> AggregateCalls = new(); public Task> ReadAtTimeAsync( string tag, IReadOnlyList timestampsUtc, CancellationToken ct) { LastReadAtTimeTag = tag; LastReadAtTimeTimestamps = timestampsUtc; ReadAtTimeCallCount++; return ThrowOnRead is not null ? Task.FromException>(ThrowOnRead) : Task.FromResult(AtTimeSamples); } public IAsyncEnumerable 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 WriteLiveValuesAsync( string tag, IReadOnlyList values, CancellationToken ct) { LastWriteLiveTag = tag; LastWriteLiveValues = values; WriteLiveValuesCallCount++; return WriteLiveValuesThrows is not null ? Task.FromException(WriteLiveValuesThrows) : Task.FromResult(WriteLiveValuesResult); } public Task SendEventAsync(HistorianEvent evt, CancellationToken ct) { LastSendEvent = evt; SendEventCallCount++; return SendEventThrows is not null ? Task.FromException(SendEventThrows) : Task.FromResult(SendEventResult); } public Task EnsureTagsAsync( IReadOnlyList definitions, CancellationToken ct) { LastEnsureDefinitions = definitions; EnsureTagsCallCount++; return EnsureTagsThrows is not null ? Task.FromException(EnsureTagsThrows) : Task.FromResult(EnsureTagsResult); } public Task ProbeAsync(CancellationToken ct) { ProbeCallCount++; return ProbeThrows is not null ? Task.FromException(ProbeThrows) : Task.FromResult(ProbeResult); } public Task GetConnectionStatusAsync(CancellationToken ct) { GetConnectionStatusCallCount++; return GetConnectionStatusThrows is not null ? Task.FromException(GetConnectionStatusThrows) : Task.FromResult(ConnectionStatus); } public ValueTask DisposeAsync() { DisposeCallCount++; return ValueTask.CompletedTask; } private async IAsyncEnumerable CountingStream(IAsyncEnumerable inner) { await foreach (var item in inner.ConfigureAwait(false)) { RawYieldedCount++; yield return item; } RawStreamsCompleted++; } private static async IAsyncEnumerable ToAsyncStream( IReadOnlyList 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); } }