Merge lane G1: OpcUaClient + MTConnect driver remediation (#743)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:16:20 -04:00
51 changed files with 4594 additions and 629 deletions
@@ -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>
@@ -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>&amp;</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);
}
}
}
@@ -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");
}
}
@@ -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);
}
@@ -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();
}
@@ -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}");
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
@@ -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);
}
}
@@ -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>
@@ -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();
}
}
}
@@ -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");
}
}
@@ -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();
}
}
}
@@ -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));
}
}
@@ -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");
}
}