Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionCommsLossTests.cs
T
Joseph Doherty 340d9d26b7 feat(mtconnect): CommsLossConfirmMs AdminUI field, refused-connection fast path reaches native conditions, dedicated live-gate Agent (#852)
- AdminUI MTConnect driver form: "Comms-loss confirm (ms)" in the Reconnect section, always written
  (0 = first refusal, negative = off is never dropped), case-insensitive load, unknown keys preserved,
  advisory at >= 90000 ms (the watchdog fires first). Form output byte-pinned and parsed through
  MTConnectDriver.ParseOptions on the driver side.
- Driver: a confirmed refused-connection outage now annotates every told CONDITION quality-only
  BadNoCommunication (state/ack kept, once per outage, UNAVAILABLE-Bad skipped); the first delivery
  afterwards re-diffs all subscribed conditions so unchanged ones get a quality-only Good.
- docker-dev/docker-compose.mtconnect-gate.yml: throwaway Agent in its adapter's network namespace so a
  stop is a genuine ConnectionRefused (a plain stopped container is a DNS failure = ambiguous).
- Docs: MTConnect.md comms-loss section + live-gate recipe, docker-dev README, drivers.md rule.

Refs #852
2026-11-11 06:23:18 -05:00

234 lines
10 KiB
C#

using System.Collections.Concurrent;
using System.Net.Http;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// lmxopcua#852 — the #846 refused-connection fast path reaches native CONDITIONS too: once the refusals are
/// confirmed every told condition gets a quality-only <c>BadNoCommunication</c> (state and ack untouched, no
/// Raise/Clear), once per outage; the first delivery afterwards lifts it with a quality-only Good — even when that
/// delivery does not mention the condition. Ambiguous failures stay with the watchdog.
/// </summary>
/// <remarks>Every absence is followed by an event that MUST arrive, asserted to be the next one.</remarks>
public sealed class MTConnectConditionCommsLossTests
{
private const string ConditionRawPath = "Plant/MT/dev1/SystemCond";
private const string ConditionId = "dev1_system_cond";
private const long Instance = 1655000000L;
private const uint BadNoCommunication = 0x80310000u;
private static readonly DateTime ObservedAt = new(2026, 11, 11, 12, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static Exception Refused() =>
new HttpRequestException("Connection refused (fixture-agent:5000)", new SocketException((int)SocketError.ConnectionRefused));
private static MTConnectStreamsResult Chunk(long first, params MTConnectObservation[] observations) =>
new(Instance, first + 1, first, observations);
private static MTConnectObservation Cond(string state, string? nativeCode = null, string? text = null) =>
new(ConditionId, state, ObservedAt, Condition: new MTConnectConditionDetail(text, nativeCode, null, null, "SYSTEM"));
private static async Task<(MTConnectDriver Driver, CannedAgentClient Agent, ConcurrentQueue<AlarmEventArgs> Events)>
SubscribedAsync()
{
var agent = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(
new MTConnectDriverOptions
{
AgentUri = "http://fixture-agent:5000",
RequestTimeoutMs = 5000,
CommsLossConfirmMs = 0,
Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 0, MaxBackoffMs = 200 },
RawTags = [new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1")],
},
"mt-852",
_ => agent);
await driver.InitializeAsync("{}", Ct);
var events = new ConcurrentQueue<AlarmEventArgs>();
driver.OnAlarmEvent += (_, e) => events.Enqueue(e);
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
return (driver, agent, events);
}
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 10_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(20, Ct);
}
}
private static async Task RecoverAsync(CannedAgentClient agent)
{
agent.SampleFailure = null;
var calls = agent.SampleCallCount;
await agent.WaitForSampleCallsAsync(calls + 1).WaitAsync(Watchdog, Ct);
}
[Fact]
public async Task A_refused_agent_annotates_an_active_condition_bad_once_and_the_first_delivery_lifts_it()
{
var (driver, agent, events) = await SubscribedAsync();
try
{
await agent.PumpAsync(Chunk(108, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
agent.SampleFailure = Refused();
agent.EndStream();
await WaitUntil(() => events.Count == 2);
var bad = events.Last();
bad.IsQualityOnly.ShouldBeTrue("the Active state and the ack must not move — quality only");
bad.QualityStatusCode.ShouldBe(BadNoCommunication);
bad.ConditionId.ShouldBe(ConditionRawPath);
bad.OpcUaSeverity.ShouldBe(800);
driver.CommsLossMarked.ShouldBeTrue();
await Task.Delay(400, Ct); // more refusals: annotated ONCE per outage
events.Count.ShouldBe(2);
// The Agent is back; an observation-free heartbeat does not mention the condition, and must still lift it.
await RecoverAsync(agent);
await agent.PumpAsync(Chunk(109)).WaitAsync(Watchdog, Ct);
await WaitUntil(() => events.Count == 3);
var good = events.Last();
good.IsQualityOnly.ShouldBeTrue("an unchanged Fault is not re-raised (that would un-acknowledge it)");
good.QualityStatusCode.ShouldBe(0u);
// Positive control for "nothing else was emitted": the next real transition is the next event.
await agent.PumpAsync(Chunk(110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
await WaitUntil(() => events.Count == 4);
events.Last().Kind.ShouldBe(AlarmTransitionKind.Clear);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task An_inactive_told_condition_is_annotated_too()
{
var (driver, agent, events) = await SubscribedAsync();
try
{
// Raise + Clear: the server holds a told Normal statement for the condition.
await agent.PumpAsync(Chunk(108, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
await agent.PumpAsync(Chunk(109, Cond("Normal"))).WaitAsync(Watchdog, Ct);
events.Count.ShouldBe(2);
agent.SampleFailure = Refused();
agent.EndStream();
await WaitUntil(() => events.Count == 3);
events.Last().IsQualityOnly.ShouldBeTrue();
events.Last().QualityStatusCode.ShouldBe(BadNoCommunication);
await RecoverAsync(agent);
await agent.PumpAsync(Chunk(110)).WaitAsync(Watchdog, Ct);
await WaitUntil(() => events.Count == 4);
events.Last().IsQualityOnly.ShouldBeTrue();
events.Last().QualityStatusCode.ShouldBe(0u);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task A_condition_already_unavailable_is_not_re_annotated_and_stays_bad_on_recovery()
{
var (driver, agent, events) = await SubscribedAsync();
try
{
await agent.PumpAsync(Chunk(108, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
await agent.PumpAsync(Chunk(109, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
events.Count.ShouldBe(2);
events.Last().QualityStatusCode.ShouldBe(BadNoCommunication);
agent.SampleFailure = Refused();
agent.EndStream();
await WaitUntil(() => driver.CommsLossMarked);
// The Agent answers, but the adapter is still UNAVAILABLE: no Good may be told.
await RecoverAsync(agent);
await agent.PumpAsync(Chunk(110)).WaitAsync(Watchdog, Ct);
await WaitUntil(() => !driver.CommsLossMarked);
// Positive control: the adapter returns with the same Fault — quality lifted, NOT re-raised — and that
// is the very next event after the UNAVAILABLE annotation.
await agent.PumpAsync(Chunk(111, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
await WaitUntil(() => events.Count >= 3);
events.Count.ShouldBe(3);
events.Last().IsQualityOnly.ShouldBeTrue();
events.Last().QualityStatusCode.ShouldBe(0u);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task An_ambiguous_failure_annotates_no_condition()
{
var (driver, agent, events) = await SubscribedAsync();
try
{
await agent.PumpAsync(Chunk(108, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
events.Count.ShouldBe(1);
agent.SampleFailure = new TimeoutException("the Agent did not answer /sample in time");
var calls = agent.SampleCallCount;
agent.EndStream();
await agent.WaitForSampleCallsAsync(calls + 3).WaitAsync(Watchdog, Ct);
driver.CommsLossMarked.ShouldBeFalse();
// Positive control: the next event is the real Clear, not a quality annotation.
await RecoverAsync(agent);
await agent.PumpAsync(Chunk(109, Cond("Normal"))).WaitAsync(Watchdog, Ct);
await WaitUntil(() => events.Count >= 2);
events.Count.ShouldBe(2);
events.Last().Kind.ShouldBe(AlarmTransitionKind.Clear);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public void CommsLost_keeps_the_told_statement_and_says_nothing_twice()
{
var handle = new MTConnectAlarmSubscriptionHandle(1);
var warning = new MTConnectConditionState(
MTConnectConditionLevel.Warning, new MTConnectConditionDetail("coolant", "W1", null, null, "SYSTEM"), ObservedAt, 1);
var told = new MTConnectToldCondition(warning, QualityBad: false);
var at = ObservedAt.AddMinutes(5);
var (evt, after) = MTConnectConditionAlarmMapper.CommsLost(told, ConditionRawPath, ConditionId, handle, at);
evt.ShouldNotBeNull();
evt!.IsQualityOnly.ShouldBeTrue();
evt.QualityStatusCode.ShouldBe(BadNoCommunication);
evt.SourceTimestampUtc.ShouldBe(at);
evt.OpcUaSeverity.ShouldBe(500);
after.ShouldBe(told with { QualityBad = true });
MTConnectConditionAlarmMapper.CommsLost(after, ConditionRawPath, ConditionId, handle, at).Event.ShouldBeNull();
MTConnectConditionAlarmMapper.CommsLost(
new MTConnectToldCondition(null, QualityBad: true), ConditionRawPath, ConditionId, handle, at).Event.ShouldBeNull();
}
}