using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Historian.Aveva;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
namespace ZB.MOM.WW.LmxOpcUa.Historian.Aveva.Tests
{
///
/// Verifies Historian data source lifecycle behavior: dispose safety,
/// post-dispose rejection, connection failure handling, and reconnect-after-error.
///
public class HistorianDataSourceLifecycleTests
{
private static HistorianConfiguration DefaultConfig => new()
{
Enabled = true,
ServerName = "test-historian",
Port = 32568,
IntegratedSecurity = true,
CommandTimeoutSeconds = 5
};
[Fact]
public void ReadRawAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw(() =>
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAggregateAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw(() =>
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAtTimeAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw(() =>
ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow })
.GetAwaiter().GetResult());
}
[Fact]
public void ReadEventsAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw(() =>
ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void Dispose_CalledTwice_DoesNotThrow()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.NotThrow(() => ds.Dispose());
}
[Fact]
public void HistorianAggregateMap_UnknownColumn_ReturnsNull()
{
HistorianAggregateMap.MapAggregateToColumn(new Opc.Ua.NodeId(99999)).ShouldBeNull();
}
[Fact]
public void ReadRawAsync_WhenConnectionFails_ReturnsEmptyResults()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("Connection refused")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
results.Count.ShouldBe(0);
factory.ConnectCallCount.ShouldBe(1);
}
[Fact]
public void ReadRawAsync_WhenConnectionTimesOut_ReturnsEmptyResults()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new TimeoutException("Connection timed out")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
results.Count.ShouldBe(0);
}
[Fact]
public void ReadRawAsync_AfterConnectionError_AttemptsReconnect()
{
var factory = new FakeHistorianConnectionFactory();
var ds = new HistorianDataSource(DefaultConfig, factory);
// First call: factory returns a HistorianAccess that isn't actually connected,
// so the query will fail and HandleConnectionError will reset the connection.
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
// Second call: should attempt reconnection via the factory
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
// Factory should have been called twice — once for initial connect, once for reconnect
factory.ConnectCallCount.ShouldBe(2);
}
[Fact]
public void ReadRawAsync_ConnectionFailure_DoesNotCorruptState()
{
var callCount = 0;
var factory = new FakeHistorianConnectionFactory
{
OnConnect = count =>
{
callCount = count;
if (count == 1)
throw new InvalidOperationException("First connection fails");
// Second call succeeds (returns unconnected HistorianAccess, but that's OK for lifecycle testing)
}
};
var ds = new HistorianDataSource(DefaultConfig, factory);
// First read: connection fails
var r1 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
r1.Count.ShouldBe(0);
// Second read: should attempt new connection without throwing from internal state corruption
var r2 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
callCount.ShouldBe(2);
}
[Fact]
public void Dispose_DuringConnectionFailure_DoesNotThrow()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("Connection refused")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
// Trigger a failed connection attempt
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
// Dispose should handle the null connection gracefully
Should.NotThrow(() => ds.Dispose());
}
}
}