feat(historian): HistoryRead override (Raw/Processed/AtTime) over IHistorianDataSource

This commit is contained in:
Joseph Doherty
2026-06-14 19:31:39 -04:00
parent 50e1141fc2
commit 13fba8f8fb
4 changed files with 910 additions and 0 deletions
@@ -0,0 +1,100 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
/// <summary>
/// The default no-op historian source. Every read returns an empty result with a null
/// continuation point; <see cref="NullHistorianDataSource.GetHealthSnapshot"/> reports a fully
/// disabled shape; and <see cref="NullHistorianDataSource.Instance"/> is the shared singleton.
/// </summary>
public sealed class NullHistorianDataSourceTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly NullHistorianDataSource Source = NullHistorianDataSource.Instance;
/// <summary><see cref="NullHistorianDataSource.Instance"/> is a single shared instance.</summary>
[Fact]
public void Instance_is_a_singleton()
{
NullHistorianDataSource.Instance.ShouldBeSameAs(NullHistorianDataSource.Instance);
}
/// <summary>ReadRawAsync returns no samples and a null continuation point.</summary>
[Fact]
public async Task ReadRawAsync_returns_empty()
{
var result = await Source.ReadRawAsync(
"WW.Tag", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxValuesPerNode: 100, Ct);
result.Samples.ShouldBeEmpty();
result.ContinuationPoint.ShouldBeNull();
}
/// <summary>ReadProcessedAsync returns no samples and a null continuation point.</summary>
[Fact]
public async Task ReadProcessedAsync_returns_empty()
{
var result = await Source.ReadProcessedAsync(
"WW.Tag", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
TimeSpan.FromSeconds(10), HistoryAggregateType.Average, Ct);
result.Samples.ShouldBeEmpty();
result.ContinuationPoint.ShouldBeNull();
}
/// <summary>ReadAtTimeAsync returns no samples and a null continuation point.</summary>
[Fact]
public async Task ReadAtTimeAsync_returns_empty()
{
var result = await Source.ReadAtTimeAsync(
"WW.Tag", new[] { DateTime.UtcNow }, Ct);
result.Samples.ShouldBeEmpty();
result.ContinuationPoint.ShouldBeNull();
}
/// <summary>ReadEventsAsync returns no events and a null continuation point.</summary>
[Fact]
public async Task ReadEventsAsync_returns_empty()
{
var result = await Source.ReadEventsAsync(
sourceName: null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxEvents: 0, Ct);
result.Events.ShouldBeEmpty();
result.ContinuationPoint.ShouldBeNull();
}
/// <summary>GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape.</summary>
[Fact]
public void GetHealthSnapshot_is_disabled()
{
var snapshot = Source.GetHealthSnapshot();
snapshot.TotalQueries.ShouldBe(0);
snapshot.TotalSuccesses.ShouldBe(0);
snapshot.TotalFailures.ShouldBe(0);
snapshot.ConsecutiveFailures.ShouldBe(0);
snapshot.LastSuccessTime.ShouldBeNull();
snapshot.LastFailureTime.ShouldBeNull();
snapshot.LastError.ShouldBeNull();
snapshot.ProcessConnectionOpen.ShouldBeFalse();
snapshot.EventConnectionOpen.ShouldBeFalse();
snapshot.ActiveProcessNode.ShouldBeNull();
snapshot.ActiveEventNode.ShouldBeNull();
snapshot.Nodes.ShouldBeEmpty();
}
/// <summary>Dispose is a safe no-op (idempotent).</summary>
[Fact]
public void Dispose_is_a_noop()
{
Should.NotThrow(() =>
{
Source.Dispose();
Source.Dispose();
});
}
}