using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming; /// /// WP-23: Site-Wide Akka Stream tests. /// WP-25: Debug View Backend tests (subscribe/unsubscribe). /// public class SiteStreamManagerTests : TestKit, IDisposable { private readonly SiteStreamManager _streamManager; public SiteStreamManagerTests() { var options = new SiteRuntimeOptions { StreamBufferSize = 100 }; _streamManager = new SiteStreamManager( options, NullLogger.Instance); _streamManager.Initialize(Sys); } void IDisposable.Dispose() { Shutdown(); } [Fact] public void Subscribe_CreatesSubscription() { var probe = CreateTestProbe(); var id = _streamManager.Subscribe("Pump1", probe.Ref); Assert.NotNull(id); Assert.Equal(1, _streamManager.SubscriptionCount); } [Fact] public void Unsubscribe_RemovesSubscription() { var probe = CreateTestProbe(); var id = _streamManager.Subscribe("Pump1", probe.Ref); Assert.True(_streamManager.Unsubscribe(id)); Assert.Equal(0, _streamManager.SubscriptionCount); } [Fact] public void Unsubscribe_InvalidId_ReturnsFalse() { Assert.False(_streamManager.Unsubscribe("nonexistent")); } [Fact] public void PublishAttributeValueChanged_ForwardsToSubscriber() { var probe = CreateTestProbe(); _streamManager.Subscribe("Pump1", probe.Ref); var changed = new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow); _streamManager.PublishAttributeValueChanged(changed); var received = probe.ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Equal("Pump1", received.InstanceUniqueName); Assert.Equal("Temperature", received.AttributeName); } [Fact] public void PublishAlarmStateChanged_ForwardsToSubscriber() { var probe = CreateTestProbe(); _streamManager.Subscribe("Pump1", probe.Ref); var changed = new AlarmStateChanged( "Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow); _streamManager.PublishAlarmStateChanged(changed); var received = probe.ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Equal("Pump1", received.InstanceUniqueName); Assert.Equal(AlarmState.Active, received.State); } [Fact] public void PublishAttributeValueChanged_FiltersbyInstance() { var probe1 = CreateTestProbe(); var probe2 = CreateTestProbe(); _streamManager.Subscribe("Pump1", probe1.Ref); _streamManager.Subscribe("Pump2", probe2.Ref); var changed = new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow); _streamManager.PublishAttributeValueChanged(changed); // Pump1 subscriber should receive probe1.ExpectMsg(TimeSpan.FromSeconds(3)); // Pump2 subscriber should NOT receive probe2.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public void SubscribeSiteAlarms_ForwardsAlarmsForAllInstances_ButNotAttributes() { var probe = CreateTestProbe(); _streamManager.SubscribeSiteAlarms(probe.Ref); // Alarm events from two different instances — both should arrive. _streamManager.PublishAlarmStateChanged(new AlarmStateChanged( "Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow)); _streamManager.PublishAlarmStateChanged(new AlarmStateChanged( "Pump2", "LowFlow", AlarmState.Active, 2, DateTimeOffset.UtcNow)); // Attribute events must be filtered out entirely. _streamManager.PublishAttributeValueChanged(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow)); _streamManager.PublishAttributeValueChanged(new AttributeValueChanged( "Pump3", "Flow", "Flow", "42", "Good", DateTimeOffset.UtcNow)); // Collect the two alarms (order across instances is not guaranteed). var received = new[] { probe.ExpectMsg(TimeSpan.FromSeconds(3)), probe.ExpectMsg(TimeSpan.FromSeconds(3)), }; var instances = received.Select(a => a.InstanceUniqueName).OrderBy(n => n).ToArray(); Assert.Equal(new[] { "Pump1", "Pump2" }, instances); // No attribute events (nor any further alarm) should be delivered. probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public void SubscribeSiteAlarms_SubscriptionRemovableViaUnsubscribe() { var probe = CreateTestProbe(); var id = _streamManager.SubscribeSiteAlarms(probe.Ref); Assert.NotNull(id); Assert.Equal(1, _streamManager.SubscriptionCount); Assert.True(_streamManager.Unsubscribe(id)); Assert.Equal(0, _streamManager.SubscriptionCount); } [Fact] public void RemoveSubscriber_RemovesAllSubscriptionsForActor() { var probe = CreateTestProbe(); _streamManager.Subscribe("Pump1", probe.Ref); _streamManager.Subscribe("Pump2", probe.Ref); Assert.Equal(2, _streamManager.SubscriptionCount); _streamManager.RemoveSubscriber(probe.Ref); Assert.Equal(0, _streamManager.SubscriptionCount); } // ── WP2.6d: separate alarm publish path, drop counter, skip-at-zero-subscribers ── /// /// WP2.6d: alarm state changes now travel a dedicated publish source, isolated from /// the (far higher-volume) attribute path — a burst of attribute events for OTHER /// instances, interleaved with an alarm-only subscriber's events, must not cause any /// alarm to be lost. /// [Fact] public void PublishAlarmStateChanged_SurvivesConcurrentAttributeStorm_ForUnrelatedInstances() { var alarmProbe = CreateTestProbe(); _streamManager.SubscribeSiteAlarms(alarmProbe.Ref); // A storm of attribute events for a DIFFERENT instance — none of which the // alarm subscriber is even listening to — interleaved with alarm events. Before // WP2.6d these shared one upstream buffer; now they are fully separate sources. for (var i = 0; i < 500; i++) { _streamManager.PublishAttributeValueChanged(new AttributeValueChanged( "NoisyPump", "Temperature", "Temperature", i.ToString(), "Good", DateTimeOffset.UtcNow)); } _streamManager.PublishAlarmStateChanged(new AlarmStateChanged( "Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow)); var received = alarmProbe.ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Equal("Pump1", received.InstanceUniqueName); } /// /// WP2.6d: with zero subscribers of any kind, PublishAlarmStateChanged/ /// PublishAttributeValueChanged must be no-ops — no exception, and (for alarms) the /// event never reaches the bounded hand-off queue, so /// stays at zero rather than /// counting events nobody could ever have received anyway. /// [Fact] public void Publish_WithNoSubscribers_IsNoOp_AndDoesNotCountAsDropped() { _streamManager.PublishAlarmStateChanged(new AlarmStateChanged( "Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow)); _streamManager.PublishAttributeValueChanged(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "1", "Good", DateTimeOffset.UtcNow)); Assert.Equal(0, _streamManager.AlarmPublishDroppedCount); // Publishing resumes working normally once a subscriber exists. var probe = CreateTestProbe(); _streamManager.SubscribeSiteAlarms(probe.Ref); _streamManager.PublishAlarmStateChanged(new AlarmStateChanged( "Pump1", "HighTemp", AlarmState.Active, 2, DateTimeOffset.UtcNow)); probe.ExpectMsg(TimeSpan.FromSeconds(3)); } /// /// WP2.6d: once the bounded alarm hand-off queue (the exact factory /// wires into the drop-count callback) is /// full, a further write evicts the oldest queued item and fires the drop callback. /// Exercised directly against the queue factory — deterministic, no actor /// system/async pump race involved (see the factory's own doc comment). /// [Fact] public void CreateAlarmPublishQueue_OverCapacity_DropsOldestAndInvokesCallback() { var dropped = 0; var queue = SiteStreamManager.CreateAlarmPublishQueue(capacity: 2, () => Interlocked.Increment(ref dropped)); // Nothing reads from this queue, so all five writes stay purely upstream — // capacity 2 means the 3rd/4th/5th writes must each evict the oldest. for (var i = 0; i < 5; i++) { var wrote = queue.Writer.TryWrite(new AlarmStateChanged( "Pump1", "HighTemp", AlarmState.Active, i, DateTimeOffset.UtcNow)); Assert.True(wrote); // DropOldest TryWrite always succeeds once capacity > 0 } Assert.Equal(3, dropped); Assert.Equal(2, queue.Reader.Count); } }