feat(GWC-37,WRK-32): refresh live alarm watch lists
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 51s
ci / java (push) Successful in 2m8s
ci / portable (push) Failing after 1h1m54s

This commit is contained in:
Joseph Doherty
2026-08-25 11:46:07 -04:00
parent 1b11e89f29
commit 484028e721
22 changed files with 3961 additions and 1858 deletions
@@ -287,8 +287,12 @@ public sealed class AlarmCommandHandlerTests
handler.PollOnce();
Assert.Equal(5, guardInvocations);
handler.Unsubscribe();
Assert.Throws<InvalidOperationException>(
() => handler.UpdateWatchList(Array.Empty<AlarmSubtagTarget>()));
Assert.Equal(6, guardInvocations);
handler.Unsubscribe();
Assert.Equal(7, guardInvocations);
}
/// <summary>
@@ -324,6 +328,8 @@ public sealed class AlarmCommandHandlerTests
() => 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());
}
@@ -412,6 +418,114 @@ public sealed class AlarmCommandHandlerTests
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
@@ -467,6 +581,14 @@ public sealed class AlarmCommandHandlerTests
};
}
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.