cfc8d44e3a
New SDK surface:
HistorianClient.EnsureTagAsync(HistorianTagDefinition)
HistorianClient.DeleteTagAsync(string tagName)
Plumbing:
src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
Public input model — TagName/Description/EngineeringUnit/DataType/MinEU/MaxEU.
Currently only HistorianDataType.Float is live-verified.
src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
SerializeAnalogCTagMetadata produces 146-byte payload byte-for-byte
identical to the captured native EnsT2(Float) request.
SerializeDeleteTagNames produces ushort 0x6751 + ushort 1 + uint count
+ per-tag (uint charCount + UTF-16 chars).
src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
Both EnsT2 and DelT run the full Stat-priming chain captured for the
analog flow (UpdC3 + Stat.GetV ×3 + Stat.GETHI ×2 + 7× GetSystemParameter
+ Trx.GetV + Retr.GetV).
src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs
MapDataType extended to accept tag-origin marker 0xC7 (SDK-created tags).
Tests:
5 golden-byte tests (HistorianTagWriteProtocolTests):
SerializeAnalogCTagMetadata byte-for-byte match against captured 146-byte fixture
SerializeAnalogCTagMetadata produces different bytes for different inputs
SerializeDeleteTagNames single-tag matches captured shape
SerializeDeleteTagNames multi-tag appends each
SerializeDeleteTagNames empty list throws
1 live integration test (gated by HISTORIAN_WRITE_SANDBOX_TAG):
EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian
EnsureTagAsync creates the sandbox tag, GetTagMetadataAsync reads it
back. 130/130 tests pass.
Harness improvements:
--write-delete-after now runs DelT independently of AddStreamedValue
outcome.
HistorianTagStatusList constructed correctly for DeleteTags reflection
call (previous StringCollection attempt failed with TypeMismatch).
Known DelT gap: SDK's DeleteTagAsync returns true but server-side
cascading deletion does not always complete (the row remains in
Runtime.dbo.Tag). The captured native flow's DelT removes the tag
cleanly (verified via harness --write-delete-after), so something
around the WCF DelT call is missing from our orchestrator. Documented
as known issue with SMC-based cleanup as workaround.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
390 lines
15 KiB
C#
390 lines
15 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 EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian()
|
|
{
|
|
// Per docs/plans/write-commands-reverse-engineering.md safety rules: localhost only,
|
|
// sandbox tag name must start with "RetestSdkWrite", tag is created if missing and
|
|
// always deleted at the end so the test leaves zero residue.
|
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
|
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
|
|
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
if (string.IsNullOrWhiteSpace(sandboxTag) || !sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
|
|
{
|
|
return; // safety gate per the plan
|
|
}
|
|
|
|
HistorianClient client = new(new HistorianClientOptions
|
|
{
|
|
Host = host,
|
|
IntegratedSecurity = true,
|
|
Transport = HistorianTransport.LocalPipe
|
|
});
|
|
|
|
AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
|
|
{
|
|
TagName = sandboxTag,
|
|
Description = "SDK live integration test sandbox",
|
|
EngineeringUnit = "test",
|
|
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
|
MinEU = 0.0,
|
|
MaxEU = 100.0,
|
|
};
|
|
|
|
try
|
|
{
|
|
// EnsureTags2 returns true on fresh creation, false on "already exists with same
|
|
// metadata" (per the captured event-flow analog). The success criterion is the
|
|
// tag being PRESENT in the DB after the call, not the return value.
|
|
_ = await client.EnsureTagAsync(definition, CancellationToken.None);
|
|
|
|
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
|
|
await client.GetTagMetadataAsync(sandboxTag, CancellationToken.None);
|
|
Assert.NotNull(metadata);
|
|
Assert.Equal(sandboxTag, metadata.Name);
|
|
Assert.Equal(AVEVA.Historian.Client.Models.HistorianDataType.Float, metadata.DataType);
|
|
}
|
|
finally
|
|
{
|
|
// Cleanup attempt — DeleteTags semantics still under investigation per the
|
|
// write-commands plan; even when DelT returns true the deletion may be
|
|
// asynchronous on the server. Don't assert post-delete state.
|
|
try { _ = await client.DeleteTagAsync(sandboxTag, CancellationToken.None); } catch { }
|
|
}
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|