feat(sphistorianclient): port SDK source + tests, rebrand namespace to ZB.MOM.WW.SPHistorianClient

This commit is contained in:
Joseph Doherty
2026-06-19 05:45:06 -04:00
parent 965f5006f2
commit 63cddfb65b
105 changed files with 11454 additions and 0 deletions
@@ -0,0 +1,38 @@
using System.Text;
using ZB.MOM.WW.SPHistorianClient.Protocol;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class BinaryPrimitiveTests
{
[Fact]
public void ToFileTimeUtc_TreatsUnspecifiedAsUtc()
{
DateTime value = new(2020, 4, 5, 10, 7, 42, DateTimeKind.Unspecified);
long actual = HistorianBinaryPrimitives.ToFileTimeUtc(value);
Assert.Equal(DateTime.SpecifyKind(value, DateTimeKind.Utc).ToFileTimeUtc(), actual);
}
[Fact]
public void WriteUtf16NullTerminated_WritesUnicodeWithTerminator()
{
using MemoryStream stream = new();
HistorianBinaryPrimitives.WriteUtf16NullTerminated(stream, "UTC");
Assert.Equal(Encoding.Unicode.GetBytes("UTC\0"), stream.ToArray());
}
[Fact]
public void WriteFileTimeUtc_WritesLittleEndianUInt64()
{
DateTime value = new(2020, 4, 5, 10, 7, 42, DateTimeKind.Utc);
using MemoryStream stream = new();
HistorianBinaryPrimitives.WriteFileTimeUtc(stream, value);
Assert.Equal(BitConverter.GetBytes(value.ToFileTimeUtc()), stream.ToArray());
}
}
@@ -0,0 +1,33 @@
using ZB.MOM.WW.SPHistorianClient.Models;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class EnumCompatibilityTests
{
[Fact]
public void RetrievalMode_ValuesMatchManagedWrapper()
{
Assert.Equal(0, (int)RetrievalMode.Cyclic);
Assert.Equal(1, (int)RetrievalMode.Delta);
Assert.Equal(2, (int)RetrievalMode.Full);
Assert.Equal(3, (int)RetrievalMode.Interpolated);
Assert.Equal(11, (int)RetrievalMode.ValueState);
Assert.Equal(14, (int)RetrievalMode.EndBound);
}
[Fact]
public void ConnectionKind_ValuesMatchManagedWrapper()
{
Assert.Equal(1, (int)HistorianConnectionKind.Process);
Assert.Equal(2, (int)HistorianConnectionKind.Event);
}
[Fact]
public void InterpolationType_ValuesMatchManagedWrapper()
{
Assert.Equal(0, (int)InterpolationType.StairStep);
Assert.Equal(1, (int)InterpolationType.Linear);
Assert.Equal(254, (int)InterpolationType.SystemDefault);
Assert.Equal(255, (int)InterpolationType.None);
}
}
@@ -0,0 +1,63 @@
using System.Runtime.Versioning;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using Xunit.Abstractions;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
[SupportedOSPlatform("windows")]
public sealed class EventChainDiagnosticTests
{
private readonly ITestOutputHelper _output;
public EventChainDiagnosticTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public async Task EventOrchestrator_DiagnosticDump_AgainstLocalHistorian()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
};
HistorianWcfEventOrchestrator orchestrator = new(options);
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
int observed = 0;
ZB.MOM.WW.SPHistorianClient.Models.HistorianEvent? firstEvent = null;
await foreach (var evt in orchestrator.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
{
observed++;
firstEvent ??= evt;
}
_output.WriteLine($"Events observed: {observed}");
if (firstEvent is not null)
{
_output.WriteLine($" EventTimeUtc: {firstEvent.EventTimeUtc:O}");
_output.WriteLine($" ReceivedTimeUtc: {firstEvent.ReceivedTimeUtc:O}");
_output.WriteLine($" Type: {firstEvent.Type}");
_output.WriteLine($" Properties.Count:{firstEvent.Properties.Count}");
_output.WriteLine($" Has alarm_id: {firstEvent.Id != Guid.Empty}");
}
_output.WriteLine($"LastEnsT2Handle: {HistorianWcfEventOrchestrator.LastEnsT2Handle}");
_output.WriteLine($"LastEnsT2PayloadSha256: {HistorianWcfEventOrchestrator.LastEnsT2PayloadSha256}");
_output.WriteLine($"LastUpdC3ReturnCode: {HistorianWcfEventOrchestrator.LastUpdC3ReturnCode}");
_output.WriteLine($"LastRTag2ReturnCode: {HistorianWcfEventOrchestrator.LastRTag2ReturnCode}");
_output.WriteLine($"LastAddReturnCode (EnsT2): {HistorianWcfEventOrchestrator.LastAddReturnCode}");
_output.WriteLine($"LastAddOutputLength: {HistorianWcfEventOrchestrator.LastAddOutputLength}");
_output.WriteLine($"LastResultBufferLength: {orchestrator.LastResultBufferLength}");
_output.WriteLine($"LastErrorBufferDescription: {orchestrator.LastErrorBufferDescription}");
}
}
@@ -0,0 +1,28 @@
using ZB.MOM.WW.SPHistorianClient.Protocol;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class FrameTests
{
[Fact]
public async Task FrameWriterAndReader_RoundTrip()
{
HistorianFrame frame = new((HistorianMessageType)42, 123u, new byte[] { 1, 2, 3, 4 });
byte[] bytes = HistorianFrameWriter.ToArray(frame);
HistorianFrame actual = await HistorianFrameReader.ReadAsync(new MemoryStream(bytes), CancellationToken.None);
Assert.Equal(frame.MessageType, actual.MessageType);
Assert.Equal(frame.CorrelationId, actual.CorrelationId);
Assert.True(frame.Payload.Span.SequenceEqual(actual.Payload.Span));
}
[Fact]
public async Task FrameReader_RejectsInvalidLength()
{
byte[] bytes = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
await Assert.ThrowsAsync<FrameFormatException>(async () =>
await HistorianFrameReader.ReadAsync(new MemoryStream(bytes), CancellationToken.None));
}
}
@@ -0,0 +1,737 @@
namespace ZB.MOM.WW.SPHistorianClient.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<ZB.MOM.WW.SPHistorianClient.Models.HistorianSample> samples = [];
await foreach (ZB.MOM.WW.SPHistorianClient.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<ZB.MOM.WW.SPHistorianClient.Models.HistorianAggregateSample> samples = [];
await foreach (ZB.MOM.WW.SPHistorianClient.Models.HistorianAggregateSample sample in client.ReadAggregateAsync(
testTag, startUtc, endUtc,
ZB.MOM.WW.SPHistorianClient.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(ZB.MOM.WW.SPHistorianClient.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(ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.MinimumWithTime)]
[InlineData(ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.MaximumWithTime)]
[InlineData(ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.BestFit)]
public async Task ReadAggregateAsync_AgainstLocalHistorian_AcceptsPreviouslyUnmappedRetrievalMode(
ZB.MOM.WW.SPHistorianClient.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<ZB.MOM.WW.SPHistorianClient.Models.HistorianAggregateSample> samples = [];
await foreach (ZB.MOM.WW.SPHistorianClient.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<ZB.MOM.WW.SPHistorianClient.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<ZB.MOM.WW.SPHistorianClient.Models.HistorianEvent> events = [];
await foreach (ZB.MOM.WW.SPHistorianClient.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
});
ZB.MOM.WW.SPHistorianClient.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
});
ZB.MOM.WW.SPHistorianClient.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,
});
ZB.MOM.WW.SPHistorianClient.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
});
ZB.MOM.WW.SPHistorianClient.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
});
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition definition = new()
{
TagName = sandboxTag,
Description = "SDK live integration test sandbox",
EngineeringUnit = "test",
DataType = ZB.MOM.WW.SPHistorianClient.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", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteDoubleRT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Double, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteInt2RT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int2, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteInt4RT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int4, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteUInt4RT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.UInt4, 0.0, 100.0, 0.0, 100.0)]
[InlineData("RetestSdkWriteFloatRangesRT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float, -50.0, 200.0, 10.0, 4095.0)]
public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_PerDataTypeAndRange(
string sandboxTag,
ZB.MOM.WW.SPHistorianClient.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
});
ZB.MOM.WW.SPHistorianClient.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 ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK Delta round-trip",
EngineeringUnit = "test",
DataType = ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
StorageType = ZB.MOM.WW.SPHistorianClient.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)ZB.MOM.WW.SPHistorianClient.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 ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK StorageRate round-trip",
EngineeringUnit = "test",
DataType = ZB.MOM.WW.SPHistorianClient.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 ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "First version",
EngineeringUnit = "test",
DataType = ZB.MOM.WW.SPHistorianClient.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 ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "Second version",
EngineeringUnit = "kPa",
DataType = ZB.MOM.WW.SPHistorianClient.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,
});
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition definition = new()
{
TagName = sandboxTag,
Description = "SDK ApplyScaling round-trip",
EngineeringUnit = "test",
DataType = ZB.MOM.WW.SPHistorianClient.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
});
ZB.MOM.WW.SPHistorianClient.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));
}
}
@@ -0,0 +1,230 @@
using System.Buffers.Binary;
using System.Text;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class HistorianEventRowProtocolTests
{
private static readonly Guid PlaceholderAlarmId = new("00000000-0000-0000-0000-000000000001");
[Fact]
public void Parse_EmptyBuffer_ReturnsEmpty()
{
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse([]);
Assert.Empty(events);
}
[Fact]
public void Parse_HeaderWithZeroRowCount_ReturnsEmpty()
{
byte[] buffer = BuildHeader(rowCount: 0);
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Empty(events);
}
[Fact]
public void Parse_WrongVersion_ReturnsEmpty()
{
byte[] buffer = new byte[6];
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 8); // not 9
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(2, 4), 5u);
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Empty(events);
}
[Fact]
public void Parse_TwoSyntheticRows_ReturnsTimestampsAndEventTypes()
{
DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
DateTime t2 = t1.AddSeconds(10);
byte[] buffer = Concat(
BuildHeader(rowCount: 2),
BuildRow(t1, "Alarm.Set", []),
BuildRow(t2, "Alarm.Clear", []));
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Equal(2, events.Count);
Assert.Equal(t1, events[0].EventTimeUtc);
Assert.Equal("Alarm.Set", events[0].Type);
Assert.Equal(t2, events[1].EventTimeUtc);
Assert.Equal("Alarm.Clear", events[1].Type);
}
[Fact]
public void Parse_RowWithKnownProperties_PopulatesEventFields()
{
DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
DateTime receivedTime = eventTime.AddMilliseconds(250);
var properties = new (string Name, byte[] Value)[]
{
("alarm_inalarm", BuildBool(true)),
("alarm_id", BuildGuid(PlaceholderAlarmId)),
("severity", BuildInt32(2)),
("priority", BuildInt32(500)),
("alarm_class", BuildUtf16String("DSC")),
("source_processvariable", BuildUtf16String("Sample.Tag")),
("provider_system", BuildUtf16String("Application Server")),
("receivedtime", BuildFiletime(receivedTime)),
("revisionversion", BuildInt32(7)),
};
byte[] buffer = Concat(BuildHeader(rowCount: 1), BuildRow(eventTime, "Alarm.Set", properties));
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
HistorianEvent evt = Assert.Single(events);
Assert.Equal(PlaceholderAlarmId, evt.Id);
Assert.Equal(eventTime, evt.EventTimeUtc);
Assert.Equal(receivedTime, evt.ReceivedTimeUtc);
Assert.Equal("Alarm.Set", evt.Type);
Assert.Equal("Sample.Tag", evt.SourceName);
Assert.Equal("Application Server", evt.Namespace);
Assert.Equal(7, evt.RevisionVersion);
Assert.Equal(true, evt.Properties["alarm_inalarm"]);
Assert.Equal("DSC", evt.Properties["alarm_class"]);
Assert.Equal(2, evt.Properties["severity"]);
Assert.Equal(500, evt.Properties["priority"]);
}
[Fact]
public void Parse_UnknownTypeMarker_KeepsRawBytesInPropertyBag()
{
DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
// Custom type 0xAA with 3-byte value.
byte[] customValue = [0xAA, 0x03, 0x00, 0xDE, 0xAD, 0xBE];
byte[] buffer = Concat(
BuildHeader(rowCount: 1),
BuildRowWithRawValue(eventTime, "Alarm.Set", "custom_field", customValue));
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
HistorianEvent evt = Assert.Single(events);
Assert.IsType<byte[]>(evt.Properties["custom_field"]);
Assert.Equal([0xDE, 0xAD, 0xBE], (byte[])evt.Properties["custom_field"]!);
}
[Fact]
public void Parse_RowWithMissingMarker_StopsAtBadRow()
{
DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
byte[] goodRow = BuildRow(t1, "Alarm.Set", []);
byte[] badRow = new byte[goodRow.Length];
byte[] buffer = Concat(BuildHeader(rowCount: 2), goodRow, badRow);
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Single(events);
Assert.Equal("Alarm.Set", events[0].Type);
}
private static byte[] BuildHeader(uint rowCount)
{
byte[] header = new byte[6];
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0, 2), HistorianEventRowProtocol.EventRowProtocolVersion);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(2, 4), rowCount);
return header;
}
private static byte[] BuildRow(DateTime eventTimeUtc, string eventType, (string Name, byte[] Value)[] properties)
{
byte[] eventTypeBytes = BuildCompactAscii(eventType);
ushort propertyCount = (ushort)properties.Length;
int propertyBlockSize = 0;
byte[][] propertyBlocks = new byte[properties.Length][];
for (int i = 0; i < properties.Length; i++)
{
byte[] nameBlock = BuildCompactAscii(properties[i].Name);
propertyBlocks[i] = Concat(nameBlock, properties[i].Value);
propertyBlockSize += propertyBlocks[i].Length;
}
byte[] row = new byte[4 + 2 + 8 + 16 + eventTypeBytes.Length + 2 + propertyBlockSize];
Span<byte> span = row;
BinaryPrimitives.WriteUInt32LittleEndian(span[..4], HistorianEventRowProtocol.RowMarker);
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(4, 2), HistorianEventRowProtocol.RowFormatV9);
BinaryPrimitives.WriteInt64LittleEndian(span.Slice(6, 8), eventTimeUtc.ToFileTimeUtc());
// 16 bytes of zeroed slot ushorts left as-is.
int eventTypeOffset = 4 + 2 + 8 + 16;
eventTypeBytes.CopyTo(span[eventTypeOffset..]);
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(eventTypeOffset + eventTypeBytes.Length, 2), propertyCount);
int cursor = eventTypeOffset + eventTypeBytes.Length + 2;
foreach (byte[] block in propertyBlocks)
{
block.CopyTo(span[cursor..]);
cursor += block.Length;
}
return row;
}
private static byte[] BuildRowWithRawValue(DateTime eventTimeUtc, string eventType, string propertyName, byte[] rawValueBytes)
{
return BuildRow(eventTimeUtc, eventType, [(propertyName, rawValueBytes)]);
}
private static byte[] BuildCompactAscii(string s)
{
byte[] ascii = Encoding.ASCII.GetBytes(s);
byte[] result = new byte[3 + ascii.Length];
result[0] = 0x09;
result[1] = (byte)ascii.Length;
result[2] = 0x00;
ascii.CopyTo(result, 3);
return result;
}
private static byte[] BuildBool(bool value) => [0x02, 0x01, 0x00, value ? (byte)1 : (byte)0];
private static byte[] BuildInt32(int value)
{
byte[] result = [0x31, 0x04, 0x00, 0, 0, 0, 0];
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(3, 4), value);
return result;
}
private static byte[] BuildGuid(Guid value)
{
byte[] result = new byte[19];
result[0] = 0x10;
result[1] = 0x10;
result[2] = 0x00;
value.ToByteArray().CopyTo(result, 3);
return result;
}
private static byte[] BuildFiletime(DateTime value)
{
byte[] result = [0x18, 0x08, 0x00, 0, 0, 0, 0, 0, 0, 0, 0];
BinaryPrimitives.WriteInt64LittleEndian(result.AsSpan(3, 8), value.ToFileTimeUtc());
return result;
}
private static byte[] BuildUtf16String(string value)
{
byte[] chars = Encoding.Unicode.GetBytes(value);
ushort innerLength = (ushort)(2 + chars.Length); // UInt16 charCount + chars
byte[] result = new byte[3 + innerLength];
result[0] = 0x43;
result[1] = (byte)innerLength;
result[2] = 0x00;
BinaryPrimitives.WriteUInt16LittleEndian(result.AsSpan(3, 2), (ushort)value.Length);
chars.CopyTo(result, 5);
return result;
}
private static byte[] Concat(params byte[][] arrays)
{
int total = 0;
foreach (byte[] a in arrays) total += a.Length;
byte[] result = new byte[total];
int offset = 0;
foreach (byte[] a in arrays)
{
Buffer.BlockCopy(a, 0, result, offset, a.Length);
offset += a.Length;
}
return result;
}
}
@@ -0,0 +1,63 @@
using ZB.MOM.WW.SPHistorianClient.Models;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
/// <summary>
/// Live integration tests for the 2023 R2 RemoteGrpc transport. Gated on a dedicated
/// <c>HISTORIAN_GRPC_HOST</c> env var (plus <c>HISTORIAN_TEST_TAG</c>) so they skip cleanly until
/// a 2023 R2 Historian is available. Optional:
/// HISTORIAN_GRPC_PORT (default 32565), HISTORIAN_GRPC_TLS (true/false),
/// HISTORIAN_USER / HISTORIAN_PASSWORD (explicit creds; otherwise IntegratedSecurity),
/// HISTORIAN_GRPC_DNSID (server certificate name when connecting by IP over TLS).
/// </summary>
public sealed class HistorianGrpcIntegrationTests
{
[Fact]
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
HistorianClient client = new(BuildOptions(host));
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
List<HistorianSample> samples = [];
await foreach (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));
}
private static HistorianClientOptions BuildOptions(string host)
{
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
bool explicitCreds = !string.IsNullOrEmpty(user);
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
? parsed
: HistorianClientOptions.DefaultGrpcPort;
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
return new HistorianClientOptions
{
Host = host,
Port = port,
Transport = HistorianTransport.RemoteGrpc,
GrpcUseTls = tls,
AllowUntrustedServerCertificate = tls,
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
IntegratedSecurity = !explicitCreds,
UserName = user ?? string.Empty,
Password = password ?? string.Empty
};
}
}
@@ -0,0 +1,114 @@
using ZB.MOM.WW.SPHistorianClient.Grpc;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using Google.Protobuf;
using ArchestrA.Grpc.Contract.Retrieval;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
/// <summary>
/// Unit coverage for the 2023 R2 RemoteGrpc transport — the parts that do not require a live
/// server: channel address/port resolution, metadata, transport routing, and the invariant that
/// gRPC request messages carry the same native byte buffers the WCF path uses.
/// </summary>
public sealed class HistorianGrpcTransportTests
{
private static HistorianClientOptions Options(
string host = "histserver",
int port = HistorianClientOptions.DefaultPort,
bool tls = false,
string? dnsIdentity = null,
bool compression = false) => new()
{
Host = host,
Port = port,
Transport = HistorianTransport.RemoteGrpc,
GrpcUseTls = tls,
ServerDnsIdentity = dnsIdentity,
Compression = compression,
IntegratedSecurity = true
};
[Fact]
public void ResolvePort_DefaultWcfPort_SubstitutesGrpcDefault()
{
Assert.Equal(HistorianClientOptions.DefaultGrpcPort, HistorianGrpcChannelFactory.ResolvePort(Options(port: HistorianClientOptions.DefaultPort)));
}
[Fact]
public void ResolvePort_ExplicitPort_IsHonoured()
{
Assert.Equal(443, HistorianGrpcChannelFactory.ResolvePort(Options(port: 443)));
}
[Fact]
public void ResolveAddress_Plaintext_UsesHttpAndHost()
{
Assert.Equal("http://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options()));
}
[Fact]
public void ResolveAddress_Tls_UsesHttpsAndHostWhenNoDnsIdentity()
{
Assert.Equal("https://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options(tls: true)));
}
[Fact]
public void ResolveAddress_Tls_PrefersDnsIdentityForCertMatch()
{
string address = HistorianGrpcChannelFactory.ResolveAddress(Options(host: "10.0.0.5", tls: true, dnsIdentity: "localhost"));
Assert.Equal("https://localhost:32565", address);
}
[Fact]
public void Create_CompressionDisabled_EmitsNoEncodingHeader()
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: false));
Assert.DoesNotContain(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
}
[Fact]
public void Create_CompressionEnabled_AdvertisesGzipRequestEncoding()
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: true));
global::Grpc.Core.Metadata.Entry entry = Assert.Single(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
Assert.Equal("gzip", entry.Value);
}
[Fact]
public void StartQueryRequest_CarriesNativeDataQueryBufferUnchanged()
{
// The gRPC envelope must wrap the exact bytes the WCF StartQuery2 path sends, so the
// already-reverse-engineered DataQueryRequest serializer is reused verbatim.
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(
"Tag.Counter", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), 100);
byte[] nativeBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
var message = new StartQueryRequest
{
UiHandle = 7,
UiQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData,
BtRequestBuffer = ByteString.CopyFrom(nativeBuffer)
};
// Round-trip through protobuf and confirm the native buffer survives byte-for-byte.
byte[] wire = message.ToByteArray();
var decoded = StartQueryRequest.Parser.ParseFrom(wire);
Assert.Equal(nativeBuffer, decoded.BtRequestBuffer.ToByteArray());
Assert.Equal(7u, decoded.UiHandle);
Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType);
}
[Fact]
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
{
byte[] open2 = HistorianNativeHandshake.BuildOpenConnection3Request(
"histserver", Guid.NewGuid(), HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
var message = new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2) };
var decoded = GrpcHistory.OpenConnectionRequest.Parser.ParseFrom(message.ToByteArray());
Assert.Equal(open2, decoded.BtConnectionRequest.ToByteArray());
}
}
@@ -0,0 +1,40 @@
using System.Runtime.Versioning;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
[SupportedOSPlatform("windows")]
public sealed class HistorianRetrievalModeMappingTests
{
// Probed 2026-05-04 via instrument-wcf-writemessage against every
// ArchestrA.HistorianRetrievalMode value — see HistorianWcfReadOrchestrator
// MapRetrievalModeToQueryType doc comment for capture details.
[Theory]
[InlineData(RetrievalMode.Cyclic, 0u)]
[InlineData(RetrievalMode.Delta, 1u)]
[InlineData(RetrievalMode.Full, 2u)]
[InlineData(RetrievalMode.Interpolated, 3u)]
[InlineData(RetrievalMode.BestFit, 4u)]
[InlineData(RetrievalMode.TimeWeightedAverage, 5u)]
[InlineData(RetrievalMode.MinimumWithTime, 6u)]
[InlineData(RetrievalMode.MaximumWithTime, 7u)]
[InlineData(RetrievalMode.Integral, 8u)]
[InlineData(RetrievalMode.Slope, 9u)]
[InlineData(RetrievalMode.Counter, 10u)]
[InlineData(RetrievalMode.ValueState, 11u)]
[InlineData(RetrievalMode.RoundTrip, 12u)]
[InlineData(RetrievalMode.StartBound, 13u)]
[InlineData(RetrievalMode.EndBound, 14u)]
public void MapRetrievalModeToQueryType_MatchesNativeEnumOrdinal(RetrievalMode mode, uint expectedQueryType)
{
Assert.Equal(expectedQueryType, HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType(mode));
}
[Fact]
public void MapRetrievalModeToQueryType_UndefinedValue_Throws()
{
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType((RetrievalMode)999));
}
}
@@ -0,0 +1,43 @@
using System.Runtime.Versioning;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
[SupportedOSPlatform("windows")]
public sealed class HistorianSspiClientTests
{
[Fact]
public void NativeFlagsRound0_MatchesDocumentedNativeWrapperValue()
{
Assert.Equal(0x2081C, HistorianSspiClient.NativeFlagsRound0);
}
[Fact]
public void NativeFlagsRoundSubsequent_MatchesDocumentedNativeWrapperValue()
{
Assert.Equal(0x81C, HistorianSspiClient.NativeFlagsRoundSubsequent);
}
[Fact]
public void Round0FlagsIncludeIdentify_LaterRoundsDoNot()
{
Assert.Equal(HistorianSspiClient.IscReqIdentify, HistorianSspiClient.NativeFlagsRound0 & HistorianSspiClient.IscReqIdentify);
Assert.Equal(0, HistorianSspiClient.NativeFlagsRoundSubsequent & HistorianSspiClient.IscReqIdentify);
}
[Fact]
public void AllRoundsRequestReplayAndSequenceDetection()
{
const int both = HistorianSspiClient.IscReqReplayDetect | HistorianSspiClient.IscReqSequenceDetect;
Assert.Equal(both, HistorianSspiClient.NativeFlagsRound0 & both);
Assert.Equal(both, HistorianSspiClient.NativeFlagsRoundSubsequent & both);
}
[Fact]
public void SelectRequestFlags_DispatchesByRoundIndex()
{
Assert.Equal(HistorianSspiClient.NativeFlagsRound0, HistorianSspiClient.SelectRequestFlags(0));
Assert.Equal(HistorianSspiClient.NativeFlagsRoundSubsequent, HistorianSspiClient.SelectRequestFlags(1));
Assert.Equal(HistorianSspiClient.NativeFlagsRoundSubsequent, HistorianSspiClient.SelectRequestFlags(7));
}
}
@@ -0,0 +1,311 @@
using System.Text;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class HistorianTagWriteProtocolTests
{
[Fact]
public void SerializeAnalogCTagMetadata_MatchesCapturedNativeBytesByteForByte()
{
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes for the sandbox
// tag with default ranges and ApplyScaling=false. 2-byte trailer = `FE 00` where
// the second byte is the ApplyScaling flag (0x00 = false; 0x01 = true).
const string ExpectedHex =
"4E6703000100000004C6020100000000000000000000000000000000"
+ "09150052657465737453646B577269746553616E64626F78"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E803000049D087CDBFDBDC011A030904007465737410270000000000000000F03FFE00";
byte[] expected = Convert.FromHexString(ExpectedHex);
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteSandbox",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01DCDBBFCD87D049L));
Assert.Equal(144, expected.Length);
Assert.Equal(144, actual.Length);
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
// Per-data-type captures from instrument-wcf-writemessage 2026-05-04 — the only
// diff vs the Float baseline is byte 11 (the data-type discriminator) plus tag-name
// length. All other inputs (description, EU, default ranges, storage rate) match
// the captured baseline so the byte-for-byte assertion exercises the dispatch.
[Theory]
[InlineData(
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Double,
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
"4E6703000100000004C6022100000000000000000000000000000000"
+ "09140052657465737453646B5772697465446F75626C65"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
[InlineData(
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int4,
"RetestSdkWriteInt4", 0x01dcdbed292e1cecL,
"4E6703000100000004C6023100000000000000000000000000000000"
+ "09120052657465737453646B5772697465496E7434"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000EC1C2E29EDDBDC011A030904007465737410270000000000000000F03FFE00")]
[InlineData(
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.UInt4,
"RetestSdkWriteUInt4", 0x01dcdbed2d33b02cL,
"4E6703000100000004C6021100000000000000000000000000000000"
+ "09130052657465737453646B577269746555496E7434"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300002CB0332DEDDBDC011A030904007465737410270000000000000000F03FFE00")]
[InlineData(
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int2,
"RetestSdkWriteInt2", 0x01dcdbed360e9b54L,
"4E6703000100000004C6022900000000000000000000000000000000"
+ "09120052657465737453646B5772697465496E7432"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType dataType,
string tagName,
long fileTimeUtc,
string expectedHex)
{
byte[] expected = Convert.FromHexString(expectedHex);
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: tagName,
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(fileTimeUtc),
dataType: dataType);
Assert.Equal(expected.Length, actual.Length);
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
// Captured 2026-05-04 with MinEU=-50, MaxEU=200, MinRaw=10, MaxRaw=4095. Verifies
// the explicit-scaling marker `1F` + 4 doubles in order (MinEU, MaxEU, MinRaw, MaxRaw).
[Fact]
public void SerializeAnalogCTagMetadata_NonDefaultRanges_EmitsExplicitMarkerAndFourDoubles()
{
const string ExpectedHex =
"4E6703000100000004C6020100000000000000000000000000000000"
+ "09190052657465737453646B5772697465466C6F617452616E676573"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF09180053444B207772697465"
+ "2D52452073616E64626F78207461670904004D444153020101000000"
+ "01E8030000BE294B47EDDBDC011F0000000000000049C00000000000"
+ "00694000000000000024400000000000FEAF40090400746573741027"
+ "0000000000000000F03FFE00";
byte[] expected = Convert.FromHexString(ExpectedHex);
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteFloatRanges",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
dataType: ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
minEU: -50.0,
maxEU: 200.0,
minRaw: 10.0,
maxRaw: 4095.0);
Assert.Equal(180, expected.Length);
Assert.Equal(180, actual.Length);
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
[Fact]
public void SerializeAnalogCTagMetadata_NonDefaultStorageRate_EncodesUInt32LittleEndianAtKnownOffset()
{
byte[] defaultRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteRate",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL));
byte[] customRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteRate",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
storageRateMs: 2500u);
Assert.Equal(defaultRate.Length, customRate.Length);
// Storage-rate uint32 is at the byte position immediately after the
// "MDAS" + flag-block sequence; the only diff between the two payloads
// is those 4 bytes.
int firstDiff = 0;
while (firstDiff < defaultRate.Length && defaultRate[firstDiff] == customRate[firstDiff]) firstDiff++;
Assert.Equal(0xE8, defaultRate[firstDiff]); // 1000 = 0x000003E8 LE → 0xE8 0x03 0x00 0x00
Assert.Equal(0x03, defaultRate[firstDiff + 1]);
Assert.Equal(0xC4, customRate[firstDiff]); // 2500 = 0x000009C4 LE → 0xC4 0x09 0x00 0x00
Assert.Equal(0x09, customRate[firstDiff + 1]);
// Beyond the 4-byte rate field, the rest is identical.
Assert.Equal(
Convert.ToHexString(defaultRate.AsSpan(firstDiff + 4)),
Convert.ToHexString(customRate.AsSpan(firstDiff + 4)));
}
[Fact]
public void SerializeAnalogCTagMetadata_ZeroStorageRate_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteRate",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.UtcNow,
storageRateMs: 0u));
}
[Fact]
public void SerializeAnalogCTagMetadata_StorageTypeDelta_FlipsHeaderByte10AndFlagBlockByte1AndAddsFourBytePadding()
{
// Captured 2026-05-04 by toggling --write-storage-type on the native 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. Net length difference is +4 bytes for Delta.
byte[] cyclic = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteStorageTypeRT",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
storageType: ZB.MOM.WW.SPHistorianClient.Models.HistorianStorageType.Cyclic);
byte[] delta = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteStorageTypeRT",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
storageType: ZB.MOM.WW.SPHistorianClient.Models.HistorianStorageType.Delta);
Assert.Equal(cyclic.Length + 4, delta.Length);
// Header byte 10 (storage-type sub-marker before the data-type code).
Assert.Equal(0x02, cyclic[10]);
Assert.Equal(0x06, delta[10]);
// The data-type code at byte 11 is unchanged.
Assert.Equal(cyclic[11], delta[11]);
}
[Fact]
public void SerializeAnalogCTagMetadata_NonDefaultIntegralDivisor_FlipsEightBytesBeforeTrailer()
{
byte[] @default = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteIntDiv",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL));
byte[] custom = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteIntDiv",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
integralDivisor: 2.5);
Assert.Equal(@default.Length, custom.Length);
// The 8 bytes immediately before the 2-byte trailer are the IntegralDivisor double.
ReadOnlySpan<byte> defaultDivisor = @default.AsSpan(@default.Length - 10, 8);
ReadOnlySpan<byte> customDivisor = custom.AsSpan(custom.Length - 10, 8);
Assert.Equal(1.0, BitConverter.ToDouble(defaultDivisor));
Assert.Equal(2.5, BitConverter.ToDouble(customDivisor));
// Bytes preceding the divisor are identical.
Assert.Equal(
Convert.ToHexString(@default.AsSpan(0, @default.Length - 10)),
Convert.ToHexString(custom.AsSpan(0, custom.Length - 10)));
}
[Fact]
public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte()
{
// Captured 2026-05-04 by toggling --write-apply-scaling on the native harness:
// ApplyScaling=true sets the trailer's second byte to 0x01 (vs 0x00 for false).
// Live-verified: with 0x01 the server persists distinct MinRaw/MaxRaw and sets
// AnalogTag.Scaling=1; with 0x00 it mirrors MinRaw to MinEU and sets Scaling=0.
byte[] withFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteFloatRanges",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
dataType: ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
applyScaling: true);
byte[] withoutFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteFloatRanges",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
dataType: ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
applyScaling: false);
Assert.Equal(withoutFlag.Length, withFlag.Length);
Assert.Equal(0xFE, withFlag[^2]);
Assert.Equal(0x01, withFlag[^1]);
Assert.Equal(0xFE, withoutFlag[^2]);
Assert.Equal(0x00, withoutFlag[^1]);
Assert.Equal(
Convert.ToHexString(withoutFlag.AsSpan(0, withoutFlag.Length - 1)),
Convert.ToHexString(withFlag.AsSpan(0, withFlag.Length - 1)));
}
[Fact]
public void GetAnalogDataTypeCode_UnsupportedType_Throws()
{
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.SingleByteString));
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int1));
}
[Fact]
public void SerializeAnalogCTagMetadata_DifferentInputsProducesDifferentBytesInExpectedSlots()
{
DateTime t = new(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
byte[] a = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag1", "DescA", "uA", t);
byte[] b = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag2", "DescB", "uB", t);
Assert.NotEqual(Convert.ToHexString(a), Convert.ToHexString(b));
// First difference must be inside the tagName region (offset 27+ after the 9-byte
// header + 16-byte zero block + 2-byte compact-ASCII len-prefix).
int firstDiff = 0;
while (firstDiff < a.Length && a[firstDiff] == b[firstDiff]) firstDiff++;
Assert.InRange(firstDiff, 25, a.Length);
}
[Fact]
public void SerializeDeleteTagNames_SingleTagMatchesCapturedShape()
{
// Captured DelT.tagNames bytes for ['RetestSdkWriteSandbox']:
// ushort 0x6751 + ushort 1 + uint32 1 + uint32 21 + UTF-16 "RetestSdkWriteSandbox"
// = 12-byte header + 42-byte UTF-16 string = 54 bytes total.
byte[] expected = Concat(
[0x51, 0x67, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00],
Encoding.Unicode.GetBytes("RetestSdkWriteSandbox"));
byte[] actual = HistorianTagWriteProtocol.SerializeDeleteTagNames(["RetestSdkWriteSandbox"]);
Assert.Equal(54, actual.Length);
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
[Fact]
public void SerializeDeleteTagNames_MultipleTagsAppendsEach()
{
byte[] result = HistorianTagWriteProtocol.SerializeDeleteTagNames(["A", "BB", "CCC"]);
// 8-byte header (ushort 0x6751 + ushort 1 + uint32 tagCount)
// + 3 × (uint32 charCount + UTF-16 chars)
// = 8 + (4 + 2) + (4 + 4) + (4 + 6) = 32 bytes
Assert.Equal(32, result.Length);
// Header: 0x6751 + 0x0001 + count=3
Assert.Equal(0x51, result[0]); Assert.Equal(0x67, result[1]);
Assert.Equal(0x01, result[2]); Assert.Equal(0x00, result[3]);
Assert.Equal(3, BitConverter.ToInt32(result, 4));
}
[Fact]
public void SerializeDeleteTagNames_EmptyListThrows()
{
Assert.Throws<ArgumentException>(() => HistorianTagWriteProtocol.SerializeDeleteTagNames([]));
}
private static byte[] Concat(params byte[][] arrays)
{
int total = 0; foreach (byte[] a in arrays) total += a.Length;
byte[] result = new byte[total]; int off = 0;
foreach (byte[] a in arrays) { Buffer.BlockCopy(a, 0, result, off, a.Length); off += a.Length; }
return result;
}
}
@@ -0,0 +1,97 @@
using System.IdentityModel.Selectors;
using System.Security.Cryptography.X509Certificates;
using System.ServiceModel;
using System.ServiceModel.Channels;
using System.ServiceModel.Security;
using ZB.MOM.WW.SPHistorianClient;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class HistorianWcfCertOptionTests
{
private static HistorianClientOptions BaseOptions(bool allowUntrusted = false, string? dnsIdentity = null) =>
new()
{
Host = "10.0.0.1",
Port = HistorianClientOptions.DefaultPort,
Transport = HistorianTransport.RemoteTcpCertificate,
IntegratedSecurity = false,
UserName = "user",
Password = "pass",
AllowUntrustedServerCertificate = allowUntrusted,
ServerDnsIdentity = dnsIdentity,
};
[Fact]
public void ClientCredentialsHelper_Disabled_LeavesValidationModeAtDefault()
{
Binding binding = HistorianWcfBindingFactory.CreateMdasNetTcpBinding(TimeSpan.FromSeconds(5));
ChannelFactory<IHistoryServiceContract2> factory = new(binding, new EndpointAddress("net.tcp://10.0.0.1:32568/Hist"));
try
{
HistorianWcfClientCredentialsHelper.Configure(factory, BaseOptions(allowUntrusted: false));
X509ServiceCertificateAuthentication auth = factory.Credentials.ServiceCertificate.SslCertificateAuthentication
?? factory.Credentials.ServiceCertificate.Authentication;
// Default validation mode is ChainTrust — explicitly NOT None / Custom.
Assert.NotEqual(X509CertificateValidationMode.None, auth.CertificateValidationMode);
Assert.Null(auth.CustomCertificateValidator);
}
finally
{
factory.Abort();
}
}
[Fact]
public void ClientCredentialsHelper_Enabled_InstallsAcceptAnyValidator()
{
Binding binding = HistorianWcfBindingFactory.CreateMdasNetTcpBinding(TimeSpan.FromSeconds(5));
ChannelFactory<IHistoryServiceContract2> factory = new(binding, new EndpointAddress("net.tcp://10.0.0.1:32568/Hist"));
try
{
HistorianWcfClientCredentialsHelper.Configure(factory, BaseOptions(allowUntrusted: true));
X509ServiceCertificateAuthentication auth = factory.Credentials.ServiceCertificate.SslCertificateAuthentication;
Assert.NotNull(auth);
Assert.Equal(X509CertificateValidationMode.Custom, auth.CertificateValidationMode);
Assert.Equal(X509RevocationMode.NoCheck, auth.RevocationMode);
Assert.NotNull(auth.CustomCertificateValidator);
Assert.IsAssignableFrom<X509CertificateValidator>(auth.CustomCertificateValidator);
}
finally
{
factory.Abort();
}
}
[Fact]
public void CreateEndpointAddress_WithoutDnsIdentity_HasNullIdentity()
{
EndpointAddress address = HistorianWcfBindingFactory.CreateEndpointAddress("10.0.0.1", 32568, "Hist");
Assert.Null(address.Identity);
}
[Fact]
public void CreateEndpointAddress_WithDnsIdentity_AttachesDnsEndpointIdentity()
{
EndpointAddress address = HistorianWcfBindingFactory.CreateEndpointAddress("10.0.0.1", 32568, "HistCert", "localhost");
Assert.NotNull(address.Identity);
DnsEndpointIdentity dns = Assert.IsType<DnsEndpointIdentity>(address.Identity);
Assert.Equal("localhost", dns.IdentityClaim.Resource);
}
[Fact]
public void CreateBindingPair_RemoteTcpCertificate_PropagatesServerDnsIdentity()
{
HistorianClientOptions options = BaseOptions(dnsIdentity: "localhost");
var (_, historyEndpoint, _, retrievalEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(options);
DnsEndpointIdentity historyIdentity = Assert.IsType<DnsEndpointIdentity>(historyEndpoint.Identity);
Assert.Equal("localhost", historyIdentity.IdentityClaim.Resource);
// The Retrieval endpoint uses plain MdasNetTcp without TLS — no DNS identity needed.
Assert.Null(retrievalEndpoint.Identity);
}
}
@@ -0,0 +1,61 @@
using System.Runtime.Versioning;
using ZB.MOM.WW.SPHistorianClient;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using Xunit.Abstractions;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
/// <remarks>
/// Probes the SDK-direct WCF revision-write path (D2 new path). Calls
/// <c>AddNonStreamValuesBegin</c> through <see cref="HistorianWcfRevisionOrchestrator"/>
/// against the live local Historian and surfaces what the server returns. The
/// underlying native wrapper is gated client-side by err 129 TagNotFoundInCache;
/// this test bypasses the wrapper entirely and asks the SERVER directly. Gated on
/// HISTORIAN_HOST=localhost; skips otherwise.
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class HistorianWcfRevisionProbeTests
{
private readonly ITestOutputHelper _output;
public HistorianWcfRevisionProbeTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public async Task AddNonStreamValuesBegin_ProbeReturnsServerResult()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
};
HistorianWcfRevisionOrchestrator orchestrator = new(options);
HistorianRevisionProbeResult result = await orchestrator.ProbeBeginAsync(CancellationToken.None);
_output.WriteLine($"OpenSucceeded: {result.OpenSucceeded}");
_output.WriteLine($"ClientHandle: {result.ClientHandle}");
_output.WriteLine($"StorageSessionId: {result.StorageSessionId}");
_output.WriteLine($"TrxInterfaceVersion: {result.TrxInterfaceVersion} (rc={result.TrxInterfaceVersionReturnCode}) ex={result.TrxInterfaceVersionException}");
_output.WriteLine($"RTag2Succeeded: {result.RTag2Succeeded} OutHex={result.RTag2OutHex} ErrHex={result.RTag2ErrorHex} Ex={result.RTag2Exception}");
_output.WriteLine($"BeginSucceeded: {result.BeginSucceeded}");
_output.WriteLine($"BeginTransactionId: {result.BeginTransactionId}");
foreach (HistorianRevisionBeginAttempt attempt in result.BeginAttempts)
{
_output.WriteLine($" attempt[{attempt.HandleLabel}] handle={attempt.HandleSent} ok={attempt.Succeeded} tx={attempt.TransactionId} err={attempt.ErrorHex} ex={attempt.Exception}");
}
Assert.True(result.OpenSucceeded, "Auth chain failed; revision probe never reached the Trx endpoint.");
// Don't assert BeginSucceeded — we're surfacing whatever the server says, not requiring success.
}
}
@@ -0,0 +1,16 @@
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class ProtocolGuardrailTests
{
[Fact]
public async Task ReadAtTime_RequiresAuthCredentials()
{
HistorianClient client = new(new HistorianClientOptions { Host = "localhost", IntegratedSecurity = false });
ProtocolEvidenceMissingException ex = await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(() =>
client.ReadAtTimeAsync("SysTimeSec", [DateTime.UtcNow], CancellationToken.None));
Assert.Contains("IntegratedSecurity", ex.Operation);
}
}
@@ -0,0 +1,233 @@
using System.Runtime.Versioning;
using ZB.MOM.WW.SPHistorianClient.Models;
using Xunit.Abstractions;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
/// <remarks>
/// Live verification of the RemoteTcpIntegrated and RemoteTcpCertificate transports
/// per <c>docs/plans/tcp-connection-validation.md</c>. Gated by env vars:
/// <list type="bullet">
/// <item><c>HISTORIAN_REMOTE_TCP_HOST</c> — hostname or IP of a reachable remote Historian.</item>
/// <item><c>HISTORIAN_REMOTE_TCP_TAG</c> — tag with non-zero history rows.</item>
/// <item><c>HISTORIAN_REMOTE_TCP_SPN</c> — optional Kerberos SPN override (default per <c>HistorianClientOptions.TargetSpn</c>).</item>
/// <item><c>HISTORIAN_REMOTE_TCPCERT_HOST</c> + <c>HISTORIAN_REMOTE_TCPCERT_DNS</c> — for the certificate transport variant.</item>
/// </list>
/// All tests skip cleanly if the gating env var isn't set.
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class RemoteTcpIntegrationTests
{
private readonly ITestOutputHelper _output;
public RemoteTcpIntegrationTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public async Task ProbeAsync_RemoteTcpIntegrated_ReturnsTrue()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
bool reachable = await client.ProbeAsync(CancellationToken.None);
Assert.True(reachable, "ProbeAsync against remote-TCP host returned false");
}
[Fact]
public async Task ReadRawAsync_RemoteTcpIntegrated_ReturnsAtLeastOneRow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
List<HistorianSample> samples = [];
await foreach (HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
{
samples.Add(sample);
}
_output.WriteLine($"Returned {samples.Count} samples for {testTag}");
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task GetTagMetadataAsync_RemoteTcpIntegrated_PopulatesFields()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(testTag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(testTag, metadata.Name);
}
[Fact]
public async Task GetSystemParameterAsync_RemoteTcpIntegrated_ReturnsHistorianVersion()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
_output.WriteLine($"HistorianVersion: {value}");
Assert.False(string.IsNullOrWhiteSpace(value));
}
[Fact]
public async Task ReadAggregateAsync_RemoteTcpIntegrated_ReturnsTimeWeightedRows()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
List<HistorianAggregateSample> samples = [];
await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync(
testTag, startUtc, endUtc, RetrievalMode.TimeWeightedAverage, TimeSpan.FromMinutes(1), CancellationToken.None))
{
samples.Add(sample);
}
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task ReadAtTimeAsync_RemoteTcpIntegrated_ReturnsTimestamps()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
DateTime now = DateTime.UtcNow;
DateTime[] timestamps = [now - TimeSpan.FromMinutes(5), now - TimeSpan.FromMinutes(2), now - TimeSpan.FromMinutes(1)];
IReadOnlyList<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 BrowseTagNamesAsync_RemoteTcpIntegrated_FindsTestTag()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
List<string> names = [];
await foreach (string name in client.BrowseTagNamesAsync(testTag, CancellationToken.None))
{
names.Add(name);
}
Assert.Contains(testTag, names);
}
[Fact]
public async Task ReadEventsAsync_RemoteTcpIntegrated_DoesNotThrow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(1);
// Empty result is acceptable — we're just verifying the chain doesn't throw over TCP.
List<HistorianEvent> events = [];
await foreach (HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
{
events.Add(evt);
}
Assert.NotNull(events);
}
[Fact]
public async Task GetConnectionStatusAsync_RemoteTcpIntegrated_ReportsConnectedToServer()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(BuildIntegratedOptions(host));
HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None);
Assert.True(status.ConnectedToServer);
Assert.False(status.ErrorOccurred);
Assert.Equal(host, status.ServerName);
}
[Fact]
public async Task ProbeAsync_RemoteTcpCertificate_ReturnsTrue()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCPCERT_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
Port = HistorianClientOptions.DefaultPort,
IntegratedSecurity = false,
Transport = HistorianTransport.RemoteTcpCertificate,
});
bool reachable = await client.ProbeAsync(CancellationToken.None);
Assert.True(reachable, "ProbeAsync over RemoteTcpCertificate returned false");
}
private static HistorianClientOptions BuildIntegratedOptions(string host)
{
string? spn = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_SPN");
return new HistorianClientOptions
{
Host = host,
Port = HistorianClientOptions.DefaultPort,
IntegratedSecurity = true,
Transport = HistorianTransport.RemoteTcpIntegrated,
// SPN default in HistorianClientOptions is "NT SERVICE\aahClientAccessPoint" which is the
// LocalPipe service identity; for remote TCP, override via env var if needed.
TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn,
};
}
}
@@ -0,0 +1,111 @@
using System.Runtime.Versioning;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using Xunit.Abstractions;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
[SupportedOSPlatform("windows")]
public sealed class TagMetadataDescriptorProbeTests
{
private readonly ITestOutputHelper _output;
public TagMetadataDescriptorProbeTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void ProbeDescriptorsForKnownSampleTags()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS")
?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (sampleTags.Length == 0)
{
return;
}
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
};
foreach (string tagName in sampleTags)
{
try
{
HistorianTagInfoResponse parsed = HistorianWcfTagClient.GetTagInfoForDescriptorProbe(options, tagName);
_output.WriteLine($" {tagName,-50} descriptor=0x{Convert.ToHexString(parsed.NativeDataTypeDescriptor)}");
}
catch (Exception ex)
{
_output.WriteLine($" {tagName,-50} ERROR: {ex.GetType().Name}: {ex.Message}");
}
}
}
[Fact]
public void DumpRawTagInfoBytesForLayoutDecoding()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_RAW_TAGINFO_TAGS") ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (string.IsNullOrWhiteSpace(host) || sampleTags.Length == 0 || !OperatingSystem.IsWindows())
{
return;
}
HistorianClientOptions options = new() { Host = host, IntegratedSecurity = true };
var results = HistorianWcfTagClient.GetTagInfoRawBytesForProbe(options, sampleTags);
foreach (var (tag, bytes) in results)
{
if (bytes is null) { _output.WriteLine($" {tag}: <null>"); continue; }
_output.WriteLine($" {tag} ({bytes.Length} bytes): {Convert.ToHexString(bytes)}");
}
}
[Fact]
public void EnumerateAllTagDescriptorsAcrossOneSession()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS") ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (sampleTags.Length == 0)
{
return;
}
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
};
IReadOnlyDictionary<string, HistorianTagInfoResponse?> results =
HistorianWcfTagClient.GetTagInfosForDescriptorProbe(options, sampleTags);
// Group by descriptor (hex string) and report counts only — no tag names in output to
// avoid leaking customer-tag identifiers.
var grouped = results
.Where(static kv => kv.Value is not null)
.GroupBy(static kv => Convert.ToHexString(kv.Value!.NativeDataTypeDescriptor))
.OrderBy(static g => g.Key);
_output.WriteLine($"Probed {results.Count} tags ({results.Count(static kv => kv.Value is null)} errors).");
foreach (var grp in grouped)
{
_output.WriteLine($" 0x{grp.Key} count={grp.Count()}");
}
}
}
@@ -0,0 +1,97 @@
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfAuthenticationProtocolTests
{
[Fact]
public void WrapValidateClientCredentialToken_UsesNativeRoundAndLengthEnvelope()
{
byte[] actual = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(
isFirstRound: true,
[0x4E, 0x54, 0x4C, 0x4D]);
Assert.Equal([0x01, 0x04, 0x00, 0x00, 0x00, 0x4E, 0x54, 0x4C, 0x4D], actual);
}
[Fact]
public void TryReadWrappedValidateClientCredentialToken_ReadsNativeEnvelope()
{
ValidateClientCredentialToken? actual =
HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken(
[0x00, 0x03, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC]);
Assert.NotNull(actual);
Assert.False(actual.IsFirstRound);
Assert.Equal([0xAA, 0xBB, 0xCC], actual.Token);
}
[Fact]
public void TryReadWrappedValidateClientCredentialToken_RejectsLengthMismatch()
{
Assert.Null(HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken(
[0x01, 0x04, 0x00, 0x00, 0x00, 0xAA]));
}
[Fact]
public void TryReadValidateClientCredentialResponse_ReadsContinueFlagAndServerToken()
{
ValidateClientCredentialResponse? actual =
HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(
[0x01, 0x11, 0x22, 0x33]);
Assert.NotNull(actual);
Assert.True(actual.Continue);
Assert.Equal([0x11, 0x22, 0x33], actual.Token);
}
[Fact]
public void TryReadValidateClientCredentialResponse_ReadsTerminalOneByteResponse()
{
ValidateClientCredentialResponse? actual =
HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([0x00]);
Assert.NotNull(actual);
Assert.False(actual.Continue);
Assert.Empty(actual.Token);
}
[Fact]
public void TryReadValidateClientCredentialResponse_RejectsEmptyResponse()
{
Assert.Null(HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([]));
}
[Fact]
public void TryApplyNativeNtlmNegotiateVersionFlag_MatchesObservedNativeFirstTokenFlag()
{
byte[] token =
[
0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00,
0x01, 0x00, 0x00, 0x00,
0xB7, 0xB2, 0x08, 0xE2
];
bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token);
Assert.True(changed);
Assert.Equal(
[
0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00,
0x01, 0x00, 0x00, 0x00,
0xB7, 0xB2, 0x18, 0xE2
],
token);
}
[Fact]
public void TryApplyNativeNtlmNegotiateVersionFlag_IgnoresNonNtlmNegotiateTokens()
{
byte[] token = [0x4B, 0x52, 0x42, 0x35];
bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token);
Assert.False(changed);
Assert.Equal([0x4B, 0x52, 0x42, 0x35], token);
}
}
@@ -0,0 +1,39 @@
using System.Runtime.Versioning;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
[SupportedOSPlatform("windows")]
public sealed class WcfBindingFactoryTests
{
[Fact]
public void CreateMdasNetNamedPipeBinding_WrapsTheInnerEncoderInMdas()
{
Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(TimeSpan.FromSeconds(5));
BindingElementCollection elements = binding.CreateBindingElements();
Assert.Contains(elements, e => e is MdasMessageEncodingBindingElement);
}
[Fact]
public void CreateMdasNetNamedPipeBinding_AppliesProvidedTimeout()
{
TimeSpan timeout = TimeSpan.FromSeconds(7);
Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(timeout);
Assert.Equal(timeout, binding.OpenTimeout);
Assert.Equal(timeout, binding.CloseTimeout);
Assert.Equal(timeout, binding.SendTimeout);
Assert.Equal(timeout, binding.ReceiveTimeout);
}
[Fact]
public void CreatePipeEndpointAddress_BuildsNetPipeUri()
{
var address = HistorianWcfBindingFactory.CreatePipeEndpointAddress("localhost", "Hist");
Assert.Equal(new Uri("net.pipe://localhost/Hist"), address.Uri);
}
}
@@ -0,0 +1,156 @@
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfDataQueryProtocolTests
{
[Fact]
public void SerializerMatchesInstrumentedNativeFullHistoryRequest()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["OtOpcUaParityTest_001.Counter"],
new DateTime(2026, 5, 1, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154),
new DateTime(2026, 5, 2, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154),
MaxStates: 100,
BatchSize: 1,
Option: string.Empty));
byte[] expected = Convert.FromBase64String(
"CQACAAAAAAAAAAAAAAAC4ScwddncAQKhkVo+2twBAAAAAAAAAAAAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
Assert.Equal(expected, actual);
}
[Fact]
public void SerializerMatchesInstrumentedNativeTimeWeightedAverageRequest()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["OtOpcUaParityTest_001.Counter"],
new DateTime(2026, 5, 1, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955),
new DateTime(2026, 5, 2, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955),
MaxStates: 100,
BatchSize: 3,
Option: string.Empty)
{
QueryType = 5,
Resolution = TimeSpan.FromMinutes(1)
});
byte[] expected = Convert.FromBase64String(
"CQAFAAAAAAAAAAAAAAB73ULbdtncAXudrAVA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
Assert.Equal(expected, actual);
}
[Fact]
public void SerializerMatchesInstrumentedNativeInterpolatedRequest()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["OtOpcUaParityTest_001.Counter"],
new DateTime(2026, 5, 1, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924),
new DateTime(2026, 5, 2, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924),
MaxStates: 100,
BatchSize: 3,
Option: string.Empty)
{
QueryType = 3,
Resolution = TimeSpan.FromMinutes(1)
});
byte[] expected = Convert.FromBase64String(
"CQADAAAAAAAAAAAAAABcnWtMd9ncAVxd1XZA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
Assert.Equal(expected, actual);
}
[Fact]
public void SerializerUsesDecompiledEmptyMetadataAndAutoSummaryLayout()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["T"],
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
MaxStates: 100,
BatchSize: 1,
Option: string.Empty));
byte[] expectedMiddle =
[
0x64, 0x00,
0x01,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00,
0x09, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x01, 0x00
];
AssertContains(expectedMiddle, actual);
AssertEndsWith(ExpectedEmptyEndpointAndAutoSummarySuffix(), actual);
}
[Fact]
public void SerializerWritesPackedCqtiFlagsSeparatelyFromColumnSelectorFlags()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["T"],
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
MaxStates: 100,
BatchSize: 1,
Option: "NoOption")
{
InterpolationType = 255,
TimestampRule = 1,
QualityRule = 0,
ColumnSelectorFlags = 0x0000_0000_0003_FFFF
});
int resultBufferOffset = 2 + 4 + 4 + 4 + 8 + 8 + 8 + 4 + 4 + 10 + 4;
Assert.Equal([0x00, 0x00, 0x01, 0x00, 0xFF, 0x01], actual[resultBufferOffset..(resultBufferOffset + 6)]);
AssertContains([0x01, 0x00, 0xFF, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], actual);
}
private static byte[] ExpectedEmptyEndpointAndAutoSummarySuffix()
{
List<byte> expected = [];
AppendEmptyEndpoint(expected);
AppendEmptyEndpoint(expected);
expected.AddRange(new byte[8]);
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
expected.AddRange([0x01, 0x00]);
expected.AddRange(new byte[16]);
expected.AddRange(new byte[5]);
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
return expected.ToArray();
}
private static void AppendEmptyEndpoint(List<byte> bytes)
{
bytes.AddRange([0x01, 0x00]);
bytes.AddRange([0x00, 0x00, 0x00, 0x00]);
bytes.AddRange([0x00, 0x00]);
}
private static void AssertContains(byte[] expected, byte[] actual)
{
for (int index = 0; index <= actual.Length - expected.Length; index++)
{
if (actual.AsSpan(index, expected.Length).SequenceEqual(expected))
{
return;
}
}
Assert.Fail($"Expected byte sequence {Convert.ToHexString(expected)} was not found.");
}
private static void AssertEndsWith(byte[] expectedSuffix, byte[] actual)
{
Assert.True(actual.Length >= expectedSuffix.Length);
Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]);
}
}
@@ -0,0 +1,109 @@
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfDataQueryResultBufferTests
{
// Captured from artifacts/reverse-engineering/instrumented-openconnection3-correlation/capture.ndjson
// Wcf.GetNextQueryResultBuffer2.ResultBytes for a 4-row OtOpcUaParityTest_001.Counter Full read.
private static readonly byte[] CapturedResultBytes = Convert.FromBase64String(
"CQAEAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAu" +
"AEMAbwB1AG4AdABlAHIAAQAAAGvPzFvD2dwBhQAAAPgAAADAAAAAAAAAAAAAAAAAAAAAAABZQAAA" +
"AWvPzFvD2dwBAAAAAAAAAAClBtClfAAAAAAAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAA" +
"YQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAABDWnAFA2twBAQAA" +
"ABgAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAARDWnAFA2twBAAAAAAAAAAAwZOgAAAAAAAEAAAAA" +
"AAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMA" +
"bwB1AG4AdABlAHIAAQAAAEA6hQJA2twBAQAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUA6" +
"hQJA2twBAAAAAAAAAABQwwAAAAAAAAEAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQBy" +
"AGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAAJD9hQJA2twBAAAAAPgA" +
"AADAAAAAAAAAAAAAAAAAAAAAAABZQAAAAZD9hQJA2twBAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAA");
private static readonly byte[] TerminalNoMoreData = Convert.FromBase64String("BB4AAAA=");
[Fact]
public void TryParseGetNextQueryResultBufferRows_ParsesFourCanonicalFixtureRows()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
CapturedResultBytes,
TerminalNoMoreData,
out IReadOnlyList<HistorianSample> rows,
out bool hasMoreData);
Assert.True(ok);
Assert.False(hasMoreData);
Assert.Equal(4, rows.Count);
Assert.All(rows, r => Assert.Equal("OtOpcUaParityTest_001.Counter", r.TagName));
HistorianSample row0 = rows[0];
Assert.Equal(133, row0.Quality);
Assert.Equal(248u, row0.QualityDetail);
Assert.Equal(192, row0.OpcQuality);
Assert.Equal(0, row0.NumericValue);
Assert.Equal(100.0, row0.PercentGood);
Assert.Equal(DateTime.FromFileTimeUtc(0x01DCD9C35BCCCF6B), row0.TimestampUtc);
HistorianSample row3 = rows[3];
Assert.Equal(0, row3.Quality);
Assert.Equal(248u, row3.QualityDetail);
Assert.Equal(192, row3.OpcQuality);
Assert.Equal(100.0, row3.PercentGood);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorTerminalIsEmpty()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
CapturedResultBytes,
errorTerminal: [],
out _,
out bool hasMoreData);
Assert.True(ok);
Assert.True(hasMoreData);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorIsNotNoMoreData()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
CapturedResultBytes,
errorTerminal: [0x04, 0x01, 0x00, 0x00, 0x00],
out _,
out bool hasMoreData);
Assert.True(ok);
Assert.True(hasMoreData);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_RejectsBufferWithUnsupportedVersion()
{
byte[] mangled = (byte[])CapturedResultBytes.Clone();
mangled[0] = 0x07;
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
mangled,
TerminalNoMoreData,
out IReadOnlyList<HistorianSample> rows,
out _);
Assert.False(ok);
Assert.Empty(rows);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_HandlesEmptyResultBuffer()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
result: [],
TerminalNoMoreData,
out IReadOnlyList<HistorianSample> rows,
out bool hasMoreData);
Assert.True(ok);
Assert.False(hasMoreData);
Assert.Empty(rows);
}
}
@@ -0,0 +1,46 @@
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfEventQueryProtocolTests
{
[Fact]
public void SerializerMatchesInstrumentedNativeEventRequest()
{
HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
new DateTime(2026, 4, 25, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646),
new DateTime(2026, 5, 2, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646),
3));
byte[] expected = Convert.FromBase64String(
"BQBuHAVXwdTcAW5c6X9B2twBAwAAAAAAAAAAAAEAAAAAAAAAAAAAAQADAAAAVQBUAEMAAQEAAAEAAAEAAAAAAAA=");
Assert.Equal(expected, attempt.RequestBuffer);
Assert.Equal("6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5", attempt.RequestSha256);
}
[Fact]
public void NativeEmptyFilterAttemptMatchesDecompiledSaveOrder()
{
HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
3));
byte[] actual = attempt.RequestBuffer;
Assert.Equal("native-empty-filter-version5", attempt.Name);
Assert.Equal(3, HistorianEventQueryProtocol.QueryRequestTypeEvent);
Assert.Equal(65, actual.Length);
Assert.Equal([0x05, 0x00], actual[..2]);
Assert.Equal(3u, BitConverter.ToUInt32(actual, 18));
Assert.Equal(0u, BitConverter.ToUInt32(actual, 22));
Assert.Equal(0, BitConverter.ToUInt16(actual, 26));
Assert.Equal(1, BitConverter.ToUInt16(actual, 28));
Assert.Equal([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], actual[30..37]);
Assert.Equal(65_536u, BitConverter.ToUInt32(actual, 37));
Assert.Equal([0x03, 0x00, 0x00, 0x00, 0x55, 0x00, 0x54, 0x00, 0x43, 0x00], actual[41..51]);
Assert.Equal([0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00], actual[51..61]);
Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[^4..]);
}
}
@@ -0,0 +1,105 @@
using System.Reflection;
using System.ServiceModel;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfEvidenceTests
{
[Fact]
public void ServiceContractsUseDecompiledNamesAndNamespace()
{
AssertServiceContract<IHistoryServiceContract>("Hist");
AssertServiceContract<IHistoryServiceContract2>("Hist");
AssertServiceContract<IRetrievalServiceContract>("Retr");
AssertServiceContract<IRetrievalServiceContract4>("Retr");
AssertServiceContract<IStatusServiceContract>("Stat");
AssertServiceContract<IStatusServiceContract2>("Stat");
AssertServiceContract<IStorageServiceContract>("Storage");
AssertServiceContract<ITransactionServiceContract>("Trx");
}
[Fact]
public void RelayEvidenceIdentifiesHistorySecurityEndpointNames()
{
Assert.Equal("HistCert", HistorianWcfServiceNames.HistoryCertificate);
Assert.Equal("Hist-Integrated", HistorianWcfServiceNames.HistoryIntegrated);
}
[Fact]
public void KnownOperationAliasesMatchManagedWrapperEvidence()
{
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.GetInterfaceVersion), "GetV");
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.OpenConnection), "Open");
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.ValidateClient), "VldC");
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.UpdateClientStatus), "UpdC");
AssertOperation<IHistoryServiceContract2>(nameof(IHistoryServiceContract2.OpenConnection2), "Open2");
AssertOperation<IHistoryServiceContract2>(nameof(IHistoryServiceContract2.ExchangeKey), "ExKey");
AssertOperation<IRetrievalServiceContract2>(nameof(IRetrievalServiceContract2.GetTagInfosFromId), "GetTg");
AssertOperation<IRetrievalServiceContract3>(nameof(IRetrievalServiceContract3.StartTagQuery), "QTB");
AssertOperation<IRetrievalServiceContract4>(nameof(IRetrievalServiceContract4.GetTagExtendedPropertiesFromName), "GetTepByNm");
AssertOperation<IStorageServiceContract>(nameof(IStorageServiceContract.OpenStorageConnection), "Open");
AssertOperation<IStorageServiceContract>(nameof(IStorageServiceContract.LoadBlocks), "LoadB");
AssertOperation<ITransactionServiceContract>(nameof(ITransactionServiceContract.GetInterfaceVersion), "GetV");
AssertDefaultOperation<IRetrievalServiceContract>(nameof(IRetrievalServiceContract.StartQuery));
AssertDefaultOperation<IRetrievalServiceContract4>(nameof(IRetrievalServiceContract4.StartEventQuery));
AssertDefaultOperation<IStatusServiceContract>(nameof(IStatusServiceContract.GetServerTime));
AssertDefaultOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.GetSystemParameter));
AssertOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.GetHistorianInfo), "GETHI");
AssertOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.PingServer), "PNGS");
AssertOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.PingPipe), "PNGP");
}
[Fact]
public void MdasBindingUsesNetTcpAndCustomContentType()
{
var binding = HistorianWcfBindingFactory.CreateMdasNetTcpBinding(TimeSpan.FromSeconds(5));
var encoder = binding.CreateBindingElements().Find<MdasMessageEncodingBindingElement>();
var endpoint = HistorianWcfBindingFactory.CreateEndpointAddress("localhost", HistorianWcfBindingFactory.DefaultPort, HistorianWcfServiceNames.History);
Assert.NotNull(encoder);
Assert.Equal("net.tcp://localhost:32568/Hist", endpoint.Uri.AbsoluteUri);
Assert.Equal(MdasMessageEncoder.MdasContentType, encoder.CreateMessageEncoderFactory().Encoder.ContentType);
}
[Fact]
public void CertificateBindingUsesMdasEncodingOverTransportSecurity()
{
var binding = HistorianWcfBindingFactory.CreateMdasNetTcpCertificateBinding(TimeSpan.FromSeconds(5));
var elements = binding.CreateBindingElements();
var encoder = elements.Find<MdasMessageEncodingBindingElement>();
var security = elements.Find<System.ServiceModel.Channels.SslStreamSecurityBindingElement>();
Assert.NotNull(encoder);
Assert.NotNull(security);
Assert.Equal(MdasMessageEncoder.MdasContentType, encoder.CreateMessageEncoderFactory().Encoder.ContentType);
}
private static void AssertServiceContract<TContract>(string name)
{
var attribute = typeof(TContract).GetCustomAttribute<ServiceContractAttribute>();
Assert.NotNull(attribute);
Assert.Equal(name, attribute.Name);
Assert.Equal("aa", attribute.Namespace);
}
private static void AssertOperation<TContract>(string methodName, string operationName)
{
var method = typeof(TContract).GetMethod(methodName);
var attribute = method?.GetCustomAttribute<OperationContractAttribute>();
Assert.NotNull(attribute);
Assert.Equal(operationName, attribute.Name);
}
private static void AssertDefaultOperation<TContract>(string methodName)
{
var method = typeof(TContract).GetMethod(methodName);
var attribute = method?.GetCustomAttribute<OperationContractAttribute>();
Assert.NotNull(attribute);
Assert.Null(attribute.Name);
}
}
@@ -0,0 +1,283 @@
using System.Text;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfOpen2ProtocolTests
{
[Fact]
public void LegacyVersion1SerializerMatchesDecompiledSaveOpenConnectionParamsLayout()
{
byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: "U",
Password: Encoding.Unicode.GetBytes("pw"),
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 2,
MetadataNamespace: HistorianMetadataNamespace.Empty));
byte[] expected =
[
0x01, 0x00,
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x01, 0x00, 0x00, 0x00, 0x50, 0x00,
0x04, 0x03, 0x02, 0x01,
0x01, 0x00, 0x00, 0x00, 0x55, 0x00,
0x04, 0x00, 0x00, 0x00, 0x70, 0x00, 0x77, 0x00,
0x04,
0x0B, 0x00,
0x02, 0x00, 0x00, 0x00,
0x01,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
];
Assert.Equal(expected, actual);
}
[Fact]
public void LegacyVersion1SerializerUsesUtf16CodeUnitStringLengths()
{
byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request(
HostName: "A\ud83d\ude00",
ProcessName: string.Empty,
ProcessId: 0,
UserName: string.Empty,
Password: [],
ClientType: 4,
ClientVersion: 0,
ConnectionMode: 2,
MetadataNamespace: HistorianMetadataNamespace.Empty));
Assert.Equal([0x03, 0x00, 0x00, 0x00], actual[2..6]);
Assert.Equal(Encoding.Unicode.GetBytes("A\ud83d\ude00"), actual[6..12]);
}
[Fact]
public void NativeErrorParserReadsObservedFiveByteBuffers()
{
HistorianNativeError? error = HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00, 0x00]);
Assert.NotNull(error);
Assert.Equal(4, error.Type);
Assert.Equal<uint>(171, error.Code);
Assert.Equal("AuthenticationFailed", error.Name);
}
[Fact]
public void NativeErrorParserRejectsShortBuffers()
{
Assert.Null(HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00]));
}
[Fact]
public void LegacyOpen2OutputParserReadsObservedWcfLayout()
{
byte[] buffer =
[
0x78, 0x56, 0x34, 0x12,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
0x44, 0x33, 0x22, 0x11
];
HistorianLegacyOpen2Output? output = HistorianOpen2Protocol.TryReadLegacyOpen2Output(buffer);
Assert.NotNull(output);
Assert.Equal<uint>(0x12345678, output.Handle);
Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId);
Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc);
Assert.Equal<uint>(0x11223344, output.ServerStatus);
}
[Fact]
public void LegacyOpen2OutputParserRejectsNonLegacyLength()
{
Assert.Null(HistorianOpen2Protocol.TryReadLegacyOpen2Output([0x00]));
}
[Fact]
public void NativeOpen3OutputParserReadsObservedDeserializerLayout()
{
byte[] buffer =
[
0x03,
0x78, 0x56, 0x34, 0x12,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
0x18, 0x17, 0x16, 0x15,
0x14, 0x13, 0x12, 0x11,
0x44, 0x33, 0x22, 0x11,
0x00
];
HistorianNativeOpen3Output? output = HistorianOpen2Protocol.TryReadNativeOpen3Output(buffer);
Assert.NotNull(output);
Assert.Equal(3, output.ProtocolVersion);
Assert.Equal<uint>(0x12345678, output.Handle);
Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId);
Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc);
Assert.Equal(0x1112131415161718, output.ServerTimeFileTimeUtc);
Assert.Equal([0x44, 0x33, 0x22, 0x11, 0x00], output.TrailingBytes);
}
[Fact]
public void NativeOpen3OutputParserRejectsUnsupportedVersion()
{
Assert.Null(HistorianOpen2Protocol.TryReadNativeOpen3Output([0x01, 0x00, 0x00, 0x00]));
}
[Fact]
public void NativeVersion3SerializerMatchesDecompiledFieldOrder()
{
byte[] actual = HistorianOpen2Protocol.SerializeNativeVersion3(
new HistorianOpen2Request(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty),
new HistorianClientCommonInfo(
FormatVersion: 3,
ServerNodeName: "S",
ClientNodeName: "C",
ProcessId: 0x11223344,
HcalVersion: 17,
ProcessName: "Proc",
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"),
ClientVersion: 0x55667788,
ClientTimestamp: 0x0102030405060708,
ClientDllVersion: string.Empty));
byte[] expectedPrefix =
[
0x03,
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x02, 0x00, 0xAA, 0xBB,
0x04,
0x02, 0x04, 0x00, 0x00
];
Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]);
Assert.Contains<byte>(0x03, actual);
byte[] expectedSuffix = [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01];
Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]);
}
[Fact]
public void NativeOpenConnection3Version6SerializerAddsObservedPrefixBeforeContent()
{
HistorianOpen2Request request = new(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: 3,
ServerNodeName: "S",
ClientNodeName: "C",
ProcessId: 0x11223344,
HcalVersion: 17,
ProcessName: "Proc",
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"),
ClientVersion: 0x55667788,
ClientTimestamp: 0x0102030405060708,
ClientDllVersion: string.Empty);
byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
request,
commonInfo,
new Guid("00112233-4455-6677-8899-aabbccddeeff"));
byte[] expectedPrefix =
[
0x06,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x00
];
Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]);
byte[] expectedContentPrefix =
[
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x02, 0x00, 0xAA, 0xBB,
0x04,
0x02, 0x04, 0x00, 0x00,
0x01,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00
];
Assert.Equal(expectedContentPrefix, actual[expectedPrefix.Length..(expectedPrefix.Length + expectedContentPrefix.Length)]);
}
[Fact]
public void NativeOpenConnection3Version6SerializerCanUseSeparateCredentialBlock()
{
HistorianOpen2Request request = new(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: 2,
ServerNodeName: string.Empty,
ClientNodeName: string.Empty,
ProcessId: 0,
HcalVersion: 17,
ProcessName: string.Empty,
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: Guid.Empty,
ClientVersion: 0,
ClientTimestamp: 0,
ClientDllVersion: string.Empty);
byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
request,
commonInfo,
Guid.Empty,
[0x00, 0x00, 0x00, 0x00]);
int hostLengthOffset = 18;
int credentialLengthOffset = hostLengthOffset + 4 + Encoding.Unicode.GetByteCount("H");
Assert.Equal([0x04, 0x00], actual[credentialLengthOffset..(credentialLengthOffset + 2)]);
Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[(credentialLengthOffset + 2)..(credentialLengthOffset + 6)]);
}
}
@@ -0,0 +1,45 @@
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfStatusProtocolTests
{
[Fact]
public void SystemTimeParserReadsWindowsSystemTimeLayout()
{
byte[] buffer =
[
0xEA, 0x07,
0x04, 0x00,
0x04, 0x00,
0x1E, 0x00,
0x0D, 0x00,
0x2A, 0x00,
0x07, 0x00,
0x7B, 0x00
];
DateTime? parsed = HistorianStatusProtocol.TryReadSystemTime(buffer);
Assert.Equal(new DateTime(2026, 4, 30, 13, 42, 7, 123), parsed);
}
[Fact]
public void SystemTimeParserRejectsShortAndInvalidBuffers()
{
Assert.Null(HistorianStatusProtocol.TryReadSystemTime([0xEA, 0x07]));
byte[] invalidMonth =
[
0xEA, 0x07,
0x00, 0x00,
0x04, 0x00,
0x1E, 0x00,
0x0D, 0x00,
0x2A, 0x00,
0x07, 0x00,
0x7B, 0x00
];
Assert.Null(HistorianStatusProtocol.TryReadSystemTime(invalidMonth));
}
}
@@ -0,0 +1,276 @@
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Tests;
public sealed class WcfTagQueryProtocolTests
{
[Fact]
public void SerializerMatchesInstrumentedNativeTagQueryRequest()
{
HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(
"TagName eq 'OtOpcUaParityTest_001.Counter'");
byte[] expected = Convert.FromBase64String(
"UWcBACoAAABUAGEAZwBOAGEAbQBlACAAZQBxACAAJwBPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAJwA=");
Assert.Equal(expected, attempt.RequestBuffer);
Assert.Equal("af1dbcdd3eb0ad91a18882c22252aa74aff82998e96a39b63415ab4792a962ac", attempt.RequestSha256);
}
[Fact]
public void SerializerUsesDecompiledMarkerVersionAndUtf16Filter()
{
HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryAttempt("T*");
byte[] actual = attempt.RequestBuffer;
Assert.Equal("native-start-tag-query-version1", attempt.Name);
Assert.Equal(HistorianTagQueryProtocol.NativeStartTagQueryMarker, BitConverter.ToUInt16(actual, 0));
Assert.Equal(HistorianTagQueryProtocol.NativeStartTagQueryVersion, BitConverter.ToUInt16(actual, 2));
Assert.Equal(2u, BitConverter.ToUInt32(actual, 4));
Assert.Equal("T*", System.Text.Encoding.Unicode.GetString(actual, 8, actual.Length - 8));
}
[Fact]
public void SerializerMatchesInstrumentedNativeHeaderOnlyTagQueryRequest()
{
HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryHeaderOnlyAttempt();
Assert.Equal("native-start-tag-query-header-only", attempt.Name);
Assert.Equal(Convert.FromBase64String("UWcBAA=="), attempt.RequestBuffer);
Assert.Equal("17956e4fbe53d5edc0f9170203b013432e4afcc0591c795a10522a98d9fce926", attempt.RequestSha256);
}
[Fact]
public void ParsesInstrumentedNativeStartTagQueryResponse()
{
byte[] response = Convert.FromBase64String("CAAAAAEAAAA=");
HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(response);
Assert.Equal(8u, parsed.QueryHandle);
Assert.Equal(1u, parsed.TagCount);
}
[Fact]
public void ParsesInstrumentedNativeGetTagInfoResponse()
{
byte[] response = Convert.FromBase64String(
"AQAAAAPDADGEIoxAWOGHSphLPb7L4KpC7gAAAAkdAE90T3BjVWFQYXJpdHlUZXN0XzAwMS5Db3VudGVyCQQATURBUwIDAQIAAADQV/SUZdjcAQoAAAAAAAAAJEAAAAAAAAAkQP4AAAAAAA==");
IReadOnlyList<HistorianTagInfoResponse> tags = HistorianTagQueryProtocol.ParseGetTagInfoResponse(response);
HistorianTagInfoResponse tag = Assert.Single(tags);
Assert.Equal("OtOpcUaParityTest_001.Counter", tag.TagName);
Assert.Equal(238u, tag.TagKey);
Assert.Equal(new Guid("408c2284-e158-4a87-984b-3dbecbe0aa42"), tag.TypeId);
Assert.Equal([0x03, 0xC3, 0x00, 0x31], tag.NativeDataTypeDescriptor);
Assert.Equal("MDAS", tag.MetadataProvider);
Assert.Equal(2, tag.NativeTagClass);
Assert.Equal(3, tag.StorageType);
Assert.Equal(1, tag.DeadbandType);
Assert.Equal(2, tag.InterpolationType);
}
[Fact]
public void ParsesDirectWcfGetTagInfoFromNameResponse()
{
byte[] response = Convert.FromBase64String(
"A8MAMYQijEBY4YdKmEs9vsvgqkLuAAAACR0AT3RPcGNVYVBhcml0eVRlc3RfMDAxLkNvdW50ZXIJBABNREFTAgMBAgAAANBX9JRl2NwBCgAAAAAAAAAkQAAAAAAAACRA/gA=");
HistorianTagInfoResponse tag = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
Assert.Equal("OtOpcUaParityTest_001.Counter", tag.TagName);
Assert.Equal(238u, tag.TagKey);
Assert.Equal([0x03, 0xC3, 0x00, 0x31], tag.NativeDataTypeDescriptor);
Assert.Equal(Models.HistorianDataType.Int4, HistorianWcfTagClient.MapDataType(tag.NativeDataTypeDescriptor));
}
[Fact]
public void MapDataType_UInt2Descriptor_ReturnsUInt2()
{
// Built-in SysTimeSec exposes this descriptor (Runtime AnalogTag.IntegerSize=16,
// SignedInteger=0 → UInt16).
Assert.Equal(Models.HistorianDataType.UInt2, HistorianWcfTagClient.MapDataType([0x03, 0xCF, 0x04, 0x09]));
}
[Theory]
// Captured descriptors from TagMetadataDescriptorProbeTests against a live local Historian.
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x01 }, Models.HistorianDataType.Float)] // SysDataAcqOverallItemsPerSec (RawType=2, IntegerSize=0)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x02 }, Models.HistorianDataType.Int1)] // SysClassicDataRedirector (DiscreteTag)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x09 }, Models.HistorianDataType.UInt2)] // SysCritErrCnt (UInt16, StorageType=Delta)
[InlineData(new byte[] { 0x03, 0xCF, 0x04, 0x09 }, Models.HistorianDataType.UInt2)] // SysTimeSec (UInt16, StorageType=Cyclic)
[InlineData(new byte[] { 0x03, 0xCF, 0x04, 0x11 }, Models.HistorianDataType.UInt4)] // SysConfigStatus (UInt32)
[InlineData(new byte[] { 0x03, 0xC3, 0x00, 0x31 }, Models.HistorianDataType.Int4)] // OtOpcUaParityTest_001.Counter (Int32)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x43 }, Models.HistorianDataType.DoubleByteString)] // SysString
// Inferred from CDataType predicate IL (IsConvertableToDouble/Int64/UInt64, IsEvent, IsStruct, IsString):
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x03 }, Models.HistorianDataType.SingleByteString)] // string class without bit 0x40 (wide flag)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x04 }, Models.HistorianDataType.Event)] // IsEvent: low 3 bits == 4
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x05 }, Models.HistorianDataType.Structure)] // IsStruct: low 3 bits == 5
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x21 }, Models.HistorianDataType.Double)] // IsConvertableToDouble matches 33
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x29 }, Models.HistorianDataType.Int2)] // IsConvertableToInt64 matches 41 (= UInt16=0x09 + signed bit 0x20)
// Newly extended HistorianDataType enum entries (codes recovered from same predicate IL):
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x08 }, Models.HistorianDataType.UInt1)] // 1-byte unsigned (IsConvertableToUInt64 matches 8)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x10 }, Models.HistorianDataType.Guid)] // IsGuid: byte == 16
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x18 }, Models.HistorianDataType.FileTime)] // IsFileTime: byte == 24
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x19 }, Models.HistorianDataType.Int8)] // 8-byte signed (IsConvertableToInt64 matches 25)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x39 }, Models.HistorianDataType.UInt8)] // 8-byte unsigned (IsConvertableToUInt64 matches 57)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x81 }, Models.HistorianDataType.Int1)] // Boolean extended (IsBoolean: byte == 129)
public void MapDataType_KnownDescriptors_ReturnsExpectedType(byte[] descriptor, Models.HistorianDataType expected)
{
Assert.Equal(expected, HistorianWcfTagClient.MapDataType(descriptor));
}
[Theory]
// Storage-attribute byte (byte 2) variants should still map to the same data type because
// the dispatch is on byte 3.
[InlineData(new byte[] { 0x03, 0xCF, 0x07, 0x09 }, Models.HistorianDataType.UInt2)]
[InlineData(new byte[] { 0x03, 0xCF, 0xFF, 0x11 }, Models.HistorianDataType.UInt4)]
public void MapDataType_StorageAttributeVariants_DispatchesByDataTypeCode(byte[] descriptor, Models.HistorianDataType expected)
{
Assert.Equal(expected, HistorianWcfTagClient.MapDataType(descriptor));
}
[Fact]
public void MapDataType_UnknownDataTypeCode_ThrowsEvidenceMissing()
{
// Byte 3 = 0xFF is not yet observed; should throw rather than guess.
Assert.Throws<ProtocolEvidenceMissingException>(() =>
HistorianWcfTagClient.MapDataType([0x03, 0xCF, 0x00, 0xFF]));
}
[Fact]
public void MapDataType_WrongFormatVersion_ThrowsEvidenceMissing()
{
// Byte 0 must be 0x03; anything else throws.
Assert.Throws<ProtocolEvidenceMissingException>(() =>
HistorianWcfTagClient.MapDataType([0x04, 0xCF, 0x00, 0x09]));
}
[Fact]
public void Parse_FullShape4Strings_PopulatesDescription()
{
byte[] response = BuildSyntheticTagInfo(
descriptor: [0x03, 0xCF, 0x00, 0x09],
tagKey: 42,
strings: ["TAG", "Tag description here", "TAG", "DOMAIN\\user"],
fixedBlock: [0x03, 0x02, 0x01, 0x00],
trailingDoubles: null,
trailingEu: null);
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
Assert.Equal("TAG", parsed.TagName);
Assert.Equal(42u, parsed.TagKey);
Assert.Equal("Tag description here", parsed.Description);
// 4-string shape uses position 1 as Description AND MetadataProvider for back-compat.
Assert.Equal("Tag description here", parsed.MetadataProvider);
}
[Fact]
public void Parse_TwoStringShape_DoesNotMisinterpretMetadataProviderAsDescription()
{
byte[] response = BuildSyntheticTagInfo(
descriptor: [0x03, 0xC3, 0x00, 0x31],
tagKey: 99,
strings: ["EXT.TAG.NAME", "MDAS"],
fixedBlock: [0x02, 0x03, 0x01, 0x02],
trailingDoubles: null,
trailingEu: null);
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
Assert.Equal("EXT.TAG.NAME", parsed.TagName);
Assert.Equal("MDAS", parsed.MetadataProvider);
// 2-string shape: don't conflate MetadataProvider with Description.
Assert.Null(parsed.Description);
}
[Fact]
public void Parse_TrailingDoublesAndEu_PopulatesMinMaxAndUnit()
{
byte[] response = BuildSyntheticTagInfo(
descriptor: [0x03, 0xCF, 0x00, 0x09],
tagKey: 12,
strings: ["TAG", "desc", "TAG", "DOMAIN\\u"],
fixedBlock: [0x03, 0x02, 0x01, 0x00],
trailingDoubles: (0.0, 59.0),
trailingEu: "Seconds");
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
Assert.Equal(0.0, parsed.MinEU);
Assert.Equal(59.0, parsed.MaxEU);
Assert.Equal("Seconds", parsed.EngineeringUnit);
}
[Fact]
public void Parse_TrailingNoDoubles_LeavesMinMaxNull()
{
byte[] response = BuildSyntheticTagInfo(
descriptor: [0x03, 0xCF, 0x00, 0x02],
tagKey: 97,
strings: ["DiscreteTag", "Description", "DiscreteTag", "DOMAIN\\u"],
fixedBlock: [0x03, 0x02, 0x01, 0x00],
trailingDoubles: null,
trailingEu: null);
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
Assert.Null(parsed.MinEU);
Assert.Null(parsed.MaxEU);
Assert.Null(parsed.EngineeringUnit);
}
private static byte[] BuildSyntheticTagInfo(
byte[] descriptor,
uint tagKey,
string[] strings,
byte[] fixedBlock,
(double Min, double Max)? trailingDoubles,
string? trailingEu)
{
using MemoryStream ms = new();
using BinaryWriter w = new(ms);
w.Write(descriptor); // 4 bytes
w.Write(System.Guid.NewGuid().ToByteArray()); // 16 bytes
w.Write(tagKey); // 4 bytes
foreach (string s in strings)
{
byte[] ascii = System.Text.Encoding.ASCII.GetBytes(s);
w.Write((byte)0x09);
w.Write((ushort)ascii.Length);
w.Write(ascii);
}
w.Write(fixedBlock); // 4 bytes
// Trailing region: padding + (optional doubles aligned to 8) + (optional EU compact ASCII).
// To keep doubles 8-byte aligned within the trailing region, pad to next 8-byte boundary.
long trailingStart = ms.Length;
// Plain alignment: add zero padding so doubles start at a stable 8-byte aligned offset
// within the trailing region — the parser scans alignments 0..7 so any padding works.
if (trailingDoubles is { } d)
{
w.Write(d.Min);
w.Write(d.Max);
}
if (trailingEu is not null)
{
byte[] euAscii = System.Text.Encoding.ASCII.GetBytes(trailingEu);
w.Write((byte)0x09);
w.Write((ushort)euAscii.Length);
w.Write(euAscii);
}
return ms.ToArray();
}
[Fact]
public void ParsesManagedWcfLikeTagNamesResponse()
{
byte[] response = Convert.FromBase64String(
"AQAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIA");
IReadOnlyList<string> tagNames = HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse(response);
Assert.Equal(["OtOpcUaParityTest_001.Counter"], tagNames);
}
[Theory]
[InlineData("*", "%")]
[InlineData("Sys*", "Sys%")]
[InlineData("OtOpcUaParityTest%", "OtOpcUaParityTest%")]
public void NormalizesPublicWildcardToHistorianLikeWildcard(string filter, string expected)
{
Assert.Equal(expected, HistorianWcfTagClient.NormalizeLikeFilter(filter));
}
}