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.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; /// /// Review 2026-10-21 01/SR-U1: persisted static overrides were applied to the Instance Actor's /// own _attributes but never republished. The Script/Alarm children are created in /// PreStart BEFORE the async override load lands, so their snapshots were seeded with the /// deploy default — and static attributes never re-emit a change — so after a restart, failover, /// enable or sweep start every trigger evaluated against the default, indefinitely. /// public class InstanceActorOverrideRepublishTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; /// Creates a temporary LocalDb-backed site store per test. public InstanceActorOverrideRepublishTests() { _localDb = TestLocalDb.CreateTemp("instance-override-republish"); _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService(NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } /// void IDisposable.Dispose() { Shutdown(); var path = _localDb.Path; _localDb.Dispose(); TestLocalDb.DeleteFiles(path); } /// /// Mode defaults to Manual; the runtime override is Auto. An Expression /// alarm on Mode == "Auto" && Temp > 100. /// private static FlattenedConfiguration ModeAlarmConfig(string instanceName) => new() { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "Mode", Value = "Manual", DataType = "String" }, new ResolvedAttribute { CanonicalName = "Temp", Value = "50", DataType = "Int32" } ], Alarms = [ new ResolvedAlarm { CanonicalName = "AutoOverTemp", TriggerType = "Expression", TriggerConfiguration = JsonSerializer.Serialize(new { expression = "\"Auto\".Equals(Attributes[\"Mode\"]) && int.Parse(Attributes[\"Temp\"].ToString()) > 100" }), PriorityLevel = 500 } ] }; private IActorRef StartInstance(string instanceName, FlattenedConfiguration config) => ActorOf(Props.Create(() => new InstanceActor( instanceName, JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger.Instance))); private AlarmState AlarmStateOf(IActorRef instance, string instanceName, string alarm) { instance.Tell(new GetAlarmSnapshotRequest("snap", instanceName, DateTimeOffset.UtcNow)); var snapshot = ExpectMsg(TimeSpan.FromSeconds(5)); return snapshot.Alarms.Single(a => a.AlarmName == alarm).State; } /// /// The finding's regression: deploy → override → restart → the Expression alarm sees the /// override once another attribute changes. Before the fix the child snapshot still held /// Manual, so the alarm never raised. /// /// A task that represents the asynchronous operation. [Fact] public async Task PersistedOverride_ReachesExpressionAlarm_AfterActorRestart() { const string name = "PumpU1"; await _storage.SetStaticOverrideAsync(name, "Mode", "Auto"); var instance = StartInstance(name, ModeAlarmConfig(name)); // The override load is async; GetAttribute is the authoritative view. Generous timeouts: // a cold Roslyn compile of the trigger expression runs inside PreStart. AwaitAssert(() => { instance.Tell(new GetAttributeRequest("g", name, "Mode", DateTimeOffset.UtcNow)); Assert.Equal("Auto", ExpectMsg(TimeSpan.FromSeconds(15)).Value?.ToString()); }, TimeSpan.FromSeconds(30)); instance.Tell(new SetStaticAttributeCommand("w", name, "Temp", "150", DateTimeOffset.UtcNow)); Assert.True(ExpectMsg().Success); AwaitAssert(() => Assert.Equal(AlarmState.Active, AlarmStateOf(instance, name, "AutoOverTemp")), TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100)); } /// /// A static write that lands BEFORE the async override load is newer than the stored /// override, so the load must not overwrite (or republish over) it. /// /// A task that represents the asynchronous operation. [Fact] public async Task WriteBeforeOverrideLoad_IsNotOverwrittenByTheOlderStoredValue() { const string name = "PumpU1Race"; await _storage.SetStaticOverrideAsync(name, "Mode", "Auto"); var gate = new TaskCompletionSource(); // A long flush window keeps the write dirty in memory, so the store still holds the // OLDER "Auto" when the (gated) load finally reads it. var flushWindow = TimeSpan.FromMinutes(1); var configJson = JsonSerializer.Serialize(ModeAlarmConfig(name)); var instance = ActorOf(Props.Create(() => new InstanceActor( name, configJson, _storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger.Instance, null, null, null, flushWindow, gate.Task))); instance.Tell(new SetStaticAttributeCommand("w", name, "Mode", "Maintenance", DateTimeOffset.UtcNow)); Assert.True(ExpectMsg(TimeSpan.FromSeconds(15)).Success); gate.SetResult(); await Task.Delay(500); instance.Tell(new GetAttributeRequest("g", name, "Mode", DateTimeOffset.UtcNow)); Assert.Equal("Maintenance", ExpectMsg().Value?.ToString()); } /// /// Review 2026-10-21 01/SR-M3: stopping the instance answers every pending /// WaitAsync at once ("instance stopped") instead of leaving the script to burn its /// whole wait timeout plus the Ask timeout, and leaves no timeout self-message to dead-letter. /// [Fact] public void Stop_AnswersPendingAttributeWaiters_Immediately() { const string name = "PumpM3"; var instance = StartInstance(name, ModeAlarmConfig(name)); var waiter = CreateTestProbe(); instance.Tell(new WaitForAttributeRequest( "wait-1", name, "Mode", "\"NeverReached\"", null, TimeSpan.FromMinutes(5), DateTimeOffset.UtcNow), waiter.Ref); // A round trip proves the waiter is registered before the stop. instance.Tell(new GetAttributeRequest("g", name, "Mode", DateTimeOffset.UtcNow)); ExpectMsg(TimeSpan.FromSeconds(15)); Sys.Stop(instance); var response = waiter.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("wait-1", response.CorrelationId); Assert.False(response.Matched); Assert.False(response.TimedOut); Assert.Contains("instance stopped", response.ErrorMessage); } private static ResolvedScript FiringScript(string name, string triggerType, object triggerConfig, string flag) => new() { CanonicalName = name, TriggerType = triggerType, TriggerConfiguration = JsonSerializer.Serialize(triggerConfig), Code = $"await Instance.SetAttribute(\"{flag}\", \"1\"); return null;" }; private string AttributeOf(IActorRef instance, string instanceName, string attribute) { instance.Tell(new GetAttributeRequest("g", instanceName, attribute, DateTimeOffset.UtcNow)); return ExpectMsg(TimeSpan.FromSeconds(15)).Value?.ToString() ?? ""; } /// /// User decision M1: an override seeded at actor start updates trigger state but does NOT run /// ValueChange / Conditional / Expression scripts; an Expression ALARM still raises; and a /// later real change fires all three scripts. /// /// A task that represents the asynchronous operation. [Fact] public async Task SeededOverride_RaisesAlarm_ButFiresNoScript_UntilARealChange() { const string name = "PumpSeed"; await _storage.SetStaticOverrideAsync(name, "Level", "50"); const string over20 = "int.Parse(Attributes[\"Level\"].ToString()) > 20"; var config = new FlattenedConfiguration { InstanceUniqueName = name, Attributes = [ new ResolvedAttribute { CanonicalName = "Level", Value = "10", DataType = "Int32" }, new ResolvedAttribute { CanonicalName = "FiredVC", Value = "0", DataType = "String" }, new ResolvedAttribute { CanonicalName = "FiredCond", Value = "0", DataType = "String" }, new ResolvedAttribute { CanonicalName = "FiredExpr", Value = "0", DataType = "String" } ], Scripts = [ FiringScript("VC", "ValueChange", new { attributeName = "Level" }, "FiredVC"), FiringScript("Cond", "Conditional", new { attributeName = "Level", @operator = ">", threshold = 20 }, "FiredCond"), FiringScript("Expr", "Expression", new { expression = over20 }, "FiredExpr") ], Alarms = [ new ResolvedAlarm { CanonicalName = "LevelHigh", TriggerType = "Expression", PriorityLevel = 500, TriggerConfiguration = JsonSerializer.Serialize(new { expression = over20 }) } ] }; var instance = StartInstance(name, config); // The alarm raises from the seeded value alone — no other write. AwaitAssert(() => Assert.Equal(AlarmState.Active, AlarmStateOf(instance, name, "LevelHigh")), TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); Assert.Equal("50", AttributeOf(instance, name, "Level")); await Task.Delay(TimeSpan.FromSeconds(1.5)); Assert.Equal("0", AttributeOf(instance, name, "FiredVC")); Assert.Equal("0", AttributeOf(instance, name, "FiredCond")); Assert.Equal("0", AttributeOf(instance, name, "FiredExpr")); // Real changes fire them: down (Expression edge resets) then up again. instance.Tell(new SetStaticAttributeCommand("w1", name, "Level", "5", DateTimeOffset.UtcNow)); ExpectMsg(TimeSpan.FromSeconds(15)); await Task.Delay(500); instance.Tell(new SetStaticAttributeCommand("w2", name, "Level", "60", DateTimeOffset.UtcNow)); ExpectMsg(TimeSpan.FromSeconds(15)); AwaitAssert(() => { Assert.Equal("1", AttributeOf(instance, name, "FiredVC")); Assert.Equal("1", AttributeOf(instance, name, "FiredCond")); Assert.Equal("1", AttributeOf(instance, name, "FiredExpr")); }, TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(200)); } }