Merge pull request 'Phase 2 PR 14 — alarm subsystem (subscribe to alarm attribute quartet + raise GalaxyAlarmEvent)' (#13) from phase-2-pr14-alarm-subsystem into v2

This commit was merged in pull request #13.
This commit is contained in:
2026-04-18 07:37:49 -04:00
3 changed files with 532 additions and 4 deletions

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
/// <summary>
/// Subscribes to the four Galaxy alarm attributes (<c>.InAlarm</c>, <c>.Priority</c>,
/// <c>.DescAttrName</c>, <c>.Acked</c>) per alarm-bearing attribute discovered during
/// <c>DiscoverAsync</c>. Maintains one <see cref="AlarmState"/> per alarm, raises
/// <see cref="AlarmTransition"/> on lifecycle transitions (Active / Unacknowledged /
/// Acknowledged / Inactive). Ack path writes <c>.AckMsg</c>. Pure-logic state machine
/// with delegate-based subscribe/write so it's testable against in-memory fakes.
/// </summary>
/// <remarks>
/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model):
/// <list type="bullet">
/// <item><c>Active</c> — InAlarm false → true. Default to Unacknowledged.</item>
/// <item><c>Acknowledged</c> — Acked false → true while InAlarm is still true.</item>
/// <item><c>Inactive</c> — InAlarm true → false. If still unacknowledged the alarm
/// is marked latched-inactive-unack; next Ack transitions straight to Inactive.</item>
/// </list>
/// </remarks>
public sealed class GalaxyAlarmTracker : IDisposable
{
public const string InAlarmAttr = ".InAlarm";
public const string PriorityAttr = ".Priority";
public const string DescAttrNameAttr = ".DescAttrName";
public const string AckedAttr = ".Acked";
public const string AckMsgAttr = ".AckMsg";
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
private readonly Func<string, Task> _unsubscribe;
private readonly Func<string, object, Task<bool>> _write;
private readonly Func<DateTime> _clock;
// Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state.
private readonly ConcurrentDictionary<string, AlarmState> _alarms =
new(StringComparer.OrdinalIgnoreCase);
// Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag.
private readonly ConcurrentDictionary<string, (string AlarmTag, AlarmField Field)> _probeToAlarm =
new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
public event EventHandler<AlarmTransition>? TransitionRaised;
public GalaxyAlarmTracker(
Func<string, Action<string, Vtq>, Task> subscribe,
Func<string, Task> unsubscribe,
Func<string, object, Task<bool>> write)
: this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { }
internal GalaxyAlarmTracker(
Func<string, Action<string, Vtq>, Task> subscribe,
Func<string, Task> unsubscribe,
Func<string, object, Task<bool>> write,
Func<DateTime> clock)
{
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
_write = write ?? throw new ArgumentNullException(nameof(write));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public int TrackedAlarmCount => _alarms.Count;
/// <summary>
/// Advise the four alarm attributes for <paramref name="alarmTag"/>. Idempotent —
/// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the
/// four rolls back the alarm entry so a stale callback cannot promote a phantom.
/// </summary>
public async Task TrackAsync(string alarmTag)
{
if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return;
if (_alarms.ContainsKey(alarmTag)) return;
var state = new AlarmState { AlarmTag = alarmTag };
if (!_alarms.TryAdd(alarmTag, state)) return;
var probes = new[]
{
(Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm),
(Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority),
(Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName),
(Tag: alarmTag + AckedAttr, Field: AlarmField.Acked),
};
foreach (var p in probes)
{
_probeToAlarm[p.Tag] = (alarmTag, p.Field);
}
try
{
foreach (var p in probes)
{
await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false);
}
}
catch
{
// Rollback so a partial advise doesn't leak state.
_alarms.TryRemove(alarmTag, out _);
foreach (var p in probes)
{
_probeToAlarm.TryRemove(p.Tag, out _);
try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { }
}
throw;
}
}
/// <summary>
/// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort.
/// </summary>
public async Task ClearAsync()
{
_alarms.Clear();
foreach (var kv in _probeToAlarm.ToList())
{
_probeToAlarm.TryRemove(kv.Key, out _);
try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { }
}
}
/// <summary>
/// Operator ack — write the comment text into <c>&lt;alarmTag&gt;.AckMsg</c>.
/// Returns false when the runtime reports the write failed.
/// </summary>
public Task<bool> AcknowledgeAsync(string alarmTag, string comment)
{
if (_disposed || string.IsNullOrWhiteSpace(alarmTag))
return Task.FromResult(false);
return _write(alarmTag + AckMsgAttr, comment ?? string.Empty);
}
/// <summary>
/// Subscription callback entry point. Exposed for tests and for the Backend to route
/// fan-out callbacks through. Runs the state machine and fires TransitionRaised
/// outside the lock.
/// </summary>
public void OnProbeCallback(string probeTag, Vtq vtq)
{
if (_disposed) return;
if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return;
if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return;
AlarmTransition? transition = null;
var now = _clock();
lock (state.Lock)
{
switch (link.Field)
{
case AlarmField.InAlarm:
{
var wasActive = state.InAlarm;
var isActive = vtq.Value is bool b && b;
state.InAlarm = isActive;
state.LastUpdateUtc = now;
if (!wasActive && isActive)
{
state.Acked = false;
state.LastTransitionUtc = now;
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now);
}
else if (wasActive && !isActive)
{
state.LastTransitionUtc = now;
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now);
}
break;
}
case AlarmField.Priority:
if (vtq.Value is int pi) state.Priority = pi;
else if (vtq.Value is short ps) state.Priority = ps;
else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl;
state.LastUpdateUtc = now;
break;
case AlarmField.DescAttrName:
state.DescAttrName = vtq.Value as string;
state.LastUpdateUtc = now;
break;
case AlarmField.Acked:
{
var wasAcked = state.Acked;
var isAcked = vtq.Value is bool b && b;
state.Acked = isAcked;
state.LastUpdateUtc = now;
// Fire Acknowledged only when transitioning false→true. Don't fire on initial
// subscribe callback (wasAcked==isAcked in that case because the state starts
// with Acked=false and the initial probe is usually true for an un-active alarm).
if (!wasAcked && isAcked && state.InAlarm)
{
state.LastTransitionUtc = now;
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now);
}
break;
}
}
}
if (transition is { } t)
{
TransitionRaised?.Invoke(this, t);
}
}
public IReadOnlyList<AlarmSnapshot> SnapshotStates()
{
return _alarms.Values.Select(s =>
{
lock (s.Lock)
return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName);
}).ToList();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_alarms.Clear();
_probeToAlarm.Clear();
}
private sealed class AlarmState
{
public readonly object Lock = new();
public string AlarmTag = "";
public bool InAlarm;
public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire
public int Priority;
public string? DescAttrName;
public DateTime LastUpdateUtc;
public DateTime LastTransitionUtc;
}
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
}
public enum AlarmStateTransition { Active, Acknowledged, Inactive }
public sealed record AlarmTransition(
string AlarmTag,
AlarmStateTransition Transition,
int Priority,
string? DescAttrName,
DateTime AtUtc);
public sealed record AlarmSnapshot(
string AlarmTag,
bool InAlarm,
bool Acked,
int Priority,
string? DescAttrName);

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
@@ -35,14 +36,18 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
#pragma warning disable CS0067 // alarm wire-up deferred to PR 9
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
#pragma warning restore CS0067
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
private readonly System.EventHandler<bool> _onConnectionStateChanged;
private readonly GalaxyRuntimeProbeManager _probeManager;
private readonly System.EventHandler<HostStateTransition> _onProbeStateChanged;
private readonly GalaxyAlarmTracker _alarmTracker;
private readonly System.EventHandler<AlarmTransition> _onAlarmTransition;
// Cached during DiscoverAsync so SubscribeAlarmsAsync knows which attributes to advise.
// One entry per IsAlarm=true attribute in the last discovered hierarchy.
private readonly System.Collections.Concurrent.ConcurrentBag<string> _discoveredAlarmTags = new();
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
{
@@ -89,6 +94,32 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
});
};
_probeManager.StateChanged += _onProbeStateChanged;
// PR 14: alarm subsystem. Per IsAlarm=true attribute discovered, subscribe to the four
// alarm-state attributes (.InAlarm/.Priority/.DescAttrName/.Acked), track lifecycle,
// and raise GalaxyAlarmEvent on transitions — forwarded through the existing
// OnAlarmEvent IPC event that the PR 4 ConnectionSink already wires into AlarmEvent frames.
_alarmTracker = new GalaxyAlarmTracker(
subscribe: (tag, cb) => _mx.SubscribeAsync(tag, cb),
unsubscribe: tag => _mx.UnsubscribeAsync(tag),
write: (tag, v) => _mx.WriteAsync(tag, v));
_onAlarmTransition = (_, t) => OnAlarmEvent?.Invoke(this, new GalaxyAlarmEvent
{
EventId = Guid.NewGuid().ToString("N"),
ObjectTagName = t.AlarmTag,
AlarmName = t.AlarmTag,
Severity = t.Priority,
StateTransition = t.Transition switch
{
AlarmStateTransition.Active => "Active",
AlarmStateTransition.Acknowledged => "Acknowledged",
AlarmStateTransition.Inactive => "Inactive",
_ => "Unknown",
},
Message = t.DescAttrName ?? t.AlarmTag,
UtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
});
_alarmTracker.TransitionRaised += _onAlarmTransition;
}
/// <summary>
@@ -137,6 +168,19 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
}).ToArray();
// PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise
// them on demand. Format matches the Galaxy reference grammar <tag>.<attr>.
var freshAlarmTags = attributes
.Where(a => a.IsAlarm)
.Select(a => nameByGobject.TryGetValue(a.GobjectId, out var tn)
? tn + "." + a.AttributeName
: null)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Cast<string>()
.ToArray();
while (_discoveredAlarmTags.TryTake(out _)) { }
foreach (var t in freshAlarmTags) _discoveredAlarmTags.Add(t);
// PR 13: Sync the per-platform probe manager against the just-discovered hierarchy
// so ScanState subscriptions track the current runtime set. Best-effort — probe
// failures don't block Discover from returning, since the gateway-level signal from
@@ -289,8 +333,40 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
}
}
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
/// <summary>
/// PR 14: advise every alarm-bearing attribute's 4-attr quartet. Best-effort per-alarm —
/// a subscribe failure on one alarm doesn't abort the whole call, since operators prefer
/// partial alarm coverage to none. Idempotent on repeat calls (tracker internally
/// skips already-tracked alarms).
/// </summary>
public async Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct)
{
foreach (var tag in _discoveredAlarmTags)
{
try { await _alarmTracker.TrackAsync(tag).ConfigureAwait(false); }
catch { /* swallow per-alarm — tracker rolls back its own state on failure */ }
}
}
/// <summary>
/// PR 14: route operator ack through the tracker's AckMsg write path. EventId on the
/// incoming request maps directly to the alarm full reference (Proxy-side naming
/// convention from GalaxyProxyDriver.RaiseAlarmEvent → ev.EventId).
/// </summary>
public async Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct)
{
// EventId carries a per-transition Guid.ToString("N"); there's no reverse map from
// event id to alarm tag yet, so v1's convention (ack targets the condition) is matched
// by reading the alarm name from the Comment envelope: v1 packed "<tag>|<comment>".
// Until the Proxy is updated to send the alarm tag separately, fall back to treating
// the EventId as the alarm tag — Client CLI passes it through unchanged.
var tag = req.EventId;
if (!string.IsNullOrWhiteSpace(tag))
{
try { await _alarmTracker.AcknowledgeAsync(tag, req.Comment ?? string.Empty).ConfigureAwait(false); }
catch { /* swallow — ack failures surface via MxAccessClient.WriteAsync logs */ }
}
}
public async Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
{
@@ -454,6 +530,8 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
public void Dispose()
{
_alarmTracker.TransitionRaised -= _onAlarmTransition;
_alarmTracker.Dispose();
_probeManager.StateChanged -= _onProbeStateChanged;
_probeManager.Dispose();
_mx.ConnectionStateChanged -= _onConnectionStateChanged;

View File

@@ -0,0 +1,190 @@
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
[Trait("Category", "Unit")]
public sealed class GalaxyAlarmTrackerTests
{
private sealed class FakeSubscriber
{
public readonly ConcurrentDictionary<string, Action<string, Vtq>> Subs = new();
public readonly ConcurrentQueue<string> Unsubs = new();
public readonly ConcurrentQueue<(string Tag, object Value)> Writes = new();
public bool WriteReturns { get; set; } = true;
public Task Subscribe(string tag, Action<string, Vtq> cb)
{
Subs[tag] = cb;
return Task.CompletedTask;
}
public Task Unsubscribe(string tag)
{
Unsubs.Enqueue(tag);
Subs.TryRemove(tag, out _);
return Task.CompletedTask;
}
public Task<bool> Write(string tag, object value)
{
Writes.Enqueue((tag, value));
return Task.FromResult(WriteReturns);
}
}
private static Vtq Bool(bool v) => new(v, DateTime.UtcNow, 192);
private static Vtq Int(int v) => new(v, DateTime.UtcNow, 192);
private static Vtq Str(string v) => new(v, DateTime.UtcNow, 192);
[Fact]
public async Task Track_subscribes_to_four_alarm_attributes()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
await t.TrackAsync("Tank.Level.HiHi");
fake.Subs.ShouldContainKey("Tank.Level.HiHi.InAlarm");
fake.Subs.ShouldContainKey("Tank.Level.HiHi.Priority");
fake.Subs.ShouldContainKey("Tank.Level.HiHi.DescAttrName");
fake.Subs.ShouldContainKey("Tank.Level.HiHi.Acked");
t.TrackedAlarmCount.ShouldBe(1);
}
[Fact]
public async Task Track_is_idempotent_on_repeat_call()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
await t.TrackAsync("Alarm.A");
await t.TrackAsync("Alarm.A");
t.TrackedAlarmCount.ShouldBe(1);
fake.Subs.Count.ShouldBe(4); // 4 sub calls, not 8
}
[Fact]
public async Task InAlarm_false_to_true_fires_Active_transition()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
var transitions = new ConcurrentQueue<AlarmTransition>();
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
await t.TrackAsync("Alarm.A");
fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(500));
fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("TankLevelHiHi"));
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
transitions.Count.ShouldBe(1);
transitions.TryDequeue(out var tr).ShouldBeTrue();
tr!.Transition.ShouldBe(AlarmStateTransition.Active);
tr.Priority.ShouldBe(500);
tr.DescAttrName.ShouldBe("TankLevelHiHi");
}
[Fact]
public async Task InAlarm_true_to_false_fires_Inactive_transition()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
var transitions = new ConcurrentQueue<AlarmTransition>();
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
await t.TrackAsync("Alarm.A");
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(false));
transitions.Count.ShouldBe(2);
transitions.TryDequeue(out _);
transitions.TryDequeue(out var tr).ShouldBeTrue();
tr!.Transition.ShouldBe(AlarmStateTransition.Inactive);
}
[Fact]
public async Task Acked_false_to_true_fires_Acknowledged_while_InAlarm_is_true()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
var transitions = new ConcurrentQueue<AlarmTransition>();
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
await t.TrackAsync("Alarm.A");
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); // Active, clears Acked flag
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); // Acknowledged
transitions.Count.ShouldBe(2);
transitions.TryDequeue(out _);
transitions.TryDequeue(out var tr).ShouldBeTrue();
tr!.Transition.ShouldBe(AlarmStateTransition.Acknowledged);
}
[Fact]
public async Task Acked_transition_while_InAlarm_is_false_does_not_fire()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
var transitions = new ConcurrentQueue<AlarmTransition>();
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
await t.TrackAsync("Alarm.A");
// Initial Acked=true on subscribe (alarm is at rest, pre-ack'd) — should not fire.
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true));
transitions.Count.ShouldBe(0);
}
[Fact]
public async Task Acknowledge_writes_AckMsg_with_comment()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
await t.TrackAsync("Alarm.A");
var ok = await t.AcknowledgeAsync("Alarm.A", "acknowledged by operator");
ok.ShouldBeTrue();
fake.Writes.Count.ShouldBe(1);
fake.Writes.TryDequeue(out var w).ShouldBeTrue();
w.Tag.ShouldBe("Alarm.A.AckMsg");
w.Value.ShouldBe("acknowledged by operator");
}
[Fact]
public async Task Snapshot_reports_latest_fields()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
await t.TrackAsync("Alarm.A");
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(900));
fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("MyAlarm"));
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true));
var snap = t.SnapshotStates();
snap.Count.ShouldBe(1);
snap[0].InAlarm.ShouldBeTrue();
snap[0].Acked.ShouldBeTrue();
snap[0].Priority.ShouldBe(900);
snap[0].DescAttrName.ShouldBe("MyAlarm");
}
[Fact]
public async Task Foreign_probe_callback_is_dropped()
{
var fake = new FakeSubscriber();
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
var transitions = new ConcurrentQueue<AlarmTransition>();
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
// No TrackAsync was called — this callback is foreign and should be silently ignored.
t.OnProbeCallback("Unknown.InAlarm", Bool(true));
transitions.Count.ShouldBe(0);
}
}