3fe6cbb905
The driver cleared its whole "last told" record whenever the subscribed alarm set changed, assuming the rebuild that accompanies an alarm-bearing delta had reset every condition node. The server now carries each surviving native condition's state (acknowledgement included) across the rebuild, so re-raising a survivor would un-acknowledge it. Only entries that left the set are dropped; a condition that comes back later is told again onto its fresh node. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
353 lines
16 KiB
C#
353 lines
16 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #626 — <see cref="MTConnectDriver"/>'s <see cref="IAlarmSource"/> half against
|
|
/// <see cref="CannedAgentClient"/>: CONDITION transitions off the <c>/sample</c> stream become Part 9 alarm
|
|
/// events keyed by the alarm tag's RawPath, an unchanged state is never re-raised on a re-subscribe or a
|
|
/// re-baseline, and a transition nobody was listening for is delivered on the next subscribe.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every "nothing was raised" assertion is followed by a transition that MUST be raised, and asserts that
|
|
/// one is the first event — an absence proven by order, never by a delay.
|
|
/// </remarks>
|
|
public sealed class MTConnectAlarmSourceTests
|
|
{
|
|
private const string ConditionRawPath = "Plant/MT/dev1/SystemCond";
|
|
private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText";
|
|
private const string ConditionId = "dev1_system_cond";
|
|
private const string OtherConditionRawPath = "Plant/MT/dev1/OtherCond";
|
|
private const long FixtureInstanceId = 1655000000L;
|
|
private const long RestartedInstanceId = 1655999999L;
|
|
|
|
private static readonly DateTime ObservedAt = new(2026, 10, 8, 12, 30, 0, DateTimeKind.Utc);
|
|
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10);
|
|
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private static MTConnectDriverOptions Opts() =>
|
|
new()
|
|
{
|
|
AgentUri = "http://fixture-agent:5000",
|
|
RequestTimeoutMs = 5000,
|
|
RawTags =
|
|
[
|
|
// The alarm-bearing raw tag and an ordinary String tag on the SAME DataItem: the alarm path
|
|
// must not disturb the existing value surface.
|
|
new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
|
|
new RawTagEntry(ConditionValueRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
|
|
new RawTagEntry(OtherConditionRawPath, """{"address":"dev1_other_cond"}""", false, "dev1"),
|
|
],
|
|
};
|
|
|
|
private static async Task<(MTConnectDriver Driver, CannedAgentClient Agent, ConcurrentQueue<AlarmEventArgs> Events)>
|
|
AlarmDriverAsync(CannedAgentClient? agent = null)
|
|
{
|
|
var client = agent ?? CannedAgentClient.FromFixtures();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ => client);
|
|
await driver.InitializeAsync("{}", Ct);
|
|
|
|
var events = new ConcurrentQueue<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, e) => events.Enqueue(e);
|
|
|
|
return (driver, client, events);
|
|
}
|
|
|
|
private static MTConnectStreamsResult Chunk(long first, long next, params MTConnectObservation[] observations) =>
|
|
new(FixtureInstanceId, next, 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"));
|
|
|
|
[Fact]
|
|
public async Task Normal_then_fault_then_normal_raises_then_clears_keyed_by_the_RawPath()
|
|
{
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
events.ShouldBeEmpty("the primed /current says Normal, and a first-seen Normal has nothing to say");
|
|
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "Spindle overtemperature"))).WaitAsync(Watchdog, Ct);
|
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
|
|
|
var seen = events.ToArray();
|
|
seen.Length.ShouldBe(2);
|
|
seen[0].Kind.ShouldBe(AlarmTransitionKind.Raise);
|
|
seen[0].ConditionId.ShouldBe(ConditionRawPath);
|
|
seen[0].OpcUaSeverity.ShouldBe(800);
|
|
seen[0].Message.ShouldBe("Spindle overtemperature [nativeCode=E17]");
|
|
seen[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
|
|
seen[1].ConditionId.ShouldBe(ConditionRawPath);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Several_native_codes_show_the_most_severe_and_clear_only_when_the_last_goes()
|
|
{
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1", "coolant low"))).WaitAsync(Watchdog, Ct);
|
|
await agent.PumpAsync(Chunk(109, 110, Cond("Fault", "F1", "spindle"))).WaitAsync(Watchdog, Ct);
|
|
await agent.PumpAsync(Chunk(110, 111, Cond("Normal", "F1"))).WaitAsync(Watchdog, Ct);
|
|
await agent.PumpAsync(Chunk(111, 112, Cond("Normal", "W1"))).WaitAsync(Watchdog, Ct);
|
|
|
|
var seen = events.ToArray();
|
|
seen.Select(e => e.Kind).ShouldBe(
|
|
[
|
|
AlarmTransitionKind.Raise, // Warning W1
|
|
AlarmTransitionKind.Retrigger, // escalation to Fault F1
|
|
AlarmTransitionKind.Retrigger, // F1 cleared — W1 is still active, NOT a return to normal
|
|
AlarmTransitionKind.Clear, // the last code cleared
|
|
]);
|
|
seen.Select(e => e.OpcUaSeverity).ShouldBe([500, 800, 500, 500]);
|
|
seen[2].Message.ShouldStartWith("coolant low");
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unavailable_condition_reaches_the_server_as_a_bad_quality_annotation()
|
|
{
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
|
|
|
|
var evt = events.ShouldHaveSingleItem();
|
|
evt.IsQualityOnly.ShouldBeTrue();
|
|
evt.QualityStatusCode.ShouldBe(0x80310000u);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_ack_survives_an_adapter_unavailable_blip()
|
|
{
|
|
// A cppagent marks an adapter's items UNAVAILABLE when the adapter socket drops. The Fault must stay
|
|
// active (no Clear/Raise pair, which would reset the operator's ack); only its quality moves.
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
|
|
await agent.PumpAsync(Chunk(109, 110, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
|
|
await agent.PumpAsync(Chunk(110, 111, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
|
|
|
|
var seen = events.ToArray();
|
|
seen.Select(e => e.Kind).ShouldBe(
|
|
[AlarmTransitionKind.Raise, AlarmTransitionKind.Unspecified, AlarmTransitionKind.Unspecified]);
|
|
seen[1].IsQualityOnly.ShouldBeTrue();
|
|
seen[1].QualityStatusCode.ShouldBe(0x80310000u);
|
|
seen[2].IsQualityOnly.ShouldBeTrue();
|
|
seen[2].QualityStatusCode.ShouldBe(0x00000000u);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_changed_alarm_set_re_tells_only_conditions_that_left_and_came_back()
|
|
{
|
|
// Gitea #641: a changed set is an alarm-bearing delta ⇒ a full rebuild, but the server now carries every
|
|
// surviving condition's state (acknowledgement included) across it. Re-raising a survivor would
|
|
// UN-acknowledge it, so only the record of a condition that LEFT the set is dropped: one that comes back
|
|
// was materialised fresh and must be told again.
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
|
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
|
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct); // A survives: silent
|
|
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // same set: silent
|
|
events.Count.ShouldBe(1, "a surviving condition is not re-raised");
|
|
|
|
await driver.SubscribeAlarmsAsync([OtherConditionRawPath], Ct); // A removed
|
|
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // A re-added: fresh node
|
|
events.Count.ShouldBe(2, "a condition that left the set and came back is told again");
|
|
events.Last().ConditionId.ShouldBe(ConditionRawPath);
|
|
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
|
|
|
// Positive control: the next real transition is the very next event.
|
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
|
events.Select(e => e.Kind).ShouldBe(
|
|
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing()
|
|
{
|
|
// The reconnect path: DriverInstanceActor re-subscribes on every Connected entry. Re-raising the
|
|
// still-active Fault would un-acknowledge an operator's ack.
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
|
events.Count.ShouldBe(1);
|
|
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
|
|
// Positive control: the next real transition is the very next event.
|
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reinitialize_replaying_the_same_active_state_raises_nothing()
|
|
{
|
|
var agent = CannedAgentClient.FromFixtures();
|
|
agent.Current = Chunk(1, 108, Cond("Fault", "E17", "overtemp"));
|
|
var (driver, _, events) = await AlarmDriverAsync(agent);
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise, "the subscribe re-establishes an active condition");
|
|
|
|
// The first pump must own its /sample before the restart, or the count barrier below is off by one.
|
|
await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
|
|
|
// A comms-loss recovery re-initializes against the same Agent, which replays the same Fault.
|
|
await driver.ReinitializeAsync("{}", Ct);
|
|
|
|
// The restarted pump republishes (values AND conditions) before it opens /sample, so its second
|
|
// /sample call is the barrier that the replay has been diffed.
|
|
await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
|
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_transition_nobody_was_listening_for_is_delivered_on_the_next_subscribe()
|
|
{
|
|
// While the host reconnects it detaches its handler; the fault that clears in that window must not be
|
|
// recorded as delivered, or the condition stays active on the server forever.
|
|
var agent = CannedAgentClient.FromFixtures();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ => agent);
|
|
await driver.InitializeAsync("{}", Ct);
|
|
var events = new ConcurrentQueue<AlarmEventArgs>();
|
|
EventHandler<AlarmEventArgs> handler = (_, e) => events.Enqueue(e);
|
|
driver.OnAlarmEvent += handler;
|
|
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
|
|
|
driver.OnAlarmEvent -= handler;
|
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
|
events.Count.ShouldBe(1);
|
|
|
|
driver.OnAlarmEvent += handler;
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
|
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_agent_restart_whose_baseline_no_longer_lists_the_fault_clears_it()
|
|
{
|
|
var agent = CannedAgentClient.FromFixtures();
|
|
var (driver, _, events) = await AlarmDriverAsync(agent);
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
|
|
|
// The restarted Agent's /current reports the condition Normal.
|
|
agent.CurrentAnswers.Enqueue(new MTConnectStreamsResult(RestartedInstanceId, 5, 1, [Cond("Normal")]));
|
|
await agent.PumpAsync(new MTConnectStreamsResult(RestartedInstanceId, 4, 1, [])).WaitAsync(Watchdog, Ct);
|
|
await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
|
|
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_dropped_stream_raises_no_alarm_event()
|
|
{
|
|
// Comms loss is the host's to annotate (#477) — the driver says nothing on its own.
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
|
|
|
agent.EndStream();
|
|
await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
|
|
|
// Positive control: after reconnecting, the next transition is the next event.
|
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_alarm_only_subscription_starts_the_sample_stream()
|
|
{
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
driver.SampleStreamTask.ShouldBeNull("no value subscription exists");
|
|
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
|
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1"))).WaitAsync(Watchdog, Ct);
|
|
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_condition_string_value_surface_is_unchanged_beside_the_alarm()
|
|
{
|
|
var (driver, agent, events) = await AlarmDriverAsync();
|
|
var values = new ConcurrentQueue<DataChangeEventArgs>();
|
|
driver.OnDataChange += (_, e) => values.Enqueue(e);
|
|
|
|
await driver.SubscribeAsync([ConditionValueRawPath], TimeSpan.FromMilliseconds(50), Ct);
|
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
|
|
|
values.Where(v => v.FullReference == ConditionValueRawPath).Select(v => v.Snapshot.Value)
|
|
.ShouldBe(["Normal", "Fault"]);
|
|
values.ShouldNotContain(v => v.FullReference == ConditionRawPath,
|
|
"the alarm RawPath is not a value subscription and gets no data-change");
|
|
events.ShouldHaveSingleItem().ConditionId.ShouldBe(ConditionRawPath);
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Acknowledge_is_a_no_op_success_that_sends_nothing_to_the_agent()
|
|
{
|
|
var (driver, agent, _) = await AlarmDriverAsync();
|
|
|
|
await driver.AcknowledgeAsync([new AlarmAcknowledgeRequest(ConditionRawPath, ConditionRawPath, "seen", "op")], Ct);
|
|
|
|
agent.Writes.ShouldBeEmpty();
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unsubscribing_the_last_alarm_subscription_stops_an_alarm_only_stream()
|
|
{
|
|
var (driver, agent, _) = await AlarmDriverAsync();
|
|
var handle = await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
|
await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
|
var pump = driver.SampleStreamTask.ShouldNotBeNull();
|
|
|
|
await driver.UnsubscribeAlarmsAsync(handle, Ct);
|
|
|
|
await pump.WaitAsync(Watchdog, Ct);
|
|
driver.SampleStreamTask.ShouldBeNull();
|
|
|
|
await driver.ShutdownAsync(Ct);
|
|
}
|
|
}
|