feat(sphistorianclient): port SDK source + tests, rebrand namespace to ZB.MOM.WW.SPHistorianClient
This commit is contained in:
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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));
|
||||
}
|
||||
}
|
||||
+737
@@ -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));
|
||||
}
|
||||
}
|
||||
+230
@@ -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;
|
||||
}
|
||||
}
|
||||
+63
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
+114
@@ -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());
|
||||
}
|
||||
}
|
||||
+40
@@ -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));
|
||||
}
|
||||
}
|
||||
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
+311
@@ -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;
|
||||
}
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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.
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+233
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+111
@@ -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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+156
@@ -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..]);
|
||||
}
|
||||
}
|
||||
+109
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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..]);
|
||||
}
|
||||
}
|
||||
+105
@@ -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);
|
||||
}
|
||||
}
|
||||
+283
@@ -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)]);
|
||||
}
|
||||
}
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
+276
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user