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:
-266
@@ -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);
|
||||
}
|
||||
}
|
||||
-215
@@ -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();
|
||||
}
|
||||
}
|
||||
-148
@@ -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();
|
||||
}
|
||||
}
|
||||
-36
@@ -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();
|
||||
}
|
||||
}
|
||||
-890
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-27
@@ -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>
|
||||
-270
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-101
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-160
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
-114
@@ -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);
|
||||
}
|
||||
}
|
||||
-114
@@ -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 });
|
||||
}
|
||||
}
|
||||
-104
@@ -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");
|
||||
}
|
||||
}
|
||||
-134
@@ -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));
|
||||
}
|
||||
}
|
||||
-69
@@ -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);
|
||||
}
|
||||
}
|
||||
-323
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
-226
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
-297
@@ -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(); }
|
||||
}
|
||||
}
|
||||
-92
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-22
@@ -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");
|
||||
}
|
||||
}
|
||||
-37
@@ -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");
|
||||
}
|
||||
|
||||
-129
@@ -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");
|
||||
}
|
||||
}
|
||||
-29
@@ -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&B?C" the '&' 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));
|
||||
}
|
||||
}
|
||||
-100
@@ -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]
|
||||
|
||||
+9
-36
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user