diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs new file mode 100644 index 0000000..ea8ec19 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs @@ -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; + +/// +/// Subscribes to the four Galaxy alarm attributes (.InAlarm, .Priority, +/// .DescAttrName, .Acked) per alarm-bearing attribute discovered during +/// DiscoverAsync. Maintains one per alarm, raises +/// on lifecycle transitions (Active / Unacknowledged / +/// Acknowledged / Inactive). Ack path writes .AckMsg. Pure-logic state machine +/// with delegate-based subscribe/write so it's testable against in-memory fakes. +/// +/// +/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model): +/// +/// Active — InAlarm false → true. Default to Unacknowledged. +/// Acknowledged — Acked false → true while InAlarm is still true. +/// Inactive — InAlarm true → false. If still unacknowledged the alarm +/// is marked latched-inactive-unack; next Ack transitions straight to Inactive. +/// +/// +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, Task> _subscribe; + private readonly Func _unsubscribe; + private readonly Func> _write; + private readonly Func _clock; + + // Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state. + private readonly ConcurrentDictionary _alarms = + new(StringComparer.OrdinalIgnoreCase); + + // Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag. + private readonly ConcurrentDictionary _probeToAlarm = + new(StringComparer.OrdinalIgnoreCase); + + private bool _disposed; + + public event EventHandler? TransitionRaised; + + public GalaxyAlarmTracker( + Func, Task> subscribe, + Func unsubscribe, + Func> write) + : this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { } + + internal GalaxyAlarmTracker( + Func, Task> subscribe, + Func unsubscribe, + Func> write, + Func 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; + + /// + /// Advise the four alarm attributes for . 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. + /// + 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; + } + } + + /// + /// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort. + /// + 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 { } + } + } + + /// + /// Operator ack — write the comment text into <alarmTag>.AckMsg. + /// Returns false when the runtime reports the write failed. + /// + public Task AcknowledgeAsync(string alarmTag, string comment) + { + if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) + return Task.FromResult(false); + return _write(alarmTag + AckMsgAttr, comment ?? string.Empty); + } + + /// + /// 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. + /// + 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 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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs index 86e520b..c3da3ff 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs @@ -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? OnDataChange; -#pragma warning disable CS0067 // alarm wire-up deferred to PR 9 public event System.EventHandler? OnAlarmEvent; -#pragma warning restore CS0067 public event System.EventHandler? OnHostStatusChanged; private readonly System.EventHandler _onConnectionStateChanged; private readonly GalaxyRuntimeProbeManager _probeManager; private readonly System.EventHandler _onProbeStateChanged; + private readonly GalaxyAlarmTracker _alarmTracker; + private readonly System.EventHandler _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 _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; } /// @@ -137,6 +168,19 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty(), }).ToArray(); + // PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise + // them on demand. Format matches the Galaxy reference grammar .. + 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() + .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; + /// + /// 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). + /// + 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 */ } + } + } + + /// + /// 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). + /// + 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 "|". + // 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 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; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs new file mode 100644 index 0000000..203f3de --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs @@ -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> Subs = new(); + public readonly ConcurrentQueue Unsubs = new(); + public readonly ConcurrentQueue<(string Tag, object Value)> Writes = new(); + public bool WriteReturns { get; set; } = true; + + public Task Subscribe(string tag, Action cb) + { + Subs[tag] = cb; + return Task.CompletedTask; + } + public Task Unsubscribe(string tag) + { + Unsubs.Enqueue(tag); + Subs.TryRemove(tag, out _); + return Task.CompletedTask; + } + public Task 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(); + 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(); + 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(); + 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(); + 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(); + 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); + } +}