Files
histsdk/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
T
dohertj2 5310952ab2 Extend HistorianTagMetadata with Description, EngineeringUnit, MinEU/MaxEU
Decoded the GetTagInfoFromName response shape across multiple tag types via
captured raw bytes (sanitized decoder script in scripts/decode-taginfo-bytes.py):

- Compact-ASCII string slot count varies by tag origin: 2 strings for
  MDAS-routed external tags (TagName + MetadataProvider), 4 strings for local
  Sys tags (TagName + Description + ItemName + CreatedBy). Parser now walks
  strings dynamically until the next byte isn't the 0x09 marker.

- Trailing region after the 4-byte fixed block holds (for analog tags) two
  doubles for MinEU/MaxEU plus an optional EngineeringUnit compact ASCII
  string and other fields whose exact positions vary. Parser uses a tolerant
  scan: tries each 8-byte alignment 0..7, picks the first sane (Min ≤ Max,
  finite, not all-zeros, |x| ≤ 1e15) double pair as MinEU/MaxEU, and finds
  the first plausible compact ASCII string (1..32 ASCII bytes, not numeric)
  as EngineeringUnit.

HistorianTagMetadata.Description / EngineeringUnit / MinRaw / MaxRaw nullable
slots already existed; they're now populated. Live verification: SysTimeSec
returns Description="System Time : Seconds", MaxRaw=59.0, EngineeringUnit
="Seconds".

Tests: 109 → 114 (+4 synthetic-fixture parser tests + 1 live integration
test for the populated analog metadata path). Bulk descriptor probe helper
(GetTagInfoRawBytesForProbe) added for future layout work; raw bytes never
committed because they contain CreatedBy DOMAIN\user identity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 07:02:31 -04:00

334 lines
12 KiB
C#

namespace AVEVA.Historian.Client.Tests;
public sealed class HistorianClientIntegrationTests
{
[Fact]
public async Task ProbeAsync_ReturnsTrueForConfiguredHistorian()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host))
{
return;
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
? parsedPort
: HistorianClientOptions.DefaultPort;
HistorianClient client = new(new HistorianClientOptions { Host = host, Port = port });
Assert.True(await client.ProbeAsync(CancellationToken.None));
}
[Fact]
public async Task BrowseTagNamesAsync_ReturnsConfiguredTestTag()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
string? filter = Environment.GetEnvironmentVariable("HISTORIAN_TAG_FILTER") ?? testTag;
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || string.IsNullOrWhiteSpace(filter))
{
return;
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
? parsedPort
: HistorianClientOptions.DefaultPort;
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
Port = port,
IntegratedSecurity = true,
UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty,
Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty
});
List<string> tagNames = [];
await foreach (string tagName in client.BrowseTagNamesAsync(filter, CancellationToken.None))
{
tagNames.Add(tagName);
}
Assert.Contains(testTag, tagNames);
}
[Fact]
public async Task ReadRawAsync_AgainstLocalHistorian_ReturnsAtLeastOneRow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
// The managed read flow currently only supports the LocalPipe transport.
return;
}
if (!OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
List<AVEVA.Historian.Client.Models.HistorianSample> samples = [];
await foreach (AVEVA.Historian.Client.Models.HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
{
samples.Add(sample);
}
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task ReadAggregateAsync_AgainstLocalHistorian_ReturnsTimeWeightedAverageRows()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
List<AVEVA.Historian.Client.Models.HistorianAggregateSample> samples = [];
await foreach (AVEVA.Historian.Client.Models.HistorianAggregateSample sample in client.ReadAggregateAsync(
testTag, startUtc, endUtc,
AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage,
TimeSpan.FromMinutes(1),
CancellationToken.None))
{
samples.Add(sample);
}
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
Assert.All(samples, s => Assert.Equal(AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage, s.RetrievalMode));
}
[Fact]
public async Task ReadAtTimeAsync_AgainstLocalHistorian_ReturnsRequestedTimestamps()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime nowUtc = DateTime.UtcNow;
DateTime[] timestamps =
[
nowUtc - TimeSpan.FromMinutes(5),
nowUtc - TimeSpan.FromMinutes(2),
nowUtc - TimeSpan.FromMinutes(1)
];
IReadOnlyList<AVEVA.Historian.Client.Models.HistorianSample> samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None);
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task ReadEventsAsync_AgainstLocalHistorian_DoesNotThrow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
// The event-row WCF wire format is not yet decoded; this test verifies the chain
// (ValCl + Open2 + Retr.IsOriginalAllowed + Retr.StartEventQuery) reaches the server
// without throwing. An empty event list is acceptable until row parsing is wired.
List<AVEVA.Historian.Client.Models.HistorianEvent> events = [];
await foreach (AVEVA.Historian.Client.Models.HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
{
events.Add(evt);
}
Assert.NotNull(events);
}
[Fact]
public async Task GetSystemParameterAsync_AgainstLocalHistorian_ReturnsHistorianVersion()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
// The server returns a non-empty version string for the documented HistorianVersion parameter.
Assert.False(string.IsNullOrWhiteSpace(value));
}
[Fact]
public async Task GetConnectionStatusAsync_AgainstLocalHistorian_ReportsConnectedToServer()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
AVEVA.Historian.Client.Models.HistorianConnectionStatus status =
await client.GetConnectionStatusAsync(CancellationToken.None);
Assert.True(status.ConnectedToServer);
Assert.False(status.ErrorOccurred);
Assert.False(status.Pending);
Assert.Equal(host, status.ServerName);
}
[Fact]
public async Task GetStoreForwardStatusAsync_AgainstLocalHistorian_ReturnsDefaults()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
AVEVA.Historian.Client.Models.HistorianStoreForwardStatus status =
await client.GetStoreForwardStatusAsync(CancellationToken.None);
// The synthesized status returns defaults — no store-forward sidecar to probe in this build.
Assert.False(status.ErrorOccurred);
Assert.False(status.Pending);
Assert.Equal(host, status.ServerName);
}
[Fact]
public async Task GetTagMetadataAsync_ReturnsConfiguredTestTagMetadata()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
? parsedPort
: HistorianClientOptions.DefaultPort;
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
Port = port,
IntegratedSecurity = true,
UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty,
Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty
});
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
await client.GetTagMetadataAsync(testTag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(testTag, metadata.Name);
Assert.NotNull(metadata.Key);
}
[Fact]
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
// SysTimeSec is a built-in analog UInt16 tag with non-empty Description, MaxEU,
// and an EngineeringUnit. Verifies the parser populates those new fields end-to-end.
const string analogTag = "SysTimeSec";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
await client.GetTagMetadataAsync(analogTag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(analogTag, metadata.Name);
Assert.False(string.IsNullOrWhiteSpace(metadata.Description));
Assert.NotNull(metadata.MaxRaw);
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit));
}
}