feat(historian-client): default ctor dials TCP
This commit is contained in:
+3
-3
@@ -33,13 +33,13 @@ public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHist
|
||||
private int _consecutiveFailures;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client over a real named-pipe connection. Tests that need an in-process
|
||||
/// duplex pair use the <see cref="ForTests"/> factory.
|
||||
/// Creates a client that connects to the Wonderware historian sidecar over TCP.
|
||||
/// Tests that need an in-process duplex pair use the <see cref="ForTests"/> factory.
|
||||
/// </summary>
|
||||
/// <param name="options">The client connection options.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger<WonderwareHistorianClient>? logger = null)
|
||||
: this(options, ct => FrameChannel.DefaultNamedPipeConnectFactory(options, ct), logger)
|
||||
: this(options, ct => FrameChannel.DefaultTcpConnectFactory(options, ct), logger)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+135
-18
@@ -1,8 +1,11 @@
|
||||
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.Internal;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
|
||||
@@ -28,6 +31,16 @@ public sealed class WonderwareHistorianClientTests
|
||||
ConnectTimeout: TimeSpan.FromSeconds(2),
|
||||
CallTimeout: TimeSpan.FromSeconds(2));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client over a named pipe using the <see cref="WonderwareHistorianClient.ForTests"/>
|
||||
/// factory seam. Existing pipe-based tests use this after the public ctor was flipped to TCP.
|
||||
/// </summary>
|
||||
private static WonderwareHistorianClient PipeClientFor(string pipe)
|
||||
{
|
||||
var opts = OptsFor(pipe);
|
||||
return WonderwareHistorianClient.ForTests(opts, ct => FrameChannel.DefaultNamedPipeConnectFactory(opts, ct));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadRawAsync round-trips samples and maps quality bytes to OPC UA status codes.</summary>
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_RoundTripsSamples_AndMapsQualityByteToOpcUaStatusCode()
|
||||
@@ -57,7 +70,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
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),
|
||||
@@ -89,7 +102,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
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),
|
||||
@@ -122,7 +135,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2 }, CancellationToken.None);
|
||||
|
||||
result.Samples.Count.ShouldBe(2);
|
||||
@@ -164,7 +177,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2, t3 }, CancellationToken.None);
|
||||
|
||||
// Result MUST be the same length and order as the request.
|
||||
@@ -209,7 +222,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
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),
|
||||
@@ -233,7 +246,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
@@ -255,7 +268,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "operator", null, DateTime.UtcNow),
|
||||
@@ -285,7 +298,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
@@ -307,7 +320,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
await using var server = new FakeSidecarServer(pipe, "different-secret");
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
var ex = await Should.ThrowAsync<UnauthorizedAccessException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
@@ -331,7 +344,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
// First call: handshake + dropped. Reconnect kicks in inside the channel; second
|
||||
// attempt within the same InvokeAsync succeeds. From the caller's perspective it's
|
||||
@@ -358,7 +371,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None);
|
||||
|
||||
@@ -394,7 +407,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
@@ -425,7 +438,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
// ReadRawAsync uses Invoke, which propagates the exception when both attempts fail.
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
@@ -453,7 +466,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
ConnectTimeout: TimeSpan.FromSeconds(2),
|
||||
CallTimeout: TimeSpan.FromMilliseconds(500)); // short timeout for test speed
|
||||
|
||||
await using var client = new WonderwareHistorianClient(opts);
|
||||
await using var client = WonderwareHistorianClient.ForTests(opts, ct => FrameChannel.DefaultNamedPipeConnectFactory(opts, ct));
|
||||
|
||||
// The stall means neither the first nor the retry can complete, so the timeout
|
||||
// linked-token should cancel the operation.
|
||||
@@ -473,7 +486,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
await using var server = new FakeSidecarServer(pipe, Secret);
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
client.ReadProcessedAsync("Tag",
|
||||
@@ -497,7 +510,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
await Should.ThrowAsync<InvalidDataException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
@@ -523,7 +536,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None));
|
||||
@@ -554,7 +567,7 @@ public sealed class WonderwareHistorianClientTests
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
await using var client = PipeClientFor(pipe);
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
var readerSawInconsistent = false;
|
||||
@@ -593,4 +606,108 @@ public sealed class WonderwareHistorianClientTests
|
||||
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(
|
||||
PipeName: "ignored-pipe",
|
||||
SharedSecret: Secret,
|
||||
ConnectTimeout: TimeSpan.FromSeconds(5),
|
||||
CallTimeout: TimeSpan.FromSeconds(5))
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = boundPort,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user