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); } }