using System; using System.Collections.Generic; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Unit tests for the per-session alarm command router. Uses a fake /// consumer factory so the lazy-construction lifecycle on /// SubscribeAlarms is exercised without touching wnwrap COM. /// public sealed class AlarmCommandHandlerTests { /// Verifies that subscribe creates a consumer and forwards the subscription when not yet subscribed. [Fact] public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); handler.Subscribe(@"\\HOST\Galaxy!Area", "session-1"); Assert.True(handler.IsSubscribed); Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription); } /// Verifies that subscribe throws when already subscribed. [Fact] public void Subscribe_WhenAlreadySubscribed_Throws() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); Assert.Throws( () => handler.Subscribe(@"\\HOST\Galaxy!B", "s1")); } /// /// Worker.Tests-024: pins both the disposal contract and the /// origin of the propagated exception. The fake throws /// InvalidOperationException("simulated wnwrap subscribe failure") /// from Subscribe; the handler must propagate that exact /// exception (not swallow it and rethrow its own) and dispose the /// just-constructed consumer so a retry can build a fresh one. /// Pinning the message guards against a regression where the /// handler throws a different /// (for example its own "already subscribed" guard) and the /// disposal assertion alone would still pass while hiding the /// real swallow. /// [Fact] public void Subscribe_WhenUnderlyingSubscribeThrows_DisposesConsumer() { FakeConsumer consumer = new FakeConsumer { ThrowOnSubscribe = true }; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); InvalidOperationException exception = Assert.Throws( () => handler.Subscribe(@"\\HOST\Galaxy!A", "s1")); Assert.Contains("simulated wnwrap subscribe failure", exception.Message); Assert.False(handler.IsSubscribed); Assert.True(consumer.Disposed); } /// Verifies that unsubscribe disposes consumer and clears state when subscribed. [Fact] public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); handler.Unsubscribe(); Assert.False(handler.IsSubscribed); Assert.True(consumer.Disposed); } /// Verifies that unsubscribe is a no-op when not yet subscribed. [Fact] public void Unsubscribe_WithoutPriorSubscribe_IsNoop() { AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => new FakeConsumer()); handler.Unsubscribe(); // Should not throw. Assert.False(handler.IsSubscribed); } /// Verifies that acknowledge forwards to consumer with full operator identity when subscribed. [Fact] public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() { FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 }; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); Guid g = Guid.NewGuid(); int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F"); Assert.Equal(0, rc); Assert.Equal(g, consumer.LastAckGuid); Assert.Equal("u", consumer.LastAckOperatorName); } /// Verifies that acknowledge throws invalid operation when called before subscribe. [Fact] public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation() { AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => new FakeConsumer()); Assert.Throws( () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); } /// Verifies that query active returns mapped proto snapshots when consumer has alarms. [Fact] public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots() { FakeConsumer consumer = new FakeConsumer { SnapshotResult = new[] { new MxAlarmSnapshotRecord { AlarmGuid = Guid.NewGuid(), ProviderName = "Galaxy", Group = "TestArea", TagName = "Tag1", Type = "DSC", Priority = 500, State = MxAlarmStateKind.UnackAlm, }, }, }; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); IReadOnlyList snapshots = handler.QueryActive(null); Assert.Single(snapshots); Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference); Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState); } /// Verifies that query active filters by prefix when prefix is provided. [Fact] public void QueryActive_WithPrefix_FiltersByPrefix() { FakeConsumer consumer = new FakeConsumer { SnapshotResult = new[] { NewRecord("Galaxy", "AreaA", "Tag1"), NewRecord("Galaxy", "AreaB", "Tag2"), }, }; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); IReadOnlyList filtered = handler.QueryActive("Galaxy!AreaA"); Assert.Single(filtered); Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference); } /// Verifies that dispose unsubscribes and disposes consumer when subscribed. [Fact] public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); handler.Dispose(); Assert.True(consumer.Disposed); Assert.Throws( () => handler.Subscribe("x", "y")); } /// /// Worker-024 regression: every method that touches the underlying /// must invoke the configured /// STA-affinity guard. A guard that throws (simulating an off-STA /// call) must propagate from every command-path entry point. /// [Fact] public void EveryCommandPathEntry_InvokesThreadAffinityGuard() { FakeConsumer consumer = new FakeConsumer(); int guardInvocations = 0; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer, () => guardInvocations++); // Subscribe is the first call — guard must run before the consumer // factory is invoked. We tally invocation counts after each call so // that a missed guard surfaces as the diagnostic count, not a generic // "Subscribe should have failed". handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); Assert.Equal(1, guardInvocations); handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F"); Assert.Equal(2, guardInvocations); handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F"); Assert.Equal(3, guardInvocations); _ = handler.QueryActive(null); Assert.Equal(4, guardInvocations); handler.PollOnce(); Assert.Equal(5, guardInvocations); handler.Unsubscribe(); Assert.Equal(6, guardInvocations); } /// /// Worker-024 regression: a guard that throws must propagate from /// every command-path entry point — proving the guard is not /// swallowed by an inner try/catch. /// [Fact] public void EveryCommandPathEntry_PropagatesAffinityGuardException() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer, threadAffinityCheck: () => throw new InvalidOperationException("off-STA")); // Subscribe: guard runs before the dispatcher is constructed. Assert.Throws( () => handler.Subscribe(@"\\HOST\Galaxy!A", "s1")); // To exercise the other entry points we need a subscribed handler. // Construct a parallel handler with a passing guard, then swap in a // throwing one — but the existing handler is the simpler vehicle: // re-build the handler with the guard initially silent, subscribe, // then verify each remaining entry by passing a guard that throws // through a second handler instance — actually the cleaner way is to // assert each independently with a fresh handler. Below we reuse // the same throwing handler for the not-subscribed-yet entries: Assert.Throws( () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Throws( () => handler.AcknowledgeByName("", "", "", "", "", "", "", "")); Assert.Throws(() => handler.QueryActive(null)); Assert.Throws(() => handler.PollOnce()); Assert.Throws(() => handler.Unsubscribe()); } private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag) { return new MxAlarmSnapshotRecord { AlarmGuid = Guid.NewGuid(), ProviderName = provider, Group = group, TagName = tag, Type = "DSC", Priority = 500, State = MxAlarmStateKind.UnackAlm, }; } private sealed class FakeConsumer : IMxAccessAlarmConsumer { #pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions. /// Emitted when an alarm state transition occurs. public event EventHandler? AlarmTransitionEmitted; #pragma warning restore CS0067 /// Gets the last subscription request. public string? LastSubscription { get; private set; } /// Gets the last acknowledged alarm GUID. public Guid LastAckGuid { get; private set; } /// Gets the last acknowledged operator name. public string? LastAckOperatorName { get; private set; } /// Gets or sets the return value for acknowledge operations. public int AcknowledgeReturn { get; set; } /// Gets or sets the snapshot result to return. public IReadOnlyList SnapshotResult { get; set; } = Array.Empty(); /// Gets or sets a value indicating whether to throw on subscribe. public bool ThrowOnSubscribe { get; set; } /// Gets a value indicating whether the consumer has been disposed. public bool Disposed { get; private set; } /// Subscribes to alarms with the given subscription string. /// The subscription reference. public void Subscribe(string subscription) { LastSubscription = subscription; if (ThrowOnSubscribe) { throw new InvalidOperationException("simulated wnwrap subscribe failure"); } } /// Acknowledges an alarm by GUID. /// The alarm GUID. /// The acknowledgment comment. /// The operator name. /// The operator node. /// The operator domain. /// The operator full name. public int AcknowledgeByGuid( Guid alarmGuid, string ackComment, string ackOperatorName, string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) { LastAckGuid = alarmGuid; LastAckOperatorName = ackOperatorName; return AcknowledgeReturn; } /// Acknowledges an alarm by name. /// The alarm name. /// The provider name. /// The alarm group name. /// The acknowledgment comment. /// The operator name. /// The operator node. /// The operator domain. /// The operator full name. public int AcknowledgeByName( string alarmName, string providerName, string groupName, string ackComment, string ackOperatorName, string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) { LastAckByNameTuple = (alarmName, providerName, groupName); LastAckOperatorName = ackOperatorName; return AcknowledgeReturn; } /// Gets the last acknowledge-by-name parameters. public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } /// Returns a snapshot of active alarms. public IReadOnlyList SnapshotActiveAlarms() => SnapshotResult; /// Gets the number of times polled. public int PollCount { get; private set; } /// Polls once for alarm updates. public void PollOnce() { PollCount++; } /// public void Dispose() { Disposed = true; } } }