675 lines
28 KiB
C#
675 lines
28 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the per-session alarm command router. Uses a fake
|
|
/// consumer factory so the lazy-construction lifecycle on
|
|
/// <c>SubscribeAlarms</c> is exercised without touching wnwrap COM.
|
|
/// </summary>
|
|
public sealed class AlarmCommandHandlerTests
|
|
{
|
|
/// <summary>Verifies that subscribe creates a consumer and forwards the subscription when not yet subscribed.</summary>
|
|
[Fact]
|
|
public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer();
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
|
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area" }, "session-1");
|
|
|
|
Assert.True(handler.IsSubscribed);
|
|
Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription);
|
|
}
|
|
|
|
/// <summary>Verifies that subscribe throws when already subscribed.</summary>
|
|
[Fact]
|
|
public void Subscribe_WhenAlreadySubscribed_Throws()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer();
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
|
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!B" }, "s1"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins both the disposal contract and the
|
|
/// origin of the propagated exception. The fake throws
|
|
/// <c>InvalidOperationException("simulated wnwrap subscribe failure")</c>
|
|
/// from <c>Subscribe</c>; 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 <see cref="InvalidOperationException"/>
|
|
/// (for example its own "already subscribed" guard) and the
|
|
/// disposal assertion alone would still pass while hiding the
|
|
/// real swallow.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Subscribe_WhenUnderlyingSubscribeThrows_DisposesConsumer()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer { ThrowOnSubscribe = true };
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
|
|
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
|
|
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"));
|
|
Assert.Contains("simulated wnwrap subscribe failure", exception.Message);
|
|
Assert.False(handler.IsSubscribed);
|
|
Assert.True(consumer.Disposed);
|
|
}
|
|
|
|
/// <summary>Verifies that unsubscribe disposes consumer and clears state when subscribed.</summary>
|
|
[Fact]
|
|
public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer();
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
|
|
handler.Unsubscribe();
|
|
|
|
Assert.False(handler.IsSubscribed);
|
|
Assert.True(consumer.Disposed);
|
|
}
|
|
|
|
/// <summary>Verifies that unsubscribe is a no-op when not yet subscribed.</summary>
|
|
[Fact]
|
|
public void Unsubscribe_WithoutPriorSubscribe_IsNoop()
|
|
{
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => new FakeConsumer());
|
|
handler.Unsubscribe(); // Should not throw.
|
|
Assert.False(handler.IsSubscribed);
|
|
}
|
|
|
|
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
|
|
[Fact]
|
|
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\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);
|
|
}
|
|
|
|
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
|
|
[Fact]
|
|
public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation()
|
|
{
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => new FakeConsumer());
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
|
}
|
|
|
|
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
|
|
[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(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
|
|
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null, out _);
|
|
|
|
Assert.Single(snapshots);
|
|
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
|
|
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
|
|
}
|
|
|
|
/// <summary>Verifies that query active filters by prefix when prefix is provided.</summary>
|
|
[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(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
|
|
IReadOnlyList<ActiveAlarmSnapshot> filtered = handler.QueryActive("Galaxy!AreaA", out _);
|
|
|
|
Assert.Single(filtered);
|
|
Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The consumer's truncation verdict reaches the caller through the
|
|
/// handler and its dispatcher. This is the middle hop of the flag's
|
|
/// journey to the QueryActiveAlarms reply payload; without it the reply
|
|
/// builder would have nothing to stamp.
|
|
/// </summary>
|
|
/// <param name="consumerReportsTruncated">The verdict the fake consumer reports.</param>
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void QueryActive_ReportsConsumerTruncationVerdict(bool consumerReportsTruncated)
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer
|
|
{
|
|
SnapshotTruncated = consumerReportsTruncated,
|
|
SnapshotResult = new[] { NewRecord("Galaxy", "AreaA", "Tag1") },
|
|
};
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
|
|
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null, out bool snapshotTruncated);
|
|
|
|
Assert.Equal(consumerReportsTruncated, snapshotTruncated);
|
|
Assert.Equal(consumerReportsTruncated, Assert.Single(snapshots).FromTruncatedSnapshot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A prefix filter that removes every record must not remove the verdict
|
|
/// with them. This is exactly why the flag rides out separately as well as
|
|
/// on each record: a scoped query over a truncated fetch can legitimately
|
|
/// return nothing and still owe the caller the completeness caveat.
|
|
/// </summary>
|
|
[Fact]
|
|
public void QueryActive_WhenPrefixFiltersOutEveryRecord_StillReportsTruncation()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer
|
|
{
|
|
SnapshotTruncated = true,
|
|
SnapshotResult = new[] { NewRecord("Galaxy", "AreaB", "Tag2") },
|
|
};
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
|
|
IReadOnlyList<ActiveAlarmSnapshot> filtered =
|
|
handler.QueryActive("Galaxy!AreaA", out bool snapshotTruncated);
|
|
|
|
Assert.Empty(filtered);
|
|
Assert.True(snapshotTruncated);
|
|
}
|
|
|
|
/// <summary>Verifies that dispose unsubscribes and disposes consumer when subscribed.</summary>
|
|
[Fact]
|
|
public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer();
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => consumer);
|
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
|
|
handler.Dispose();
|
|
|
|
Assert.True(consumer.Disposed);
|
|
Assert.Throws<ObjectDisposedException>(
|
|
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = "x" }, "y"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every method that touches the underlying
|
|
/// <see cref="IMxAccessAlarmConsumer"/> must invoke the configured
|
|
/// STA-affinity guard. A guard that throws (simulating an off-STA
|
|
/// call) must propagate from every command-path entry point.
|
|
/// </summary>
|
|
[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(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\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, out _);
|
|
Assert.Equal(4, guardInvocations);
|
|
|
|
handler.PollOnce();
|
|
Assert.Equal(5, guardInvocations);
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.UpdateWatchList(Array.Empty<AlarmSubtagTarget>()));
|
|
Assert.Equal(6, guardInvocations);
|
|
|
|
handler.Unsubscribe();
|
|
Assert.Equal(7, guardInvocations);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A guard that throws must propagate from
|
|
/// every command-path entry point — proving the guard is not
|
|
/// swallowed by an inner try/catch.
|
|
/// </summary>
|
|
[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<InvalidOperationException>(
|
|
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\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<InvalidOperationException>(
|
|
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
|
|
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
|
|
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.UpdateWatchList(Array.Empty<AlarmSubtagTarget>()));
|
|
Assert.Throws<InvalidOperationException>(() => handler.Unsubscribe());
|
|
}
|
|
|
|
/// <summary>
|
|
/// ForcedMode=Subtag builds a subtag consumer (via the
|
|
/// injected standby factory) and advises it — the primary
|
|
/// (alarmmgr) consumer is NOT created.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Subscribe_WithForcedSubtagMode_BuildsStandbyConsumerOnly()
|
|
{
|
|
FakeConsumer primary = new FakeConsumer();
|
|
FakeConsumer standby = new FakeConsumer();
|
|
IReadOnlyList<AlarmSubtagTarget>? capturedWatchList = null;
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => primary,
|
|
threadAffinityCheck: null,
|
|
comFactory: null,
|
|
standbyFactory: watch =>
|
|
{
|
|
capturedWatchList = watch;
|
|
return standby;
|
|
});
|
|
|
|
SubscribeAlarmsCommand command = new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
ForcedMode = AlarmProviderMode.Subtag,
|
|
};
|
|
command.WatchList.Add(new AlarmSubtagTarget { AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi" });
|
|
|
|
handler.Subscribe(command, "s1");
|
|
|
|
Assert.True(handler.IsSubscribed);
|
|
Assert.Equal(@"\\HOST\Galaxy!Area", standby.LastSubscription); // standby advised
|
|
Assert.Null(primary.LastSubscription); // primary never built
|
|
Assert.NotNull(capturedWatchList);
|
|
Assert.Single(capturedWatchList!);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ForcedMode=Unspecified + a non-empty watch list builds a
|
|
/// failover composite (primary + subtag standby). Forcing the primary
|
|
/// to fail on subscribe with a threshold of 1 drives the composite to
|
|
/// switch to the subtag provider, which must enqueue an
|
|
/// OnAlarmProviderModeChanged event carrying mode=Subtag.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Subscribe_AutoModeWithWatchList_FailoverModeChange_EnqueuesProviderModeChangedEvent()
|
|
{
|
|
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true };
|
|
FakeConsumer standby = new FakeConsumer();
|
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
queue,
|
|
() => primary,
|
|
threadAffinityCheck: null,
|
|
comFactory: null,
|
|
standbyFactory: _ => standby);
|
|
|
|
SubscribeAlarmsCommand command = new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
ForcedMode = AlarmProviderMode.Unspecified,
|
|
Failover = new AlarmFailoverConfig
|
|
{
|
|
ConsecutiveFailureThreshold = 1,
|
|
FailbackProbeIntervalSeconds = 1,
|
|
FailbackStableProbes = 1,
|
|
},
|
|
};
|
|
command.WatchList.Add(new AlarmSubtagTarget { AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi" });
|
|
|
|
// Subscribe: standby is armed cleanly; the primary subscribe throws and,
|
|
// at threshold 1, the failover composite switches to standby and raises
|
|
// ProviderModeChanged. The handler enqueues the proto event.
|
|
handler.Subscribe(command, "s1");
|
|
|
|
IReadOnlyList<WorkerEvent> drained = queue.Drain(0);
|
|
Assert.Single(drained);
|
|
MxEvent evt = drained[0].Event;
|
|
Assert.Equal(MxEventFamily.OnAlarmProviderModeChanged, evt.Family);
|
|
Assert.Equal("s1", evt.SessionId);
|
|
Assert.NotNull(evt.OnAlarmProviderModeChanged);
|
|
Assert.Equal(AlarmProviderMode.Subtag, evt.OnAlarmProviderModeChanged.Mode);
|
|
}
|
|
|
|
/// <summary>An explicitly fallback-capable auto subscription builds a replaceable standby even when discovery is initially empty.</summary>
|
|
[Fact]
|
|
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
|
|
{
|
|
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
|
|
FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
|
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
|
|
int buildCount = 0;
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => primary,
|
|
threadAffinityCheck: null,
|
|
comFactory: null,
|
|
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement);
|
|
|
|
handler.Subscribe(new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
FallbackEnabled = true,
|
|
}, "s1");
|
|
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank01.Alarm") });
|
|
|
|
Assert.Equal(2, buildCount);
|
|
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
|
|
Assert.True(initialStandby.Disposed);
|
|
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
|
}
|
|
|
|
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
|
|
[Fact]
|
|
public void UpdateWatchList_WithoutFallbackCapableSubscription_Throws()
|
|
{
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => new FakeConsumer());
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank01.Alarm") }));
|
|
|
|
handler.Subscribe(new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
ForcedMode = AlarmProviderMode.Alarmmgr,
|
|
}, "s1");
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") }));
|
|
}
|
|
|
|
/// <summary>A replacement setup failure leaves the old standby and primary mode intact.</summary>
|
|
[Fact]
|
|
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
|
|
{
|
|
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
|
|
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
|
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 };
|
|
int buildCount = 0;
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => primary,
|
|
null,
|
|
null,
|
|
_ => ++buildCount == 1 ? oldStandby : failedReplacement);
|
|
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
FallbackEnabled = true,
|
|
};
|
|
subscribe.WatchList.Add(NewTarget("Galaxy!Area.Tank01.Alarm"));
|
|
handler.Subscribe(subscribe, "s1");
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") }));
|
|
|
|
Assert.False(oldStandby.Disposed);
|
|
Assert.True(failedReplacement.Disposed);
|
|
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
|
}
|
|
|
|
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
|
|
[Fact]
|
|
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
|
|
{
|
|
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 };
|
|
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
|
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
|
|
int buildCount = 0;
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
|
new MxAccessEventQueue(),
|
|
() => primary,
|
|
null,
|
|
null,
|
|
_ => ++buildCount == 1 ? oldStandby : replacement);
|
|
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
FallbackEnabled = true,
|
|
Failover = new AlarmFailoverConfig { ConsecutiveFailureThreshold = 1 },
|
|
};
|
|
subscribe.WatchList.Add(NewTarget("Galaxy!Area.Tank01.Alarm"));
|
|
handler.Subscribe(subscribe, "s1");
|
|
|
|
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
|
|
|
|
Assert.True(oldStandby.Disposed);
|
|
Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A non-failover subscribe (alarmmgr-only) never enqueues a
|
|
/// provider-mode-changed event, and a subsequent Unsubscribe detaches
|
|
/// the handler so no event leaks.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Subscribe_AlarmmgrOnly_DoesNotEnqueueProviderModeChangedEvent()
|
|
{
|
|
FakeConsumer consumer = new FakeConsumer();
|
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
|
AlarmCommandHandler handler = new AlarmCommandHandler(queue, () => consumer);
|
|
|
|
handler.Subscribe(
|
|
new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
|
handler.Unsubscribe();
|
|
|
|
Assert.Empty(queue.Drain(0));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The mapper builds a well-formed OnAlarmProviderModeChanged
|
|
/// MxEvent — correct family and populated body fields.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Mapper_CreateOnAlarmProviderModeChanged_PopulatesFamilyAndBody()
|
|
{
|
|
MxAccessEventMapper mapper = new MxAccessEventMapper();
|
|
DateTime at = new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
|
|
|
|
MxEvent evt = mapper.CreateOnAlarmProviderModeChanged(
|
|
"session-7", AlarmProviderMode.Subtag, "primary PollOnce failed", unchecked((int)0x80004005), at);
|
|
|
|
Assert.Equal(MxEventFamily.OnAlarmProviderModeChanged, evt.Family);
|
|
Assert.Equal("session-7", evt.SessionId);
|
|
Assert.NotNull(evt.OnAlarmProviderModeChanged);
|
|
Assert.Equal(AlarmProviderMode.Subtag, evt.OnAlarmProviderModeChanged.Mode);
|
|
Assert.Equal("primary PollOnce failed", evt.OnAlarmProviderModeChanged.Reason);
|
|
Assert.Equal(unchecked((int)0x80004005), evt.OnAlarmProviderModeChanged.Hresult);
|
|
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
|
|
}
|
|
|
|
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 static AlarmSubtagTarget NewTarget(string alarmFullReference) => new AlarmSubtagTarget
|
|
{
|
|
AlarmFullReference = alarmFullReference,
|
|
ActiveSubtag = alarmFullReference + ".InAlarm",
|
|
AckedSubtag = alarmFullReference + ".Acked",
|
|
AckCommentSubtag = alarmFullReference + ".AckMsg",
|
|
};
|
|
|
|
private sealed class FakeConsumer : IMxAccessAlarmConsumer
|
|
{
|
|
#pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions.
|
|
/// <summary>Emitted when an alarm state transition occurs.</summary>
|
|
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
|
#pragma warning restore CS0067
|
|
|
|
/// <summary>Gets the last subscription request.</summary>
|
|
public string? LastSubscription { get; private set; }
|
|
/// <summary>Gets the last acknowledged alarm GUID.</summary>
|
|
public Guid LastAckGuid { get; private set; }
|
|
/// <summary>Gets the last acknowledged operator name.</summary>
|
|
public string? LastAckOperatorName { get; private set; }
|
|
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
|
|
public int AcknowledgeReturn { get; set; }
|
|
/// <summary>Gets or sets the snapshot result to return.</summary>
|
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
|
Array.Empty<MxAlarmSnapshotRecord>();
|
|
/// <summary>Gets or sets a value indicating whether to throw on subscribe.</summary>
|
|
public bool ThrowOnSubscribe { get; set; }
|
|
/// <summary>Gets a value indicating whether the consumer has been disposed.</summary>
|
|
public bool Disposed { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public void Subscribe(string subscription)
|
|
{
|
|
LastSubscription = subscription;
|
|
if (ThrowOnSubscribe)
|
|
{
|
|
throw new InvalidOperationException("simulated wnwrap subscribe failure");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int AcknowledgeByGuid(
|
|
Guid alarmGuid, string ackComment, string ackOperatorName,
|
|
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
|
|
{
|
|
LastAckGuid = alarmGuid;
|
|
LastAckOperatorName = ackOperatorName;
|
|
return AcknowledgeReturn;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
|
|
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
|
|
|
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
|
public bool SnapshotTruncated { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
|
{
|
|
truncated = SnapshotTruncated;
|
|
return SnapshotResult;
|
|
}
|
|
|
|
/// <summary>Gets the number of times polled.</summary>
|
|
public int PollCount { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public void PollOnce()
|
|
{
|
|
PollCount++;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
Disposed = true;
|
|
}
|
|
}
|
|
}
|