Per-kind override shape: each hook receives the pre-filtered nodesToProcess list (NodeHandles for nodes this manager claimed), iterates them, resolves handle.NodeId.Identifier to the driver-side full reference string, and dispatches to the right IHistoryProvider method. Write back into the outer results + errors slots at handle.Index (not the local loop counter — nodesToProcess is a filtered subset of nodesToRead, so indexing by the loop counter lands in the wrong slot for mixed-manager batches). WriteResult helper sets both results[i] AND errors[i]; this matters because MasterNodeManager merges them and leaving errors[i] at its default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported on the wire — this was the subtle failure mode that masked a correctly-constructed HistoryData response during debugging. Failure-isolation per node: NotSupportedException from a driver that doesn't implement a particular HistoryProvider method translates to BadHistoryOperationUnsupported in that slot; generic exceptions log and surface BadInternalError; unresolvable NodeIds get BadNodeIdUnknown. The batch continues unconditionally. Aggregate mapping: MapAggregate translates ObjectIds.AggregateFunction_Average / Minimum / Maximum / Total / Count to the driver's HistoryAggregateType enum. Null for anything else (e.g. TimeAverage, Interpolative) so the handler surfaces BadAggregateNotSupported at the batch level — per Part 13, one unsupported aggregate means the whole request fails since ReadProcessedDetails carries one aggregate list for all nodes. BuildHistoryData wraps driver DataValueSnapshots as Opc.Ua.HistoryData in an ExtensionObject; BuildHistoryEvent wraps HistoricalEvents as Opc.Ua.HistoryEvent with the canonical BaseEventType field list (EventId, SourceName, Message, Severity, Time, ReceiveTime — the order OPC UA clients that didn't customize the SelectClause expect). ToDataValue preserves null SourceTimestamp (Galaxy historian rows often carry only ServerTimestamp) — synthesizing a SourceTimestamp would lie about actual sample time. Two address-space changes were required to make the stack dispatch reach the per-kind hooks at all: (1) historized variables get AccessLevels.HistoryRead added to their AccessLevel byte — the base's early-gate check on (variable.AccessLevel & HistoryRead != 0) was rejecting requests before our override ever ran; (2) the driver-root folder gets EventNotifiers.HistoryRead | SubscribeToEvents so HistoryReadEvents can target it (the conventional pattern for alarm-history browse against a driver-owned object). Document the 'set both bits' requirement inline since it's not obvious from the surface API. OpcHistoryReadResult alias: Opc.Ua.HistoryReadResult (service-layer per-node result) collides with Core.Abstractions.HistoryReadResult (driver-side samples + continuation point) by type name; the alias 'using OpcHistoryReadResult = Opc.Ua.HistoryReadResult' keeps the override signatures unambiguous and the test project applies the mirror pattern for its stub driver impl. Tests — DriverNodeManagerHistoryMappingTests (12 new Category=Unit cases): MapAggregate translates each supported aggregate NodeId via reflection-backed theory (guards against the stack renaming AggregateFunction_* constants); returns null for unsupported NodeIds (TimeAverage) and null input; BuildHistoryData wraps samples with correct DataValues + SourceTimestamp preservation; BuildHistoryEvent emits the 6-element BaseEventType field list in canonical order (regression guard for a future 'respect the client's SelectClauses' change); null SourceName / Message translate to empty-string Variants (nullable-Variant refactor trap); ToDataValue preserves StatusCode + both timestamps; ToDataValue leaves SourceTimestamp at default when the snapshot omits it. HistoryReadIntegrationTests (5 new Category=Integration): drives a real OPC UA client Session.HistoryRead against a fake HistoryDriver through the running server. Covers raw round-trip (verifies per-node DataValue ordering + values); processed with Average aggregate (captures the driver's received aggregate + interval, asserting MapAggregate routed correctly); unsupported aggregate (TimeAverage → BadAggregateNotSupported); at-time (forwards the per-timestamp list); events (BaseEventType field list shape, SelectClauses populated to satisfy the stack's filter validator). Server.Tests Unit: 55 pass / 0 fail (43 prior + 12 new mapping). Server.Tests Integration: 14 pass / 0 fail (9 prior + 5 new history). Full solution build clean, 0 errors. lmx-followups.md #1 updated to 'DONE (PRs 35 + 38)' with two explicit deferred items: continuation-point plumbing (driver returns null today so pass-through is fine) and per-SelectClause evaluation in HistoryReadEvents (clients with custom field selections get the canonical BaseEventType layout today). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
357 lines
17 KiB
C#
357 lines
17 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
// Core.Abstractions.HistoryReadResult (driver-side samples) collides with Opc.Ua.HistoryReadResult
|
|
// (service-layer per-node result). Alias the driver type so the stub's interface implementations
|
|
// are unambiguous.
|
|
using DriverHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's
|
|
/// <see cref="IHistoryProvider"/> via <see cref="DriverNodeManager"/>'s
|
|
/// <c>HistoryReadRawModified</c> / <c>HistoryReadProcessed</c> / <c>HistoryReadAtTime</c> /
|
|
/// <c>HistoryReadEvents</c> overrides. Boots the full OPC UA stack + a stub
|
|
/// <see cref="IHistoryProvider"/> driver, opens a client session, issues each HistoryRead
|
|
/// variant, and asserts the client receives the expected per-kind payload.
|
|
/// </summary>
|
|
[Trait("Category", "Integration")]
|
|
public sealed class HistoryReadIntegrationTests : IAsyncLifetime
|
|
{
|
|
private static readonly int Port = 48600 + Random.Shared.Next(0, 99);
|
|
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaHistoryTest";
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-history-test-{Guid.NewGuid():N}");
|
|
|
|
private DriverHost _driverHost = null!;
|
|
private OpcUaApplicationHost _server = null!;
|
|
private HistoryDriver _driver = null!;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
_driverHost = new DriverHost();
|
|
_driver = new HistoryDriver();
|
|
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
|
|
|
var options = new OpcUaServerOptions
|
|
{
|
|
EndpointUrl = _endpoint,
|
|
ApplicationName = "OtOpcUaHistoryTest",
|
|
ApplicationUri = "urn:OtOpcUa:Server:HistoryTest",
|
|
PkiStoreRoot = _pkiRoot,
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
};
|
|
|
|
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
|
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
|
await _server.StartAsync(CancellationToken.None);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _server.DisposeAsync();
|
|
await _driverHost.DisposeAsync();
|
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoryReadRaw_round_trips_driver_samples_to_the_client()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
|
var nodeId = new NodeId("raw.var", nsIndex);
|
|
|
|
// The Opc.Ua client exposes HistoryRead via Session.HistoryRead. We construct a
|
|
// ReadRawModifiedDetails (IsReadModified=false → raw path) and a single
|
|
// HistoryReadValueId targeting the driver-backed variable.
|
|
var details = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
EndTime = new DateTime(2024, 1, 1, 0, 0, 10, DateTimeKind.Utc),
|
|
NumValuesPerNode = 100,
|
|
IsReadModified = false,
|
|
ReturnBounds = false,
|
|
};
|
|
var extObj = new ExtensionObject(details);
|
|
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
|
|
|
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
|
out var results, out _);
|
|
|
|
results.Count.ShouldBe(1);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good, $"HistoryReadRaw returned {results[0].StatusCode}");
|
|
var hd = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
|
hd.DataValues.Count.ShouldBe(_driver.RawSamplesReturned, "one DataValue per driver sample");
|
|
hd.DataValues[0].Value.ShouldBe(_driver.FirstRawValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoryReadProcessed_maps_Average_aggregate_and_routes_to_ReadProcessedAsync()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
|
var nodeId = new NodeId("proc.var", nsIndex);
|
|
|
|
var details = new ReadProcessedDetails
|
|
{
|
|
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
|
ProcessingInterval = 10_000, // 10s buckets
|
|
AggregateType = [ObjectIds.AggregateFunction_Average],
|
|
};
|
|
var extObj = new ExtensionObject(details);
|
|
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
|
|
|
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
|
out var results, out _);
|
|
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
_driver.LastProcessedAggregate.ShouldBe(HistoryAggregateType.Average,
|
|
"MapAggregate must translate ObjectIds.AggregateFunction_Average → driver enum");
|
|
_driver.LastProcessedInterval.ShouldBe(TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoryReadProcessed_returns_BadAggregateNotSupported_for_unmapped_aggregate()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
|
var nodeId = new NodeId("proc.var", nsIndex);
|
|
|
|
var details = new ReadProcessedDetails
|
|
{
|
|
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
|
ProcessingInterval = 10_000,
|
|
// TimeAverage is a valid OPC UA aggregate NodeId but not one the driver implements —
|
|
// the override returns BadAggregateNotSupported per Part 13 rather than coercing.
|
|
AggregateType = [ObjectIds.AggregateFunction_TimeAverage],
|
|
};
|
|
var extObj = new ExtensionObject(details);
|
|
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
|
|
|
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
|
out var results, out _);
|
|
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoryReadAtTime_forwards_timestamp_list_to_driver()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
|
var nodeId = new NodeId("atTime.var", nsIndex);
|
|
|
|
var t1 = new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc);
|
|
var t2 = new DateTime(2024, 3, 1, 10, 0, 30, DateTimeKind.Utc);
|
|
var details = new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t1, t2 } };
|
|
var extObj = new ExtensionObject(details);
|
|
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
|
|
|
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
|
out var results, out _);
|
|
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
_driver.LastAtTimeRequestedTimes.ShouldNotBeNull();
|
|
_driver.LastAtTimeRequestedTimes!.Count.ShouldBe(2);
|
|
_driver.LastAtTimeRequestedTimes[0].ShouldBe(t1);
|
|
_driver.LastAtTimeRequestedTimes[1].ShouldBe(t2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoryReadEvents_returns_HistoryEvent_with_BaseEventType_field_list()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
// Events target the driver-root notifier (not a specific variable) which is the
|
|
// conventional pattern for alarm-history browse.
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
|
var nodeId = new NodeId("history-driver", nsIndex);
|
|
|
|
// EventFilter must carry at least one SelectClause or the stack rejects it as
|
|
// BadEventFilterInvalid before our override runs — empty filters are spec-forbidden.
|
|
// We populate the standard BaseEventType selectors any real client would send; my
|
|
// override's BuildHistoryEvent ignores the specific clauses and emits the canonical
|
|
// field list anyway (the richer "respect exact SelectClauses" behavior is on the PR 38
|
|
// follow-up list).
|
|
var filter = new EventFilter();
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.ReceiveTime);
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
EndTime = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
|
NumValuesPerNode = 10,
|
|
Filter = filter,
|
|
};
|
|
var extObj = new ExtensionObject(details);
|
|
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
|
|
|
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
|
out var results, out _);
|
|
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
var he = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
|
he.Events.Count.ShouldBe(_driver.EventsReturned);
|
|
he.Events[0].EventFields.Count.ShouldBe(6, "BaseEventType default field layout is 6 entries");
|
|
}
|
|
|
|
private async Task<ISession> OpenSessionAsync()
|
|
{
|
|
var cfg = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUaHistoryTestClient",
|
|
ApplicationUri = "urn:OtOpcUa:HistoryTestClient",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
|
SubjectName = "CN=OtOpcUaHistoryTestClient",
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
|
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
|
AutoAcceptUntrustedCertificates = true,
|
|
AddAppCertToTrustedStore = true,
|
|
},
|
|
TransportConfigurations = new TransportConfigurationCollection(),
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
|
};
|
|
await cfg.Validate(ApplicationType.Client);
|
|
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
|
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
|
|
|
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
|
var endpointConfig = EndpointConfiguration.Create(cfg);
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
|
|
|
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaHistoryTestClientSession", 60000,
|
|
new UserIdentity(new AnonymousIdentityToken()), null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stub driver that implements <see cref="IHistoryProvider"/> so the service dispatch
|
|
/// can be verified without bringing up a real Galaxy or Historian. Captures the last-
|
|
/// seen arguments so tests can assert what the service handler forwarded.
|
|
/// </summary>
|
|
private sealed class HistoryDriver : IDriver, ITagDiscovery, IReadable, IHistoryProvider
|
|
{
|
|
public string DriverInstanceId => "history-driver";
|
|
public string DriverType => "HistoryStub";
|
|
|
|
public int RawSamplesReturned => 3;
|
|
public int FirstRawValue => 100;
|
|
public int EventsReturned => 2;
|
|
|
|
public HistoryAggregateType? LastProcessedAggregate { get; private set; }
|
|
public TimeSpan? LastProcessedInterval { get; private set; }
|
|
public IReadOnlyList<DateTime>? LastAtTimeRequestedTimes { get; private set; }
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
|
{
|
|
// Every variable must be Historized for HistoryRead to route — the node-manager's
|
|
// stack base class checks the bit before dispatching.
|
|
builder.Variable("raw", "raw",
|
|
new DriverAttributeInfo("raw.var", DriverDataType.Int32, false, null,
|
|
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
|
builder.Variable("proc", "proc",
|
|
new DriverAttributeInfo("proc.var", DriverDataType.Float64, false, null,
|
|
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
|
builder.Variable("atTime", "atTime",
|
|
new DriverAttributeInfo("atTime.var", DriverDataType.Int32, false, null,
|
|
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
IReadOnlyList<DataValueSnapshot> r =
|
|
[.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))];
|
|
return Task.FromResult(r);
|
|
}
|
|
|
|
public Task<DriverHistoryReadResult> ReadRawAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var samples = new List<DataValueSnapshot>();
|
|
for (var i = 0; i < RawSamplesReturned; i++)
|
|
{
|
|
samples.Add(new DataValueSnapshot(
|
|
Value: FirstRawValue + i,
|
|
StatusCode: StatusCodes.Good,
|
|
SourceTimestampUtc: startUtc.AddSeconds(i),
|
|
ServerTimestampUtc: startUtc.AddSeconds(i)));
|
|
}
|
|
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
|
}
|
|
|
|
public Task<DriverHistoryReadResult> ReadProcessedAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
|
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
|
{
|
|
LastProcessedAggregate = aggregate;
|
|
LastProcessedInterval = interval;
|
|
return Task.FromResult(new DriverHistoryReadResult(
|
|
[new DataValueSnapshot(1.0, StatusCodes.Good, startUtc, startUtc)],
|
|
null));
|
|
}
|
|
|
|
public Task<DriverHistoryReadResult> ReadAtTimeAsync(
|
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
LastAtTimeRequestedTimes = timestampsUtc;
|
|
var samples = timestampsUtc
|
|
.Select(t => new DataValueSnapshot(42, StatusCodes.Good, t, t))
|
|
.ToArray();
|
|
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
|
}
|
|
|
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var events = new List<HistoricalEvent>();
|
|
for (var i = 0; i < EventsReturned; i++)
|
|
{
|
|
events.Add(new HistoricalEvent(
|
|
EventId: $"e{i}",
|
|
SourceName: sourceName,
|
|
EventTimeUtc: startUtc.AddHours(i),
|
|
ReceivedTimeUtc: startUtc.AddHours(i).AddSeconds(1),
|
|
Message: $"Event {i}",
|
|
Severity: (ushort)(500 + i)));
|
|
}
|
|
return Task.FromResult(new HistoricalEventsResult(events, null));
|
|
}
|
|
}
|
|
}
|