Merge lane G1: OpcUaClient + MTConnect driver remediation (#743)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+53
@@ -165,6 +165,59 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 T-7 — the connectivity probe's headers-only ping (no device-model download/parse) answers
|
||||
/// against the live Agent, and fails against a closed port (the falsifiability control).
|
||||
/// </summary>
|
||||
[Fact(Timeout = TestTimeoutMs)]
|
||||
public async Task Ping_reaches_the_live_agent_and_not_a_dead_port()
|
||||
{
|
||||
SkipIfDown();
|
||||
|
||||
using (var client = new MTConnectAgentClient(LiveOptions()))
|
||||
{
|
||||
await client.PingAsync(Ct);
|
||||
}
|
||||
|
||||
using var dead = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = $"http://127.0.0.1:{ClosedLoopbackPort()}", RequestTimeoutMs = 5_000 });
|
||||
await Should.ThrowAsync<Exception>(async () => await dead.PingAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 T-4 / T-5 against the live Agent: once the <c>/sample</c> stream is delivering, a read is
|
||||
/// answered (from the pump's live index) with Good values, and the stream's lag measurement — the real Agent
|
||||
/// sends <c>lastSequence</c> — is on the diagnostics bag beside the effective count.
|
||||
/// </summary>
|
||||
[Fact(Timeout = TestTimeoutMs)]
|
||||
public async Task A_delivering_stream_serves_reads_and_reports_its_lag()
|
||||
{
|
||||
SkipIfDown();
|
||||
|
||||
var ids = await NumericSampleIdsAsync();
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value.");
|
||||
}
|
||||
|
||||
await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
|
||||
await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct);
|
||||
|
||||
var deadline = DateTime.UtcNow + StreamWait;
|
||||
while (!live.Driver.GetDiagnostics().ContainsKey("sampleLag") && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(100, Ct);
|
||||
}
|
||||
|
||||
var diag = live.Driver.GetDiagnostics();
|
||||
diag.ShouldContainKey("sampleLag", "the live stream must have delivered a measured chunk");
|
||||
diag["sampleCount"].ShouldBe("500");
|
||||
long.Parse(diag["sampleLag"], System.Globalization.CultureInfo.InvariantCulture).ShouldBeGreaterThanOrEqualTo(0);
|
||||
|
||||
var results = await live.Driver.ReadAsync([.. ids], Ct);
|
||||
results.Where(r => r.StatusCode == Good).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
// ---- discovery: the /probe device model ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It honours the seam's stream-end contract</b> (see
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/>): cancelling the token is the only way the
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>): cancelling the token is the only way the
|
||||
/// enumeration ends without throwing. Closing the scripted stream via
|
||||
/// <see cref="EndStream"/> raises <see cref="MTConnectStreamEndedException"/>, exactly as the
|
||||
/// production client does when an Agent drops the connection — so a pump written against the
|
||||
@@ -178,6 +178,13 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// </summary>
|
||||
public TaskCompletionSource? CurrentGate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, a <c>/sample</c> enumeration that is cancelled or whose client is disposed does NOT end until this
|
||||
/// source completes — a pump that cannot unwind (a wedged socket read). The deterministic way to keep a retired
|
||||
/// pump alive past the point a lifecycle restart retires it.
|
||||
/// </summary>
|
||||
public TaskCompletionSource? HoldSampleExit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signalled the moment a <c>/current</c> request <b>lands</b> — before
|
||||
/// <see cref="CurrentGate"/> parks it. This is the deterministic "the caller is now inside
|
||||
@@ -272,6 +279,22 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return answer;
|
||||
}
|
||||
|
||||
/// <summary>Number of connectivity pings (headers-only <c>/probe</c>) issued against this client.</summary>
|
||||
public int PingCallCount => Volatile.Read(ref _pingCallCount);
|
||||
|
||||
private int _pingCallCount;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Same failure surface as <see cref="ProbeAsync"/> (<see cref="ProbeFailure"/>), counted separately.</remarks>
|
||||
public Task PingAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Interlocked.Increment(ref _pingCallCount);
|
||||
|
||||
return ProbeFailure is null ? Task.CompletedTask : throw ProbeFailure;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -297,6 +320,20 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return CurrentFailure is null ? Current : throw CurrentFailure;
|
||||
}
|
||||
|
||||
/// <summary>The <c>count</c> the most recent <c>/sample</c> enumeration was opened with (null = the 2-arg overload).</summary>
|
||||
public int? LastSampleCount { get; private set; }
|
||||
|
||||
/// <summary>Every <c>count</c> a <c>/sample</c> enumeration was opened with, in order.</summary>
|
||||
public ConcurrentQueue<int> SampleCounts { get; } = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct)
|
||||
{
|
||||
LastSampleCount = count;
|
||||
SampleCounts.Enqueue(count);
|
||||
return SampleAsync(from, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from, [EnumeratorCancellation] CancellationToken ct)
|
||||
@@ -330,6 +367,11 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
scripted = await chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (HoldSampleExit is { } hold && ex is OperationCanceledException or ChannelClosedException)
|
||||
{
|
||||
await hold.Task.ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
// Mirrors the production client: a stream that stops for any reason other than the
|
||||
@@ -377,16 +419,39 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// </summary>
|
||||
public Action? AfterWrite { get; set; }
|
||||
|
||||
/// <summary>Every Agent write REQUEST, in order: the device and the (id, value) pairs it carried.</summary>
|
||||
public ConcurrentQueue<(string DeviceName, IReadOnlyList<KeyValuePair<string, string>> Pairs)> WriteRequests { get; } = new();
|
||||
|
||||
/// <summary>DataItem ids the Agent refuses: a request naming any of them is refused as a whole.</summary>
|
||||
public HashSet<string> RefusedDataItems { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken) =>
|
||||
WriteObservationsAsync(deviceName, [new KeyValuePair<string, string>(dataItemId, value)], cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectWriteOutcome> WriteObservationsAsync(
|
||||
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Writes.Enqueue((deviceName, dataItemId, value));
|
||||
WriteRequests.Enqueue((deviceName, observations));
|
||||
foreach (var (dataItemId, value) in observations)
|
||||
{
|
||||
Writes.Enqueue((deviceName, dataItemId, value));
|
||||
}
|
||||
|
||||
AfterWrite?.Invoke();
|
||||
|
||||
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
|
||||
if (WriteFailure is not null)
|
||||
{
|
||||
throw WriteFailure;
|
||||
}
|
||||
|
||||
return Task.FromResult(observations.Any(o => RefusedDataItems.Contains(o.Key))
|
||||
? new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: refused data item")
|
||||
: WriteOutcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+17
@@ -108,6 +108,23 @@ public sealed class MTConnectAgentClientWriteTests
|
||||
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-6 — several observations on one device are one POST carrying one escaped pair each.</summary>
|
||||
[Fact]
|
||||
public async Task Several_observations_are_one_request_with_one_pair_each()
|
||||
{
|
||||
var handler = new RecordingHandler(_ => Ok());
|
||||
using var client = new MTConnectAgentClient(Options(), handler);
|
||||
|
||||
var outcome = await client.WriteObservationsAsync(
|
||||
"StubDevice",
|
||||
[new KeyValuePair<string, string>("dev1_msg", "a&b"), new KeyValuePair<string, string>("dev1_count", "7")],
|
||||
CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeTrue();
|
||||
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=a%26b&dev1_count=7");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A value carrying a space, an <c>&</c> and a <c>=</c> — each of which silently ends the
|
||||
/// query pair early if it reaches the wire raw — plus a DataItem id needing the same
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-6 — writes were one POST per slot although the Agent's PUT surface accepts several
|
||||
/// <c>id=value</c> pairs per device. Surviving slots now go out as one request per device, with a refused
|
||||
/// multi-slot request retried slot by slot so one refused DataItem still costs only its own slot. And the health
|
||||
/// contract: an Agent answer (accepted or refused) is the Agent talking, so it advances LastSuccessfulRead.
|
||||
/// </summary>
|
||||
public sealed class MTConnectBatchedWriteTests
|
||||
{
|
||||
private const string FixtureDeviceName = "VMC-3Axis";
|
||||
private const string PosRaw = "Plant/MTConnect/dev1/Position";
|
||||
private const string CountRaw = "Plant/MTConnect/dev1/PartCount";
|
||||
private const string ProgramRaw = "Plant/MTConnect/dev1/Program";
|
||||
private const uint Good = 0x00000000u;
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static RawTagEntry Entry(string rawPath, string dataItemId) =>
|
||||
new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1");
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableAsync()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
Tags = [],
|
||||
RawTags =
|
||||
[
|
||||
Entry(PosRaw, "dev1_pos"),
|
||||
Entry(CountRaw, "dev1_partcount"),
|
||||
Entry(ProgramRaw, "dev1_program"),
|
||||
],
|
||||
Write = new MTConnectWriteOptions { Enabled = true },
|
||||
},
|
||||
"mt-t6",
|
||||
_ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
return (driver, client);
|
||||
}
|
||||
|
||||
/// <summary>Three writes to three DataItems of one device are ONE Agent request carrying three pairs.</summary>
|
||||
[Fact]
|
||||
public async Task Writes_to_one_device_go_out_as_one_request()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct);
|
||||
|
||||
results.ShouldAllBe(r => r.StatusCode == Good);
|
||||
var request = client.WriteRequests.ShouldHaveSingleItem();
|
||||
request.DeviceName.ShouldBe(FixtureDeviceName);
|
||||
request.Pairs.Select(p => (p.Key, p.Value)).ShouldBe(
|
||||
[("dev1_pos", "1.5"), ("dev1_partcount", "7"), ("dev1_program", "O1000")]);
|
||||
}
|
||||
|
||||
/// <summary>Two writes to the SAME DataItem are never merged into one request — they land in request order.</summary>
|
||||
[Fact]
|
||||
public async Task Two_writes_to_one_data_item_stay_separate_and_ordered()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.0), new WriteRequest(CountRaw, 3), new WriteRequest(PosRaw, 2.0)], Ct);
|
||||
|
||||
client.WriteRequests.Count.ShouldBe(2);
|
||||
client.Writes.Where(w => w.DataItemId == "dev1_pos").Select(w => w.Value).ShouldBe(["1", "2"]);
|
||||
}
|
||||
|
||||
/// <summary>A refused multi-slot request is retried per slot: only the refused DataItem's slot fails.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_batch_is_retried_per_slot_so_only_the_refused_item_fails()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.RefusedDataItems.Add("dev1_partcount");
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct);
|
||||
|
||||
results.Select(r => r.StatusCode).ShouldBe([Good, BadCommunicationError, Good]);
|
||||
client.WriteRequests.Count.ShouldBe(4, "the batch, then one retry per slot");
|
||||
}
|
||||
|
||||
/// <summary>A refusal is a reply: it advances LastSuccessfulRead (the Agent answered).</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_is_an_answer_and_advances_the_stamp()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.RefusedDataItems.Add("dev1_pos");
|
||||
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
// The stamp is DateTime.UtcNow: wait (bounded) for the clock to tick past it so an advance is observable.
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield();
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Review I-3 (coordinator ruling): a write that got NO reply degrades health, with the stamp preserved.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
var before = driver.GetHealth();
|
||||
before.State.ShouldBe(DriverState.Healthy, "positive control: Healthy before the failed write");
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(BadCommunicationError);
|
||||
var h = driver.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
h.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
||||
h.LastError.ShouldNotBeNull().ShouldContain(nameof(HttpRequestException));
|
||||
}
|
||||
|
||||
/// <summary>The unanswered-write degradation is cleared by the next ANSWERED write (it cannot latch).</summary>
|
||||
[Fact]
|
||||
public async Task An_answered_write_clears_an_unanswered_write_degradation()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
client.WriteFailure = null;
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>…and by the next good READ (the other direction of the shared health writer).</summary>
|
||||
[Fact]
|
||||
public async Task A_good_read_clears_an_unanswered_write_degradation()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
(await driver.ReadAsync([PosRaw], Ct)).Single().StatusCode.ShouldBe(Good);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>The converse: a failed READ is not cleared by an answered write (each path clears only what it proves).</summary>
|
||||
[Fact]
|
||||
public async Task An_answered_write_does_not_clear_a_failed_read()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.CurrentFailure = new HttpRequestException("connection refused");
|
||||
await driver.ReadAsync([PosRaw], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r2 N-5 (coordinator ruling): on a subscription-only driver an unanswered write degrades, and the next delivered
|
||||
/// /sample chunk restores Healthy (a delivered chunk proves the Agent is alive). Positive control: with no chunk
|
||||
/// it stays Degraded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_delivered_chunk_clears_an_unanswered_write_and_without_one_it_stays_degraded()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync([PosRaw], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5")).WaitAsync(TimeSpan.FromSeconds(30), Ct);
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 9.5)], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// Positive control: nothing else happens — it stays Degraded (no read, no write, no chunk).
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
client.CurrentCallCount.ShouldBe(currentCalls);
|
||||
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(113, 118, "2.5")).WaitAsync(TimeSpan.FromSeconds(30), Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "a delivered chunk proves the Agent is alive");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.WriteFailure = null;
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-14
@@ -95,14 +95,11 @@ public sealed class MTConnectDriverOptionsTests
|
||||
var pos = new MTConnectTagDefinition(
|
||||
FullName: "dev1_pos",
|
||||
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
|
||||
MtCategory: "SAMPLE",
|
||||
MtType: "POSITION",
|
||||
Units: "MILLIMETER");
|
||||
MtCategory: "SAMPLE");
|
||||
var partCount = new MTConnectTagDefinition(
|
||||
FullName: "dev1_partcount",
|
||||
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int64,
|
||||
MtCategory: "EVENT",
|
||||
MtType: "PART_COUNT");
|
||||
MtCategory: "EVENT");
|
||||
|
||||
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000", Tags = [pos, partCount] };
|
||||
|
||||
@@ -111,25 +108,47 @@ public sealed class MTConnectDriverOptionsTests
|
||||
o.Tags[1].ShouldBe(partCount);
|
||||
}
|
||||
|
||||
/// <summary>MTConnectTagDefinition round-trips its positional fields, including MTConnect metadata.</summary>
|
||||
/// <summary>MTConnectTagDefinition round-trips its positional fields, including the category the write path reads.</summary>
|
||||
[Fact]
|
||||
public void TagDefinition_carries_dataItemId_and_mtconnect_metadata()
|
||||
public void TagDefinition_carries_dataItemId_and_mtconnect_category()
|
||||
{
|
||||
var tag = new MTConnectTagDefinition(
|
||||
FullName: "dtop_2",
|
||||
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
|
||||
MtCategory: "SAMPLE",
|
||||
MtType: "POSITION",
|
||||
MtSubType: "ACTUAL",
|
||||
Units: "MILLIMETER");
|
||||
MtCategory: "SAMPLE");
|
||||
|
||||
tag.FullName.ShouldBe("dtop_2");
|
||||
tag.DriverDataType.ShouldBe(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64);
|
||||
tag.IsArray.ShouldBeFalse();
|
||||
tag.ArrayDim.ShouldBe(0);
|
||||
tag.MtCategory.ShouldBe("SAMPLE");
|
||||
tag.MtType.ShouldBe("POSITION");
|
||||
tag.MtSubType.ShouldBe("ACTUAL");
|
||||
tag.Units.ShouldBe("MILLIMETER");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 T-3: mtType / mtSubType / units were deleted from the definition as dead, but a stored
|
||||
/// config that still carries them — in tags[] or in a raw tag's TagConfig blob — must keep loading.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stored_configs_carrying_the_deleted_metadata_keys_still_load()
|
||||
{
|
||||
const string config = """
|
||||
{
|
||||
"agentUri": "http://agent:5000",
|
||||
"tags": [ { "fullName": "dev1_pos", "driverDataType": "Float64", "mtCategory": "SAMPLE",
|
||||
"mtType": "POSITION", "mtSubType": "ACTUAL", "units": "MILLIMETER" } ]
|
||||
}
|
||||
""";
|
||||
|
||||
var options = MTConnectDriver.ParseOptions("mt-t3", config);
|
||||
|
||||
options.Tags.ShouldHaveSingleItem().ShouldBe(new MTConnectTagDefinition(
|
||||
"dev1_pos", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64, MtCategory: "SAMPLE"));
|
||||
|
||||
MTConnectTagDefinitionFactory.TryFromTagConfig(
|
||||
"""{ "dataItemId": "dev1_pos", "dataType": "Float64", "mtCategory": "SAMPLE", "mtType": "POSITION", "mtSubType": "ACTUAL", "units": "MILLIMETER" }""",
|
||||
out var dataItemId, out var authored)
|
||||
.ShouldBeTrue();
|
||||
dataItemId.ShouldBe("dev1_pos");
|
||||
authored.ShouldNotBeNull().MtCategory.ShouldBe("SAMPLE");
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -385,19 +385,22 @@ public sealed class MTConnectHostAndRediscoverTests
|
||||
{
|
||||
var (_, client, ticker) = await ProbingDriverAsync();
|
||||
|
||||
// Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is
|
||||
// the only call so far.
|
||||
// Parked before the first tick: the loop waits, THEN probes, so nothing has been pinged yet.
|
||||
client.PingCallCount.ShouldBe(0);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(1);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(2);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(3);
|
||||
|
||||
// Review 2026-10-21 T-7: a tick is a headers-only ping — the full /probe (download + parse of the device
|
||||
// model) is still initialize's one call, never a tick's.
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(2);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(3);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(4);
|
||||
|
||||
// …and it waits the authored interval between them, rather than a hard-coded cadence.
|
||||
ticker.LastInterval.ShouldBe(AuthoredInterval);
|
||||
}
|
||||
@@ -535,11 +538,12 @@ public sealed class MTConnectHostAndRediscoverTests
|
||||
|
||||
// The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach
|
||||
// nobody — no probe against a disposed client, and no further waits on the scheduler.
|
||||
var calls = client.ProbeCallCount;
|
||||
var calls = client.PingCallCount;
|
||||
calls.ShouldBeGreaterThan(0, "positive control: the tick before shutdown did reach the Agent");
|
||||
var waits = ticker.Waits;
|
||||
ticker.Tick();
|
||||
|
||||
client.ProbeCallCount.ShouldBe(calls);
|
||||
client.PingCallCount.ShouldBe(calls);
|
||||
ticker.Waits.ShouldBe(waits);
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -502,14 +502,13 @@ public sealed class MTConnectObservationIndexTests
|
||||
idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNotSupported);
|
||||
}
|
||||
|
||||
/// <summary>The parser carries the observation's own sampleCount / sampleRate attributes.</summary>
|
||||
/// <summary>The parser carries the observation's own sampleCount (sampleRate is not parsed — T-3: nothing read it).</summary>
|
||||
[Fact]
|
||||
public void Parser_carries_time_series_sampleCount_and_sampleRate()
|
||||
public void Parser_carries_time_series_sampleCount()
|
||||
{
|
||||
var obs = ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_vibration_ts");
|
||||
|
||||
obs.SampleCount.ShouldBe(10);
|
||||
obs.SampleRate.ShouldBe(100.0);
|
||||
ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_pos").SampleCount.ShouldBeNull();
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -37,6 +37,7 @@ public sealed class MTConnectPumpGenerationTests
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
Task[] retired = [];
|
||||
|
||||
var holdStalePump = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var blockOnce = 0;
|
||||
var blocking = new ManualResetEventSlim(false);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -68,8 +69,12 @@ public sealed class MTConnectPumpGenerationTests
|
||||
(++spins).ShouldBeLessThan(500, "restart never cleared the pump slot");
|
||||
}
|
||||
|
||||
// A second subscriber arrives mid-restart: it starts a pump on the (still-live) old client.
|
||||
// A second subscriber arrives mid-restart: it starts a pump on the (still-live) old client. That pump is
|
||||
// held alive past the retire point (it cannot unwind until released), so the retention is observable
|
||||
// deterministically rather than racing the pump's own exit.
|
||||
clientA.HoldSampleExit = holdStalePump;
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
var staleSessionPump = driver.SampleStreamTask.ShouldNotBeNull();
|
||||
await clientA.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
restart.IsCompleted.ShouldBeFalse();
|
||||
|
||||
@@ -77,9 +82,12 @@ public sealed class MTConnectPumpGenerationTests
|
||||
await restart.WaitAsync(Watchdog, Ct);
|
||||
|
||||
// The pump retired by the restart is retained so shutdown can join it (it used to be dropped
|
||||
// un-awaited and could write health after the new session was Healthy).
|
||||
// un-awaited and could write health after the new session was Healthy). Since review 2026-10-21 T-1 a
|
||||
// pump also stops by itself once it sees its session generation is gone, so it may already have
|
||||
// finished before the restart got to retire it — either way it is not left running unjoined.
|
||||
retired = driver.SupersededPumpTasks;
|
||||
retired.ShouldNotBeEmpty();
|
||||
staleSessionPump.IsCompleted.ShouldBeFalse("positive control: the stale pump is still running here");
|
||||
retired.ShouldContain(staleSessionPump, "a retired pump that is still running must be retained for shutdown to join");
|
||||
|
||||
// The restart installed a new client; the standing subscription must be streaming from IT.
|
||||
await clientB.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
@@ -88,6 +96,7 @@ public sealed class MTConnectPumpGenerationTests
|
||||
finally
|
||||
{
|
||||
blocking.Set();
|
||||
holdStalePump.TrySetResult();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-4 and T-2 on the read path.
|
||||
/// <list type="bullet">
|
||||
/// <item>T-4 — every <c>ReadAsync</c> batch fetched + parsed the whole device <c>/current</c> even while the
|
||||
/// <c>/sample</c> pump held a live, healthy index. A read now answers from that index (READ only — the pump
|
||||
/// stays its single writer) while the pump is live and not degraded, and keeps <c>/current</c> as the
|
||||
/// fallback.</item>
|
||||
/// <item>T-2 — Agent-restart detection lived only on the pump, and the read path discarded the
|
||||
/// <c>/current</c> answer's <c>instanceId</c>: a browse-only driver kept a cached <c>/probe</c> model across an
|
||||
/// Agent restart and never raised rediscovery. The fallback read now checks it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class MTConnectReadPathTests
|
||||
{
|
||||
private const long RestartedInstance = 1655000777L;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static MTConnectDriverOptions Opts() => new()
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
};
|
||||
|
||||
/// <summary>T-4: with a live, healthy pump the read is answered from its index — no /current round trip.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_with_a_live_pump_answers_from_the_index_without_a_current_call()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t4", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "7.25")).WaitAsync(Watchdog, Ct);
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
|
||||
var result = (await driver.ReadAsync(["dev1_pos"], Ct)).Single();
|
||||
|
||||
result.Value.ShouldBe(7.25d, "the read must serve the pump's newest value");
|
||||
client.CurrentCallCount.ShouldBe(currentCalls, "no /current while the pump's index is live");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Positive control for T-4: with no pump the read still issues exactly one /current.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_with_no_pump_falls_back_to_one_current_call()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t4-fallback", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
client.CurrentCallCount.ShouldBe(currentCalls + 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T-4: a pump whose stream is failing does not serve reads — they fall back to /current.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_with_a_degraded_pump_falls_back_to_current()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
// Every /sample fails: the pump stays alive (reconnecting under backoff) but its stream is degraded.
|
||||
client.SampleFailure = new IOException("connection refused");
|
||||
var options = new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 1000, MaxBackoffMs = 1000 },
|
||||
};
|
||||
var driver = new MTConnectDriver(options, "mt-t4-degraded", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
var deadline = DateTime.UtcNow + Watchdog;
|
||||
while (driver.GetHealth().State != DriverState.Degraded && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20, Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
client.CurrentCallCount.ShouldBe(currentCalls + 1, "a degraded stream's index is not trusted for reads");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T-2: a browse-only driver's read sees the Agent restart — once — and drops the stale model.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_that_sees_a_new_instance_id_raises_rediscovery_once_and_drops_the_probe_model()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t2", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
var raised = 0;
|
||||
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
|
||||
try
|
||||
{
|
||||
driver.CachedProbeModel.ShouldNotBeNull("positive control: initialize cached the model");
|
||||
client.Current = new MTConnectStreamsResult(RestartedInstance, 5, 1, client.Current.Observations);
|
||||
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
raised.ShouldBe(1, "once per distinct instanceId, not once per read");
|
||||
driver.CachedProbeModel.ShouldBeNull("the model describes a process that no longer exists");
|
||||
// Review m6: the session's cursor moves to the new process, so a later subscription does not open at the
|
||||
// dead instance's sequence (and flicker through a clear + re-baseline on its first chunk).
|
||||
driver.AgentInstanceId.ShouldBe(RestartedInstance);
|
||||
driver.AgentNextSequence.ShouldBe(5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T-2 control: reads against the same Agent process never raise rediscovery.</summary>
|
||||
[Fact]
|
||||
public async Task Reads_against_the_same_instance_raise_nothing()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t2-same", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
var raised = 0;
|
||||
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
|
||||
try
|
||||
{
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
raised.ShouldBe(0);
|
||||
driver.CachedProbeModel.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private const string NoDeviceError =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header creationTime="2026-10-22T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Errors><Error errorCode="NO_DEVICE">Could not find the device 'VMC-9'</Error></Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
private static Exception ParseFailure(string xml)
|
||||
{
|
||||
try
|
||||
{
|
||||
MTConnectStreamsParser.Parse(xml);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("expected the parse to fail");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health contract: an MTConnectError answer to /current is the Agent REFUSING (it answered) — the batch is Bad,
|
||||
/// but health is restored / kept Healthy with the stamp advanced, and the refusal is logged once, not per read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_agent_refusal_of_current_is_a_reply_not_a_comms_fault()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-refusal", _ => client, logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
// A comms fault first, so the refusal has a Degraded to restore.
|
||||
client.CurrentFailure = new HttpRequestException("connection refused");
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault does degrade");
|
||||
var stamp = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
|
||||
var refusal = ParseFailure(NoDeviceError);
|
||||
MTConnectXml.IsAgentRefusal(refusal).ShouldBeTrue();
|
||||
client.CurrentFailure = refusal;
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow <= stamp && DateTime.UtcNow < deadline) await Task.Yield();
|
||||
|
||||
var first = await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
first.Single().StatusCode.ShouldBe(0x80050000u, "the batch is still Bad");
|
||||
var h = driver.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(stamp);
|
||||
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "a persistent refusal warns once");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentFailure = null;
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An answer that is not an MTConnect document at all is NOT marked a refusal (it stays a comms fault).</summary>
|
||||
[Fact]
|
||||
public void A_garbage_answer_is_not_an_agent_refusal() =>
|
||||
MTConnectXml.IsAgentRefusal(ParseFailure("<html><body>proxy error</body></html>")).ShouldBeFalse();
|
||||
|
||||
/// <summary>Review m7: a refusal that recurs AFTER a good read is news again and is logged again.</summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_recurring_after_a_good_read_is_logged_again()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-refusal-again", _ => client, logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
var refusal = ParseFailure(NoDeviceError);
|
||||
client.CurrentFailure = refusal;
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "positive control: deduplicated while it persists");
|
||||
|
||||
client.CurrentFailure = null;
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
client.CurrentFailure = refusal;
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentFailure = null;
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-5 — <c>/sample</c> throughput was hard-capped at <c>SampleCount / SampleIntervalMs</c>
|
||||
/// with no lag detection: a machine producing more fell behind the Agent's ring buffer forever, re-baselining
|
||||
/// every buffer-length with every value backlog-stale and nothing surfacing it. The pump now measures the lag
|
||||
/// (<c>lastSequence + 1 − nextSequence</c>, plus chunk saturation), surfaces it on the diagnostics bag, and raises
|
||||
/// the per-stream <c>count</c> when it is persistently behind.
|
||||
/// </summary>
|
||||
public sealed class MTConnectSampleLagTests
|
||||
{
|
||||
private const long Instance = 1655000000L;
|
||||
private const long Primed = 108L;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>A chunk of <paramref name="observations"/> dev1_pos values from <paramref name="first"/>.</summary>
|
||||
private static MTConnectStreamsResult Chunk(long first, int observations, long? last)
|
||||
{
|
||||
var obs = Enumerable.Range(0, observations)
|
||||
.Select(i => new MTConnectObservation(
|
||||
"dev1_pos", (first + i).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc).AddSeconds(first + i)))
|
||||
.ToList();
|
||||
return new MTConnectStreamsResult(Instance, first + observations, first, obs, last);
|
||||
}
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> SubscribedDriverAsync(int sampleCount)
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
SampleCount = sampleCount,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t5",
|
||||
_ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
return (driver, client);
|
||||
}
|
||||
|
||||
/// <summary>Three full chunks with a positive lag reopen the stream, at the cursor reached, with count doubled.</summary>
|
||||
[Fact]
|
||||
public async Task A_persistently_behind_stream_reopens_with_a_doubled_count()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
await client.PumpAsync(Chunk(Primed, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
await client.PumpAsync(Chunk(Primed + 2, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
await client.PumpAsync(Chunk(Primed + 4, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.ShouldBe([2, 4]);
|
||||
client.LastSampleFrom.ShouldBe(Primed + 6, "the raised stream resumes where the behind one got to");
|
||||
|
||||
var diag = driver.GetDiagnostics();
|
||||
diag["sampleCount"].ShouldBe("4");
|
||||
diag["sampleLag"].ShouldBe((900 + 1 - (Primed + 6)).ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
diag["sampleSaturatedChunks"].ShouldBe("3");
|
||||
diag["sampleCountRaises"].ShouldBe("1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An Agent that omits lastSequence: persistent saturation alone is enough.</summary>
|
||||
[Fact]
|
||||
public async Task Persistent_saturation_without_a_lastSequence_still_raises_the_count()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
await client.PumpAsync(Chunk(Primed + (2 * i), 2, last: null)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.ShouldBe([2, 4]);
|
||||
driver.GetDiagnostics().ContainsKey("sampleLag").ShouldBeFalse("an unknown lag is not reported as a number");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full chunks with NO lag (the Agent happened to have exactly count observations) are not "behind": the stream
|
||||
/// is kept. Positive control: the first test, through the same pump, does reopen.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Full_chunks_with_no_lag_keep_the_stream()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var first = Primed + (2 * i);
|
||||
await client.PumpAsync(Chunk(first, 2, last: first + 1)).WaitAsync(Watchdog, Ct);
|
||||
}
|
||||
|
||||
client.SampleCallCount.ShouldBe(1, "a caught-up stream must not be reopened");
|
||||
driver.GetDiagnostics()["sampleLag"].ShouldBe("0");
|
||||
driver.GetDiagnostics()["sampleCountRaises"].ShouldBe("0");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A raised count the Agent refuses (its stream delivers nothing) steps back to the last count that worked.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_raised_count_steps_back_to_the_last_count_that_delivered()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
// The NEXT enumeration (the raised one) is refused the way a real Agent refuses a count above its buffer.
|
||||
// Enqueued only once the first stream has consumed a chunk — i.e. is past its own one-shot-failure check —
|
||||
// so the failure can only ever hit the raised stream (enqueueing earlier raced the first enumeration).
|
||||
await client.PumpAsync(Chunk(Primed, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
client.SampleFailures.Enqueue(new InvalidDataException("MTConnectError: INVALID_REQUEST count too large"));
|
||||
for (var i = 1; i < 3; i++)
|
||||
await client.PumpAsync(Chunk(Primed + (2 * i), 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.Take(3).ShouldBe([2, 4, 2]);
|
||||
driver.GetDiagnostics()["sampleCount"].ShouldBe("2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ceiling is 16× the configured count, capped at 65 536, and never below the configured count.</summary>
|
||||
[Theory]
|
||||
[InlineData(1000, 16000)]
|
||||
[InlineData(10_000, 65_536)]
|
||||
[InlineData(100_000, 100_000)]
|
||||
public void The_count_ceiling_is_bounded(int configured, int expected) =>
|
||||
MTConnectDriver.SampleCountCeiling(configured).ShouldBe(expected);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-1 — a pump the lifecycle abandoned (its teardown wait timed out) that finished after
|
||||
/// the restart installed the NEW session published its stale cursor onto it: the old Agent's instanceId and
|
||||
/// sequence replaced the new session's, so the next pump to start treated its first chunk as an Agent restart
|
||||
/// (index cleared, a Bad flicker on every subscribed tag, a needless re-baseline). The pump now carries the
|
||||
/// session generation it was started against and its cursor publish is dropped once that generation is gone.
|
||||
/// </summary>
|
||||
public sealed class MTConnectStaleCursorTests
|
||||
{
|
||||
private const long InstanceB = 1655000099L;
|
||||
private const long NextSequenceB = 500L;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>The abandoned pump's late cursor publish leaves the new session's cursor alone.</summary>
|
||||
[Fact]
|
||||
public async Task An_abandoned_pump_does_not_publish_its_cursor_onto_the_new_session()
|
||||
{
|
||||
var clientA = CannedAgentClient.FromFixtures();
|
||||
var clientB = CannedAgentClient.FromFixtures();
|
||||
clientB.Current = new MTConnectStreamsResult(InstanceB, NextSequenceB, NextSequenceB - 10, clientB.Current.Observations);
|
||||
var clients = new Queue<CannedAgentClient>([clientA, clientB]);
|
||||
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t1",
|
||||
_ => clients.Dequeue());
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
|
||||
var wedgeOnce = 0;
|
||||
var wedge = new ManualResetEventSlim(false);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.Snapshot.Value is not 1.5d || Interlocked.Exchange(ref wedgeOnce, 1) != 0) return;
|
||||
entered.TrySetResult();
|
||||
wedge.Wait();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
var pumpA = driver.SampleStreamTask.ShouldNotBeNull();
|
||||
_ = clientA.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5"));
|
||||
await entered.Task.WaitAsync(Watchdog, Ct);
|
||||
|
||||
// The restart gives up on the wedged pump (bounded teardown wait), retires clientA and installs B.
|
||||
await driver.InitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct);
|
||||
driver.AgentInstanceId.ShouldBe(InstanceB, "positive control: the new session is installed");
|
||||
pumpA.IsCompleted.ShouldBeFalse("pump A must still be alive — it is the late writer under test");
|
||||
|
||||
// Now let the abandoned pump finish: its finally publishes (instanceA, 113).
|
||||
wedge.Set();
|
||||
await pumpA.WaitAsync(Watchdog, Ct);
|
||||
|
||||
driver.AgentInstanceId.ShouldBe(InstanceB, "a superseded pump must not write the old Agent's instanceId");
|
||||
driver.AgentNextSequence.ShouldBe(NextSequenceB, "nor its cursor");
|
||||
}
|
||||
finally
|
||||
{
|
||||
wedge.Set();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control: the CURRENT pump's own cursor publish still lands — a last unsubscribe stops it, and the
|
||||
/// session's cursor moves to where that pump got to.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_current_pump_still_publishes_its_cursor_when_it_stops()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t1-ok",
|
||||
_ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5")).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await driver.UnsubscribeAsync(handle, Ct);
|
||||
|
||||
driver.AgentNextSequence.ShouldBe(113);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I-1: a pump wedged inside its fan-out that comes back after the restart must not restore the NEW
|
||||
/// session's health (nor mark its stream live for the read path) while the new pump's stream is down. Positive
|
||||
/// control: the CURRENT pump's own chunk still restores Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_abandoned_pump_does_not_restore_the_new_sessions_health()
|
||||
{
|
||||
var clientA = CannedAgentClient.FromFixtures();
|
||||
var clientB = CannedAgentClient.FromFixtures();
|
||||
clientB.SampleFailure = new IOException("connection refused");
|
||||
var clients = new Queue<CannedAgentClient>([clientA, clientB]);
|
||||
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 50, MaxBackoffMs = 200 },
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-i1",
|
||||
_ => clients.Dequeue());
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
|
||||
var wedgeOnce = 0;
|
||||
var wedge = new ManualResetEventSlim(false);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.Snapshot.Value is not 1.5d || Interlocked.Exchange(ref wedgeOnce, 1) != 0) return;
|
||||
entered.TrySetResult();
|
||||
wedge.Wait();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
var pumpA = driver.SampleStreamTask.ShouldNotBeNull();
|
||||
_ = clientA.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5"));
|
||||
await entered.Task.WaitAsync(Watchdog, Ct);
|
||||
|
||||
await driver.InitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct);
|
||||
|
||||
// The new session's pump fails its /sample: the driver goes Degraded.
|
||||
await clientB.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
await WaitUntilAsync(() => driver.GetHealth().State == DriverState.Degraded, "the new stream is down");
|
||||
|
||||
wedge.Set();
|
||||
await pumpA.WaitAsync(Watchdog, Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(
|
||||
DriverState.Degraded, "a superseded pump must not report the new session's stream healthy");
|
||||
|
||||
// Positive control: the new session's own stream recovering does restore Healthy.
|
||||
clientB.SampleFailure = null;
|
||||
var calls = clientB.SampleCallCount;
|
||||
await clientB.WaitForSampleCallsAsync(calls + 1).WaitAsync(Watchdog, Ct);
|
||||
await clientB.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "2.5")).WaitAsync(Watchdog, Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
wedge.Set();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + Watchdog;
|
||||
while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20, Ct);
|
||||
condition().ShouldBeTrue(because);
|
||||
}
|
||||
}
|
||||
@@ -572,6 +572,32 @@ public sealed class MTConnectStreamsParseTests
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await client.CurrentAsync(Ct));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- MTConnectAgentClient.PingAsync
|
||||
|
||||
/// <summary>R06:T-7 — the ping reads the status only: a body that is not a device model does not matter.</summary>
|
||||
[Fact]
|
||||
public async Task PingAsync_succeeds_on_a_success_status_without_parsing_the_body()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("this is not an MTConnectDevices document");
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await client.PingAsync(Ct);
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
|
||||
// Positive control: the full probe DOES parse that body, and refuses it.
|
||||
await Should.ThrowAsync<Exception>(async () => await client.ProbeAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>A non-success status is an unreachable / refusing Agent, exactly as for the full probe.</summary>
|
||||
[Fact]
|
||||
public async Task PingAsync_throws_on_a_non_success_status()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await client.PingAsync(Ct));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- MTConnectAgentClient.SampleAsync
|
||||
|
||||
[Fact]
|
||||
@@ -593,6 +619,38 @@ public sealed class MTConnectStreamsParseTests
|
||||
"http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-5 — the count overload sends the raised count in place of the configured one.</summary>
|
||||
[Fact]
|
||||
public async Task SampleAsync_with_a_count_sends_that_count_instead_of_the_configured_one()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://agent:5000",
|
||||
DeviceName = "VMC-3Axis",
|
||||
SampleIntervalMs = 250,
|
||||
SampleCount = 64,
|
||||
HeartbeatMs = 7000
|
||||
});
|
||||
|
||||
await DrainAsync(client.SampleAsync(108, 256, Ct));
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe(
|
||||
"http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=256&heartbeat=7000");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-5 — the header's lastSequence is carried (it measures the stream's lag); absent ⇒ null.</summary>
|
||||
[Fact]
|
||||
public async Task The_header_lastSequence_is_carried_when_present_and_null_when_absent()
|
||||
{
|
||||
var current = MTConnectStreamsParser.Parse(await File.ReadAllTextAsync("Fixtures/current.xml", Ct));
|
||||
current.LastSequence.ShouldBe(107);
|
||||
|
||||
var stripped = (await File.ReadAllTextAsync("Fixtures/current.xml", Ct)).Replace("lastSequence=\"107\"", string.Empty);
|
||||
stripped.ShouldNotContain("lastSequence");
|
||||
MTConnectStreamsParser.Parse(stripped).LastSequence.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_yields_one_result_per_multipart_chunk()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-8 — the driver logged and surfaced the raw <c>AgentUri</c> (which may carry
|
||||
/// <c>user:pass@</c>) as its <c>HostName</c>, in every log line and in exception messages that reach
|
||||
/// <c>DriverHealth.LastError</c> → <c>/hosts</c>, while its own Test-Connect probe deliberately redacted it.
|
||||
/// </summary>
|
||||
public sealed class MTConnectUriRedactionTests
|
||||
{
|
||||
private const string Secret = "s3cretPw";
|
||||
private const string CredentialedUri = "http://operator:" + Secret + "@fixture-agent:5000";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://fixture-agent:5000", "http://fixture-agent:5000")] // unchanged when clean
|
||||
[InlineData("http://agent:5000/prefix", "http://agent:5000/prefix")]
|
||||
[InlineData("http://u:p@agent:5000", "http://agent:5000")]
|
||||
[InlineData("https://u:p@agent/prefix?token=abc", "https://agent/prefix")]
|
||||
[InlineData("not a uri with u:p@host", MTConnectUriRedaction.Unparseable)]
|
||||
[InlineData("not-a-uri", "not-a-uri")]
|
||||
public void The_configured_uri_loses_its_user_info(string raw, string expected) =>
|
||||
MTConnectUriRedaction.Redact(raw).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void A_request_uri_loses_its_user_info_and_keeps_its_query() =>
|
||||
MTConnectUriRedaction.Redact(new Uri("http://u:p@agent:5000/sample?from=5&count=10"))
|
||||
.ShouldBe("http://agent:5000/sample?from=5&count=10");
|
||||
|
||||
/// <summary>The host key, every log line and the health text of a credentialed driver carry no password.</summary>
|
||||
[Fact]
|
||||
public async Task A_credentialed_agent_uri_never_reaches_host_name_logs_or_health()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = CredentialedUri,
|
||||
RequestTimeoutMs = 200,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t8",
|
||||
_ => client,
|
||||
logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
// A /current that never answers: the read times out, degrades health and logs a Warning.
|
||||
client.CurrentGate = new TaskCompletionSource();
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded, "positive control: the failure path that carries the URI ran");
|
||||
health.LastError.ShouldNotBeNull().ShouldNotContain(Secret);
|
||||
health.LastError.ShouldContain("fixture-agent:5000", Case.Sensitive, "the host is still named");
|
||||
|
||||
driver.HostName.ShouldBe("http://fixture-agent:5000");
|
||||
((IHostConnectivityProbe)driver).GetHostStatuses().ShouldAllBe(s => !s.HostName.Contains(Secret));
|
||||
|
||||
var lines = logger.AllSnapshot();
|
||||
lines.ShouldNotBeEmpty("positive control: the initialize line and the read warning were logged");
|
||||
lines.ShouldContain(l => l.Contains("fixture-agent:5000"));
|
||||
lines.ShouldAllBe(l => !l.Contains(Secret));
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentGate?.TrySetResult();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The production client's own exception messages (they become health text) carry no password.</summary>
|
||||
[Fact]
|
||||
public async Task The_client_timeout_message_carries_no_password()
|
||||
{
|
||||
using var handler = new HangingHandler();
|
||||
using var client = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = CredentialedUri, RequestTimeoutMs = 200 }, handler);
|
||||
|
||||
var ex = await Should.ThrowAsync<TimeoutException>(async () => await client.CurrentAsync(Ct));
|
||||
|
||||
ex.Message.ShouldContain("fixture-agent:5000");
|
||||
ex.Message.ShouldNotContain(Secret);
|
||||
}
|
||||
|
||||
/// <summary>The stream-ended exception (it becomes the pump's Degrade text) carries no password.</summary>
|
||||
[Fact]
|
||||
public async Task The_client_stream_messages_carry_no_password()
|
||||
{
|
||||
using var handler = new SingleDocumentHandler();
|
||||
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = CredentialedUri }, handler);
|
||||
|
||||
var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(async () =>
|
||||
{
|
||||
await foreach (var _ in client.SampleAsync(1, Ct)) { }
|
||||
});
|
||||
|
||||
ex.Message.ShouldContain("fixture-agent:5000");
|
||||
ex.Message.ShouldNotContain(Secret);
|
||||
}
|
||||
|
||||
private sealed class HangingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleDocumentHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(File.ReadAllText("Fixtures/current.xml"), System.Text.Encoding.UTF8, "text/xml"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,24 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
/// <summary>Formatted <see cref="LogLevel.Error"/> lines, in order.</summary>
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
/// <summary>Formatted <see cref="LogLevel.Information"/> lines, in order.</summary>
|
||||
public List<string> Infos { get; } = [];
|
||||
|
||||
/// <summary>Every recorded line at Information and above, with its exception's message appended.</summary>
|
||||
public IReadOnlyList<string> AllSnapshot()
|
||||
{
|
||||
var all = new List<string>();
|
||||
foreach (var sink in new[] { Infos, Warnings, Errors })
|
||||
{
|
||||
lock (sink)
|
||||
{
|
||||
all.AddRange(sink);
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull => null;
|
||||
@@ -39,6 +57,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
{
|
||||
LogLevel.Warning => Warnings,
|
||||
LogLevel.Error => Errors,
|
||||
LogLevel.Information => Infos,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -50,7 +69,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
|
||||
lock (sink)
|
||||
{
|
||||
sink.Add(formatter(state, exception));
|
||||
sink.Add(exception is null ? formatter(state, exception) : $"{formatter(state, exception)} | {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-3
@@ -63,10 +63,10 @@ public sealed class OpcUaClientBrowseSessionTests
|
||||
children.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The OPC UA picker treats variables as terminal leaves, so
|
||||
/// AttributesAsync must always be empty for this driver.</summary>
|
||||
/// <summary>Review 2026-10-21 O-5: an Object (folder) has no value attributes, so AttributesAsync is empty for
|
||||
/// it. (It used to be empty for EVERYTHING — the side-panel was a stub; see the variable test below.)</summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_is_empty_for_opcuaclient()
|
||||
public async Task AttributesAsync_is_empty_for_an_object_node()
|
||||
{
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
@@ -75,4 +75,25 @@ public sealed class OpcUaClientBrowseSessionTests
|
||||
var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
|
||||
attrs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 O-5 against the live server: a standard variable (Server/ServerStatus/CurrentTime — a
|
||||
/// read-only scalar UtcTime every server exposes) reports its mapped type, shape and access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_reports_a_live_variables_type_shape_and_access()
|
||||
{
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var browser = new OpcUaClientDriverBrowser();
|
||||
await using var session = await browser.OpenAsync(ConfigJson, ct);
|
||||
|
||||
var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=2258", ct);
|
||||
|
||||
var a = attrs.ShouldHaveSingleItem();
|
||||
a.Name.ShouldBe("CurrentTime");
|
||||
a.DriverDataType.ShouldBe("DateTime");
|
||||
a.IsArray.ShouldBeFalse();
|
||||
a.SecurityClass.ShouldBe("ViewOnly");
|
||||
}
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-5 — the live picker's side-panel <c>AttributesAsync</c> was a stub returning nothing,
|
||||
/// so the operator saw no DataType / array / access information for an upstream variable. It now reads the
|
||||
/// variable's attributes and maps them through the same <see cref="UpstreamAttributeMapping"/> the runtime
|
||||
/// driver's discovery uses.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientBrowseSessionAttributesTests
|
||||
{
|
||||
private const string Speed = "ns=0;s=Speed";
|
||||
|
||||
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
mock.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(n => n.AttributeId switch
|
||||
{
|
||||
Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)),
|
||||
Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
|
||||
Attributes.DataType => new DataValue(new Variant(dataType)),
|
||||
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
|
||||
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
|
||||
_ => new DataValue(StatusCodes.BadAttributeIdInvalid),
|
||||
})],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static OpcUaClientBrowseSession NewSession(Mock<ISession> mock)
|
||||
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
|
||||
|
||||
/// <summary>A writable Double array variable reports Float64 / array / Operate.</summary>
|
||||
[Fact]
|
||||
public async Task A_variable_reports_its_mapped_data_type_array_shape_and_access()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Double, valueRank: 1, userAccess: 0x03);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
var attrs = await session.AttributesAsync(Speed, TestContext.Current.CancellationToken);
|
||||
|
||||
var a = attrs.ShouldHaveSingleItem();
|
||||
a.Name.ShouldBe("Speed");
|
||||
a.DriverDataType.ShouldBe("Float64");
|
||||
a.IsArray.ShouldBeTrue();
|
||||
a.SecurityClass.ShouldBe("Operate");
|
||||
a.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A read-only scalar Int32 variable reports Int32 / scalar / ViewOnly.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_only_scalar_reports_view_only()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
var a = (await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.DriverDataType.ShouldBe("Int32");
|
||||
a.IsArray.ShouldBeFalse();
|
||||
a.SecurityClass.ShouldBe("ViewOnly");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An Object (folder) has no value attributes to show. The positive control is the variable case above,
|
||||
/// through the same mock and read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_object_node_has_no_attributes()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Object, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
(await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An unresolvable node id is refused like <c>ExpandAsync</c> does.</summary>
|
||||
[Fact]
|
||||
public async Task An_unresolvable_node_id_throws()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(() =>
|
||||
session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private sealed class TrackingCredential : IDisposable
|
||||
{
|
||||
public int Disposed { get; private set; }
|
||||
|
||||
public void Dispose() => Disposed++;
|
||||
}
|
||||
|
||||
/// <summary>Review m9: the browse session disposes the user certificate it was handed, with itself.</summary>
|
||||
[Fact]
|
||||
public async Task The_session_disposes_the_credential_it_owns()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
||||
var credential = new TrackingCredential();
|
||||
var session = new OpcUaClientBrowseSession(
|
||||
mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder, credential);
|
||||
|
||||
credential.Disposed.ShouldBe(0, "positive control: still owned while the session is open");
|
||||
await session.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
|
||||
credential.Disposed.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-5 — the AdminUI browse refused <c>Certificate</c> auth "in v1", so a
|
||||
/// certificate-authenticated upstream could not be browsed at all. The browse now builds its identity through
|
||||
/// the same <see cref="OpcUaUserIdentityFactory"/> the runtime driver uses (incl. the <c>secret:</c> resolution
|
||||
/// of the credential fields, fail-closed), so it presents exactly the credentials the deployed driver will.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientBrowserCertificateAuthTests
|
||||
{
|
||||
private sealed class OneSecret(string name, string value) : ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(SecretName secretName, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(secretName.Value == name ? value : null);
|
||||
}
|
||||
|
||||
private static string WritePfx(string password)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=OpcUaClientBrowserCertAuthTests", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
var path = Path.Combine(Path.GetTempPath(), $"opcua-browse-cert-{Guid.NewGuid():N}.pfx");
|
||||
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, password));
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Certificate auth is no longer refused: a missing cert path fails on the certificate itself.</summary>
|
||||
[Fact]
|
||||
public async Task OpenAsync_with_certificate_auth_fails_on_the_certificate_not_a_refusal()
|
||||
{
|
||||
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => new OpcUaClientDriverBrowser().OpenAsync(json, TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Message.ShouldContain("UserCertificatePath");
|
||||
ex.Message.ShouldNotContain("does not support");
|
||||
}
|
||||
|
||||
/// <summary>A valid PFX whose password is a <c>secret:</c> ref resolves through the injected store.</summary>
|
||||
[Fact]
|
||||
public async Task A_certificate_identity_is_built_with_its_secret_password_resolved()
|
||||
{
|
||||
var path = WritePfx("s3cret-pw");
|
||||
try
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = path,
|
||||
UserCertificatePassword = "secret:opcua/browse-cert",
|
||||
};
|
||||
|
||||
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
||||
opts, new OneSecret("opcua/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
|
||||
|
||||
identity.TokenType.ShouldBe(UserTokenType.Certificate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>With no secret store, a <c>secret:</c> ref fails closed instead of being sent as the literal.</summary>
|
||||
[Fact]
|
||||
public async Task A_secret_ref_with_no_secret_store_fails_closed()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Username,
|
||||
Username = "op",
|
||||
Password = "secret:opcua/pw",
|
||||
};
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("fail-closed");
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a literal password is used as-is (no store needed).</summary>
|
||||
[Fact]
|
||||
public async Task A_literal_username_password_needs_no_store()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Username, Username = "op", Password = "pw" };
|
||||
|
||||
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken);
|
||||
|
||||
identity.TokenType.ShouldBe(UserTokenType.UserName);
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -41,10 +41,9 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
ex.Message.ShouldContain("null");
|
||||
}
|
||||
|
||||
/// <summary>Certificate auth is not supported by the browser; the failure message
|
||||
/// must say so explicitly rather than surfacing a downstream COM/SDK error.
|
||||
/// <c>OpcUaAuthType.Certificate</c> serializes as the numeric value 2 under the
|
||||
/// browser's default System.Text.Json options (no string-enum converter).</summary>
|
||||
/// <summary>Certificate auth with no certificate configured fails before any network I/O with a message
|
||||
/// naming the certificate (review 2026-10-21 O-5: Certificate auth is browsable now; it used to be refused),
|
||||
/// rather than surfacing a downstream SDK error. Numeric enum form (<c>OpcUaAuthType.Certificate</c> == 2).</summary>
|
||||
[Fact]
|
||||
public async Task OpenAsync_with_certificate_auth_throws_clear_message()
|
||||
{
|
||||
@@ -76,7 +75,7 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
{
|
||||
// "AuthType":"Certificate" — STRING form, as AdminUI emits.
|
||||
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
||||
// The browser should reach its own Certificate-auth guard and throw
|
||||
// The browser should reach its own Certificate-identity check (no UserCertificatePath) and throw
|
||||
// InvalidOperationException with a message that mentions "Certificate".
|
||||
// Without JsonStringEnumConverter the deserialization itself throws JsonException,
|
||||
// which means this Should.ThrowAsync<InvalidOperationException> assertion fails.
|
||||
@@ -118,8 +117,10 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange: build a minimal OpcUaClientBrowseSession without a live server.
|
||||
// AttributesAsync does not call ISession — MockBehavior.Loose is safe.
|
||||
// A loose mock answers the attribute read with no response, so nothing is known about the node.
|
||||
var mockSession = new Mock<ISession>(MockBehavior.Loose);
|
||||
mockSession.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
mockSession.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
||||
var nsMap = NamespaceMap.FromTable(new NamespaceTable());
|
||||
var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
|
||||
|
||||
@@ -132,7 +133,7 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
var attrs = await sut.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
|
||||
|
||||
// Assert: LastUsedUtc must have been refreshed, and the result must be empty
|
||||
// (OPC UA picker treats variables as leaves, no attribute side-panel).
|
||||
// (a node not known to be a Variable has no value attributes to show).
|
||||
attrs.ShouldBeEmpty();
|
||||
sut.LastUsedUtc.ShouldBeGreaterThan(before,
|
||||
"AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract");
|
||||
|
||||
+57
@@ -92,4 +92,61 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
|
||||
|
||||
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 O-9 + lifecycle generation, through the real SDK: a shutdown with several live
|
||||
/// subscriptions completes inside the one teardown budget, lands Unknown, and a re-initialise of the same
|
||||
/// instance reconnects and reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_with_live_subscriptions_is_bounded_and_the_instance_reinitialises()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-lifecycle");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
for (var i = 0; i < 3; i++)
|
||||
await drv.SubscribeAsync([OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250), ct);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(ct);
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "one 4 s budget bounds the whole teardown");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var read = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct);
|
||||
read.Single().StatusCode.ShouldBe(0u, "the re-initialised instance must be fully usable");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The health contract against a real server: a write the server REFUSES (a string into the UInt32 StepUp —
|
||||
/// BadTypeMismatch) is a reply — the refusal status passes through and the driver stays Healthy with its stamp
|
||||
/// advanced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_server_refused_write_is_a_reply_and_keeps_the_driver_healthy()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-refused-write");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var before = drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield();
|
||||
|
||||
var result = await drv.WriteAsync([new WriteRequest(OpcPlcProfile.StepUpRef, "not-a-number")], ct);
|
||||
|
||||
(result.Single().StatusCode & 0x80000000u).ShouldNotBe(0u, "opc-plc refuses a string written into a UInt32");
|
||||
result.Single().StatusCode.ShouldNotBe(0x80050000u, "a server refusal is not a local comms error");
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
||||
}
|
||||
}
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-8 — <c>AcknowledgeAsync</c> swallowed every non-cancellation transport failure, so
|
||||
/// the host logged the Part 9 ack as applied while upstream still showed the condition unacknowledged. A
|
||||
/// transport failure (no reply) now propagates with its status; a reply — even one carrying per-ack refusals —
|
||||
/// still returns normally (that is the server answering, logged per ack).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
{
|
||||
private const string Condition = "ns=2;s=Pump17";
|
||||
|
||||
private static Mock<ISession> NewSession()
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static AlarmAcknowledgeRequest[] Ack() => [new(Condition, Condition, "operator ack")];
|
||||
|
||||
/// <summary>A transport failure on the Acknowledge call reaches the caller with the transport status.</summary>
|
||||
[Fact]
|
||||
public async Task A_transport_failure_propagates_with_its_status()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8");
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
var ex = await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCodes.BadConnectionClosed);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a reply returns normally and the call reached the wire.</summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_a_reply_returns_normally()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-ok");
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CallResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new CallMethodResult { StatusCode = StatusCodes.BadConditionBranchAlreadyAcked }],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
await drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken);
|
||||
|
||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>A session lost while the ack queued on the gate fails the ack rather than reporting it applied.</summary>
|
||||
[Fact]
|
||||
public async Task A_session_lost_while_queued_fails_the_ack()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-lost");
|
||||
drv.SetSessionForTest(NewSession().Object);
|
||||
|
||||
await drv.Gate.WaitAsync(ct);
|
||||
var call = drv.AcknowledgeAsync(Ack(), ct);
|
||||
drv.SetSessionForTest(null);
|
||||
drv.Gate.Release();
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() => call);
|
||||
}
|
||||
|
||||
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger<OpcUaClientDriver>
|
||||
{
|
||||
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = [];
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
|
||||
private static AlarmAcknowledgeRequest[] Unresolvable() =>
|
||||
[new("nsu=urn:not-in-table;s=X", "nsu=urn:not-in-table;s=X", "ack")];
|
||||
|
||||
/// <summary>
|
||||
/// Review m3 / r1 N-1: an ack none of whose condition ids resolves sends nothing upstream and says so at Warning —
|
||||
/// but does NOT throw (a deterministic per-ack refusal must not count toward the AlarmAcknowledge breaker).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_ack_with_no_resolvable_condition_warns_and_does_not_throw()
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-m3", logger);
|
||||
var session = NewSession();
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
await drv.AcknowledgeAsync(Unresolvable(), TestContext.Current.CancellationToken);
|
||||
|
||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
lock (logger.Entries)
|
||||
logger.Entries.ShouldContain(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("resolved none"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r1 N-1 through the host's real resilience pipeline: six unresolvable acks (more than the AlarmAcknowledge
|
||||
/// breaker's threshold of five) must not open the breaker — a genuine ack that follows is still delivered
|
||||
/// upstream (the positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unresolvable_acks_do_not_open_the_ack_breaker_for_real_acks()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-n1");
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CallResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new CallMethodResult { StatusCode = StatusCodes.Good }],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
var options = new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResilienceOptions { Tier = DriverTier.A };
|
||||
var invoker = new ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker(
|
||||
new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResiliencePipelineBuilder(), "opcua-n1", () => options, "OpcUaClient");
|
||||
|
||||
// Failures are tolerated here on purpose (the host logs and drops them): what is asserted is that the six do
|
||||
// not trip the breaker — a driver that THREW for them would fail the genuine ack below with an open circuit.
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
||||
async c => await drv.AcknowledgeAsync(Unresolvable(), c), ct);
|
||||
}
|
||||
catch (ServiceResultException) { }
|
||||
}
|
||||
|
||||
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
||||
async c => await drv.AcknowledgeAsync(Ack(), c), ct);
|
||||
|
||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once, "the genuine ack must still reach the server");
|
||||
}
|
||||
|
||||
/// <summary>Review m4: an ack with no reply is a comms fault like a read or write — Degraded, stamp preserved.</summary>
|
||||
[Fact]
|
||||
public async Task An_ack_transport_failure_degrades_and_preserves_the_stamp()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-m4");
|
||||
var stamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, stamp, null));
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() => drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken));
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(stamp);
|
||||
}
|
||||
}
|
||||
@@ -51,15 +51,17 @@ public sealed class OpcUaClientAlarmTests
|
||||
await drv.UnsubscribeAlarmsAsync(new FakeAlarmHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync without initialize throws InvalidOperationException.</summary>
|
||||
/// <summary>Verifies that AcknowledgeAsync without initialize throws ServiceResultException(BadNotConnected).</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_without_initialize_throws_InvalidOperationException()
|
||||
public async Task AcknowledgeAsync_without_initialize_throws_BadNotConnected()
|
||||
{
|
||||
// Review m5: the same status as a session lost while the ack queued (one "no session" answer).
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
var ex = await Should.ThrowAsync<Opc.Ua.ServiceResultException>(async () =>
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("ns=2;s=Src", "ns=2;s=Cond", "operator ack")],
|
||||
TestContext.Current.CancellationToken));
|
||||
ex.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadNotConnected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync with empty batch is noop even without init.</summary>
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-6 — one <c>SemaphoreSlim</c> serialised reads, writes, subscribes, acks AND the
|
||||
/// whole recursive discovery browse, so a stalled write (up to the operation timeout) blocked every read and
|
||||
/// subscription create, and a discovery browse blocked the data plane for its full duration. Reads (and acks)
|
||||
/// keep their gate; writes and the discovery browse take none (the SDK session is thread-safe for concurrent
|
||||
/// service calls); subscription management has its own gate.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientGateSplitTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10);
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o6");
|
||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(_ => new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
return (drv, session);
|
||||
}
|
||||
|
||||
private static async Task ReadCompletes(OpcUaClientDriver drv, string because)
|
||||
{
|
||||
var result = await drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken).WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
result.Single().Value.ShouldBe(42, because);
|
||||
}
|
||||
|
||||
/// <summary>A write stalled on the wire does not hold up a read.</summary>
|
||||
[Fact]
|
||||
public async Task A_stalled_write_does_not_block_a_read()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var writeOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, WriteValueCollection nodes, CancellationToken _) =>
|
||||
{
|
||||
writeOnWire.TrySetResult();
|
||||
await releaseWrite.Task;
|
||||
return new WriteResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(_ => (StatusCode)StatusCodes.Good)],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
});
|
||||
|
||||
var write = drv.WriteAsync([new ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest(SpeedRef, 7)], TestContext.Current.CancellationToken);
|
||||
await writeOnWire.Task.WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
|
||||
await ReadCompletes(drv, "a read must not queue behind a stalled write");
|
||||
|
||||
releaseWrite.SetResult();
|
||||
(await write).Single().StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>Subscription management queues on its own gate, not the read gate.</summary>
|
||||
[Fact]
|
||||
public async Task Subscription_management_does_not_share_the_read_gate()
|
||||
{
|
||||
var (drv, _) = NewConnectedDriver();
|
||||
using var __ = drv;
|
||||
|
||||
await drv.SubscriptionGate.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var subscribe = drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
// Positive control: the subscribe really is parked on the subscription gate.
|
||||
subscribe.IsCompleted.ShouldBeFalse();
|
||||
await ReadCompletes(drv, "a read must not queue behind subscription management");
|
||||
|
||||
drv.SubscriptionGate.Release();
|
||||
// The mock never attaches the subscription, so the create itself throws — what matters is that it ran.
|
||||
await Should.ThrowAsync<Exception>(() => subscribe.WaitAsync(Bound, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>A discovery browse in progress does not hold the read gate.</summary>
|
||||
[Fact]
|
||||
public async Task A_discovery_browse_in_progress_does_not_block_a_read()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var browseOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseBrowse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.Setup(s => s.BrowseAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
||||
It.IsAny<BrowseDescriptionCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, ViewDescription _, uint _, BrowseDescriptionCollection _, CancellationToken _) =>
|
||||
{
|
||||
browseOnWire.TrySetResult();
|
||||
await releaseBrowse.Task;
|
||||
return new BrowseResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new BrowseResult { References = [], ContinuationPoint = [] }],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
});
|
||||
|
||||
var discover = drv.DiscoverAsync(new NullAddressSpaceBuilder(), TestContext.Current.CancellationToken);
|
||||
await browseOnWire.Task.WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
|
||||
await ReadCompletes(drv, "a read must not queue behind a recursive discovery browse");
|
||||
|
||||
releaseBrowse.SetResult();
|
||||
await discover.WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-2 / O-9 and the lifecycle-generation contract: an initialise that throws after its
|
||||
/// session activated closes that LOCAL session; an initialise the host abandoned that finishes after the next
|
||||
/// shutdown discards its session instead of installing it; a read that outlives a shutdown does not overwrite the
|
||||
/// shutdown's health; and the whole shutdown spends one bounded budget on wire calls, issued concurrently.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientLifecycleGenerationTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static OpcUaClientDriverOptions Options() =>
|
||||
new() { EndpointUrl = "opc.tcp://127.0.0.1:1", RawTags = Tags };
|
||||
|
||||
private static Mock<ISession> NewSession()
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static ReadResponse GoodRead(ReadValueIdCollection nodes) => new()
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(_ => new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
condition().ShouldBeTrue(because);
|
||||
}
|
||||
|
||||
/// <summary>O-2: a throw after the session opened (here: building the namespace map) closes + disposes it.</summary>
|
||||
[Fact]
|
||||
public async Task An_init_that_throws_after_the_session_opened_closes_and_disposes_that_session()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-o2");
|
||||
var session = NewSession();
|
||||
session.SetupGet(s => s.NamespaceUris).Throws(new InvalidOperationException("namespace table unavailable"));
|
||||
drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.CloseAsync(It.IsAny<int>(), true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
session.Verify(s => s.Dispose(), Times.Once, "the activated session must not be orphaned until GC");
|
||||
drv.Session.ShouldBeNull();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the init seam: an uninterrupted init installs the session and goes Healthy.</summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_an_uninterrupted_init_installs_its_session()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-gen-ok");
|
||||
var session = NewSession();
|
||||
drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object);
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
drv.Session.ShouldBeSameAs(session.Object);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
session.Verify(s => s.Dispose(), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An init abandoned by the host whose connect completes AFTER the next ShutdownAsync must not install its
|
||||
/// session (it would be live with nothing to ever close it) nor overwrite the shutdown's health.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_init_superseded_by_a_shutdown_discards_its_session_and_leaves_the_shutdown_health()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(Options(), "opcua-gen-superseded");
|
||||
using var _ = drv;
|
||||
var session = NewSession();
|
||||
var opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.OpenSessionForTest = async (_, _) =>
|
||||
{
|
||||
opened.TrySetResult();
|
||||
await release.Task;
|
||||
return session.Object;
|
||||
};
|
||||
|
||||
var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await opened.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => init);
|
||||
|
||||
drv.Session.ShouldBeNull("a superseded init must not resurrect a session behind the shutdown");
|
||||
session.Verify(s => s.Dispose(), Times.Once);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the abandoned init must not overwrite the newer health");
|
||||
}
|
||||
|
||||
/// <summary>A read whose response arrives after a shutdown does not write Healthy over the shutdown.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_that_outlives_a_shutdown_does_not_overwrite_the_shutdown_health()
|
||||
{
|
||||
var (drv, session, respond) = ConnectedDriverWithParkedRead();
|
||||
using var _ = drv;
|
||||
|
||||
var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => respond.Count == 1, "the read must reach the wire");
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
respond.Single().SetResult();
|
||||
await read;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the same parked read with no shutdown in between writes Healthy + a new stamp.</summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_a_read_with_no_intervening_shutdown_writes_healthy()
|
||||
{
|
||||
var (drv, _, respond) = ConnectedDriverWithParkedRead();
|
||||
using var __ = drv;
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Degraded, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), "x"));
|
||||
|
||||
var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => respond.Count == 1, "the read must reach the wire");
|
||||
respond.Single().SetResult();
|
||||
await read;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull().Year.ShouldBeGreaterThan(2020);
|
||||
}
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session, System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource> Respond)
|
||||
ConnectedDriverWithParkedRead()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(Options(), "opcua-gen-read");
|
||||
var session = NewSession();
|
||||
var respond = new System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource>();
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
respond.Enqueue(gate);
|
||||
await gate.Task;
|
||||
return GoodRead(nodes);
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
return (drv, session, respond);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// O-9: against a half-dead upstream every subscription delete and the session close hang. They are issued
|
||||
/// concurrently under ONE budget, so the shutdown ends near the budget rather than N × 5 s.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_spends_one_budget_on_concurrent_subscription_deletes_and_the_close()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-o9");
|
||||
var session = NewSession();
|
||||
// A close that ignores its token entirely — only the driver's own abandonment can bound it.
|
||||
session.Setup(s => s.CloseAsync(It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new TaskCompletionSource<StatusCode>().Task);
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.TeardownBudget = TimeSpan.FromSeconds(1);
|
||||
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
drv.DeleteSubscriptionAsync = async (_, ct) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
InterlockedMax(ref maxInFlight, now);
|
||||
try { await Task.Delay(Timeout.Infinite, ct); }
|
||||
finally { Interlocked.Decrement(ref inFlight); }
|
||||
};
|
||||
for (var i = 0; i < 4; i++) drv.AddSubscriptionForTest([SpeedRef]);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
maxInFlight.ShouldBe(4, "the four deletes must be in flight together, not one after another");
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3.5),
|
||||
"one 1 s budget bounds the whole teardown; serial deletes alone would take 4 × the budget");
|
||||
session.Verify(s => s.Dispose(), Times.Once, "the session is still disposed when the close was abandoned");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
private static void InterlockedMax(ref int target, int value)
|
||||
{
|
||||
int current;
|
||||
while ((current = Volatile.Read(ref target)) < value)
|
||||
if (Interlocked.CompareExchange(ref target, value, current) == current) return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r1 N-2: a subscribe that arrives while shutdown is in its delete phase must not create a subscription on the
|
||||
/// closing session (it would register after the registry was enumerated and outlive the shutdown). It waits for
|
||||
/// the teardown and then finds no session. Positive control: before the shutdown the same subscribe reaches the
|
||||
/// session.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_subscribe_during_the_shutdown_delete_phase_never_touches_the_closing_session()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-n2");
|
||||
var session = NewSession();
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
|
||||
// Positive control: an ordinary subscribe does reach the session (the mock then fails the create).
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once);
|
||||
|
||||
var deleting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseDelete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.DeleteSubscriptionAsync = async (_, _) =>
|
||||
{
|
||||
deleting.TrySetResult();
|
||||
await releaseDelete.Task;
|
||||
};
|
||||
drv.AddSubscriptionForTest([SpeedRef]);
|
||||
|
||||
var shutdown = drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
await deleting.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
var lateSubscribe = drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
releaseDelete.SetResult();
|
||||
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
await Should.ThrowAsync<Exception>(() => lateSubscribe.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once,
|
||||
"the late subscribe must not have created anything on the closing session");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r2 R2-2: PostStop runs ShutdownAsync then DisposeAsync. With subscription management contended (a subscribe
|
||||
/// still holding the gate), the shutdown spends its budget waiting on the gate; the dispose that follows must not
|
||||
/// re-run a nested shutdown and wait all over again. Positive control: the shutdown itself did wait.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_after_a_shutdown_of_the_same_lifecycle_does_not_rewait_the_gate()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(Options(), "opcua-r22") { TeardownBudget = TimeSpan.FromSeconds(3) };
|
||||
drv.SetSessionForTest(NewSession().Object);
|
||||
await drv.SubscriptionGate.WaitAsync(TestContext.Current.CancellationToken); // a "subscribe" that never finishes
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
sw.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(2), "positive control: the shutdown waited on the held gate");
|
||||
|
||||
sw.Restart();
|
||||
await drv.DisposeAsync();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2), "the dispose must not wait the 3 s gate budget a second time");
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-4 — <c>ProbeTimeoutSeconds</c> was authored by the AdminUI and parsed, but consumed
|
||||
/// by nothing: the Test Connect probe ran on the caller's budget alone. It now caps the probe deadline (it can
|
||||
/// shorten the caller's budget, never extend it) — the same rule as the Galaxy probe.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientProbeTimeoutTests
|
||||
{
|
||||
/// <summary>The effective deadline is the shorter of the caller's budget and the configured cap.</summary>
|
||||
[Theory]
|
||||
[InlineData(10, 30, 10)] // caller shorter → caller wins
|
||||
[InlineData(10, 2, 2)] // config shorter → config caps
|
||||
[InlineData(10, 0, 10)] // unset config → caller
|
||||
[InlineData(10, -1, 10)] // nonsense config → caller
|
||||
[InlineData(0, 5, 1)] // no caller budget → 1 s floor beats a longer config
|
||||
public void EffectiveTimeout_is_the_shorter_of_caller_and_config(int callerSeconds, int configSeconds, int expectedSeconds)
|
||||
=> OpcUaClientDriverProbe.EffectiveTimeout(TimeSpan.FromSeconds(callerSeconds), configSeconds)
|
||||
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
|
||||
/// <summary>
|
||||
/// Against a port that accepts TCP but never speaks OPC UA, a 1 s configured cap returns long before the
|
||||
/// caller's 30 s budget — the discovery handshake does the waiting, so this exercises the real deadline path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Configured_cap_shortens_the_probe_against_a_silent_listener()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var config = $$"""{ "endpointUrl": "opc.tcp://127.0.0.1:{{port}}", "probeTimeoutSeconds": 1 }""";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = await new OpcUaClientDriverProbe().ProbeAsync(config, TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(15), $"the 1 s configured cap must bound the probe (message: {result.Message})");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-1 — a data <c>SubscribeAsync</c> whose wire create throws used to leave the
|
||||
/// half-built <see cref="Subscription"/> on the session (untracked, so neither Unsubscribe nor Shutdown
|
||||
/// ever deleted it). The host's reconcile retries every health tick, so a server that refused the create
|
||||
/// accumulated one live subscription per retry. The alarm path already cleaned up; the data path now does too.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A Moq session never attaches the subscription (<c>AddSubscription</c> is a no-op), so the SDK's
|
||||
/// <c>Subscription.CreateAsync</c> throws — the exact "create failed after AddSubscription" shape.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSubscribeCleanupTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o1");
|
||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
return (drv, session);
|
||||
}
|
||||
|
||||
/// <summary>A failed data subscribe removes the subscription it added to the session, then rethrows.</summary>
|
||||
[Fact]
|
||||
public async Task A_failed_data_subscribe_removes_the_half_built_subscription_from_the_session()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once);
|
||||
session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny<Subscription>(), It.IsAny<CancellationToken>()), Times.Once,
|
||||
"the subscription added before the failing create must not stay on the session");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control for the observation above: the alarm path (which always cleaned up) is seen removing
|
||||
/// its subscription through the same mock, so a "Times.Once" pass on the data path is not vacuous.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_the_alarm_path_is_observed_removing_its_half_built_subscription()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAlarmsAsync([SpeedRef], TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny<Subscription>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>Repeated failing subscribes (the host's reconcile retry) each clean up their own subscription.</summary>
|
||||
[Fact]
|
||||
public async Task Each_retry_of_a_failing_subscribe_cleans_up_after_itself()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Exactly(3));
|
||||
session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny<Subscription>(), It.IsAny<CancellationToken>()), Times.Exactly(3));
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The health contract for the write path and the steady keep-alive: a write RESPONSE — whatever per-node
|
||||
/// statuses it carries, a refusal included — is the server answering, so it restores Healthy and advances
|
||||
/// <see cref="DriverHealth.LastSuccessfulRead"/>; a write that got NO response degrades health with the stamp
|
||||
/// preserved; a caller-cancelled write is not a comms fault. A good keep-alive clears a Degraded left by a failed
|
||||
/// read/write (it used to only move the stamp, leaving a subscription-only driver Degraded forever).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientWriteHealthTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
private static readonly DateTime Stale = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver(DriverHealth health)
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health");
|
||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.SetHealthForTest(health);
|
||||
return (drv, session);
|
||||
}
|
||||
|
||||
private static ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest[] Write() => [new(SpeedRef, 7)];
|
||||
|
||||
/// <summary>A refused write (BadNotWritable) is a reply: Healthy again, stamp advanced.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_is_a_reply_and_restores_healthy()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read failed"));
|
||||
using var _ = drv;
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WriteResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [StatusCodes.BadNotWritable],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable, "the upstream refusal still passes through verbatim");
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||
h.LastError.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A write with no reply (transport failure) degrades health and preserves the stamp.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
using var _ = drv;
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0x80050000u); // BadCommunicationError
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
h.LastSuccessfulRead.ShouldBe(Stale, "the comms-loss watchdog measures silence from this stamp");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write the CALLER cancelled says nothing about the upstream: health is left alone. (The positive control is
|
||||
/// the test above — the same throw-path does write health when the failure is the transport's.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_caller_cancelled_write_leaves_health_alone()
|
||||
{
|
||||
var healthy = new DriverHealth(DriverState.Healthy, Stale, null);
|
||||
var (drv, session) = NewConnectedDriver(healthy);
|
||||
using var _ = drv;
|
||||
using var cts = new CancellationTokenSource();
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, WriteValueCollection _, CancellationToken ct) =>
|
||||
{
|
||||
cts.Cancel();
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return new WriteResponse();
|
||||
});
|
||||
|
||||
var results = await drv.WriteAsync(Write(), cts.Token);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0x800A0000u); // BadTimeout — outcome unknown
|
||||
drv.GetHealth().ShouldBe(healthy);
|
||||
}
|
||||
|
||||
/// <summary>A good keep-alive is a reply: it clears a Degraded a failed read left behind.</summary>
|
||||
[Fact]
|
||||
public void A_good_keep_alive_clears_a_degraded_left_by_a_failed_read()
|
||||
{
|
||||
var (drv, _) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read: BadTimeout"));
|
||||
using var __ = drv;
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||
h.LastError.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m8: a write that TIMED OUT (an OperationCanceledException the caller did not cause) is a call with no
|
||||
/// reply — Degraded, stamp preserved. The genuine positive control for the caller-cancelled test above (same
|
||||
/// catch branch, the caller's token is the only difference).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_write_that_timed_out_without_caller_cancellation_degrades()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
using var _ = drv;
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException("SDK request timeout"));
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0x800A0000u); // BadTimeout
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(Stale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m7: a reply that was in flight across a bad keep-alive advances the stamp (it is evidence) but does not
|
||||
/// declare the published outage over — health stays Degraded until the keep-alive recovers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reply_during_a_published_outage_advances_the_stamp_but_keeps_degraded()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
using var _ = drv;
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the outage is published");
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), Results = [StatusCodes.Good], DiagnosticInfos = [] });
|
||||
|
||||
await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the keep-alive recovery is what ends the outage");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user