Files
histsdk/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
Joseph Doherty 7e4d713eb3 Cross-platform NegotiateAuthentication; StorageType field; docs polish
HistorianSspiClient rewritten on top of System.Net.Security.NegotiateAuthentication
in place of P/Invoke into secur32.dll's InitializeSecurityContextW. The class
keeps the same Next() / Dispose() / two-constructor surface so callers don't
change. RequiredProtectionLevel=EncryptAndSign + AllowedImpersonationLevel=
Identification produces a request-flag set equivalent to the captured native
0x2081C / 0x81C bitmasks (still preserved as constants for the existing unit
tests). Removes the only Windows P/Invoke in the production SDK; the
[SupportedOSPlatform("windows")] gating elsewhere stays in place pending a
separate sweep.

HistorianStorageType (Cyclic = 1, Delta = 2):
Captured 2026-05-04 via --write-storage-type on the harness. Delta differs
from Cyclic in three places — header byte 10 (0x02 -> 0x06), flag-block
byte 1 (0x01 -> 0x02), and 4 zero bytes inserted after StorageRate before
the FILETIME. Server persists Tag.StorageType=1/2 accordingly. Plumbed
through HistorianTagDefinition.StorageType + serializer + orchestrator + 2
new tests (golden bytes + live SQL persistence verification).

Docs polish:
CLAUDE.md no longer claims "no P/Invoke" (HistorianSspiClient is the one
allowed P/Invoke surface); updated test count to 169+; AGENTS.md Required
SDK Surface and Repository Layout brought up to date with the live state
including the write surface; handoff.md "not a git working tree" obsolete
note removed.

171/171 tests pass with the NegotiateAuthentication replacement (was 169;
+2 new tests for StorageType).

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

738 lines
31 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));
}
// Verifies a previously-unmapped RetrievalMode (one of the 11 modes that prior to
// 2026-05-04 threw ProtocolEvidenceMissingException). MinimumWithTime → QueryType=6
// exercises the "QueryType is the native enum ordinal" mapping against the live server.
[Theory]
[InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MinimumWithTime)]
[InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MaximumWithTime)]
[InlineData(AVEVA.Historian.Client.Models.RetrievalMode.BestFit)]
public async Task ReadAggregateAsync_AgainstLocalHistorian_AcceptsPreviouslyUnmappedRetrievalMode(
AVEVA.Historian.Client.Models.RetrievalMode mode)
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)
|| !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 s in client.ReadAggregateAsync(
testTag, startUtc, endUtc, mode, TimeSpan.FromMinutes(2), CancellationToken.None))
{
samples.Add(s);
}
// Server should accept the request without error. Even if no rows come back
// (unlikely for a 10-minute window on a steadily-counting tag), the absence of an
// exception proves the QueryType byte was accepted.
Assert.All(samples, s => Assert.Equal(mode, 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);
}
// The validator inside HistorianWcfTagClient now allows IntegratedSecurity=false WHEN
// explicit UserName + Password are provided (NTLM/Kerberos with non-current-user creds).
// It still rejects the no-credentials-at-all case since there's no way to authenticate
// against /Hist-Integrated.
[Fact]
public async Task GetTagMetadataAsync_NoAuthAndNoCredentials_Throws()
{
HistorianClient client = new(new HistorianClientOptions
{
Host = "localhost",
IntegratedSecurity = false,
UserName = string.Empty,
Password = string.Empty,
});
await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(
() => client.GetTagMetadataAsync("anytag", CancellationToken.None));
}
[Fact]
public async Task GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian()
{
// Live verification of the explicit-creds tag-metadata path. Gated on
// HISTORIAN_USER + HISTORIAN_PASSWORD being set; skips cleanly otherwise. The path
// routes through WCF Windows transport security with Credentials.Windows.ClientCredential.
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)
|| string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(password)
|| !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = false,
UserName = user,
Password = password,
});
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
await client.GetTagMetadataAsync(testTag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(testTag, metadata.Name);
}
[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,
};
// Both EnsureTagAsync and DeleteTagAsync now work end-to-end against the live
// Historian. Open2 must use write-enabled connectionMode 0x401 (not the default
// 0x402 read-only); the EnsT2 InBuff layout is corrected to native parity (144
// bytes incl 0x4E leading marker, no trailing 01 01 01 closing markers).
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
Assert.True(ensured, "EnsureTagAsync returned false against the live Historian.");
bool deleted = await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
Assert.True(deleted, "DeleteTagAsync returned false against the live Historian.");
}
// Round-trip every live-verified analog data type + the non-default-range case. The
// sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
[Theory]
[InlineData("RetestSdkWriteFloatRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteDoubleRT", AVEVA.Historian.Client.Models.HistorianDataType.Double, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteInt2RT", AVEVA.Historian.Client.Models.HistorianDataType.Int2, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.Int4, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteUInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.UInt4, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteFloatRangesRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, -50.0, 200.0, 10.0, 4095.0)]
public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_PerDataTypeAndRange(
string sandboxTag,
AVEVA.Historian.Client.Models.HistorianDataType dataType,
double minEU, double maxEU, double minRaw, double maxRaw)
{
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.HistorianTagDefinition definition = new()
{
TagName = sandboxTag,
Description = $"SDK round-trip {dataType}",
EngineeringUnit = "test",
DataType = dataType,
MinEU = minEU,
MaxEU = maxEU,
MinRaw = minRaw,
MaxRaw = maxRaw,
};
try
{
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
Assert.True(ensured, $"EnsureTagAsync({dataType}) returned false against the live Historian.");
}
finally
{
// Always clean up — DeleteTagAsync returns true on a freshly-created tag.
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[Fact]
public async Task EnsureTagAsync_StorageTypeDelta_PersistsToTagTableAsTwo()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteStorageTypeDeltaRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
try
{
bool ok = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK Delta round-trip",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
StorageType = AVEVA.Historian.Client.Models.HistorianStorageType.Delta,
}, CancellationToken.None);
Assert.True(ok, "EnsureTagAsync(Delta) returned false");
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT StorageType FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? st = cmd.ExecuteScalar();
Assert.NotNull(st);
Assert.Equal((int)AVEVA.Historian.Client.Models.HistorianStorageType.Delta, Convert.ToInt32(st));
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[Fact]
public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteStorageRateRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
try
{
bool ok = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK StorageRate round-trip",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
// Server only accepts quantized rates — 1000, 5000, 10000, 60000, 300000 ms.
StorageRateMs = 5000u,
}, CancellationToken.None);
Assert.True(ok, "EnsureTagAsync returned false");
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT StorageRate FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? rate = cmd.ExecuteScalar();
Assert.NotNull(rate);
Assert.Equal(5000, Convert.ToInt32(rate));
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[Fact]
public async Task EnsureTagAsync_CalledTwiceOnSameTag_UpdatesFieldsInPlace()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteIdempotencyRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
try
{
bool firstOk = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "First version",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = 0.0, MaxEU = 100.0, MinRaw = 0.0, MaxRaw = 100.0,
ApplyScaling = false,
}, CancellationToken.None);
Assert.True(firstOk, "First EnsureTagAsync returned false");
(string desc1, double minEU1, double maxEU1, double minRaw1, double maxRaw1, int scaling1) = ReadTagState(sandboxTag);
Assert.Equal("First version", desc1);
Assert.Equal(0.0, minEU1);
Assert.Equal(0, scaling1);
bool secondOk = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "Second version",
EngineeringUnit = "kPa",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = -50.0, MaxEU = 200.0, MinRaw = 10.0, MaxRaw = 4095.0,
ApplyScaling = true,
}, CancellationToken.None);
Assert.True(secondOk, "Second EnsureTagAsync returned false");
(string desc2, double minEU2, double maxEU2, double minRaw2, double maxRaw2, int scaling2) = ReadTagState(sandboxTag);
// EnsureTagAsync upserts: second call updates the existing row in place.
Assert.Equal("Second version", desc2);
Assert.Equal(-50.0, minEU2);
Assert.Equal(200.0, maxEU2);
Assert.Equal(10.0, minRaw2);
Assert.Equal(4095.0, maxRaw2);
Assert.Equal(1, scaling2);
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
static (string desc, double minEU, double maxEU, double minRaw, double maxRaw, int scaling) ReadTagState(string tagName)
{
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT t.[Description], a.MinEU, a.MaxEU, a.MinRaw, a.MaxRaw, a.Scaling FROM Tag t JOIN AnalogTag a ON a.TagName=t.TagName WHERE t.TagName=@t";
cmd.Parameters.AddWithValue("@t", tagName);
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
Assert.True(r.Read(), $"Tag {tagName} not found");
return (r.GetString(0), r.GetDouble(1), r.GetDouble(2), r.GetDouble(3), r.GetDouble(4), Convert.ToInt32(r.GetValue(5)));
}
}
[Fact]
public async Task EnsureTagAsync_ApplyScalingTrue_PersistsDistinctMinRawAndMaxRaw()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteApplyScalingRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
{
TagName = sandboxTag,
Description = "SDK ApplyScaling round-trip",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = -50.0,
MaxEU = 200.0,
MinRaw = 10.0,
MaxRaw = 4095.0,
ApplyScaling = true,
};
try
{
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
Assert.True(ensured, "EnsureTagAsync(ApplyScaling=true) returned false against the live Historian.");
// Verify directly against the AnalogTag table — the read-path GetTagMetadataAsync
// surfaces only one of (MinRaw, MinEU); SQL is the unambiguous source of truth.
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT MinEU, MaxEU, MinRaw, MaxRaw, Scaling FROM AnalogTag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
Assert.True(r.Read(), $"AnalogTag row for {sandboxTag} not found after EnsureTag.");
Assert.Equal(-50.0, r.GetDouble(0));
Assert.Equal(200.0, r.GetDouble(1));
Assert.Equal(10.0, r.GetDouble(2));
Assert.Equal(4095.0, r.GetDouble(3));
Assert.Equal(1, Convert.ToInt32(r.GetValue(4)));
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[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));
}
}