feat(mtconnect): CONDITION data items as native Part 9 alarms via IAlarmSource (#626)
The parser keeps a condition's text, nativeCode, nativeSeverity, qualifier and type. A new MTConnectConditionTracker applies MTConnect's multi-condition semantics (per-nativeCode activation/clearing across /sample deltas, /current baseline reset, Unavailable), and MTConnectConditionAlarmMapper turns tracker state into Part 9 transitions: Warning=500, Fault=800, Normal=clear, Unavailable=inactive + BadNoCommunication; escalation or a new code retriggers. The driver implements IAlarmSource keyed by the alarm tag's RawPath, diffs against what the server was actually told (only when a handler is attached), so a reconnect/re-baseline never re-raises an unchanged condition and a missed transition is delivered on the next subscribe. Ack is a no-op success. Existing CONDITION String tags are unchanged; a tag becomes an alarm only via the standard TagConfig 'alarm' object. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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 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"),
|
||||
],
|
||||
};
|
||||
|
||||
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 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");
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #626 — the pure halves of the CONDITION → Part 9 path: <see cref="MTConnectConditionTracker"/>
|
||||
/// (MTConnect's multi-condition semantics) and <see cref="MTConnectConditionAlarmMapper"/> (state mapping and
|
||||
/// transition sequencing). The driver wiring is covered by <c>MTConnectAlarmSourceTests</c>.
|
||||
/// </summary>
|
||||
public sealed class MTConnectConditionAlarmMappingTests
|
||||
{
|
||||
private const string Id = "dev1_system_cond";
|
||||
private const string RawPath = "Plant/MT/dev1/SystemCond";
|
||||
private const uint Good = 0x00000000u;
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
private static readonly DateTime T0 = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly IAlarmSubscriptionHandle Handle = new MTConnectAlarmSubscriptionHandle(1);
|
||||
|
||||
// ---- tracker ----
|
||||
|
||||
[Fact]
|
||||
public void A_never_observed_item_has_no_state()
|
||||
{
|
||||
new MTConnectConditionTracker().Get(Id).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Samples_and_events_never_reach_the_tracker()
|
||||
{
|
||||
var tracker = new MTConnectConditionTracker();
|
||||
|
||||
var touched = tracker.Apply(Doc(new MTConnectObservation(Id, "Fault", T0)), baseline: false);
|
||||
|
||||
touched.ShouldBeEmpty("an observation without Condition detail did not come from a <Condition>");
|
||||
tracker.Get(Id).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_most_severe_of_several_simultaneous_conditions_wins()
|
||||
{
|
||||
var tracker = new MTConnectConditionTracker();
|
||||
|
||||
tracker.Apply(Doc(Cond("Warning", "W1", at: 0), Cond("Fault", "F1", at: 1), Cond("Warning", "W2", at: 2)), baseline: false);
|
||||
|
||||
var state = tracker.Get(Id).ShouldNotBeNull();
|
||||
state.Level.ShouldBe(MTConnectConditionLevel.Fault);
|
||||
state.Detail.NativeCode.ShouldBe("F1");
|
||||
state.ActiveCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_normal_for_one_native_code_in_a_later_sample_clears_only_that_code()
|
||||
{
|
||||
// The index's per-document reconcile would read this chunk as "Normal"; the alarm path must not.
|
||||
var tracker = new MTConnectConditionTracker();
|
||||
tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
|
||||
tracker.Apply(Doc(Cond("Warning", "B", at: 1)), baseline: false);
|
||||
|
||||
tracker.Apply(Doc(Cond("Normal", "A", at: 2)), baseline: false);
|
||||
|
||||
var state = tracker.Get(Id).ShouldNotBeNull();
|
||||
state.Level.ShouldBe(MTConnectConditionLevel.Warning);
|
||||
state.Detail.NativeCode.ShouldBe("B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_normal_without_a_native_code_clears_every_code()
|
||||
{
|
||||
var tracker = new MTConnectConditionTracker();
|
||||
tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
|
||||
|
||||
tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
|
||||
|
||||
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unavailable_clears_every_code_until_any_other_level_arrives()
|
||||
{
|
||||
var tracker = new MTConnectConditionTracker();
|
||||
tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
|
||||
|
||||
tracker.Apply(Doc(Cond("UNAVAILABLE", null, at: 1)), baseline: false);
|
||||
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Unavailable);
|
||||
|
||||
tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
|
||||
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_baseline_drops_a_code_the_current_document_no_longer_lists()
|
||||
{
|
||||
// A code cleared while the driver was not listening must not survive a /current re-baseline.
|
||||
var tracker = new MTConnectConditionTracker();
|
||||
tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
|
||||
|
||||
tracker.Apply(Doc(Cond("Warning", "B", at: 2)), baseline: true);
|
||||
|
||||
var state = tracker.Get(Id).ShouldNotBeNull();
|
||||
state.Level.ShouldBe(MTConnectConditionLevel.Warning);
|
||||
state.ActiveCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unrecognised_vendor_state_is_surfaced_as_a_warning()
|
||||
{
|
||||
MTConnectConditionTracker.LevelOf("Alarm").ShouldBe(MTConnectConditionLevel.Warning);
|
||||
MTConnectConditionTracker.LevelOf("UNAVAILABLE").ShouldBe(MTConnectConditionLevel.Unavailable);
|
||||
MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault);
|
||||
}
|
||||
|
||||
// ---- mapper: state mapping ----
|
||||
|
||||
[Fact]
|
||||
public void Fault_raises_active_at_severity_800_with_the_text_as_message()
|
||||
{
|
||||
var evt = Map(null, State(MTConnectConditionLevel.Fault, "Spindle overtemperature", "E17", "2", "HIGH"))
|
||||
.ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
evt.ConditionId.ShouldBe(RawPath, "the host routes on ConditionId == RawPath");
|
||||
evt.SourceNodeId.ShouldBe(Id);
|
||||
evt.OpcUaSeverity.ShouldBe(800);
|
||||
evt.Severity.ShouldBe(AlarmSeverity.High);
|
||||
evt.QualityStatusCode.ShouldBe(Good);
|
||||
evt.Message.ShouldBe("Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]");
|
||||
evt.AlarmType.ShouldBe("SYSTEM");
|
||||
evt.SourceTimestampUtc.ShouldBe(T0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warning_raises_active_at_severity_500()
|
||||
{
|
||||
var evt = Map(null, State(MTConnectConditionLevel.Warning)).ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
evt.OpcUaSeverity.ShouldBe(500);
|
||||
evt.Severity.ShouldBe(AlarmSeverity.Medium);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_condition_without_text_falls_back_to_its_type_level_and_native_code()
|
||||
{
|
||||
MTConnectConditionAlarmMapper.MessageOf(State(MTConnectConditionLevel.Fault, text: null, nativeCode: "1001"))
|
||||
.ShouldBe("SYSTEM Fault [nativeCode=1001]");
|
||||
}
|
||||
|
||||
// ---- mapper: transition sequencing ----
|
||||
|
||||
[Fact]
|
||||
public void Normal_then_fault_then_normal_is_raise_then_clear()
|
||||
{
|
||||
var normal = State(MTConnectConditionLevel.Normal);
|
||||
var fault = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
|
||||
|
||||
Map(null, normal).ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
|
||||
|
||||
var raise = Map(normal, fault).ShouldNotBeNull();
|
||||
raise.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
var clear = Map(fault, normal).ShouldNotBeNull();
|
||||
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
clear.QualityStatusCode.ShouldBe(Good);
|
||||
clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity()
|
||||
{
|
||||
var warning = State(MTConnectConditionLevel.Warning, "coolant low", "W1");
|
||||
var fault = State(MTConnectConditionLevel.Fault, "coolant empty", "F1");
|
||||
|
||||
var evt = Map(warning, fault).ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||
evt.OpcUaSeverity.ShouldBe(800);
|
||||
evt.Message.ShouldStartWith("coolant empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_different_active_native_code_at_the_same_level_is_a_retrigger()
|
||||
{
|
||||
var evt = Map(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))
|
||||
.ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_active_statement_emits_nothing_even_with_a_new_timestamp_or_count()
|
||||
{
|
||||
// The reconnect replay: /current re-reports the same Fault under a new sequence. Re-raising it would
|
||||
// un-acknowledge an operator's ack.
|
||||
var before = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
|
||||
var replayed = before with { TimestampUtc = T0.AddMinutes(5), ActiveCount = 2 };
|
||||
|
||||
Map(before, replayed).ShouldBeNull();
|
||||
Map(before, replayed, resync: true).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_repeated_normal_with_different_text_does_not_re_clear()
|
||||
{
|
||||
Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_active_condition_going_unavailable_clears_with_bad_quality()
|
||||
{
|
||||
var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable))
|
||||
.ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
evt.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||
evt.Message.ShouldBe("SYSTEM UNAVAILABLE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_inactive_condition_going_unavailable_and_back_is_quality_only()
|
||||
{
|
||||
var normal = State(MTConnectConditionLevel.Normal);
|
||||
var unavailable = State(MTConnectConditionLevel.Unavailable);
|
||||
|
||||
var lost = Map(normal, unavailable).ShouldNotBeNull();
|
||||
lost.IsQualityOnly.ShouldBeTrue();
|
||||
lost.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||
|
||||
var back = Map(unavailable, normal).ShouldNotBeNull();
|
||||
back.IsQualityOnly.ShouldBeTrue();
|
||||
back.QualityStatusCode.ShouldBe(Good);
|
||||
|
||||
Map(null, unavailable).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue("first-seen Unavailable must reach the node");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_unavailable_is_reasserted_only_on_a_resync()
|
||||
{
|
||||
// The host re-annotates every condition Good on reconnect (#477); only the resync puts the Bad back.
|
||||
var unavailable = State(MTConnectConditionLevel.Unavailable);
|
||||
|
||||
Map(unavailable, unavailable).ShouldBeNull();
|
||||
Map(unavailable, unavailable, resync: true).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unavailable_recovering_straight_to_fault_raises_with_good_quality()
|
||||
{
|
||||
var evt = Map(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"))
|
||||
.ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
evt.QualityStatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static AlarmEventArgs? Map(MTConnectConditionState? delivered, MTConnectConditionState current, bool resync = false) =>
|
||||
MTConnectConditionAlarmMapper.Transition(delivered, current, RawPath, Id, Handle, resync);
|
||||
|
||||
private static MTConnectConditionState State(
|
||||
MTConnectConditionLevel level,
|
||||
string? text = null,
|
||||
string? nativeCode = null,
|
||||
string? nativeSeverity = null,
|
||||
string? qualifier = null) =>
|
||||
new(level, new MTConnectConditionDetail(text, nativeCode, nativeSeverity, qualifier, "SYSTEM"), T0,
|
||||
level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault ? 1 : 0);
|
||||
|
||||
private static MTConnectObservation Cond(string state, string? nativeCode, int at) =>
|
||||
new(Id, state, T0.AddSeconds(at),
|
||||
Condition: new MTConnectConditionDetail($"{state} {nativeCode}", nativeCode, null, null, "SYSTEM"));
|
||||
|
||||
private static MTConnectStreamsResult Doc(params MTConnectObservation[] observations) =>
|
||||
new(1, 2, 1, observations);
|
||||
}
|
||||
@@ -131,6 +131,36 @@ public sealed class MTConnectStreamsParseTests
|
||||
result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_condition_carries_its_text_and_native_attributes_as_condition_detail()
|
||||
{
|
||||
// #626: the alarm path needs the message (the text) and the controller's own identity for it.
|
||||
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||
"""<Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" type="TEMPERATURE" nativeCode=" E17 " nativeSeverity="2" qualifier="HIGH"> Spindle overtemperature </Fault></Condition>"""));
|
||||
|
||||
var detail = result.Observations.ShouldHaveSingleItem().Condition.ShouldNotBeNull();
|
||||
detail.Text.ShouldBe("Spindle overtemperature");
|
||||
detail.NativeCode.ShouldBe("E17");
|
||||
detail.NativeSeverity.ShouldBe("2");
|
||||
detail.Qualifier.ShouldBe("HIGH");
|
||||
detail.ConditionType.ShouldBe("TEMPERATURE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_condition_has_detail_with_null_text_and_samples_have_none()
|
||||
{
|
||||
var current = ParseCurrent();
|
||||
|
||||
var condition = current["dev1_system_cond"].Condition.ShouldNotBeNull();
|
||||
condition.Text.ShouldBeNull();
|
||||
condition.NativeCode.ShouldBeNull();
|
||||
condition.ConditionType.ShouldBe("SYSTEM");
|
||||
|
||||
// The detail is the alarm path's "this came from a <Condition>" marker — never on a Sample/Event.
|
||||
current["dev1_pos"].Condition.ShouldBeNull();
|
||||
current["dev1_execution"].Condition.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unavailable_condition_element_is_the_unavailable_sentinel()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user