refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend)

The HistorianGateway driver is now the sole historian read/write+alarm backend, so the
Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver,
.Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully
retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver
factory): the Host probe registration, the AdminUI driver-config surface (driver page,
tag-config editor/model/validator entry, address picker/builder, driver-type catalog +
dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware
connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from
AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes
the stale XML docs that named Wonderware as the production backend.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 19:25:21 -04:00
parent 245db98f5e
commit 0b4b2e4cfd
84 changed files with 37 additions and 9345 deletions
@@ -1,266 +0,0 @@
// xUnit1051: MessagePackSerializer.Serialize/Deserialize have optional CancellationToken
// overloads; these are synchronous parity tests — suppressing the false-positive advisory.
#pragma warning disable xUnit1051
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
/// <summary>
/// Wire-parity tests for the client-side IPC contracts (Contracts.cs + Framing.cs).
/// These tests pin the MessagePack byte representation of each DTO using known inputs
/// and assert byte-equality against expected values. Because the sidecar (.NET 4.8)
/// carries a byte-identical mirror of these DTOs, a silent <c>[Key]</c> index drift or
/// field-type change in either copy would cause a mismatch here and be caught at build
/// time — without needing to reference the net48 sidecar assembly from a net10 test
/// project (which the TFM mismatch prevents). (Finding 009.)
/// </summary>
public sealed class ContractsWireParityTests
{
// ---- HistorianSampleDto ----
// Fields at Key(0)=ValueBytes(null), Key(1)=Quality(0), Key(2)=TimestampUtcTicks(0)
// MessagePack fixarray(3) + nil + fixint(0) + fixint(0) = 93 c0 00 00
/// <summary>Verifies that HistorianSampleDto serialized bytes are stable.</summary>
[Fact]
public void HistorianSampleDto_SerializedBytes_AreStable()
{
var dto = new HistorianSampleDto { ValueBytes = null, Quality = 0, TimestampUtcTicks = 0 };
var bytes = MessagePackSerializer.Serialize(dto);
// fixarray(3) = 0x93, nil = 0xC0, fixint(0) = 0x00, fixint(0) = 0x00
bytes.ShouldBe(new byte[] { 0x93, 0xC0, 0x00, 0x00 });
}
/// <summary>Verifies that HistorianSampleDto with value round-trips correctly.</summary>
[Fact]
public void HistorianSampleDto_WithValue_RoundTrips()
{
var original = new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(42.5),
Quality = 192,
TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks,
};
var bytes = MessagePackSerializer.Serialize(original);
var roundTripped = MessagePackSerializer.Deserialize<HistorianSampleDto>(bytes);
roundTripped.Quality.ShouldBe((byte)192);
roundTripped.TimestampUtcTicks.ShouldBe(original.TimestampUtcTicks);
roundTripped.ValueBytes.ShouldBe(original.ValueBytes);
}
// ---- HistorianAggregateSampleDto ----
// Key(0)=Value(null), Key(1)=TimestampUtcTicks(0)
// fixarray(2) + nil + fixint(0) = 92 c0 00
/// <summary>Verifies that HistorianAggregateSampleDto serialized bytes are stable.</summary>
[Fact]
public void HistorianAggregateSampleDto_SerializedBytes_AreStable()
{
var dto = new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = 0 };
var bytes = MessagePackSerializer.Serialize(dto);
// fixarray(2) = 0x92, nil = 0xC0, fixint(0) = 0x00
bytes.ShouldBe(new byte[] { 0x92, 0xC0, 0x00 });
}
// ---- ReadRawRequest ----
// 5 fields at Key(0..4). TagName="", StartUtcTicks=0, EndUtcTicks=0, MaxValues=0, CorrelationId=""
// fixarray(5) + fixstr(0)="" + fixint(0) + fixint(0) + fixint(0) + fixstr(0)=""
/// <summary>Verifies that an empty ReadRawRequest serializes as a fixed array of 5 elements.</summary>
[Fact]
public void ReadRawRequest_EmptyInstance_SerializesAsFixArray5()
{
var req = new ReadRawRequest();
var bytes = MessagePackSerializer.Serialize(req);
// Should start with fixarray(5) = 0x95
bytes[0].ShouldBe((byte)0x95);
// Round-trip verification
var rt = MessagePackSerializer.Deserialize<ReadRawRequest>(bytes);
rt.TagName.ShouldBe(string.Empty);
rt.MaxValues.ShouldBe(0);
}
/// <summary>Verifies that ReadRawRequest with values round-trips correctly.</summary>
[Fact]
public void ReadRawRequest_WithValues_RoundTrips()
{
var original = new ReadRawRequest
{
TagName = "Tank.Level",
StartUtcTicks = 100L,
EndUtcTicks = 200L,
MaxValues = 500,
CorrelationId = "abc",
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<ReadRawRequest>(bytes);
rt.TagName.ShouldBe("Tank.Level");
rt.StartUtcTicks.ShouldBe(100L);
rt.EndUtcTicks.ShouldBe(200L);
rt.MaxValues.ShouldBe(500);
rt.CorrelationId.ShouldBe("abc");
}
// ---- ReadRawReply ----
/// <summary>Verifies that ReadRawReply round-trips correctly.</summary>
[Fact]
public void ReadRawReply_RoundTrips()
{
var original = new ReadRawReply
{
CorrelationId = "x",
Success = true,
Error = null,
Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = 99L }],
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<ReadRawReply>(bytes);
rt.CorrelationId.ShouldBe("x");
rt.Success.ShouldBeTrue();
rt.Error.ShouldBeNull();
rt.Samples.Length.ShouldBe(1);
rt.Samples[0].Quality.ShouldBe((byte)192);
rt.Samples[0].TimestampUtcTicks.ShouldBe(99L);
}
// ---- ReadAtTimeRequest / ReadAtTimeReply ----
/// <summary>Verifies that ReadAtTimeRequest round-trips correctly.</summary>
[Fact]
public void ReadAtTimeRequest_RoundTrips()
{
var ticks = new long[] { 100L, 200L, 300L };
var original = new ReadAtTimeRequest { TagName = "T", TimestampsUtcTicks = ticks, CorrelationId = "c" };
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(bytes);
rt.TagName.ShouldBe("T");
rt.TimestampsUtcTicks.ShouldBe(ticks);
rt.CorrelationId.ShouldBe("c");
}
// ---- WriteAlarmEventsRequest / WriteAlarmEventsReply ----
/// <summary>Verifies that WriteAlarmEventsRequest round-trips correctly.</summary>
[Fact]
public void WriteAlarmEventsRequest_RoundTrips()
{
var original = new WriteAlarmEventsRequest
{
Events =
[
new AlarmHistorianEventDto
{
EventId = "ev1",
SourceName = "Tank/HiHi",
ConditionId = "HiHi",
AlarmType = "LimitAlarm:Activated",
Message = "msg",
Severity = 700,
EventTimeUtcTicks = 999L,
AckComment = null,
},
],
CorrelationId = "r",
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(bytes);
rt.CorrelationId.ShouldBe("r");
rt.Events.Length.ShouldBe(1);
rt.Events[0].EventId.ShouldBe("ev1");
rt.Events[0].SourceName.ShouldBe("Tank/HiHi");
rt.Events[0].Severity.ShouldBe((ushort)700);
rt.Events[0].EventTimeUtcTicks.ShouldBe(999L);
}
/// <summary>Verifies that WriteAlarmEventsReply round-trips correctly (legacy PerEventOk path).</summary>
[Fact]
public void WriteAlarmEventsReply_RoundTrips()
{
var original = new WriteAlarmEventsReply
{
CorrelationId = "r",
Success = true,
Error = null,
PerEventOk = [true, false, true],
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(bytes);
rt.CorrelationId.ShouldBe("r");
rt.Success.ShouldBeTrue();
rt.PerEventOk.ShouldBe(new[] { true, false, true });
}
/// <summary>
/// Pins the <c>[Key(4)]</c> index for <see cref="WriteAlarmEventsReply.PerEventStatus"/>,
/// the additive granular status field added in the <c>feddc2b8</c> commit. A silent
/// Key-index drift in either the client or the sidecar mirror copy would swap the legacy
/// <c>PerEventOk</c> bool array and the new status byte array, misclassifying outcomes
/// at runtime. (Finding 013.)
/// </summary>
[Fact]
public void WriteAlarmEventsReply_PerEventStatus_IsAtKey4_AndRoundTrips()
{
var original = new WriteAlarmEventsReply
{
CorrelationId = "s",
Success = true,
PerEventOk = [true],
PerEventStatus = [0, 1, 2], // Ack, Retry, Permanent
};
var bytes = MessagePackSerializer.Serialize(original);
// The array must start with fixarray(5) — five keys at indices 0-4.
bytes[0].ShouldBe((byte)0x95, "WriteAlarmEventsReply must be a 5-field MessagePack array");
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(bytes);
rt.CorrelationId.ShouldBe("s");
rt.Success.ShouldBeTrue();
rt.PerEventOk.ShouldBe(new[] { true });
// Key(4): PerEventStatus must round-trip independently of Key(3): PerEventOk.
rt.PerEventStatus.ShouldBe(new byte[] { 0, 1, 2 });
}
// ---- MessageKind enum values are pinned ----
// Changing a MessageKind value is a wire break; pin them explicitly.
/// <summary>Verifies that MessageKind enum values are stable.</summary>
[Fact]
public void MessageKind_Values_AreStable()
{
((byte)MessageKind.Hello).ShouldBe((byte)0x01);
((byte)MessageKind.HelloAck).ShouldBe((byte)0x02);
((byte)MessageKind.ReadRawRequest).ShouldBe((byte)0x10);
((byte)MessageKind.ReadRawReply).ShouldBe((byte)0x11);
((byte)MessageKind.ReadProcessedRequest).ShouldBe((byte)0x12);
((byte)MessageKind.ReadProcessedReply).ShouldBe((byte)0x13);
((byte)MessageKind.ReadAtTimeRequest).ShouldBe((byte)0x14);
((byte)MessageKind.ReadAtTimeReply).ShouldBe((byte)0x15);
((byte)MessageKind.ReadEventsRequest).ShouldBe((byte)0x16);
((byte)MessageKind.ReadEventsReply).ShouldBe((byte)0x17);
((byte)MessageKind.WriteAlarmEventsRequest).ShouldBe((byte)0x20);
((byte)MessageKind.WriteAlarmEventsReply).ShouldBe((byte)0x21);
}
// ---- Framing constants are pinned ----
/// <summary>Verifies that framing constants are stable.</summary>
[Fact]
public void Framing_Constants_AreStable()
{
Framing.LengthPrefixSize.ShouldBe(4);
Framing.KindByteSize.ShouldBe(1);
Framing.MaxFrameBodyBytes.ShouldBe(16 * 1024 * 1024);
}
}
@@ -1,215 +0,0 @@
using System.Net;
using System.Net.Sockets;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
/// <summary>
/// In-process fake of the Wonderware historian sidecar. Reuses the client-side framing
/// code (which is byte-identical to the real sidecar) so the wire bytes round-trip
/// correctly without requiring the .NET 4.8 sidecar binary at test time. Listens on a
/// loopback <see cref="TcpListener"/> and serves one connection at a time, mirroring the
/// real sidecar's <c>TcpFrameServer</c> single-active-connection model.
/// </summary>
internal sealed class FakeSidecarServer : IAsyncDisposable
{
private readonly string _expectedSecret;
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
/// <summary>Gets or sets the handler for ReadRaw requests.</summary>
public Func<ReadRawRequest, ReadRawReply> OnReadRaw { get; set; } = _ => new ReadRawReply { Success = true };
/// <summary>Gets or sets the handler for ReadProcessed requests.</summary>
public Func<ReadProcessedRequest, ReadProcessedReply> OnReadProcessed { get; set; } = _ => new ReadProcessedReply { Success = true };
/// <summary>Gets or sets the handler for ReadAtTime requests.</summary>
public Func<ReadAtTimeRequest, ReadAtTimeReply> OnReadAtTime { get; set; } = _ => new ReadAtTimeReply { Success = true };
/// <summary>Gets or sets the handler for ReadEvents requests.</summary>
public Func<ReadEventsRequest, ReadEventsReply> OnReadEvents { get; set; } = _ => new ReadEventsReply { Success = true };
/// <summary>Gets or sets the handler for WriteAlarmEvents requests.</summary>
public Func<WriteAlarmEventsRequest, WriteAlarmEventsReply> OnWriteAlarmEvents { get; set; } = req
=> new WriteAlarmEventsReply { Success = true, PerEventOk = Enumerable.Repeat(true, req.Events.Length).ToArray() };
/// <summary>Force-disconnect the next accepted client mid-call to exercise reconnect.</summary>
public bool DisconnectAfterHandshake { get; set; }
/// <summary>
/// Drop the connection after the handshake but before replying to any non-Hello request.
/// Armed for every connection until reset. Used to exercise the WriteBatchAsync catch
/// path and the second-attempt-also-fails propagation path.
/// </summary>
public bool DisconnectBeforeReply { get; set; }
/// <summary>
/// Reply to the first non-Hello request with this kind instead of the expected kind,
/// to exercise <see cref="System.IO.InvalidDataException"/> detection in ExchangeAsync.
/// Reset to null after the first mis-routed reply.
/// </summary>
public MessageKind? ReplyWithWrongKind { get; set; }
/// <summary>
/// Stall indefinitely after receiving a request before sending any reply, so the client's
/// call-timeout token fires. Used to test the CallTimeout path.
/// </summary>
public bool StallAfterRequest { get; set; }
/// <summary>Initializes a new instance of FakeSidecarServer with the specified expected secret.</summary>
/// <param name="expectedSecret">The expected shared secret for handshake validation.</param>
public FakeSidecarServer(string expectedSecret)
{
_expectedSecret = expectedSecret;
// Bind synchronously in the ctor so BoundPort is readable before StartAsync returns.
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
}
/// <summary>Gets the loopback host the listener is bound to.</summary>
public string Host => "127.0.0.1";
/// <summary>Gets the TCP port the listener actually bound (OS-assigned).</summary>
public int BoundPort => ((IPEndPoint)_listener.LocalEndpoint).Port;
/// <summary>Starts the fake sidecar server asynchronously. The listener is already bound (ctor).</summary>
public Task StartAsync()
{
_loop = Task.Run(() => RunAsync(_cts.Token));
return Task.CompletedTask;
}
private async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
TcpClient tcpClient;
try { tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
using (tcpClient)
{
try
{
tcpClient.NoDelay = true;
var stream = tcpClient.GetStream();
using var reader = new FrameReader(stream, leaveOpen: true);
using var writer = new FrameWriter(stream, leaveOpen: true);
// Hello handshake.
var first = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
if (first is null || first.Value.Kind != MessageKind.Hello) continue;
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
if (!string.Equals(hello.SharedSecret, _expectedSecret, StringComparison.Ordinal))
{
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, ct);
continue;
}
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = true, HostName = "fake-sidecar" }, ct);
if (DisconnectAfterHandshake)
{
DisconnectAfterHandshake = false; // arm once
tcpClient.Close();
continue;
}
while (!ct.IsCancellationRequested)
{
var frame = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
if (frame is null) break;
// Drop before sending any reply — lets the client fall into its catch /
// retry path or propagate on second failure.
if (DisconnectBeforeReply)
{
tcpClient.Close();
break;
}
// Stall indefinitely to let the client's call-timeout token fire.
if (StallAfterRequest)
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
break;
}
// Optionally send a deliberately wrong kind back to exercise
// InvalidDataException detection in the client's ExchangeAsync.
if (ReplyWithWrongKind.HasValue)
{
var wrongKind = ReplyWithWrongKind.Value;
ReplyWithWrongKind = null; // arm once
// Send an empty body with the wrong kind so the client can parse it.
await writer.WriteAsync(wrongKind, new ReadRawReply { Success = false }, ct);
continue;
}
switch (frame.Value.Kind)
{
case MessageKind.ReadRawRequest:
{
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(frame.Value.Body);
var reply = OnReadRaw(req);
reply.CorrelationId = req.CorrelationId;
await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct);
break;
}
case MessageKind.ReadProcessedRequest:
{
var req = MessagePackSerializer.Deserialize<ReadProcessedRequest>(frame.Value.Body);
var reply = OnReadProcessed(req);
reply.CorrelationId = req.CorrelationId;
await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct);
break;
}
case MessageKind.ReadAtTimeRequest:
{
var req = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(frame.Value.Body);
var reply = OnReadAtTime(req);
reply.CorrelationId = req.CorrelationId;
await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct);
break;
}
case MessageKind.ReadEventsRequest:
{
var req = MessagePackSerializer.Deserialize<ReadEventsRequest>(frame.Value.Body);
var reply = OnReadEvents(req);
reply.CorrelationId = req.CorrelationId;
await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct);
break;
}
case MessageKind.WriteAlarmEventsRequest:
{
var req = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(frame.Value.Body);
var reply = OnWriteAlarmEvents(req);
reply.CorrelationId = req.CorrelationId;
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct);
break;
}
}
}
}
catch (OperationCanceledException) { break; }
catch (IOException) { /* peer dropped — accept next */ }
}
}
}
/// <summary>Releases all resources used by the fake sidecar server.</summary>
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try { _listener.Stop(); } catch { /* ignore */ }
if (_loop is not null)
{
try { await _loop.ConfigureAwait(false); } catch { /* ignore shutdown errors */ }
}
_cts.Dispose();
}
}
@@ -1,148 +0,0 @@
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
/// <summary>
/// Tests for <see cref="FrameChannel.DefaultTcpConnectFactory"/>. Each scenario binds a
/// loopback <see cref="TcpListener"/> on <c>127.0.0.1:0</c>, accepts on a background task,
/// and drives the client factory against it — proving a plaintext stream round-trips a byte,
/// a TLS connection succeeds when the pinned thumbprint matches, and fails when it does not.
/// </summary>
public sealed class TcpConnectFactoryTests
{
// Generous timeout so the deterministic tests never hang CI if a side stalls.
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
/// <summary>Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key.</summary>
private static X509Certificate2 MakeSelfSignedCert()
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false));
using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
// Round-trip through a PFX so the returned cert carries an exportable private key.
var pfx = ephemeral.Export(X509ContentType.Pfx, "pw");
return X509CertificateLoader.LoadPkcs12(pfx, "pw", X509KeyStorageFlags.Exportable);
}
/// <summary>Plaintext: the factory returns a connected stream; a byte written server-side reads back client-side.</summary>
[Fact]
public async Task Plaintext_ReturnsConnectedStream_ByteRoundTrips()
{
using var cts = new CancellationTokenSource(Timeout);
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
// Accept one client and push a single byte from the server side.
var serverTask = Task.Run(async () =>
{
using var server = await listener.AcceptTcpClientAsync(cts.Token);
var serverStream = server.GetStream();
await serverStream.WriteAsync(new byte[] { 0x7A }, cts.Token);
await serverStream.FlushAsync(cts.Token);
// Hold the connection open until the client has read.
await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token);
}, cts.Token);
var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
{
UseTls = false,
};
await using var clientStream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token);
var buffer = new byte[1];
var read = await clientStream.ReadAsync(buffer, cts.Token);
read.ShouldBe(1);
buffer[0].ShouldBe((byte)0x7A);
await serverTask;
listener.Stop();
}
/// <summary>TLS pin match: a self-signed cert pinned by thumbprint authenticates successfully.</summary>
[Fact]
public async Task Tls_PinnedThumbprintMatches_ConnectsSuccessfully()
{
using var cts = new CancellationTokenSource(Timeout);
using var cert = MakeSelfSignedCert();
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
var serverTask = Task.Run(async () =>
{
using var server = await listener.AcceptTcpClientAsync(cts.Token);
var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false,
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false);
// Hold open until the client finished its handshake.
await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token);
ssl.Dispose();
}, cts.Token);
var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
{
UseTls = true,
ServerCertThumbprint = cert.GetCertHashString(),
};
await using var stream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token);
stream.ShouldBeOfType<SslStream>();
((SslStream)stream).IsAuthenticated.ShouldBeTrue();
await serverTask;
listener.Stop();
}
/// <summary>TLS wrong thumbprint: the pin check fails the validation callback → AuthenticationException.</summary>
[Fact]
public async Task Tls_WrongThumbprint_ThrowsAuthenticationException()
{
using var cts = new CancellationTokenSource(Timeout);
using var cert = MakeSelfSignedCert();
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
// The server still attempts its handshake; it will fault when the client aborts. Swallow.
var serverTask = Task.Run(async () =>
{
try
{
using var server = await listener.AcceptTcpClientAsync(cts.Token);
var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false,
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false);
ssl.Dispose();
}
catch
{
// Expected — the client rejects the cert and tears the connection down.
}
}, cts.Token);
var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
{
UseTls = true,
ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus
};
await Should.ThrowAsync<AuthenticationException>(
async () => await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token));
try { await serverTask; } catch { /* ignore */ }
listener.Stop();
}
}
@@ -1,36 +0,0 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
/// <summary>
/// Unit tests for <see cref="WonderwareHistorianClientOptions"/> TCP/TLS fields.
/// </summary>
public sealed class WonderwareHistorianClientOptionsTests
{
[Fact]
public void TcpTlsFields_AreStoredCorrectly_WhenExplicitlySet()
{
var opts = new WonderwareHistorianClientOptions("h", 32569, "secret")
{
UseTls = true,
ServerCertThumbprint = "AB"
};
opts.Host.ShouldBe("h");
opts.Port.ShouldBe(32569);
opts.UseTls.ShouldBeTrue();
opts.ServerCertThumbprint.ShouldBe("AB");
}
[Fact]
public void TcpTlsFields_HaveCorrectDefaults_WhenNotSet()
{
var opts = new WonderwareHistorianClientOptions("host", 32569, "secret");
opts.Host.ShouldBe("host");
opts.Port.ShouldBe(32569);
opts.UseTls.ShouldBeFalse();
opts.ServerCertThumbprint.ShouldBeNull();
}
}
@@ -1,890 +0,0 @@
using System.Net;
using System.Net.Sockets;
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
/// <summary>
/// End-to-end tests for <see cref="WonderwareHistorianClient"/>: every interface method
/// round-trips over a real loopback TCP connection against the in-process
/// <see cref="FakeSidecarServer"/>, which reuses the client's own byte-identical framing
/// code. Covers byte→uint quality mapping, BadNoData propagation for null aggregate
/// buckets, alarm-write per-event status flow, Hello handshake rejection on bad secret,
/// and reconnect after a transport drop.
/// </summary>
public sealed class WonderwareHistorianClientTests
{
private const string Secret = "test-secret-123";
private static WonderwareHistorianClientOptions OptsFor(FakeSidecarServer server) => new(
Host: "127.0.0.1",
Port: server.BoundPort,
SharedSecret: Secret,
PeerName: "test",
ConnectTimeout: TimeSpan.FromSeconds(2),
CallTimeout: TimeSpan.FromSeconds(2))
{
UseTls = false,
};
/// <summary>
/// Creates a client over loopback TCP against the fake's bound port using the public ctor
/// (which dials TCP).
/// </summary>
private static WonderwareHistorianClient TcpClientFor(FakeSidecarServer server)
=> new(OptsFor(server));
/// <summary>Verifies that ReadRawAsync round-trips samples and maps quality bytes to OPC UA status codes.</summary>
[Fact]
public async Task ReadRawAsync_RoundTripsSamples_AndMapsQualityByteToOpcUaStatusCode()
{
await using var server = new FakeSidecarServer(Secret)
{
OnReadRaw = req => new ReadRawReply
{
Success = true,
Samples =
[
new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(42.0),
Quality = 192, // Good
TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks,
},
new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(43.5),
Quality = 8, // Bad_NotConnected
TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 1, DateTimeKind.Utc).Ticks,
},
],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var result = await client.ReadRawAsync("Tank.Level",
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc),
100, CancellationToken.None);
result.ContinuationPoint.ShouldBeNull();
result.Samples.Count.ShouldBe(2);
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
result.Samples[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc));
result.Samples[1].StatusCode.ShouldBe(0x808A0000u); // Bad_NotConnected
}
/// <summary>Verifies that ReadProcessedAsync maps null buckets to BadNoData status.</summary>
[Fact]
public async Task ReadProcessedAsync_NullBuckets_MapToBadNoData()
{
await using var server = new FakeSidecarServer(Secret)
{
OnReadProcessed = _ => new ReadProcessedReply
{
Success = true,
Buckets =
[
new HistorianAggregateSampleDto { Value = 50.0, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks },
new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 1, 0, DateTimeKind.Utc).Ticks },
],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var result = await client.ReadProcessedAsync("Tank.Level",
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc),
TimeSpan.FromMinutes(1), HistoryAggregateType.Average, CancellationToken.None);
result.Samples.Count.ShouldBe(2);
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
result.Samples[0].Value.ShouldBe(50.0);
result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData
result.Samples[1].Value.ShouldBeNull();
}
/// <summary>Verifies that ReadAtTimeAsync preserves timestamp order.</summary>
[Fact]
public async Task ReadAtTimeAsync_PreservesTimestampOrder()
{
var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc);
await using var server = new FakeSidecarServer(Secret)
{
OnReadAtTime = req => new ReadAtTimeReply
{
Success = true,
Samples = req.TimestampsUtcTicks
.Select(ticks => new HistorianSampleDto { Quality = 192, TimestampUtcTicks = ticks })
.ToArray(),
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2 }, CancellationToken.None);
result.Samples.Count.ShouldBe(2);
result.Samples[0].SourceTimestampUtc.ShouldBe(t1);
result.Samples[1].SourceTimestampUtc.ShouldBe(t2);
}
/// <summary>Verifies that ReadAtTimeAsync aligns by timestamp and fills gaps with bad status.</summary>
[Fact]
public async Task ReadAtTimeAsync_PartialAndReorderedReply_AlignsByTimestamp_AndFillsGapsAsBad()
{
var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc);
var t3 = new DateTime(2026, 4, 29, 3, 0, 0, DateTimeKind.Utc);
await using var server = new FakeSidecarServer(Secret)
{
// Sidecar returns only t3 and t1 (out of order), drops t2 entirely. A
// contract-compliant client must realign by timestamp and synthesize a
// Bad-quality snapshot for the missing t2.
OnReadAtTime = _ => new ReadAtTimeReply
{
Success = true,
Samples =
[
new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(3.0),
Quality = 192, TimestampUtcTicks = t3.Ticks,
},
new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(1.0),
Quality = 192, TimestampUtcTicks = t1.Ticks,
},
],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2, t3 }, CancellationToken.None);
// Result MUST be the same length and order as the request.
result.Samples.Count.ShouldBe(3);
result.Samples[0].SourceTimestampUtc.ShouldBe(t1);
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
result.Samples[0].Value.ShouldBe(1.0);
// t2 was not returned by the sidecar → Bad-quality gap snapshot at the requested time.
result.Samples[1].SourceTimestampUtc.ShouldBe(t2);
result.Samples[1].StatusCode.ShouldBe(0x80000000u); // Bad
result.Samples[1].Value.ShouldBeNull();
result.Samples[2].SourceTimestampUtc.ShouldBe(t3);
result.Samples[2].StatusCode.ShouldBe(0x00000000u); // Good
result.Samples[2].Value.ShouldBe(3.0);
}
/// <summary>Verifies that ReadEventsAsync preserves event field values.</summary>
[Fact]
public async Task ReadEventsAsync_PreservesEventFields()
{
var eid = Guid.NewGuid().ToString("N");
await using var server = new FakeSidecarServer(Secret)
{
OnReadEvents = _ => new ReadEventsReply
{
Success = true,
Events =
[
new HistorianEventDto
{
EventId = eid, Source = "Tank.HiHi",
EventTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc).Ticks,
ReceivedTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 1, DateTimeKind.Utc).Ticks,
DisplayText = "Level high-high", Severity = 800,
},
],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var result = await client.ReadEventsAsync("Tank.HiHi",
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc),
100, CancellationToken.None);
result.Events.Count.ShouldBe(1);
result.Events[0].EventId.ShouldBe(eid);
result.Events[0].SourceName.ShouldBe("Tank.HiHi");
result.Events[0].Message.ShouldBe("Level high-high");
result.Events[0].Severity.ShouldBe((ushort)800);
}
/// <summary>Verifies that ReadRawAsync throws InvalidOperationException on server errors.</summary>
[Fact]
public async Task ReadRawAsync_ServerError_ThrowsInvalidOperation()
{
await using var server = new FakeSidecarServer(Secret)
{
OnReadRaw = _ => new ReadRawReply { Success = false, Error = "historian unreachable" },
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
ex.Message.ShouldContain("historian unreachable");
}
/// <summary>Verifies that WriteBatchAsync maps per-event results to acknowledge or retry outcomes.</summary>
[Fact]
public async Task WriteBatchAsync_PerEventOk_MapsToAckOrRetryPlease()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = req => new WriteAlarmEventsReply
{
Success = true,
PerEventOk = req.Events.Select(e => e.EventId != "ev-fail").ToArray(),
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "operator", null, DateTime.UtcNow),
new AlarmHistorianEvent("ev-fail", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "operator", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(2);
outcomes[0].ShouldBe(HistorianWriteOutcome.Ack);
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease);
}
/// <summary>Verifies that WriteBatchAsync returns retry outcomes for whole call failures.</summary>
[Fact]
public async Task WriteBatchAsync_WholeCallFailure_ReturnsRetryPleaseForEveryEvent()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = false,
Error = "historian event-store down",
PerEventOk = new bool[2],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(2);
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease);
}
/// <summary>
/// The granular PerEventStatus wire field maps directly: 0→Ack, 1→Retry, 2→PermanentFail.
/// A poison event the sidecar marks Permanent (status 2) must dead-letter via
/// <see cref="HistorianWriteOutcome.PermanentFail"/> rather than retrying.
/// </summary>
[Fact]
public async Task WriteBatchAsync_PerEventStatusPermanent_MapsToPermanentFail()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [2], // Permanent
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-poison", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(1);
outcomes[0].ShouldBe(HistorianWriteOutcome.PermanentFail);
}
/// <summary>
/// PerEventStatus = 0 maps to <see cref="HistorianWriteOutcome.Ack"/>; the granular path
/// takes precedence over the legacy PerEventOk bool when both are present.
/// </summary>
[Fact]
public async Task WriteBatchAsync_PerEventStatusAck_MapsToAck()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [0], // Ack
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-ok", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(1);
outcomes[0].ShouldBe(HistorianWriteOutcome.Ack);
}
/// <summary>
/// When <c>PerEventStatus</c> is present but its length does not equal the batch size,
/// the client must ignore it and fall back to the legacy <c>PerEventOk</c> path to
/// avoid mis-indexing into the status array. Here a 2-event batch receives
/// <c>PerEventStatus=[1]</c> (length 1) but <c>PerEventOk=[true, false]</c>; the
/// outcomes must reflect the PerEventOk values ([Ack, RetryPlease]), not the status
/// byte (which would have produced [RetryPlease] had it been used).
/// </summary>
[Fact]
public async Task WriteBatchAsync_PerEventStatusLengthMismatch_FallsBackToPerEventOk()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [1], // length 1 ≠ batch count 2 → must be ignored
PerEventOk = [true, false], // legacy fallback: true→Ack, false→RetryPlease
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(2);
outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); // PerEventOk[0] = true
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); // PerEventOk[1] = false
}
/// <summary>
/// Status byte 1 (the only value that is neither 0 nor 2) must map to
/// <see cref="HistorianWriteOutcome.RetryPlease"/> via the default arm of the
/// <c>PerEventStatus</c> switch. A single-event batch with <c>PerEventStatus=[1]</c>
/// (length matches batch) must yield <c>[RetryPlease]</c>.
/// </summary>
[Fact]
public async Task WriteBatchAsync_PerEventStatusRetry_MapsToRetryPlease()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [1], // status 1 → RetryPlease
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-retry", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(1);
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
}
/// <summary>
/// Rolling-deploy back-compat: an older sidecar that sends an empty PerEventStatus but a
/// populated PerEventOk must still classify via the legacy bool path (false→RetryPlease).
/// </summary>
[Fact]
public async Task WriteBatchAsync_EmptyPerEventStatus_FallsBackToLegacyPerEventOk()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [], // older sidecar — no granular status
PerEventOk = [false],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-legacy", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(1);
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
}
/// <summary>Verifies that Hello handshake throws UnauthorizedAccessException on secret mismatch.</summary>
[Fact]
public async Task Hello_BadSecret_ThrowsUnauthorizedAccess()
{
await using var server = new FakeSidecarServer("different-secret");
await server.StartAsync();
await using var client = TcpClientFor(server);
var ex = await Should.ThrowAsync<UnauthorizedAccessException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
ex.Message.ShouldContain("shared-secret-mismatch");
}
/// <summary>Verifies that the client retries after a transport drop.</summary>
[Fact]
public async Task Reconnect_AfterTransportDrop_RetriesOnce()
{
await using var server = new FakeSidecarServer(Secret)
{
// First connection drops after handshake → client retries on next call.
DisconnectAfterHandshake = true,
OnReadRaw = req => new ReadRawReply
{
Success = true,
Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = req.StartUtcTicks }],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
// First call: handshake + dropped. Reconnect kicks in inside the channel; second
// attempt within the same InvokeAsync succeeds. From the caller's perspective it's
// one ReadRawAsync that returns a sample.
var result = await client.ReadRawAsync("Tag",
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc),
100, CancellationToken.None);
result.Samples.Count.ShouldBe(1);
}
/// <summary>Verifies that GetHealthSnapshot tracks success and failure counts.</summary>
[Fact]
public async Task GetHealthSnapshot_TracksSuccessAndFailureCounts()
{
var failNext = false;
await using var server = new FakeSidecarServer(Secret)
{
OnReadRaw = _ => failNext
? new ReadRawReply { Success = false, Error = "boom" }
: new ReadRawReply { Success = true },
};
await server.StartAsync();
await using var client = TcpClientFor(server);
await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None);
failNext = true;
await Should.ThrowAsync<InvalidOperationException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None));
var snap = client.GetHealthSnapshot();
snap.TotalQueries.ShouldBe(2);
snap.TotalSuccesses.ShouldBe(1);
snap.TotalFailures.ShouldBe(1);
snap.ConsecutiveFailures.ShouldBe(1);
snap.LastError.ShouldNotBeNull();
snap.ProcessConnectionOpen.ShouldBeTrue();
}
// ===== Finding-009: missing edge-case tests =====
/// <summary>
/// (2) A transport drop during a write (the catch path in WriteBatchAsync) must return
/// RetryPlease for every event in the batch — never throw, never PermanentFail.
/// </summary>
[Fact]
public async Task WriteBatchAsync_TransportDropDuringWrite_ReturnsRetryPleaseForEveryEvent()
{
// Server disconnects before replying to the write request. The client's single retry
// reconnects; on the second attempt the server is still armed to disconnect, so both
// attempts fail and the catch block fires.
await using var server = new FakeSidecarServer(Secret)
{
DisconnectBeforeReply = true,
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow),
};
// WriteBatchAsync must not throw — it absorbs transport failures as RetryPlease.
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(2);
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease);
}
/// <summary>
/// (3) When both the first attempt and the single retry fail (the "second attempt also
/// fails" path in InvokeAsync), the exception propagates to the caller.
/// </summary>
[Fact]
public async Task InvokeAsync_BothAttemptsFailTransport_PropagatesException()
{
// DisconnectBeforeReply stays true so both the first attempt and the single retry
// inside InvokeAsync are dropped, causing the second ExchangeAsync to throw.
await using var server = new FakeSidecarServer(Secret)
{
DisconnectBeforeReply = true,
};
await server.StartAsync();
await using var client = TcpClientFor(server);
// ReadRawAsync uses Invoke, which propagates the exception when both attempts fail.
await Should.ThrowAsync<Exception>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
}
/// <summary>
/// (4) A stalled sidecar that never sends a reply must cause an
/// <see cref="OperationCanceledException"/> within the configured CallTimeout.
/// </summary>
[Fact]
public async Task ReadRawAsync_StalledSidecar_TimesOutWithOperationCanceledException()
{
await using var server = new FakeSidecarServer(Secret)
{
StallAfterRequest = true,
};
await server.StartAsync();
var opts = new WonderwareHistorianClientOptions(
Host: "127.0.0.1",
Port: server.BoundPort,
SharedSecret: Secret,
PeerName: "test",
ConnectTimeout: TimeSpan.FromSeconds(2),
CallTimeout: TimeSpan.FromMilliseconds(500)) // short timeout for test speed
{
UseTls = false,
};
await using var client = new WonderwareHistorianClient(opts);
// The stall means neither the first nor the retry can complete, so the timeout
// linked-token should cancel the operation.
await Should.ThrowAsync<OperationCanceledException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
}
/// <summary>
/// (5) <see cref="HistoryAggregateType.Total"/> is derived client-side as the
/// time-weighted Average multiplied by the interval duration in seconds, because the
/// Wonderware AnalogSummary query exposes no Total column. The client must issue the
/// wire request with the Average column and scale every returned bucket value by
/// <c>interval.TotalSeconds</c>, carrying the bucket's quality and timestamp through.
/// </summary>
[Fact]
public async Task ReadProcessedAsync_TotalAggregate_ReturnsAverageTimesIntervalSeconds()
{
var bucketTs = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc);
string? requestedColumn = null;
await using var server = new FakeSidecarServer(Secret)
{
OnReadProcessed = req =>
{
// Capture the column the client asked for: Total must be requested as Average.
requestedColumn = req.AggregateColumn;
return new ReadProcessedReply
{
Success = true,
Buckets =
[
// One Good Average bucket of 2.0; with a 60s interval the derived
// Total is 2.0 * 60 = 120.0.
new HistorianAggregateSampleDto { Value = 2.0, TimestampUtcTicks = bucketTs.Ticks },
// A null (unavailable) Average bucket must stay BadNoData / null.
new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = bucketTs.AddMinutes(1).Ticks },
],
};
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var result = await client.ReadProcessedAsync("Tank.Level",
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc),
TimeSpan.FromMinutes(1), HistoryAggregateType.Total, CancellationToken.None);
// The wire request asks for the Average column — Total has no AnalogSummary column.
requestedColumn.ShouldBe("Average");
result.Samples.Count.ShouldBe(2);
// Total = Average (2.0) x interval-seconds (60) = 120.0, quality + timestamp carried.
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
result.Samples[0].Value.ShouldBe(120.0);
result.Samples[0].SourceTimestampUtc.ShouldBe(bucketTs);
// Null Average bucket → still BadNoData / null after scaling.
result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData
result.Samples[1].Value.ShouldBeNull();
result.Samples[1].SourceTimestampUtc.ShouldBe(bucketTs.AddMinutes(1));
}
/// <summary>
/// (6) When the sidecar replies with a <see cref="MessageKind"/> the client does not
/// expect (e.g. ReadRawReply where ReadAtTimeReply was expected), the client must throw
/// <see cref="InvalidDataException"/>.
/// </summary>
[Fact]
public async Task ReadRawAsync_SidecarRepliesWithWrongKind_ThrowsInvalidDataException()
{
await using var server = new FakeSidecarServer(Secret)
{
// Force the server to reply with ReadAtTimeReply instead of ReadRawReply.
ReplyWithWrongKind = MessageKind.ReadAtTimeReply,
};
await server.StartAsync();
await using var client = TcpClientFor(server);
await Should.ThrowAsync<InvalidDataException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
}
// ===== Finding-003 / Finding-004: health counter consistency =====
/// <summary>
/// (Finding 003 + 004) A sidecar-level failure must be classified once: TotalSuccesses
/// must stay at 0, TotalFailures must become 1, and TotalQueries / TotalSuccesses /
/// TotalFailures must all be updated under the same lock so a concurrent snapshot can
/// never observe inflated successes or out-of-band TotalQueries. This pins behaviour so
/// a future regression to the "RecordSuccess then undo via ReclassifySuccessAsFailure"
/// dance is caught.
/// </summary>
[Fact]
public async Task GetHealthSnapshot_SidecarFailure_NeverInflatesSuccessCounter()
{
await using var server = new FakeSidecarServer(Secret)
{
OnReadRaw = _ => new ReadRawReply { Success = false, Error = "boom" },
};
await server.StartAsync();
await using var client = TcpClientFor(server);
await Should.ThrowAsync<InvalidOperationException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None));
var snap = client.GetHealthSnapshot();
snap.TotalQueries.ShouldBe(1);
snap.TotalSuccesses.ShouldBe(0);
snap.TotalFailures.ShouldBe(1);
snap.ConsecutiveFailures.ShouldBe(1);
snap.LastError.ShouldNotBeNull();
}
/// <summary>
/// (Finding 003) Concurrent calls + concurrent <see cref="WonderwareHistorianClient.GetHealthSnapshot"/>
/// reads must observe consistent counters. Specifically, TotalSuccesses + TotalFailures
/// must equal TotalQueries at every observed snapshot (no torn read between an
/// Interlocked-incremented TotalQueries and a lock-protected outcome counter). The
/// channel serializes calls, so the test is observable: each completed query strictly
/// increments either successes or failures by one.
/// </summary>
[Fact]
public async Task GetHealthSnapshot_ConcurrentCallsAndReads_CountersAreInternallyConsistent()
{
await using var server = new FakeSidecarServer(Secret)
{
OnReadRaw = _ => new ReadRawReply { Success = true },
};
await server.StartAsync();
await using var client = TcpClientFor(server);
using var stop = new CancellationTokenSource();
var readerSawInconsistent = false;
#pragma warning disable xUnit1051 // Internal Task.Run loop drives a polling stress test; cancellation flows via stop.IsCancellationRequested below.
var reader = Task.Run(() =>
{
while (!stop.IsCancellationRequested)
{
var snap = client.GetHealthSnapshot();
// Every completed call increments TotalQueries AND exactly one of
// TotalSuccesses or TotalFailures under the same lock; an in-flight call
// has not yet incremented any of them. So TotalQueries should always equal
// the sum of TotalSuccesses + TotalFailures (no in-between state visible).
if (snap.TotalSuccesses + snap.TotalFailures != snap.TotalQueries)
{
readerSawInconsistent = true;
}
}
});
#pragma warning restore xUnit1051
for (var i = 0; i < 50; i++)
{
await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, TestContext.Current.CancellationToken);
}
stop.Cancel();
await reader;
readerSawInconsistent.ShouldBeFalse(
"GetHealthSnapshot exposed TotalQueries that disagreed with the sum of TotalSuccesses + TotalFailures — counters are not updated under a single lock.");
var final = client.GetHealthSnapshot();
final.TotalQueries.ShouldBe(50);
final.TotalSuccesses.ShouldBe(50);
final.TotalFailures.ShouldBe(0);
}
// ===== Task 3: default public ctor dials TCP =====
/// <summary>
/// Verifies that the default public ctor connects over TCP rather than named-pipe by
/// constructing the client against a loopback <see cref="TcpListener"/> and asserting
/// that a ReadRaw round-trip returns the known sample. If the ctor still dialled a
/// named pipe the connect would fail because no pipe is listening.
/// </summary>
[Fact]
public async Task DefaultCtor_DialsTcp_ReadRawRoundTrips()
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(10));
// 1. Start a loopback TCP listener on an OS-assigned port.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
var expectedTicks = new DateTime(2026, 6, 12, 8, 0, 0, DateTimeKind.Utc).Ticks;
var expectedValue = MessagePackSerializer.Serialize<object>(99.0, cancellationToken: TestContext.Current.CancellationToken);
// 2. Accept one client in the background and drive the server side of the protocol.
// Intentional: the background server task uses cts.Token (a linked+timeout source)
// rather than TestContext.Current.CancellationToken directly, because it adds a
// wall-clock safety bound so the test never hangs CI.
#pragma warning disable xUnit1051
var serverTask = Task.Run(async () =>
{
using var server = await listener.AcceptTcpClientAsync(cts.Token);
server.NoDelay = true;
var stream = server.GetStream();
using var reader = new FrameReader(stream, leaveOpen: true);
using var writer = new FrameWriter(stream, leaveOpen: true);
// Hello handshake.
var helloFrame = await reader.ReadFrameAsync(cts.Token);
helloFrame.ShouldNotBeNull();
helloFrame!.Value.Kind.ShouldBe(MessageKind.Hello);
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = true, HostName = "test-tcp-sidecar" }, cts.Token);
// ReadRaw request.
var reqFrame = await reader.ReadFrameAsync(cts.Token);
reqFrame.ShouldNotBeNull();
reqFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawRequest);
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(reqFrame.Value.Body);
var reply = new ReadRawReply
{
Success = true,
CorrelationId = req.CorrelationId,
Samples =
[
new HistorianSampleDto
{
ValueBytes = expectedValue,
Quality = 192, // Good
TimestampUtcTicks = expectedTicks,
},
],
};
await writer.WriteAsync(MessageKind.ReadRawReply, reply, cts.Token);
}, cts.Token);
#pragma warning restore xUnit1051
// 3. Construct the client via the PUBLIC ctor (no ForTests factory).
var opts = new WonderwareHistorianClientOptions(
Host: "127.0.0.1",
Port: boundPort,
SharedSecret: Secret,
ConnectTimeout: TimeSpan.FromSeconds(5),
CallTimeout: TimeSpan.FromSeconds(5))
{
UseTls = false,
};
WonderwareHistorianClient? client = null;
try
{
client = new WonderwareHistorianClient(opts);
var result = await client.ReadRawAsync(
"Tank.Level",
new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 6, 13, 0, 0, 0, DateTimeKind.Utc),
100, cts.Token);
// 4. Assert the known sample came back.
result.Samples.Count.ShouldBe(1);
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
result.Samples[0].SourceTimestampUtc.ShouldBe(new DateTime(expectedTicks, DateTimeKind.Utc));
result.Samples[0].Value.ShouldBe(99.0);
await serverTask;
}
finally
{
if (client is not null) await client.DisposeAsync();
listener.Stop();
}
}
}
@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack"/>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
</ItemGroup>
</Project>
@@ -1,270 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
/// <summary>
/// PR C.1 — pins the trinary outcome → IPC bool[] mapping that the sidecar uses
/// on the WriteAlarmEvents reply. Per-event outcomes:
/// Ack → true, RetryPlease → false, PermanentFail → false.
/// The sender's B.4 widens the IPC bool back into the trinary outcome at the
/// IPC boundary using structured diagnostics; the wire intentionally collapses
/// to "ok / not-ok".
/// </summary>
[Trait("Category", "Unit")]
public sealed class AahClientManagedAlarmEventWriterTests
{
/// <summary>Verifies that an empty batch returns an empty array without invoking the backend.</summary>
[Fact]
public async Task Empty_batch_returns_empty_array_without_invoking_backend()
{
var backend = new RecordingBackend(_ => throw new InvalidOperationException("must not invoke for empty input"));
var writer = new AahClientManagedAlarmEventWriter(backend);
var result = await writer.WriteAsync(Array.Empty<AlarmHistorianEventDto>(), CancellationToken.None);
result.ShouldBeEmpty();
backend.Calls.ShouldBe(0);
}
/// <summary>Verifies that a single acknowledgment outcome maps to true.</summary>
[Fact]
public async Task Single_ack_outcome_maps_to_true()
{
var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray());
var writer = new AahClientManagedAlarmEventWriter(backend);
var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None);
result.ShouldBe(new[] { true });
}
/// <summary>Verifies that a mixed batch preserves per-slot outcome ordering.</summary>
[Fact]
public async Task Mixed_batch_preserves_per_slot_ordering()
{
// Ack / Retry / Permanent / Ack — the sender uses positional matching against
// its queue, so every slot must hit the exact bool corresponding to its input.
var backend = new RecordingBackend(_ => new[]
{
AlarmHistorianWriteOutcome.Ack,
AlarmHistorianWriteOutcome.RetryPlease,
AlarmHistorianWriteOutcome.PermanentFail,
AlarmHistorianWriteOutcome.Ack,
});
var writer = new AahClientManagedAlarmEventWriter(backend);
var result = await writer.WriteAsync(
new[] { Event("E1"), Event("E2"), Event("E3"), Event("E4") },
CancellationToken.None);
result.ShouldBe(new[] { true, false, false, true });
}
/// <summary>Verifies that backend exceptions mark the whole batch as RetryPlease.</summary>
[Fact]
public async Task Backend_exception_marks_whole_batch_RetryPlease()
{
var backend = new RecordingBackend(_ => throw new InvalidOperationException("cluster unreachable"));
var writer = new AahClientManagedAlarmEventWriter(backend);
var result = await writer.WriteAsync(
new[] { Event("E1"), Event("E2"), Event("E3") },
CancellationToken.None);
// Whole batch must end up as "not ok" (RetryPlease at the trinary layer) —
// dropping a transiently-failed batch corrupts the sender's queue.
result.ShouldBe(new[] { false, false, false });
}
/// <summary>Verifies that cancellation propagates from the backend.</summary>
[Fact]
public async Task Cancellation_propagates_from_backend()
{
var backend = new RecordingBackend(_ => throw new OperationCanceledException());
var writer = new AahClientManagedAlarmEventWriter(backend);
var ex = await Should.ThrowAsync<OperationCanceledException>(() =>
writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None));
ex.ShouldNotBeNull();
}
/// <summary>Verifies that a backend returning the wrong outcome count degrades to RetryPlease.</summary>
[Fact]
public async Task Backend_returning_wrong_count_degrades_to_RetryPlease()
{
// Backend returns more outcomes than inputs — defensive degrade rather than
// letting a backend bug desync the sender's queue accounting.
var backend = new RecordingBackend(_ => new[]
{
AlarmHistorianWriteOutcome.Ack,
AlarmHistorianWriteOutcome.Ack,
});
var writer = new AahClientManagedAlarmEventWriter(backend);
var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None);
result.ShouldBe(new[] { false });
}
/// <summary>Verifies that a large batch with all acknowledgments returns all true outcomes.</summary>
/// <param name="batchSize">The batch size to test.</param>
[Theory]
[InlineData(100)]
[InlineData(1000)]
public async Task Large_batch_all_ack_returns_all_true(int batchSize)
{
// Spec: "1 / 100 / 1000 events through a fake aahClientManaged writer;
// assert per-row outcome list parallel to input order."
var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray());
var writer = new AahClientManagedAlarmEventWriter(backend);
var batch = Enumerable.Range(0, batchSize)
.Select(i => Event($"E{i}"))
.ToArray();
var result = await writer.WriteAsync(batch, CancellationToken.None);
result.Length.ShouldBe(batchSize);
result.ShouldAllBe(ok => ok);
backend.Calls.ShouldBe(1);
}
/// <summary>Verifies that a large batch with alternating outcomes preserves positional ordering.</summary>
/// <param name="batchSize">The batch size to test.</param>
[Theory]
[InlineData(100)]
[InlineData(1000)]
public async Task Large_batch_alternating_outcomes_are_positionally_correct(int batchSize)
{
// Verifies that per-row outcome ordering is preserved for large batches;
// a backend that returns the outcomes in a different allocation order would
// fail this test if the writer incorrectly indexing outcomes.
var backend = new RecordingBackend(events =>
events.Select((_, i) => i % 2 == 0
? AlarmHistorianWriteOutcome.Ack
: AlarmHistorianWriteOutcome.RetryPlease).ToArray());
var writer = new AahClientManagedAlarmEventWriter(backend);
var batch = Enumerable.Range(0, batchSize).Select(i => Event($"E{i}")).ToArray();
var result = await writer.WriteAsync(batch, CancellationToken.None);
result.Length.ShouldBe(batchSize);
for (var i = 0; i < result.Length; i++)
{
var expected = i % 2 == 0;
result[i].ShouldBe(expected, $"slot {i}: expected {expected}");
}
}
/// <summary>Verifies that retry then succeed correctly simulates cluster failover.</summary>
[Fact]
public async Task Backend_retry_then_succeed_simulates_cluster_failover()
{
// Spec: "Cluster failover: primary node returns BadCommunicationError;
// picker rotates to secondary; assert eventual success."
//
// The real cluster-failover path is internal to SdkAlarmHistorianWriteBackend
// (which is rig-gated) and is exercised at the HistorianClusterEndpointPicker
// level in HistorianClusterEndpointPickerTests. Here we test the
// AahClientManagedAlarmEventWriter's handling of a backend that returns
// RetryPlease on the first call (primary-node failure) and Ack on the
// second call (secondary-node success), confirming the IPC layer correctly
// propagates the trinary outcome across two separate drain ticks.
var callCount = 0;
var backend = new RecordingBackend(events =>
{
callCount++;
if (callCount == 1)
{
// First call: simulate communication error (isCommunicationError=true)
// which produces RetryPlease — equivalent to primary node failing.
return events.Select(_ => AlarmHistorianWriteOutcome.RetryPlease).ToArray();
}
// Second call (after cluster picker has rotated to secondary): Ack.
return events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray();
});
var writer = new AahClientManagedAlarmEventWriter(backend);
var batch = new[] { Event("E1"), Event("E2") };
// First drain tick: primary "fails" → all RetryPlease (false at IPC layer).
var firstResult = await writer.WriteAsync(batch, CancellationToken.None);
firstResult.ShouldBe(new[] { false, false });
// Second drain tick: secondary succeeds → all Ack (true at IPC layer).
var secondResult = await writer.WriteAsync(batch, CancellationToken.None);
secondResult.ShouldBe(new[] { true, true });
backend.Calls.ShouldBe(2);
}
/// <summary>Verifies outcome mapping across various HRESULT and error condition combinations.</summary>
/// <param name="hresult">The HRESULT code to test.</param>
/// <param name="isCommunicationError">Whether the error is a communication error.</param>
/// <param name="isMalformedInput">Whether the input is malformed.</param>
/// <param name="expected">The expected outcome.</param>
[Theory]
// hresult 0 + clean → Ack
[InlineData(0, false, false, AlarmHistorianWriteOutcome.Ack)]
// hresult 0 but malformed → PermanentFail (malformed wins)
[InlineData(0, false, true, AlarmHistorianWriteOutcome.PermanentFail)]
// non-zero hresult + comm error → RetryPlease
[InlineData(unchecked((int)0x80131500), true, false, AlarmHistorianWriteOutcome.RetryPlease)]
// non-zero hresult, no comm flag, no malformed → conservative RetryPlease
[InlineData(unchecked((int)0x80131500), false, false, AlarmHistorianWriteOutcome.RetryPlease)]
// any malformed input → PermanentFail regardless of hresult
[InlineData(unchecked((int)0x80131500), true, true, AlarmHistorianWriteOutcome.PermanentFail)]
public void MapOutcome_table(int hresult, bool isCommunicationError, bool isMalformedInput, AlarmHistorianWriteOutcome expected)
{
AahClientManagedAlarmEventWriter
.MapOutcome(hresult, isCommunicationError, isMalformedInput)
.ShouldBe(expected);
}
private static AlarmHistorianEventDto Event(string id) => new AlarmHistorianEventDto
{
EventId = id,
SourceName = "Tank01",
ConditionId = "Tank01.Level.HiHi",
AlarmType = "AnalogLimitAlarm.HiHi",
Message = "Tank 01 high-high level",
Severity = 750,
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
AckComment = null,
};
/// <summary>Test double that records calls and returns outcomes via a delegate.</summary>
private sealed class RecordingBackend : IAlarmHistorianWriteBackend
{
private readonly Func<AlarmHistorianEventDto[], AlarmHistorianWriteOutcome[]> _produce;
/// <summary>Gets the number of calls recorded.</summary>
public int Calls { get; private set; }
/// <summary>Initializes a new instance of the <see cref="RecordingBackend"/> class.</summary>
/// <param name="produce">A delegate that produces outcomes for the given events.</param>
public RecordingBackend(Func<AlarmHistorianEventDto[], AlarmHistorianWriteOutcome[]> produce)
{
_produce = produce;
}
/// <summary>Records a call and returns outcomes from the delegate.</summary>
/// <param name="events">The events to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcomes produced by the delegate.</returns>
public Task<AlarmHistorianWriteOutcome[]> WriteBatchAsync(
AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
{
Calls++;
return Task.FromResult(_produce(events));
}
}
}
}
@@ -1,101 +0,0 @@
using System;
using System.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
[Trait("Category", "Unit")]
public sealed class HistorianClusterEndpointPickerTests
{
private static HistorianConfiguration Config(params string[] nodes) => new()
{
ServerName = "ignored",
ServerNames = nodes.ToList(),
FailureCooldownSeconds = 60,
};
/// <summary>Verifies that a single-node configuration falls back to ServerName when ServerNames is empty.</summary>
[Fact]
public void Single_node_config_falls_back_to_ServerName_when_ServerNames_empty()
{
var cfg = new HistorianConfiguration { ServerName = "only-node", ServerNames = new() };
var p = new HistorianClusterEndpointPicker(cfg);
p.NodeCount.ShouldBe(1);
p.GetHealthyNodes().ShouldBe(new[] { "only-node" });
}
/// <summary>Verifies that a failed node enters cooldown and is skipped from the healthy nodes list.</summary>
[Fact]
public void Failed_node_enters_cooldown_and_is_skipped()
{
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now);
p.MarkFailed("a", "boom");
p.GetHealthyNodes().ShouldBe(new[] { "b" });
}
/// <summary>Verifies that the cooldown period expires after the configured time window.</summary>
[Fact]
public void Cooldown_expires_after_configured_window()
{
var clock = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => clock);
p.MarkFailed("a", "boom");
p.GetHealthyNodes().ShouldBe(new[] { "b" });
clock = clock.AddSeconds(61);
p.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
}
/// <summary>Verifies that marking a node healthy immediately clears its cooldown.</summary>
[Fact]
public void MarkHealthy_immediately_clears_cooldown()
{
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
var p = new HistorianClusterEndpointPicker(Config("a"), () => now);
p.MarkFailed("a", "boom");
p.GetHealthyNodes().ShouldBeEmpty();
p.MarkHealthy("a");
p.GetHealthyNodes().ShouldBe(new[] { "a" });
}
/// <summary>Verifies that when all nodes are in cooldown, an empty healthy list is returned.</summary>
[Fact]
public void All_nodes_in_cooldown_returns_empty_healthy_list()
{
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now);
p.MarkFailed("a", "x");
p.MarkFailed("b", "y");
p.GetHealthyNodes().ShouldBeEmpty();
p.NodeCount.ShouldBe(2);
}
/// <summary>Verifies that a snapshot reports failure count and the last error message.</summary>
[Fact]
public void Snapshot_reports_failure_count_and_last_error()
{
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
var p = new HistorianClusterEndpointPicker(Config("a"), () => now);
p.MarkFailed("a", "first");
p.MarkFailed("a", "second");
var snap = p.SnapshotNodeStates().Single();
snap.FailureCount.ShouldBe(2);
snap.LastError.ShouldBe("second");
snap.IsHealthy.ShouldBeFalse();
snap.CooldownUntil.ShouldNotBeNull();
}
/// <summary>Verifies that duplicate hostnames are deduplicated case-insensitively.</summary>
[Fact]
public void Duplicate_hostnames_are_deduplicated_case_insensitively()
{
var p = new HistorianClusterEndpointPicker(Config("NodeA", "nodea", "NodeB"));
p.NodeCount.ShouldBe(2);
}
}
}
@@ -1,160 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
/// <summary>
/// Driver.Historian.Wonderware-012 coverage — pins <see cref="HistorianDataSource"/>'s
/// connect-failover / cooldown loop via a fake <see cref="IHistorianConnectionFactory"/>.
/// A live <see cref="HistorianAccess"/> is never instantiated; the fake throws on every
/// attempt so the read path surfaces the connect failure without touching the SDK.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistorianDataSourceConnectFailoverTests
{
/// <summary>Verifies that ReadRaw throws when no nodes are healthy.</summary>
[Fact]
public async Task ReadRaw_when_no_nodes_are_healthy_throws_so_IPC_surfaces_Success_false()
{
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerNames = new List<string> { "node-a" },
FailureCooldownSeconds = 60,
// Disable the outer request timeout so the test doesn't race the connect failure
// against the timeout (we want the connect failure path, not a TimeoutException).
RequestTimeoutSeconds = 0,
};
var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory());
// Read methods used to swallow the connect exception and return an empty list with
// Success=true; the fix re-throws so the IPC layer surfaces Success=false. The
// exception must therefore propagate.
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
"Tank.Level",
new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc),
maxValues: 100,
CancellationToken.None));
}
/// <summary>Verifies that ReadRaw tries each cluster node in order.</summary>
[Fact]
public async Task ReadRaw_tries_each_cluster_node_in_order_until_one_succeeds_or_all_fail()
{
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerNames = new List<string> { "node-a", "node-b", "node-c" },
FailureCooldownSeconds = 60,
RequestTimeoutSeconds = 0,
};
var factory = new TrackingThrowingConnectionFactory();
var ds = new HistorianDataSource(cfg, factory);
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
"Tank.Level",
new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc),
maxValues: 100,
CancellationToken.None));
// All three candidates must be attempted in the configured order before the
// connect-loop gives up.
factory.AttemptedNodes.ShouldBe(new[] { "node-a", "node-b", "node-c" });
}
/// <summary>Verifies that failed nodes are marked in cooldown and not retried immediately.</summary>
[Fact]
public async Task ReadRaw_marks_failed_nodes_in_cooldown_so_a_subsequent_call_sees_no_healthy_nodes()
{
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerNames = new List<string> { "node-a", "node-b" },
FailureCooldownSeconds = 60,
RequestTimeoutSeconds = 0,
};
var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory());
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
"Tank.Level",
DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow,
maxValues: 100, CancellationToken.None));
var snap = ds.GetHealthSnapshot();
snap.NodeCount.ShouldBe(2);
snap.HealthyNodeCount.ShouldBe(0, "both nodes failed and entered cooldown after the connect attempts");
snap.ProcessConnectionOpen.ShouldBeFalse();
snap.ActiveProcessNode.ShouldBeNull();
}
/// <summary>Verifies that ReadEvents uses a separate event connection path.</summary>
[Fact]
public async Task ReadEvents_uses_a_separate_event_connection_path()
{
// ReadEventsAsync uses _eventConnection / EnsureEventConnected — a different
// codepath than ReadRawAsync. Symmetric test to pin the dual-connection design.
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerNames = new List<string> { "node-a" },
FailureCooldownSeconds = 60,
RequestTimeoutSeconds = 0,
};
var factory = new TrackingThrowingConnectionFactory();
var ds = new HistorianDataSource(cfg, factory);
await Should.ThrowAsync<Exception>(() => ds.ReadEventsAsync(
sourceName: "Tank.HiHi",
DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow,
maxEvents: 100, CancellationToken.None));
factory.AttemptedTypes.ShouldContain(HistorianConnectionType.Event,
"event reads must open an Event-typed connection");
factory.AttemptedNodes.ShouldBe(new[] { "node-a" });
}
// ── helpers ──────────────────────────────────────────────────────────
private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory
{
/// <summary>
/// Simulates a connection failure by throwing an exception.
/// </summary>
/// <param name="config">The historian configuration.</param>
/// <param name="type">The connection type.</param>
/// <param name="readOnly">Whether to open a read-only connection.</param>
public HistorianAccess CreateAndConnect(
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
=> throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
}
private sealed class TrackingThrowingConnectionFactory : IHistorianConnectionFactory
{
/// <summary>Gets the list of node names that were attempted.</summary>
public List<string> AttemptedNodes { get; } = new();
/// <summary>Gets the list of connection types that were attempted.</summary>
public List<HistorianConnectionType> AttemptedTypes { get; } = new();
/// <summary>
/// Tracks connection attempts and simulates a connection failure.
/// </summary>
/// <param name="config">The historian configuration.</param>
/// <param name="type">The connection type.</param>
/// <param name="readOnly">Whether to open a read-only connection.</param>
public HistorianAccess CreateAndConnect(
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
{
AttemptedNodes.Add(config.ServerName);
AttemptedTypes.Add(type);
throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
}
}
}
@@ -1,114 +0,0 @@
using System;
using System.Reflection;
using ArchestrA;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
/// <summary>
/// Driver.Historian.Wonderware-005 regression tests for <see cref="HistorianDataSource.GetHealthSnapshot"/>.
/// The active-node strings and the connection-open booleans were published under different
/// locks, so a snapshot could observe an internally inconsistent pairing (open with no node,
/// or closed with a non-null node). The fix derives the open booleans from the same field
/// that is published under the same lock so the snapshot is self-consistent by construction.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistorianDataSourceHealthSnapshotTests
{
/// <summary>
/// Drives the "half-published" state directly via reflection: set <c>_connection</c>
/// to a non-null sentinel but leave <c>_activeProcessNode</c> null. The snapshot must
/// report <c>ProcessConnectionOpen = false</c> and <c>ActiveProcessNode = null</c>
/// consistently — never a mismatch.
/// </summary>
[Fact]
public void Snapshot_with_connection_set_but_active_node_null_is_consistent()
{
var ds = new HistorianDataSource(
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
SetField(ds, "_connection", new HistorianAccess());
SetField(ds, "_activeProcessNode", (string?)null);
var snap = ds.GetHealthSnapshot();
(snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue(
"snapshot must not advertise open with no node — picks one source of truth");
}
/// <summary>
/// Symmetric case for the event connection.
/// </summary>
[Fact]
public void Snapshot_with_event_connection_set_but_active_node_null_is_consistent()
{
var ds = new HistorianDataSource(
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
SetField(ds, "_eventConnection", new HistorianAccess());
SetField(ds, "_activeEventNode", (string?)null);
var snap = ds.GetHealthSnapshot();
(snap.EventConnectionOpen == (snap.ActiveEventNode != null)).ShouldBeTrue(
"snapshot must not advertise event open with no node");
}
/// <summary>
/// The other direction: connection cleared but node still populated (the failure path
/// between the two field clears). The snapshot must still pair them consistently.
/// </summary>
[Fact]
public void Snapshot_with_connection_cleared_but_active_node_populated_is_consistent()
{
var ds = new HistorianDataSource(
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
SetField(ds, "_connection", (HistorianAccess?)null);
SetField(ds, "_activeProcessNode", "node-stale");
var snap = ds.GetHealthSnapshot();
(snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue(
"snapshot must not advertise closed with a node still set");
}
/// <summary>
/// Steady-state happy path: both fields populated — snapshot reports both consistently.
/// </summary>
[Fact]
public void Snapshot_with_both_fields_populated_reports_open_and_active_node()
{
var ds = new HistorianDataSource(
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
SetField(ds, "_connection", new HistorianAccess());
SetField(ds, "_activeProcessNode", "h1");
var snap = ds.GetHealthSnapshot();
snap.ProcessConnectionOpen.ShouldBeTrue();
snap.ActiveProcessNode.ShouldBe("h1");
}
/// <summary>
/// Steady-state default (no connect attempted): both null.
/// </summary>
[Fact]
public void Snapshot_with_default_fields_reports_closed_with_no_active_node()
{
var ds = new HistorianDataSource(
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
var snap = ds.GetHealthSnapshot();
snap.ProcessConnectionOpen.ShouldBeFalse();
snap.ActiveProcessNode.ShouldBeNull();
snap.EventConnectionOpen.ShouldBeFalse();
snap.ActiveEventNode.ShouldBeNull();
}
private static void SetField(object target, string name, object? value)
{
var f = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
f.ShouldNotBeNull($"private field '{name}' must exist on {target.GetType().Name}");
f!.SetValue(target, value);
}
}
@@ -1,114 +0,0 @@
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
/// <summary>
/// Driver.Historian.Wonderware-010 regression. <see cref="HistorianConfiguration.RequestTimeoutSeconds"/>
/// was documented as the "outer safety timeout applied to sync-over-async Historian
/// operations" but was never read or enforced — a hung <c>StartQuery</c> or a slow
/// <c>MoveNext</c> could block the single pipe-server connection thread indefinitely.
/// The fix wires it into the read paths via a linked <see cref="CancellationTokenSource"/>
/// so the documented safety net actually exists.
///
/// The SDK-touching read methods cannot be unit-driven without a live AVEVA Historian.
/// This test pins the helper that derives the effective timeout from the config — the
/// read methods invoke that helper, so a regression in either the helper or the wiring
/// would break the test.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistorianDataSourceRequestTimeoutTests
{
/// <summary>Verifies default request timeout is 60 seconds.</summary>
[Fact]
public void Default_request_timeout_is_60_seconds()
{
new HistorianConfiguration().RequestTimeoutSeconds.ShouldBe(60);
}
/// <summary>Verifies positive request timeout values are applied correctly.</summary>
[Fact]
public void Positive_request_timeout_is_used_verbatim()
{
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 30 },
CancellationToken.None,
out var cts);
cts.ShouldNotBeNull();
// The helper must wire CancelAfter — easiest cross-check is to observe that the
// returned CTS is NOT already cancelled, and that disposing it is safe.
cts!.IsCancellationRequested.ShouldBeFalse();
cts.Dispose();
}
/// <summary>Verifies zero or negative timeout values disable the outer safety timeout.</summary>
[Fact]
public void Zero_or_negative_request_timeout_is_treated_as_no_timeout()
{
// A zero/negative value means "no outer timeout" — the helper must still return a
// linked CTS so callers can use one code path, but it must not auto-cancel.
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 0 },
CancellationToken.None,
out var cts);
cts.ShouldNotBeNull();
cts!.IsCancellationRequested.ShouldBeFalse();
// Give the runtime a moment — a misconfigured CancelAfter(0) would fire immediately.
Thread.Sleep(50);
cts.IsCancellationRequested.ShouldBeFalse("RequestTimeoutSeconds <= 0 must not auto-cancel");
cts.Dispose();
}
/// <summary>Verifies short timeout values correctly fire cancellation on the linked token.</summary>
[Fact]
public async Task Small_timeout_cancels_the_linked_token()
{
// 50 ms timeout — sleep 250 ms then assert the linked CTS has fired.
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 1 }, // smallest non-zero whole-second value
CancellationToken.None,
out var cts);
cts.ShouldNotBeNull();
// The wall-clock cost of waiting a full second per test is acceptable — this
// pins the actual CancelAfter wiring rather than just the conditional logic.
await Task.Delay(1500);
cts!.IsCancellationRequested.ShouldBeTrue("RequestTimeoutSeconds=1 must cancel within 1.5s");
cts.Dispose();
}
/// <summary>Verifies caller's cancellation token propagates to the linked token.</summary>
[Fact]
public void Inbound_cancellation_propagates_into_the_linked_token()
{
using var outer = new CancellationTokenSource();
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 60 },
outer.Token,
out var cts);
cts.ShouldNotBeNull();
cts!.IsCancellationRequested.ShouldBeFalse();
outer.Cancel();
cts.IsCancellationRequested.ShouldBeTrue("cancelling the caller's CT must cancel the linked CTS");
cts.Dispose();
}
private static void InvokeBuildLinkedTokenSource(
HistorianConfiguration cfg, CancellationToken ct, out CancellationTokenSource? cts)
{
// The helper is internal so the InternalsVisibleTo on the data-source project lets
// us bind to it directly. Reflection keeps the test resilient if the method name is
// ever shortened.
var method = typeof(HistorianDataSource)
.GetMethod("BuildRequestCts", BindingFlags.Static | BindingFlags.NonPublic);
method.ShouldNotBeNull(
"HistorianDataSource.BuildRequestCts must exist — wires RequestTimeoutSeconds into the read paths");
cts = (CancellationTokenSource?)method!.Invoke(null, new object[] { cfg, ct });
}
}
@@ -1,104 +0,0 @@
using ArchestrA;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
/// <summary>
/// Driver.Historian.Wonderware-008 regression. The previous implementation unconditionally
/// called <c>HandleConnectionError()</c> whenever <c>StartQuery</c> returned <c>false</c>,
/// which tore down the (relatively expensive) shared SDK connection on a query-class error
/// such as a bad tag name. A burst of bad-tag queries could therefore push an otherwise
/// healthy cluster node into cooldown via the picker's <c>MarkFailed</c>. The fix
/// classifies the SDK error code: connection-class codes drop the connection; query-class
/// codes leave it intact.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistorianDataSourceStartQueryClassificationTests
{
// ── Connection-class codes — the connection should be reset ───────────
/// <summary>Verifies that connection-class error codes are classified as connection errors.</summary>
/// <param name="code">The historian error code to test.</param>
[Theory]
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect)]
[InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession)]
[InlineData(HistorianAccessError.ErrorValue.NoReply)]
[InlineData(HistorianAccessError.ErrorValue.NotReady)]
[InlineData(HistorianAccessError.ErrorValue.NotInitialized)]
[InlineData(HistorianAccessError.ErrorValue.Stopping)]
[InlineData(HistorianAccessError.ErrorValue.Win32Exception)]
[InlineData(HistorianAccessError.ErrorValue.InvalidResponse)]
public void Connection_class_codes_are_classified_as_connection_errors(HistorianAccessError.ErrorValue code)
{
HistorianDataSource.IsConnectionClassError(code).ShouldBeTrue(
$"{code} is a connection/server failure — the SDK connection should be reset");
}
// ── Query-class codes — the connection should NOT be reset ────────────
/// <summary>Verifies that query-class error codes are NOT classified as connection errors.</summary>
/// <param name="code">The historian error code to test.</param>
[Theory]
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument)] // bad tag name, etc.
[InlineData(HistorianAccessError.ErrorValue.ValidationFailed)] // bad query args
[InlineData(HistorianAccessError.ErrorValue.NotApplicable)] // wrong tag kind for query
[InlineData(HistorianAccessError.ErrorValue.NotImplemented)] // unsupported aggregate
[InlineData(HistorianAccessError.ErrorValue.NoData)] // empty range
public void Query_class_codes_are_NOT_classified_as_connection_errors(HistorianAccessError.ErrorValue code)
{
HistorianDataSource.IsConnectionClassError(code).ShouldBeFalse(
$"{code} is a query payload problem — must NOT tear down the SDK connection");
}
// ── Driver.Historian.Wonderware-014: the at-time loop must classify a per-timestamp
// StartQuery failure the same way the raw / aggregate / event paths do. The SDK
// HistoryQuery type is sealed-by-non-virtual + has no interface, so the loop itself
// can't be driven offline; the per-failure decision is therefore extracted into a
// pure helper that the at-time loop calls and these tests pin directly. ──────────
/// <summary>
/// A connection-class StartQuery error in the at-time loop must signal "reset the
/// connection and abort the read" (true) — not silently record a Bad sample and keep
/// hammering the dead connection for every remaining timestamp.
/// </summary>
/// <param name="code">The connection-class error code.</param>
[Theory]
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect)]
[InlineData(HistorianAccessError.ErrorValue.NoReply)]
[InlineData(HistorianAccessError.ErrorValue.NotReady)]
public void AtTime_StartQuery_failure_with_connection_class_code_requests_connection_reset(
HistorianAccessError.ErrorValue code)
{
var error = new HistorianAccessError { ErrorCode = code };
HistorianDataSource.ShouldResetConnectionForStartQueryFailure(error).ShouldBeTrue(
$"{code} is a connection failure — the at-time loop must reset the connection, not record Bad");
}
/// <summary>
/// A query-class StartQuery error (or a missing error) in the at-time loop must NOT
/// reset the connection (false): a single bad/empty timestamp records a per-timestamp
/// Bad sample and continues to the next without tearing down the shared connection.
/// </summary>
/// <param name="code">The query-class error code.</param>
[Theory]
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument)]
[InlineData(HistorianAccessError.ErrorValue.NoData)]
[InlineData(HistorianAccessError.ErrorValue.NotApplicable)]
public void AtTime_StartQuery_failure_with_query_class_code_does_not_request_reset(
HistorianAccessError.ErrorValue code)
{
var error = new HistorianAccessError { ErrorCode = code };
HistorianDataSource.ShouldResetConnectionForStartQueryFailure(error).ShouldBeFalse(
$"{code} is a query/no-data problem — the at-time loop keeps the connection and records Bad");
}
/// <summary>A null error defaults to query-class (no reset) — the caller still records a Bad sample.</summary>
[Fact]
public void AtTime_StartQuery_failure_with_null_error_defaults_to_no_reset()
{
HistorianDataSource.ShouldResetConnectionForStartQueryFailure(null).ShouldBeFalse(
"a null error must not be promoted to a connection reset");
}
}
@@ -1,134 +0,0 @@
using System.Runtime.Serialization;
using ArchestrA;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
/// <summary>
/// Driver.Historian.Wonderware-012 coverage — pins the two static helpers on
/// <see cref="HistorianDataSource"/> that previously had no direct tests:
/// <see cref="HistorianDataSource.SelectValueFromPair"/> (the string-vs-numeric heuristic
/// for the raw + at-time read paths) and <see cref="HistorianDataSource.ExtractAggregateValue"/>
/// (the aggregate-column dispatch). The SDK <c>HistoryQueryResult</c> initialises internal
/// state lazily on first property access, which makes it impractical to fake via
/// <see cref="FormatterServices.GetUninitializedObject"/>; the heuristic was therefore
/// refactored into an SDK-independent overload that the tests drive directly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistorianDataSourceValueAndAggregateTests
{
// ── SelectValueFromPair ───────────────────────────────────────────────
/// <summary>Verifies that numeric value is returned when StringValue is empty.</summary>
[Fact]
public void SelectValueFromPair_returns_numeric_value_when_StringValue_is_empty()
{
HistorianDataSource.SelectValueFromPair(42.5, string.Empty).ShouldBe(42.5);
}
/// <summary>Verifies that numeric value is returned when Value is non-zero even if StringValue is populated.</summary>
[Fact]
public void SelectValueFromPair_returns_numeric_value_when_Value_is_non_zero_even_with_StringValue_populated()
{
// Tag is numeric and sampled non-zero; the SDK may still populate a formatted
// StringValue but the value path wins.
HistorianDataSource.SelectValueFromPair(3.14, "3.14").ShouldBe(3.14);
}
/// <summary>Verifies that StringValue is returned when Value is zero and StringValue is non-empty.</summary>
[Fact]
public void SelectValueFromPair_returns_StringValue_when_Value_is_zero_and_StringValue_non_empty()
{
// String tags in the SDK always project Value=0 — that's the documented heuristic.
HistorianDataSource.SelectValueFromPair(0.0, "Ready").ShouldBe("Ready");
}
/// <summary>Verifies that numeric zero is returned when Value is zero and StringValue is empty.</summary>
[Fact]
public void SelectValueFromPair_returns_numeric_zero_when_Value_is_zero_and_StringValue_empty()
{
// Numeric tag legitimately samples zero, no formatted text — must remain numeric.
HistorianDataSource.SelectValueFromPair(0.0, string.Empty).ShouldBe(0.0);
}
/// <summary>Verifies that null StringValue falls back to numeric value.</summary>
[Fact]
public void SelectValueFromPair_null_StringValue_falls_back_to_numeric()
{
HistorianDataSource.SelectValueFromPair(7.7, null).ShouldBe(7.7);
}
/// <summary>Verifies the documented edge case where numeric zero with a formatted string returns the string.</summary>
[Fact]
public void SelectValueFromPair_documented_edge_case_numeric_zero_with_formatted_string_returns_string()
{
// The doc comment on SelectValue calls this out as a known SDK-binding edge case:
// "A numeric tag at exactly zero with a non-empty formatted StringValue (e.g. '0.00')
// would be mis-reported as a string". This test pins that documented behaviour so
// a future SDK upgrade that surfaces a real data-type field can replace the
// heuristic deliberately rather than by accident.
HistorianDataSource.SelectValueFromPair(0.0, "0.00").ShouldBe("0.00");
}
// ── ExtractAggregateValue ─────────────────────────────────────────────
/// <summary>Verifies that aggregate value extraction dispatches correctly for known columns.</summary>
/// <param name="column">The aggregate result column name to extract.</param>
/// <param name="expected">The expected aggregate double value.</param>
[Theory]
[InlineData("Average", 10.0)]
[InlineData("Minimum", 1.0)]
[InlineData("Maximum", 20.0)]
[InlineData("First", 2.0)]
[InlineData("Last", 8.0)]
[InlineData("StdDev", 1.5)]
public void ExtractAggregateValue_dispatches_known_columns(string column, double expected)
{
var result = NewAggregateResult();
result.Average = 10.0;
result.Minimum = 1.0;
result.Maximum = 20.0;
result.ValueCount = 5;
result.First = 2.0;
result.Last = 8.0;
result.StdDev = 1.5;
HistorianDataSource.ExtractAggregateValue(result, column).ShouldBe(expected);
}
/// <summary>Verifies that ValueCount is dispatched to the uint field.</summary>
[Fact]
public void ExtractAggregateValue_ValueCount_dispatches_to_uint_field()
{
var result = NewAggregateResult();
result.ValueCount = 42;
HistorianDataSource.ExtractAggregateValue(result, "ValueCount").ShouldBe(42.0);
}
/// <summary>Verifies that an unknown column returns null.</summary>
[Fact]
public void ExtractAggregateValue_unknown_column_returns_null()
{
// Unknown column → null → IPC sample carries no value → client maps to BadNoData.
HistorianDataSource.ExtractAggregateValue(NewAggregateResult(), "NotAColumn").ShouldBeNull();
}
/// <summary>Verifies that aggregate value dispatch is case-sensitive.</summary>
[Fact]
public void ExtractAggregateValue_case_sensitive_dispatch()
{
// The switch is case-sensitive — "average" (lowercase) does NOT dispatch. Pinned so
// the canonical column-name casing is preserved across refactors.
var result = NewAggregateResult();
result.Average = 99.0;
HistorianDataSource.ExtractAggregateValue(result, "average").ShouldBeNull();
HistorianDataSource.ExtractAggregateValue(result, "Average").ShouldBe(99.0);
}
private static AnalogSummaryQueryResult NewAggregateResult()
{
return (AnalogSummaryQueryResult)FormatterServices.GetUninitializedObject(typeof(AnalogSummaryQueryResult));
}
}
@@ -1,69 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
[Trait("Category", "Unit")]
public sealed class HistorianQualityMapperTests
{
/// <summary>
/// Rich mapping preserves specific OPC DA subcodes through the historian ToWire path.
/// Before PR 12 the category-only fallback collapsed e.g. BadNotConnected(8) to
/// Bad(0x80000000) so downstream OPC UA clients could not distinguish transport issues
/// from sensor issues. After PR 12 every known subcode round-trips to its canonical
/// uint32 StatusCode and Proxy translation stays byte-for-byte with v1 QualityMapper.
/// </summary>
/// <param name="quality">The OPC DA quality code to map.</param>
/// <param name="expected">The expected canonical OPC UA StatusCode.</param>
[Theory]
[InlineData((byte)192, 0x00000000u)] // Good
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
[InlineData((byte)64, 0x40000000u)] // Uncertain
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
[InlineData((byte)0, 0x80000000u)] // Bad
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
[InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure
[InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure
[InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError
[InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService
[InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData
public void Maps_specific_OPC_DA_codes_to_canonical_StatusCode(byte quality, uint expected)
{
HistorianQualityMapper.Map(quality).ShouldBe(expected);
}
/// <summary>Verifies that unknown good-family quality codes fall back to plain Good.</summary>
/// <param name="q">The OPC DA quality byte to test.</param>
[Theory]
[InlineData((byte)200)] // Good — unknown subcode in Good family
[InlineData((byte)255)] // Good — unknown
public void Unknown_good_family_codes_fall_back_to_plain_Good(byte q)
{
HistorianQualityMapper.Map(q).ShouldBe(0x00000000u);
}
/// <summary>Verifies that unknown uncertain-family quality codes fall back to plain Uncertain.</summary>
/// <param name="q">The OPC DA quality byte to test.</param>
[Theory]
[InlineData((byte)100)] // Uncertain — unknown subcode
[InlineData((byte)150)] // Uncertain — unknown
public void Unknown_uncertain_family_codes_fall_back_to_plain_Uncertain(byte q)
{
HistorianQualityMapper.Map(q).ShouldBe(0x40000000u);
}
/// <summary>Verifies that unknown bad-family quality codes fall back to plain Bad.</summary>
/// <param name="q">The OPC DA quality byte to test.</param>
[Theory]
[InlineData((byte)1)] // Bad — unknown subcode
[InlineData((byte)50)] // Bad — unknown
public void Unknown_bad_family_codes_fall_back_to_plain_Bad(byte q)
{
HistorianQualityMapper.Map(q).ShouldBe(0x80000000u);
}
}
@@ -1,323 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
/// <summary>
/// PR C.1 — covers <see cref="SdkAlarmHistorianWriteBackend"/>, the aahClientManaged-bound
/// alarm-event writer. The SDK-touching batch loop itself is exercised by the rig-gated
/// <c>Live_*</c> tests (D.1); the unit tests below pin the parts that are SDK-type-free:
/// <list type="bullet">
/// <item><description>connection-unavailable → whole batch deferred as RetryPlease;</description></item>
/// <item><description><see cref="SdkAlarmHistorianWriteBackend.ClassifyOutcome"/> error-code mapping;</description></item>
/// <item><description><see cref="SdkHistorianConnectionFactory.BuildConnectionArgs"/> read-only-vs-write shaping.</description></item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class SdkAlarmHistorianWriteBackendTests
{
// ── Connection-unavailable path (deterministic, no SDK load) ──────────
/// <summary>Verifies that an empty batch returns an empty outcome array.</summary>
[Fact]
public async Task Empty_batch_returns_empty_array()
{
var backend = new SdkAlarmHistorianWriteBackend(
Config("any"), new ThrowingConnectionFactory());
var outcomes = await backend.WriteBatchAsync(
Array.Empty<AlarmHistorianEventDto>(), CancellationToken.None);
outcomes.ShouldBeEmpty();
}
/// <summary>Verifies that when all nodes are unreachable, the entire batch is deferred as RetryPlease.</summary>
[Fact]
public async Task Unreachable_node_defers_whole_batch_as_RetryPlease()
{
// No node can be connected — the backend must defer every event so the
// lmxopcua-side SQLite store-and-forward sink retains the rows rather than
// dropping them.
var backend = new SdkAlarmHistorianWriteBackend(
Config("unreachable"), new ThrowingConnectionFactory());
var events = new[] { AlarmEvent("E1"), AlarmEvent("E2"), AlarmEvent("E3") };
var outcomes = await backend.WriteBatchAsync(events, CancellationToken.None);
outcomes.Length.ShouldBe(events.Length);
outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
}
/// <summary>Verifies that a large batch with unreachable nodes returns one outcome per event.</summary>
[Fact]
public async Task Unreachable_node_large_batch_returns_one_outcome_per_event()
{
// Guards the outcome-array allocation: WriteBatchAsync must always return exactly
// as many outcomes as input events, even on the whole-batch-deferred path.
var backend = new SdkAlarmHistorianWriteBackend(
Config("unreachable"), new ThrowingConnectionFactory());
var batch = Enumerable.Range(0, 1000).Select(i => AlarmEvent($"E{i}")).ToArray();
var outcomes = await backend.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Length.ShouldBe(1000);
outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
}
/// <summary>Verifies that a connection failure marks the node as failed in the endpoint picker.</summary>
[Fact]
public async Task Connect_failure_marks_node_failed_in_picker()
{
// Every connect attempt throws → the picker should record the failure so the
// node enters cooldown (cluster-failover plumbing).
var cfg = Config("node-a");
var picker = new HistorianClusterEndpointPicker(cfg);
var backend = new SdkAlarmHistorianWriteBackend(cfg, new ThrowingConnectionFactory(), picker);
await backend.WriteBatchAsync(new[] { AlarmEvent("E1") }, CancellationToken.None);
picker.HealthyNodeCount.ShouldBe(0, "the only node failed to connect and is now in cooldown");
}
// ── ClassifyOutcome — error-code → outcome mapping ────────────────────
/// <summary>Verifies that error codes map to the expected write outcomes.</summary>
/// <param name="code">The historian access error code to classify.</param>
/// <param name="expected">The expected write outcome.</param>
[Theory]
[InlineData(HistorianAccessError.ErrorValue.Success, AlarmHistorianWriteOutcome.Ack)]
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect, AlarmHistorianWriteOutcome.RetryPlease)]
[InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession, AlarmHistorianWriteOutcome.RetryPlease)]
[InlineData(HistorianAccessError.ErrorValue.NoReply, AlarmHistorianWriteOutcome.RetryPlease)]
[InlineData(HistorianAccessError.ErrorValue.NotReady, AlarmHistorianWriteOutcome.RetryPlease)]
[InlineData(HistorianAccessError.ErrorValue.Failure, AlarmHistorianWriteOutcome.RetryPlease)]
[InlineData(HistorianAccessError.ErrorValue.NoData, AlarmHistorianWriteOutcome.RetryPlease)]
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument, AlarmHistorianWriteOutcome.PermanentFail)]
[InlineData(HistorianAccessError.ErrorValue.ValidationFailed, AlarmHistorianWriteOutcome.PermanentFail)]
[InlineData(HistorianAccessError.ErrorValue.NullPointerArgument, AlarmHistorianWriteOutcome.PermanentFail)]
[InlineData(HistorianAccessError.ErrorValue.NotImplemented, AlarmHistorianWriteOutcome.PermanentFail)]
public void ClassifyOutcome_maps_error_code_to_expected_outcome(
HistorianAccessError.ErrorValue code, AlarmHistorianWriteOutcome expected)
{
SdkAlarmHistorianWriteBackend.ClassifyOutcome(code).ShouldBe(expected);
}
// ── ToHistorianEvent — EventId handling ───────────────────────────────
/// <summary>Verifies that a parseable event ID is used verbatim in the historian event.</summary>
[Fact]
public void ToHistorianEvent_parseable_event_id_is_used_verbatim()
{
// Sanity case: a real GUID round-trips into HistorianEvent.Id.
var id = Guid.Parse("12345678-1234-1234-1234-123456789abc");
var dto = new AlarmHistorianEventDto
{
EventId = id.ToString(),
SourceName = "Tank01",
AlarmType = "AnalogLimitAlarm.HiHi",
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
};
#pragma warning disable CS0618
SdkAlarmHistorianWriteBackend.ToHistorianEvent(dto).Id.ShouldBe(id);
#pragma warning restore CS0618
}
/// <summary>Verifies that an unparseable event ID is synthesized as a unique non-empty GUID.</summary>
[Fact]
public void ToHistorianEvent_unparseable_event_id_synthesizes_unique_non_empty_Guid()
{
// Driver.Historian.Wonderware-004 regression: when EventId is not a parseable
// GUID (or is empty) the previous implementation silently left HistorianEvent.Id
// as Guid.Empty, so multiple alarms collided on the same id with no warning.
// The fix synthesizes a fresh Guid so every event still gets a unique identifier.
var dtoA = new AlarmHistorianEventDto
{
EventId = "not-a-guid",
SourceName = "Tank01",
AlarmType = "Active",
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
};
var dtoB = new AlarmHistorianEventDto
{
EventId = string.Empty,
SourceName = "Tank01",
AlarmType = "Active",
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
};
#pragma warning disable CS0618
var idA = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoA).Id;
var idB = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoB).Id;
#pragma warning restore CS0618
idA.ShouldNotBe(Guid.Empty, "unparseable EventId must not collapse to Guid.Empty");
idB.ShouldNotBe(Guid.Empty, "empty EventId must not collapse to Guid.Empty");
idA.ShouldNotBe(idB, "every event needs a unique synthesized id");
}
/// <summary>Verifies that a write-to-read-only-file error is classified as RetryPlease, not PermanentFail.</summary>
[Fact]
public void ClassifyOutcome_WriteToReadOnlyFile_is_RetryPlease_not_PermanentFail()
{
// Driver.Historian.Wonderware-001 regression: WriteToReadOnlyFile is a
// connection-configuration fault (the write session was opened without
// ReadOnly = false), NOT a malformed-event fault. Routing it to PermanentFail
// would dead-letter every alarm event in the batch on a misconfigured/regressed
// connection — data loss. It must be treated as a transient connection-class
// error so the events are deferred and retried once the connection is corrected.
SdkAlarmHistorianWriteBackend.ClassifyOutcome(
HistorianAccessError.ErrorValue.WriteToReadOnlyFile)
.ShouldBe(AlarmHistorianWriteOutcome.RetryPlease);
}
// ── BuildConnectionArgs — read-only vs write shaping ──────────────────
/// <summary>Verifies that a write connection is opened with ReadOnly set to false.</summary>
[Fact]
public void BuildConnectionArgs_write_connection_is_not_read_only()
{
// The alarm-event write path must open ReadOnly=false; AddStreamedValue on a
// read-only session fails with WriteToReadOnlyFile.
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
Config("h1"), HistorianConnectionType.Event, readOnly: false);
args.ReadOnly.ShouldBeFalse();
args.ConnectionType.ShouldBe(HistorianConnectionType.Event);
args.ServerName.ShouldBe("h1");
}
/// <summary>Verifies that a query connection is opened with ReadOnly set to true.</summary>
[Fact]
public void BuildConnectionArgs_query_connection_is_read_only()
{
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
Config("h1"), HistorianConnectionType.Process, readOnly: true);
args.ReadOnly.ShouldBeTrue();
args.ConnectionType.ShouldBe(HistorianConnectionType.Process);
}
/// <summary>Verifies that non-integrated security credentials are preserved in connection arguments.</summary>
[Fact]
public void BuildConnectionArgs_non_integrated_security_carries_credentials()
{
var cfg = Config("h1");
cfg.IntegratedSecurity = false;
cfg.UserName = "histuser";
cfg.Password = "histpass";
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
cfg, HistorianConnectionType.Event, readOnly: false);
args.IntegratedSecurity.ShouldBeFalse();
args.UserName.ShouldBe("histuser");
args.Password.ShouldBe("histpass");
}
// ── Rig-gated integration tests ───────────────────────────────────────
//
// The entry point (HistorianAccess.AddStreamedValue) is pinned and implemented;
// these need a live AVEVA Historian and are un-skipped during the PR D.1 smoke.
/// <summary>Verifies that a single alarm event roundtrip returns an Ack outcome.</summary>
[Fact(Skip = "rig-required: needs a live AVEVA Historian — un-skip during the PR D.1 rollout smoke")]
public async Task Live_single_event_roundtrip_returns_Ack()
{
var backend = new SdkAlarmHistorianWriteBackend(BuildRigConfig());
var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-E1") }, CancellationToken.None);
outcomes.Length.ShouldBe(1);
outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
}
/// <summary>Verifies that cluster failover rotates from a bad primary node to a secondary node.</summary>
[Fact(Skip = "rig-required: needs a live AVEVA Historian cluster (two nodes) — un-skip during the PR D.1 rollout smoke")]
public async Task Live_cluster_failover_primary_bad_rotates_to_secondary()
{
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerNames = new List<string>
{
"invalid-primary-node-deliberately-unreachable",
Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
},
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = true,
FailureCooldownSeconds = 5,
CommandTimeoutSeconds = 10,
};
var backend = new SdkAlarmHistorianWriteBackend(cfg);
var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-failover-E1") }, CancellationToken.None);
outcomes.Length.ShouldBe(1);
outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
}
// ── helpers ───────────────────────────────────────────────────────────
private static HistorianConfiguration Config(string server) => new HistorianConfiguration
{
Enabled = true,
ServerName = server,
Port = 32568,
IntegratedSecurity = true,
CommandTimeoutSeconds = 30,
FailureCooldownSeconds = 60,
};
private static AlarmHistorianEventDto AlarmEvent(string id) => new AlarmHistorianEventDto
{
EventId = id,
SourceName = "TestSource",
ConditionId = "TestSource.Level.HiHi",
AlarmType = "AnalogLimitAlarm.HiHi",
Message = "C.1 test alarm",
Severity = 500,
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
AckComment = null,
};
private static HistorianConfiguration BuildRigConfig() => new HistorianConfiguration
{
Enabled = true,
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = true,
CommandTimeoutSeconds = 30,
FailureCooldownSeconds = 60,
};
private static int TryParseInt(string envName, int defaultValue)
{
var raw = Environment.GetEnvironmentVariable(envName);
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
}
/// <summary>
/// Fake factory whose every connect attempt throws — drives the
/// connection-unavailable path without loading the native SDK.
/// </summary>
private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory
{
/// <summary>Creates and attempts to connect, always throwing a simulated connect failure.</summary>
/// <param name="config">The historian configuration specifying the target server.</param>
/// <param name="type">The connection type (Process or Event).</param>
/// <param name="readOnly">Whether to open a read-only connection.</param>
public HistorianAccess CreateAndConnect(
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
=> throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
}
}
}
@@ -1,226 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog.Core;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc
{
/// <summary>
/// Pins the sidecar's poison-event classifier and the per-event status mapping in
/// <see cref="HistorianFrameHandler"/>. A structurally-malformed alarm event is marked
/// Permanent (status 2) and excluded from the writer batch so the store-and-forward sink
/// dead-letters it immediately rather than looping to the retry cap; well-formed events
/// map to Ack (0) / Retry (1) from the writer's per-event bool result.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistorianEventClassifierTests
{
/// <summary>Verifies a blank source name is classified structurally malformed.</summary>
[Fact]
public void IsStructurallyMalformed_BlankSourceName_IsTrue()
{
var e = WellFormed();
e.SourceName = " ";
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
}
/// <summary>Verifies a blank alarm type is classified structurally malformed.</summary>
[Fact]
public void IsStructurallyMalformed_BlankAlarmType_IsTrue()
{
var e = WellFormed();
e.AlarmType = "";
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
}
/// <summary>Verifies a non-positive event timestamp is classified structurally malformed.</summary>
/// <param name="ticks">The event timestamp in ticks to test.</param>
[Theory]
[InlineData(0L)]
[InlineData(-1L)]
public void IsStructurallyMalformed_NonPositiveTimestamp_IsTrue(long ticks)
{
var e = WellFormed();
e.EventTimeUtcTicks = ticks;
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
}
/// <summary>Verifies a well-formed event is not classified structurally malformed.</summary>
[Fact]
public void IsStructurallyMalformed_WellFormedEvent_IsFalse()
{
HistorianFrameHandler.IsStructurallyMalformed(WellFormed()).ShouldBeFalse();
}
/// <summary>
/// A mixed batch — one poison event then one well-formed event the writer acks — must
/// yield PerEventStatus = [2, 0]: the poison event is Permanent and excluded from the
/// writer batch, and only the well-formed event reaches the writer.
/// </summary>
[Fact]
public async Task Handler_MixedBatch_MarksPoisonPermanent_AndOnlyWritesWellFormed()
{
var poison = WellFormed();
poison.EventId = "poison";
poison.SourceName = ""; // structurally malformed
var good = WellFormed();
good.EventId = "good";
var fakeWriter = new RecordingAlarmEventWriter(_ => true);
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
var req = new WriteAlarmEventsRequest { Events = new[] { poison, good }, CorrelationId = "c1" };
var reply = await RoundTripAsync(handler, req);
reply.Success.ShouldBeTrue();
reply.PerEventStatus.ShouldBe(new byte[] { 2, 0 });
reply.PerEventOk.ShouldBe(new[] { false, true });
// The writer only ever saw the well-formed event.
fakeWriter.Received.Count.ShouldBe(1);
fakeWriter.Received[0].EventId.ShouldBe("good");
}
/// <summary>
/// A well-formed event the writer reports as not-persisted maps to Retry (status 1),
/// not Permanent — only structurally-malformed events are Permanent.
/// </summary>
[Fact]
public async Task Handler_WriterReportsNotPersisted_MapsToRetry()
{
var good = WellFormed();
good.EventId = "good";
var fakeWriter = new RecordingAlarmEventWriter(_ => false);
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
var req = new WriteAlarmEventsRequest { Events = new[] { good }, CorrelationId = "c2" };
var reply = await RoundTripAsync(handler, req);
reply.Success.ShouldBeTrue();
reply.PerEventStatus.ShouldBe(new byte[] { 1 });
reply.PerEventOk.ShouldBe(new[] { false });
}
/// <summary>
/// An all-poison batch must short-circuit the writer entirely (no WriteAsync call)
/// and mark every slot Permanent.
/// </summary>
[Fact]
public async Task Handler_AllPoison_SkipsWriter_AllPermanent()
{
var p1 = WellFormed();
p1.SourceName = "";
var p2 = WellFormed();
p2.AlarmType = "";
var fakeWriter = new RecordingAlarmEventWriter(_ => true);
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
var req = new WriteAlarmEventsRequest { Events = new[] { p1, p2 }, CorrelationId = "c3" };
var reply = await RoundTripAsync(handler, req);
reply.Success.ShouldBeTrue();
reply.PerEventStatus.ShouldBe(new byte[] { 2, 2 });
fakeWriter.Received.Count.ShouldBe(0);
}
private static AlarmHistorianEventDto WellFormed() => new()
{
EventId = "ev",
SourceName = "Tank.HiHi",
ConditionId = "HiHi",
AlarmType = "LimitAlarm:Activated",
Message = "msg",
Severity = 700,
EventTimeUtcTicks = new DateTime(2026, 6, 18, 12, 0, 0, DateTimeKind.Utc).Ticks,
AckComment = null,
};
/// <summary>
/// Drives a WriteAlarmEvents request through the real frame handler over an in-memory
/// duplex stream pair and deserializes the reply the handler writes back.
/// </summary>
private static async Task<WriteAlarmEventsReply> RoundTripAsync(
HistorianFrameHandler handler, WriteAlarmEventsRequest req)
{
var capture = new MemoryStream();
using var writer = new FrameWriter(capture, leaveOpen: true);
var body = MessagePackSerializer.Serialize(req);
await handler.HandleAsync(MessageKind.WriteAlarmEventsRequest, body, writer, CancellationToken.None);
capture.Position = 0;
using var reader = new FrameReader(capture, leaveOpen: true);
var frame = await reader.ReadFrameAsync(CancellationToken.None);
frame.ShouldNotBeNull();
frame!.Value.Kind.ShouldBe(MessageKind.WriteAlarmEventsReply);
return MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(frame.Value.Body);
}
/// <summary>An <see cref="IAlarmEventWriter"/> that records the batch it received and returns a fixed verdict.</summary>
private sealed class RecordingAlarmEventWriter : IAlarmEventWriter
{
private readonly Func<AlarmHistorianEventDto, bool> _verdict;
/// <summary>Initializes a new instance with the given per-event verdict.</summary>
/// <param name="verdict">Maps each received event to its persisted/not-persisted result.</param>
public RecordingAlarmEventWriter(Func<AlarmHistorianEventDto, bool> verdict) => _verdict = verdict;
/// <summary>The events the writer was handed, in order.</summary>
public List<AlarmHistorianEventDto> Received { get; } = new();
/// <inheritdoc/>
public Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
{
Received.AddRange(events);
return Task.FromResult(events.Select(_verdict).ToArray());
}
}
/// <summary>
/// A read data source the WriteAlarmEvents path never touches — present only to
/// satisfy the <see cref="HistorianFrameHandler"/> ctor's non-null requirement.
/// </summary>
private sealed class StubHistorian : IHistorianDataSource
{
/// <inheritdoc/>
public Task<List<HistorianSample>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc/>
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc/>
public Task<List<HistorianSample>> ReadAtTimeAsync(
string tagName, DateTime[] timestamps, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc/>
public Task<List<global::ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend.HistorianEventDto>> ReadEventsAsync(
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc/>
public HistorianHealthSnapshot GetHealthSnapshot() => throw new NotSupportedException();
/// <inheritdoc/>
public void Dispose() { }
}
}
}
@@ -1,297 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using Serilog.Core;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc;
/// <summary>
/// Round-trip tests for <see cref="TcpFrameServer"/> added with the TCP transport. Each
/// scenario binds the server on <c>127.0.0.1:0</c>, connects a real <see cref="TcpClient"/>,
/// performs the Hello handshake, and exercises a request/reply over the wire framing — both
/// plaintext and over TLS. These target net48 and run on Windows in CI; on the macOS dev box
/// they only compile.
/// </summary>
public sealed class TcpRoundTripTests
{
private static readonly ILogger Quiet = Logger.None;
// Generous timeout so the deterministic tests don't hang CI if the server misbehaves.
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
/// <summary>
/// Fake handler that echoes a fixed <see cref="ReadRawReply"/> when it sees a
/// <see cref="MessageKind.ReadRawRequest"/>, mirroring the client correlation id.
/// </summary>
private sealed class EchoHandler : IFrameHandler
{
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
{
if (kind != MessageKind.ReadRawRequest)
return Task.CompletedTask;
var request = MessagePackSerializer.Deserialize<ReadRawRequest>(body);
var reply = new ReadRawReply
{
CorrelationId = request.CorrelationId,
Success = true,
Samples = new[]
{
new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize(42.0),
Quality = 192,
TimestampUtcTicks = new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc).Ticks,
},
},
};
return writer.WriteAsync(MessageKind.ReadRawReply, reply, ct);
}
}
/// <summary>Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key.</summary>
private static X509Certificate2 MakeSelfSignedCert()
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false));
using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
// Round-trip through a PFX so the returned cert carries an exportable private key on net48.
var pfx = ephemeral.Export(X509ContentType.Pfx, "pw");
return new X509Certificate2(pfx, "pw", X509KeyStorageFlags.Exportable);
}
/// <summary>Performs the Hello handshake on the given stream and returns the deserialized ack.</summary>
private static async Task<HelloAck> HelloAsync(Stream stream, string secret, CancellationToken ct)
{
using var writer = new FrameWriter(stream, leaveOpen: true);
using var reader = new FrameReader(stream, leaveOpen: true);
await writer.WriteAsync(MessageKind.Hello,
new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test-client", SharedSecret = secret }, ct);
var ackFrame = await reader.ReadFrameAsync(ct);
ackFrame.ShouldNotBeNull();
ackFrame!.Value.Kind.ShouldBe(MessageKind.HelloAck);
return MessagePackSerializer.Deserialize<HelloAck>(ackFrame.Value.Body);
}
/// <summary>Wraps a connected client socket stream in an SslStream that pins the server cert thumbprint.</summary>
private static async Task<SslStream> ClientTlsAsync(NetworkStream inner, string expectedThumbprint, CancellationToken ct)
{
var ssl = new SslStream(inner, leaveInnerStreamOpen: false,
userCertificateValidationCallback: (_, cert, _, _) =>
cert is not null &&
string.Equals(
cert.GetCertHashString(),
expectedThumbprint,
StringComparison.OrdinalIgnoreCase));
await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null,
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false);
return ssl;
}
/// <summary>Plaintext: Hello (good secret) is accepted and a ReadRaw request is echoed back.</summary>
[Fact]
public async Task Plaintext_RoundTrip_HelloAcceptedAndRequestEchoed()
{
using var cts = new CancellationTokenSource(Timeout);
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet);
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
var stream = client.GetStream();
var ack = await HelloAsync(stream, "shh", cts.Token);
ack.Accepted.ShouldBeTrue();
using var writer = new FrameWriter(stream, leaveOpen: true);
using var reader = new FrameReader(stream, leaveOpen: true);
await writer.WriteAsync(MessageKind.ReadRawRequest,
new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "corr-1" }, cts.Token);
var replyFrame = await reader.ReadFrameAsync(cts.Token);
replyFrame.ShouldNotBeNull();
replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply);
var reply = MessagePackSerializer.Deserialize<ReadRawReply>(replyFrame.Value.Body);
reply.Success.ShouldBeTrue();
reply.CorrelationId.ShouldBe("corr-1");
reply.Samples.Length.ShouldBe(1);
MessagePackSerializer.Deserialize<double>(reply.Samples[0].ValueBytes!).ShouldBe(42.0);
client.Close();
await serverTask;
}
/// <summary>TLS: a self-signed server cert; the client pins its thumbprint; same exchange succeeds.</summary>
[Fact]
public async Task Tls_RoundTrip_HelloAcceptedAndRequestEchoed()
{
using var cts = new CancellationTokenSource(Timeout);
using var cert = MakeSelfSignedCert();
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet);
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
using var ssl = await ClientTlsAsync(client.GetStream(), cert.Thumbprint, cts.Token);
var ack = await HelloAsync(ssl, "shh", cts.Token);
ack.Accepted.ShouldBeTrue();
using var writer = new FrameWriter(ssl, leaveOpen: true);
using var reader = new FrameReader(ssl, leaveOpen: true);
await writer.WriteAsync(MessageKind.ReadRawRequest,
new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "tls-1" }, cts.Token);
var replyFrame = await reader.ReadFrameAsync(cts.Token);
replyFrame.ShouldNotBeNull();
replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply);
var reply = MessagePackSerializer.Deserialize<ReadRawReply>(replyFrame.Value.Body);
reply.Success.ShouldBeTrue();
reply.CorrelationId.ShouldBe("tls-1");
client.Close();
await serverTask;
}
/// <summary>
/// TLS: when the client pins a wrong thumbprint the validation callback returns false,
/// causing <see cref="SslStream.AuthenticateAsClientAsync"/> to throw
/// <see cref="AuthenticationException"/> before any Hello is exchanged.
/// </summary>
[Fact]
public async Task Tls_BadThumbprint_AuthenticationFails()
{
using var cts = new CancellationTokenSource(Timeout);
using var cert = MakeSelfSignedCert();
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet);
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
// Deliberately pin the wrong thumbprint — all zeros.
const string wrongThumbprint = "0000000000000000000000000000000000000000";
var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false,
userCertificateValidationCallback: (_, serverCert, _, _) =>
serverCert is not null &&
string.Equals(serverCert.GetCertHashString(), wrongThumbprint, StringComparison.OrdinalIgnoreCase));
await Should.ThrowAsync<AuthenticationException>(async () =>
await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null,
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false));
ssl.Dispose();
// Server will see the broken TLS handshake and end the connection; let it finish.
try { await serverTask; } catch { /* server may throw on the aborted TLS */ }
}
/// <summary>Bad secret: Hello is rejected with Accepted=false and the shared-secret-mismatch reason.</summary>
[Fact]
public async Task BadSecret_HelloRejected()
{
using var cts = new CancellationTokenSource(Timeout);
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "right-secret", tlsCert: null, Quiet);
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
var ack = await HelloAsync(client.GetStream(), "wrong-secret", cts.Token);
ack.Accepted.ShouldBeFalse();
ack.RejectReason.ShouldBe("shared-secret-mismatch");
client.Close();
await serverTask;
}
/// <summary>
/// Single-active serial accept: while client A is connected (Hello done), client B's
/// Hello does not complete until A disconnects. The server only accepts one connection
/// per <see cref="TcpFrameServer.RunOneConnectionAsync"/>, so B's handshake is served by
/// the second loop iteration that runs only after A's connection ends.
/// </summary>
[Fact]
public async Task SingleActive_SecondClientHelloCompletesOnlyAfterFirstCloses()
{
using var cts = new CancellationTokenSource(Timeout);
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet);
// Run the server loop: it accepts one connection at a time, serially.
var serverLoop = server.RunAsync(new EchoHandler(), cts.Token);
// Client A connects and completes its Hello — it now owns the single active slot.
using var clientA = new TcpClient();
await clientA.ConnectAsync(IPAddress.Loopback, server.BoundPort);
var ackA = await HelloAsync(clientA.GetStream(), "shh", cts.Token);
ackA.Accepted.ShouldBeTrue();
// Client B connects. The TCP connect may complete (OS backlog) but the server is still
// busy with A, so B's Hello round-trip must NOT complete yet.
using var clientB = new TcpClient();
await clientB.ConnectAsync(IPAddress.Loopback, server.BoundPort);
var bHelloTask = HelloAsync(clientB.GetStream(), "shh", cts.Token);
// Give B a chance to (wrongly) complete — it must remain pending while A is connected.
var earlyWinner = await Task.WhenAny(bHelloTask, Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token));
earlyWinner.ShouldNotBe(bHelloTask, "client B's Hello completed while client A was still connected");
// Now disconnect A. The server's next loop iteration accepts B and serves its Hello.
clientA.Close();
var ackB = await bHelloTask;
ackB.Accepted.ShouldBeTrue();
// Tear down: cancel the loop and let it unwind.
cts.Cancel();
try { await serverLoop; } catch (OperationCanceledException) { /* expected */ }
}
[Fact]
public async Task BindFailure_SurfacesBindError_NotPermanentNotListening()
{
// Regression (live-caught 2026-06-12): when TcpFrameServer's listener bind fails (port in a
// Windows excluded range → WSAEACCES, or already in use), the failure must surface as the
// bind SocketException on EVERY accept attempt — NOT a one-time bind error followed by a
// permanent InvalidOperationException "Not listening". The latter is the assign-before-Start
// wedge: a non-null-but-unstarted listener that EnsureListening's guard never re-Starts,
// which crash-looped the live sidecar on the reserved port 32569.
using var cts = new CancellationTokenSource(Timeout);
// Occupy a loopback port exclusively so the server's Start() bind is forbidden.
var blocker = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true };
blocker.Start();
try
{
var takenPort = ((IPEndPoint)blocker.LocalEndpoint).Port;
using var server = new TcpFrameServer(IPAddress.Loopback, takenPort, "shh", tlsCert: null, Quiet);
// First accept attempt: the bind fails with a SocketException.
await Should.ThrowAsync<SocketException>(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token));
// Second attempt MUST also be the bind SocketException — not InvalidOperationException
// "Not listening". This is the assertion that fails against the assign-before-Start bug.
var second = await Should.ThrowAsync<Exception>(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token));
second.ShouldBeOfType<SocketException>();
}
finally { blocker.Stop(); }
}
}
@@ -1,92 +0,0 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
/// <summary>
/// PR C.2 — pins the env-var contract that gates whether the sidecar boots an
/// alarm-event writer. Default-on (when the historian itself is enabled) so a
/// fresh deploy picks up the writer without a service-config edit; explicit
/// <c>false</c> opts a read-only deployment out.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ProgramAlarmWriterTests
{
/// <summary>Verifies that BuildAlarmWriter returns a writer when the environment variable is unset.</summary>
[Fact]
public void BuildAlarmWriter_returns_writer_when_env_unset()
{
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", null);
var writer = Program.BuildAlarmWriter();
writer.ShouldNotBeNull();
writer.ShouldBeOfType<AahClientManagedAlarmEventWriter>();
}
/// <summary>Verifies that BuildAlarmWriter returns a writer when the environment variable is explicitly true.</summary>
/// <param name="value">The truthy environment variable string value to test.</param>
[Theory]
[InlineData("true")]
[InlineData("True")]
[InlineData("TRUE")]
public void BuildAlarmWriter_returns_writer_when_env_explicitly_true(string value)
{
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value);
var writer = Program.BuildAlarmWriter();
writer.ShouldNotBeNull();
}
/// <summary>Verifies that BuildAlarmWriter returns null when the environment variable is false.</summary>
/// <param name="value">The falsy environment variable string value to test.</param>
[Theory]
[InlineData("false")]
[InlineData("False")]
[InlineData("FALSE")]
public void BuildAlarmWriter_returns_null_when_env_false(string value)
{
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value);
var writer = Program.BuildAlarmWriter();
writer.ShouldBeNull();
}
/// <summary>Verifies that BuildAlarmWriter treats unrecognized values as enabled.</summary>
[Fact]
public void BuildAlarmWriter_treats_unrecognized_value_as_enabled()
{
// Anything other than the literal "false" (case-insensitive) keeps the writer
// wired — fail-open under accidental misconfiguration so an alarm-write deploy
// doesn't silently lose alarms because of a typo.
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", "yes");
var writer = Program.BuildAlarmWriter();
writer.ShouldNotBeNull();
}
private static IDisposable ScopedEnv(string name, string? value)
{
var prior = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
return new DisposableAction(() => Environment.SetEnvironmentVariable(name, prior));
}
/// <summary>Disposable wrapper for an action that executes on disposal.</summary>
private sealed class DisposableAction : IDisposable
{
private readonly Action _action;
/// <summary>Initializes a new instance that will execute the given action on disposal.</summary>
/// <param name="action">The action to execute when disposed.</param>
public DisposableAction(Action action) { _action = action; }
/// <summary>Executes the stored action.</summary>
public void Dispose() => _action();
}
}
}
@@ -1,22 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests;
/// <summary>
/// Smoke test confirming the sidecar project links and the test project resolves a
/// ProjectReference to it. Real behavioural tests live with the TCP frame server
/// (<c>TcpFrameServer</c>); here we just verify the assembly identity is what the
/// csproj declares.
/// </summary>
public class ProgramSmokeTests
{
/// <summary>Verifies that the Program assembly has the expected name.</summary>
[Fact]
public void Program_Assembly_HasExpectedName()
{
typeof(Program).Assembly.GetName().Name
.ShouldBe("OtOpcUa.Driver.Historian.Wonderware");
}
}
@@ -1,37 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- Wonderware Historian SDK — SdkAlarmHistorianWriteBackendTests pins the
error-code (HistorianAccessError.ErrorValue) and connection-arg shaping;
a DLL <Reference> doesn't flow transitively through the ProjectReference. -->
<Reference Include="aahClientManaged">
<HintPath>..\..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
</Project>
@@ -62,7 +62,7 @@ public sealed class DriverPageJsonConverterTests
var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
.ToList();
allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-page fleet");
allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-page fleet");
DriverPageTypes.Count.ShouldBe(allDriverPages.Count,
"every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
}
@@ -1,129 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
public sealed class HistorianWonderwareDriverPageFormSerializationTests
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
[Fact]
public void RoundTrip_PreservesKnownFields()
{
var original = new WonderwareHistorianClientOptions(
Host: "historian-prod.zb.local",
Port: 32569,
SharedSecret: "t0ps3cr3t",
PeerName: "OtOpcUa-Primary",
ConnectTimeout: TimeSpan.FromSeconds(20),
CallTimeout: TimeSpan.FromSeconds(60))
{
ProbeTimeoutSeconds = 25,
UseTls = true,
ServerCertThumbprint = "A1B2C3D4E5F60718293A4B5C6D7E8F9012345678",
};
var json = JsonSerializer.Serialize(original, _opts);
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _opts);
back.ShouldNotBeNull();
back.Host.ShouldBe("historian-prod.zb.local");
back.Port.ShouldBe(32569);
back.SharedSecret.ShouldBe("t0ps3cr3t");
back.PeerName.ShouldBe("OtOpcUa-Primary");
back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20));
back.CallTimeout.ShouldBe(TimeSpan.FromSeconds(60));
back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20));
back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(60));
back.ProbeTimeoutSeconds.ShouldBe(25);
back.UseTls.ShouldBeTrue();
back.ServerCertThumbprint.ShouldBe("A1B2C3D4E5F60718293A4B5C6D7E8F9012345678");
}
[Fact]
public void RoundTrip_NullTimeouts_UsesDefaults()
{
var original = new WonderwareHistorianClientOptions(
Host: "localhost",
Port: 32569,
SharedSecret: "secret");
var json = JsonSerializer.Serialize(original, _opts);
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _opts);
back.ShouldNotBeNull();
back.ConnectTimeout.ShouldBeNull();
back.CallTimeout.ShouldBeNull();
back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(10));
back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(30));
back.UseTls.ShouldBeFalse();
back.ServerCertThumbprint.ShouldBeNull();
}
[Fact]
public void Deserialize_DropsUnknownFields()
{
var jsonWithExtra = """
{
"unknownField": "old-value",
"host": "historian.zb.local",
"port": 32569,
"sharedSecret": "s3cr3t",
"probeTimeoutSeconds": 20
}
""";
var optsWithSkip = new JsonSerializerOptions(_opts)
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(jsonWithExtra, optsWithSkip);
back.ShouldNotBeNull();
back.ProbeTimeoutSeconds.ShouldBe(20);
back.Host.ShouldBe("historian.zb.local");
back.Port.ShouldBe(32569);
}
[Fact]
public void FormModel_RoundTrip_PreservesAllFields()
{
// Construct a record with non-default values for every property and verify
// that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless.
var original = new WonderwareHistorianClientOptions(
Host: "historian-prod.zb.local",
Port: 32570,
SharedSecret: "sup3rs3cr3t",
PeerName: "OtOpcUa-Redundant",
ConnectTimeout: TimeSpan.FromSeconds(18),
CallTimeout: TimeSpan.FromSeconds(45))
{
ProbeTimeoutSeconds = 30,
UseTls = true,
ServerCertThumbprint = "0011223344556677889AABBCCDDEEFF001122334",
};
var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original);
var result = form.ToRecord();
result.Host.ShouldBe("historian-prod.zb.local");
result.Port.ShouldBe(32570);
result.SharedSecret.ShouldBe("sup3rs3cr3t");
result.PeerName.ShouldBe("OtOpcUa-Redundant");
result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
result.CallTimeout.ShouldBe(TimeSpan.FromSeconds(45));
result.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
result.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(45));
result.ProbeTimeoutSeconds.ShouldBe(30);
result.UseTls.ShouldBeTrue();
result.ServerCertThumbprint.ShouldBe("0011223344556677889AABBCCDDEEFF001122334");
}
}
@@ -1,29 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class HistorianWonderwareAddressBuilderTests
{
[Theory]
[InlineData("SysTimeHour", "Cyclic", 60, "SysTimeHour?mode=Cyclic&interval=60")]
[InlineData("ReactorTemp", "Last", 1, "ReactorTemp?mode=Last&interval=1")]
[InlineData("FlowRate", "Delta", 30, "FlowRate?mode=Delta&interval=30")]
public void Build_Canonical(string tag, string mode, int interval, string expected)
=> HistorianWonderwareAddressBuilder.Build(tag, mode, interval).ShouldBe(expected);
/// <summary>A tag name carrying query-reserved characters is percent-encoded so the produced
/// address stays a well-formed query string (AdminUI-005). With "A&amp;B?C" the '&amp;' and '?'
/// must not be read as a query separator / start, so they are escaped.</summary>
[Fact]
public void Build_escapes_reserved_characters_in_tag_name()
{
var result = HistorianWonderwareAddressBuilder.Build("A&B?C", "Cyclic", 60);
// The only literal '?' is the query separator the builder inserts; the only literal '&'
// is the one between mode and interval. The reserved characters in the name are escaped.
result.ShouldBe("A%26B%3FC?mode=Cyclic&interval=60");
result.IndexOf('?').ShouldBe(result.IndexOf("?mode=", System.StringComparison.Ordinal));
}
}
@@ -1,100 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class HistorianWonderwareTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = HistorianWonderwareTagConfigModel.FromJson(json);
m.FullName.ShouldBe("");
}
[Fact]
public void FromJson_reads_FullName()
{
var m = HistorianWonderwareTagConfigModel.FromJson(
"""{"FullName":"Reactor1.Temp"}""");
m.FullName.ShouldBe("Reactor1.Temp");
}
[Fact]
public void Round_trip_preserves_FullName()
{
var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
var json = m.ToJson();
var m2 = HistorianWonderwareTagConfigModel.FromJson(json);
m2.FullName.ShouldBe("Reactor1.Temp");
}
[Fact]
public void ToJson_emits_PascalCase_FullName()
{
var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
var json = m.ToJson();
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = HistorianWonderwareTagConfigModel
.FromJson("""{"FullName":"Reactor1.Temp","deadband":0.5}""")
.ToJson();
json.ShouldContain("deadband");
json.ShouldContain("0.5");
// and the exposed field still round-trips
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
}
[Fact]
public void FromJson_then_ToJson_preserves_TagModal_merged_history_keys()
{
// The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model
// does NOT model them, so they must survive a load→save untouched as preserved unknown keys.
var json = HistorianWonderwareTagConfigModel
.FromJson("""{"FullName":"Reactor1.Temp","isHistorized":true,"historianTagname":"Reactor1.Temp.Override"}""")
.ToJson();
json.ShouldContain("\"isHistorized\":true");
json.ShouldContain("\"historianTagname\":\"Reactor1.Temp.Override\"");
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
}
[Fact]
public void ToJson_trims_FullName()
{
var json = new HistorianWonderwareTagConfigModel { FullName = " Reactor1.Temp " }.ToJson();
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
}
[Fact]
public void Validate_returns_error_when_FullName_blank()
{
new HistorianWonderwareTagConfigModel().Validate().ShouldNotBeNullOrEmpty();
new HistorianWonderwareTagConfigModel { FullName = " " }.Validate().ShouldNotBeNullOrEmpty();
}
[Fact]
public void Validate_returns_null_when_FullName_present()
{
new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }.Validate().ShouldBeNull();
}
}
@@ -31,7 +31,6 @@ public sealed class TagConfigValidatorTests
[InlineData("TwinCat")]
[InlineData("Focas")]
[InlineData("OpcUaClient")]
[InlineData("Historian.Wonderware")]
public void Required_field_blank_is_rejected(string driverType)
{
TagConfigValidator.Validate(driverType, "{}").ShouldNotBeNullOrEmpty();
@@ -42,10 +41,6 @@ public sealed class TagConfigValidatorTests
public void OpcUaClient_with_full_name_is_valid()
=> TagConfigValidator.Validate("OpcUaClient", """{"FullName":"ns=2;s=Line3.Temp"}""").ShouldBeNull();
[Fact]
public void HistorianWonderware_with_full_name_is_valid()
=> TagConfigValidator.Validate("Historian.Wonderware", """{"FullName":"Reactor1.Temp"}""").ShouldBeNull();
[Fact]
public void S7_with_address_is_valid()
=> TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0"}""").ShouldBeNull();
@@ -29,7 +29,6 @@ public sealed class DriverProbeRegistrationTests
"Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
"OpcUaClient",
"GalaxyMxGateway",
"Historian.Wonderware",
];
[Fact]
@@ -112,81 +112,54 @@ public sealed class AlarmHistorianRegistrationTests
opts.DeadLetterRetentionDays.ShouldBe(7);
}
[Fact]
public void Validate_warns_on_empty_shared_secret_when_enabled()
{
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "/var/h.db" };
opts.Validate().ShouldContain(w => w.Contains("SharedSecret"));
}
[Fact]
public void Validate_warns_on_relative_database_path_when_enabled()
{
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "alarm-historian.db" };
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db" };
opts.Validate().ShouldContain(w => w.Contains("DatabasePath"));
}
[Fact]
public void Validate_is_silent_when_correctly_configured()
{
new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty();
new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty();
}
[Fact]
public void Validate_is_silent_when_disabled()
{
new AlarmHistorianOptions { Enabled = false, SharedSecret = "" }.Validate().ShouldBeEmpty();
new AlarmHistorianOptions { Enabled = false }.Validate().ShouldBeEmpty();
}
[Fact]
public void Validate_warns_on_non_positive_drain_interval()
{
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 };
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 };
opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds"));
}
[Fact]
public void Validate_warns_on_non_positive_capacity()
{
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", Capacity = 0 };
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", Capacity = 0 };
opts.Validate().ShouldContain(w => w.Contains("Capacity"));
}
[Fact]
public void Validate_warns_on_non_positive_retention()
{
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 };
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 };
opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays"));
}
[Fact]
public void Validate_accumulates_multiple_warnings()
{
// relative path + empty secret ⇒ both warnings, not short-circuited on the first.
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "alarm-historian.db" };
// relative path + non-positive drain interval ⇒ both warnings, not short-circuited on the first.
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db", DrainIntervalSeconds = 0 };
var warnings = opts.Validate();
warnings.ShouldContain(w => w.Contains("SharedSecret"));
warnings.ShouldContain(w => w.Contains("DatabasePath"));
warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds"));
warnings.Count.ShouldBeGreaterThanOrEqualTo(2);
}
[Fact]
public void Section_binds_tcp_host_port_tls_fields()
{
var config = ConfigFrom(new Dictionary<string, string?>
{
["AlarmHistorian:Host"] = "historian.example.com",
["AlarmHistorian:Port"] = "12345",
["AlarmHistorian:UseTls"] = "true",
["AlarmHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
});
var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get<AlarmHistorianOptions>();
opts.ShouldNotBeNull();
opts.Host.ShouldBe("historian.example.com");
opts.Port.ShouldBe(12345);
opts.UseTls.ShouldBeTrue();
opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
}
}