Files
histsdk/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
T
Joseph Doherty d23722ea73 Merge re/r1.10-rename-tags: RenameTagsAsync via History StartJob
# Conflicts:
#	docs/plans/hcal-capability-matrix.md
#	docs/plans/hcal-roadmap.md
#	src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
#	tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
#	tools/AVEVA.Historian.NativeTraceHarness/Program.cs
2026-06-21 16:31:44 -04:00

1111 lines
47 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_ReturnsWellFormedEvents()
{
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(30);
// The full chain (ValCl + Open2 + Retr.IsOriginalAllowed + Retr.StartEventQuery +
// GetNextEventQueryResultBuffer + HistorianEventRowProtocol.Parse) returns real, parsed
// events. Requires the local store to hold events in the window — System-Platform
// alarm/user-write events are present on a working Historian. NOTE: enumeration currently
// stops at the first benign `type=4 code=85` soft-terminal, so this verifies parsing
// correctness rather than exhaustive retrieval (decoding code 85 to drain all rows is a
// separate capture task).
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.NotEmpty(events);
Assert.All(events, evt =>
{
Assert.False(string.IsNullOrWhiteSpace(evt.Type)); // e.g. "User.Write", "Alarm.Set"
Assert.NotNull(evt.Properties);
Assert.InRange(evt.EventTimeUtc, startUtc - TimeSpan.FromDays(1), endUtc + TimeSpan.FromDays(1));
});
}
[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 GetRuntimeParameterAsync_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
});
// GETRP rides the storage-session GUID as an uppercase string handle. HistorianVersion is
// a known-good runtime parameter returning the server version (e.g. "20,0,000,000").
string? value = await client.GetRuntimeParameterAsync("HistorianVersion", CancellationToken.None);
Assert.False(string.IsNullOrWhiteSpace(value));
}
[Fact]
public async Task GetTagExtendedPropertiesAsync_AgainstLocalHistorian_ReturnsProperties()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
// A tag that carries at least one extended property. Gated on its own env var so the test
// skips cleanly when no such tag is configured (no tag name is hardcoded).
string? tepTag = Environment.GetEnvironmentVariable("HISTORIAN_TEP_TAG");
if (string.IsNullOrWhiteSpace(host)
|| !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
|| !OperatingSystem.IsWindows()
|| string.IsNullOrWhiteSpace(tepTag))
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
// GetTepByNm rides the storage-session GUID as an uppercase string handle. The configured
// tag has at least one string-valued extended property (e.g. Location).
IReadOnlyList<AVEVA.Historian.Client.Models.HistorianTagExtendedProperty> properties =
await client.GetTagExtendedPropertiesAsync(tepTag, CancellationToken.None);
Assert.NotEmpty(properties);
Assert.All(properties, p =>
{
Assert.False(string.IsNullOrWhiteSpace(p.Name));
Assert.NotNull(p.Value);
});
}
[Fact]
public async Task ExecuteSqlCommandAsync_AgainstLocalHistorian_ReturnsRecordSet()
{
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
});
// ExeC/GetR ride the storage-session GUID as an uppercase string handle. A constant SELECT
// returns a single int column; the DataTable is decoded from the NRBF stream (no BinaryFormatter).
AVEVA.Historian.Client.Models.HistorianSqlResult result =
await client.ExecuteSqlCommandAsync("SELECT 1 AS ProbeValue", cancellationToken: CancellationToken.None);
AVEVA.Historian.Client.Models.HistorianSqlColumn column = Assert.Single(result.Columns);
Assert.Equal("ProbeValue", column.Name);
IReadOnlyList<object?> row = Assert.Single(result.Rows);
Assert.Equal(1, Assert.Single(row));
}
[Fact]
public async Task ExecuteSqlCommandAsync_AgainstLocalHistorian_MultiColumnMultiRow()
{
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
});
// Fully synthetic query (no server data): two columns (int + string), two rows, one NULL —
// exercises the schema/diffgram parser beyond the single-cell case.
AVEVA.Historian.Client.Models.HistorianSqlResult result = await client.ExecuteSqlCommandAsync(
"SELECT 10 AS Num, 'alpha' AS Word UNION ALL SELECT 20, NULL",
cancellationToken: CancellationToken.None);
Assert.Equal(2, result.Columns.Count);
Assert.Equal("Num", result.Columns[0].Name);
Assert.Equal("Word", result.Columns[1].Name);
Assert.Equal(2, result.Rows.Count);
Assert.Equal(10, result.Rows[0][0]);
Assert.Equal("alpha", result.Rows[0][1]);
Assert.Equal(20, result.Rows[1][0]);
Assert.Null(result.Rows[1][1]);
}
[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.");
}
[Fact]
public async Task AddTagExtendedPropertiesAsync_AgainstLocalHistorian_WritesAndReadsBack()
{
// Safety: localhost only, sandbox tag must start with "RetestSdkWrite". Creates the tag,
// adds an extended property, reads it back via R1.5, and deletes the tag. Gated on
// HISTORIAN_WRITE_SANDBOX_TAG.
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
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK ext-property write live test",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = 0.0,
MaxEU = 100.0,
}, CancellationToken.None);
try
{
const string propName = "SdkLiveTestProp";
const string propValue = "SdkLiveTestValue";
bool added = await client.AddTagExtendedPropertyAsync(sandboxTag, propName, propValue, CancellationToken.None);
Assert.True(added, "AddTagExtendedPropertyAsync returned false against the live Historian.");
// Read back via R1.5 (server may take a moment to surface the new property).
bool found = false;
for (int i = 0; i < 10 && !found; i++)
{
await Task.Delay(500);
var props = await client.GetTagExtendedPropertiesAsync(sandboxTag, CancellationToken.None);
found = props.Any(p =>
string.Equals(p.Name, propName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p.Value, propValue, StringComparison.Ordinal));
}
Assert.True(found, $"Extended property '{propName}={propValue}' was not read back after the write.");
}
finally
{
try { await client.DeleteTagAsync(sandboxTag, CancellationToken.None); } catch { }
}
}
// Extended-property DELETE (DelTep) has no live integration test: the wire format is captured and
// the serializer is golden-verified (WcfTagExtendedPropertyWriteProtocolTests), but the SDK
// cannot yet make the server accept the delete (the native single-connection working-set
// requirement isn't reproduced by per-service WCF channels). See
// docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
// 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));
}
[Fact]
public async Task ReadEventsAsync_WithFilter_IsHonoredByServer()
{
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(30);
// A predicate that matches nothing must return zero events — proving the server applies
// the filter (not inert), unlike e.g. the analog-summary knobs.
List<AVEVA.Historian.Client.Models.HistorianEvent> noMatch = [];
await foreach (var evt in client.ReadEventsAsync(startUtc, endUtc,
new AVEVA.Historian.Client.Models.HistorianEventFilter("Type",
AVEVA.Historian.Client.Models.HistorianEventComparison.Equal, "ZZZ_NoSuchEventType"),
CancellationToken.None))
{
noMatch.Add(evt);
}
Assert.Empty(noMatch);
// A matching predicate returns events, all of the filtered Type.
List<AVEVA.Historian.Client.Models.HistorianEvent> matched = [];
await foreach (var evt in client.ReadEventsAsync(startUtc, endUtc,
new AVEVA.Historian.Client.Models.HistorianEventFilter("Type",
AVEVA.Historian.Client.Models.HistorianEventComparison.Equal, "User.Write"),
CancellationToken.None))
{
matched.Add(evt);
}
// Requires User.Write events in the window (present on a working Historian). If the store
// is empty in the window this asserts nothing was wrongly returned; otherwise every row
// must match the filtered type.
Assert.All(matched, evt => Assert.Equal("User.Write", evt.Type));
}
[Fact]
public async Task SendEventAsync_AgainstLocalHistorian_AcceptedByServer()
{
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
});
Guid eventId = Guid.NewGuid();
DateTime eventTime = DateTime.UtcNow;
AVEVA.Historian.Client.Models.HistorianEvent evt = new(
Id: eventId,
EventTimeUtc: eventTime,
ReceivedTimeUtc: eventTime,
Type: "User.Write",
SourceName: string.Empty,
Namespace: "RetestSdkEventSend",
RevisionVersion: 0,
Properties: new Dictionary<string, object?>
{
["Source"] = "RetestSdkEventSend",
["TestMarker"] = "histsdk-R2.5-roundtrip",
});
// The full managed event-send chain (Open2 event-mode 0x501 → CM_EVENT RTag2/EnsT2 →
// AddS2) reaches the server and the server accepts the AddS2 delivery. NOTE: whether the
// event is then persisted to the queryable store depends on the historian's event
// ingestion pipeline being active — on this dev box new events are accepted but not
// persisted (the native client behaves identically), so this asserts acceptance, which
// is the SDK-level signal. Round-trip read-back is best-effort below.
bool accepted = await client.SendEventAsync(evt, CancellationToken.None);
Assert.True(accepted);
// Best-effort round-trip: if the event store persisted it, it should be readable in a
// tight time window. Not asserted hard because event persistence is environment-gated.
try
{
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
await sql.OpenAsync();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM v_AlarmEventHistory2 " +
"WHERE EventStampUTC BETWEEN @s AND @e AND Type = @t";
cmd.Parameters.AddWithValue("@s", eventTime.AddMinutes(-2));
cmd.Parameters.AddWithValue("@e", eventTime.AddMinutes(2));
cmd.Parameters.AddWithValue("@t", "User.Write");
int count = Convert.ToInt32(await cmd.ExecuteScalarAsync());
// If persistence is active the event is present; if not, count is 0 (env limitation).
Assert.True(count >= 0);
}
catch
{
// SQL read-back is diagnostic only; never fail the send test on a query issue.
}
}
[Fact]
public async Task RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag()
{
// Safety: localhost only, names must start with "RetestSdkWrite". Requires the server's
// AllowRenameTags system parameter to be enabled (otherwise StartJob returns false). Gated
// on HISTORIAN_RENAME_SANDBOX so it stays skipped unless explicitly enabled.
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_RENAME_SANDBOX");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
if (string.IsNullOrWhiteSpace(sandbox) || !sandbox.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
return; // safety gate
}
string src = sandbox + "Src";
string dst = sandbox + "Dst";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
// Fresh source tag.
await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = src,
Description = "SDK rename live test",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = 0.0,
MaxEU = 100.0,
}, CancellationToken.None);
try
{
AVEVA.Historian.Client.Models.HistorianTagRenameResult result =
await client.RenameTagAsync(src, dst, CancellationToken.None);
Assert.True(result.Accepted, "RenameTagsAsync was not accepted by the server (is AllowRenameTags enabled?).");
Assert.NotEqual(Guid.Empty, result.JobId);
Assert.Equal(1, result.PairCount);
// Rename completes asynchronously; poll the new name's metadata briefly.
bool renamed = false;
for (int i = 0; i < 10 && !renamed; i++)
{
await Task.Delay(500);
try
{
var md = await client.GetTagMetadataAsync(dst, CancellationToken.None);
renamed = md is not null && string.Equals(md.Name, dst, StringComparison.OrdinalIgnoreCase);
}
catch { /* not yet visible */ }
}
Assert.True(renamed, $"Renamed tag '{dst}' did not become visible after the job completed.");
}
finally
{
// Clean up whichever name ended up in the DB.
try { await client.DeleteTagAsync(dst, CancellationToken.None); } catch { }
try { await client.DeleteTagAsync(src, CancellationToken.None); } catch { }
}
}
}