using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// The deployed alarm's IsMesReportable flag must reach the site-local alarm /// snapshot that Alarms.CurrentAsync() and the Debug View query both build (the /// flag does not cross DebugAlarmStateDto to central in this slice). It is stamped in ONE /// place — BuildAlarmStatesSnapshot — so a computed alarm that has fired (served /// from the retained AlarmStateChanged) and one that never has (served from the /// bare Normal fallback) can never disagree about the flag. /// public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options = new(); private readonly TestLocalDb _localDb; public InstanceActorMesReportableSnapshotTests() { _localDb = TestLocalDb.CreateTemp("instance-mes-reportable"); _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService(NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance); } private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) => ActorOf(Props.Create(() => new InstanceActor( instanceName, JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, _options, NullLogger.Instance, null))); /// /// One static attribute driving two computed alarms: HighTemp is flagged /// MES-reportable and fires at 80; TempSpike is not flagged and only matches /// the exact value 100, so it never fires in this test. /// private static FlattenedConfiguration TwoComputedAlarmsConfig(string instanceName) => new() { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "20", DataType = "Double" } ], Alarms = [ new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "HiLo", TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"hi\":80}", PriorityLevel = 700, IsMesReportable = true }, new ResolvedAlarm { CanonicalName = "TempSpike", TriggerType = "ValueMatch", TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}", PriorityLevel = 300, IsMesReportable = false } ] }; private GetAlarmSnapshotResponse AskSnapshot(IActorRef actor, string instanceName) { var correlationId = Guid.NewGuid().ToString(); actor.Tell(new GetAlarmSnapshotRequest(correlationId, instanceName, DateTimeOffset.UtcNow)); // Match on the correlation id so a late reply from a timed-out probe can never be // consumed as the "current" snapshot by a later call; keep the inner wait well // inside the AwaitAssert budget so one slow probe doesn't eat the whole retry window. return ExpectMsg( m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1)); } [Fact] public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms() { const string instance = "Oven1"; var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance)); // Drive Temperature above the Hi setpoint: HighTemp fires, TempSpike does not. actor.Tell(new SetStaticAttributeCommand( "corr-temp", instance, "Temperature", "90", DateTimeOffset.UtcNow)); var write = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(write.Success); // The alarm child evaluates asynchronously — poll the snapshot until the // retained event for HighTemp has landed. AwaitAssert(() => { var pending = AskSnapshot(actor, instance); var firing = Assert.Single(pending.Alarms, a => a.AlarmName == "HighTemp"); Assert.Equal(AlarmState.Active, firing.State); }, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100)); var snapshot = AskSnapshot(actor, instance); // Both rows are still returned — stamping must not drop or duplicate anything. Assert.Equal(2, snapshot.Alarms.Count); // Fired alarm: served from the retained AlarmStateChanged. var fired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp"); Assert.Equal(AlarmState.Active, fired.State); Assert.True(fired.IsMesReportable); // Never-fired alarm: served from the bare Normal fallback projection. var unfired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike"); Assert.Equal(AlarmState.Normal, unfired.State); Assert.False(unfired.IsMesReportable); } [Fact] public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires() { // The fallback branch must carry the flag on its own: an alarm that has never // emitted an event is still MES-reportable per its deployed configuration. const string instance = "Oven2"; var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance)); var snapshot = AskSnapshot(actor, instance); Assert.Equal(2, snapshot.Alarms.Count); Assert.True(Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp").IsMesReportable); Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable); } void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); var path = _localDb.Path; _localDb.Dispose(); TestLocalDb.DeleteFiles(path); } }