Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorOverrideRepublishTests.cs
T
Joseph Doherty 3c11510c52 fix(site-runtime): seed republished overrides into Script Actors without firing scripts; alarms still evaluate (review 2026-10-21 01/SR-U1, decision M1)
HandleOverridesLoaded now sends children a site-local AttributeValueSeed
(stream still gets the AttributeValueChanged). ScriptActor updates its
snapshot, Conditional truth value and Expression last result (seed-only eval,
never fires); AlarmActor evaluates it as a normal change.
2026-10-22 09:10:26 -04:00

257 lines
12 KiB
C#

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;
/// <summary>
/// Review 2026-10-21 01/SR-U1: persisted static overrides were applied to the Instance Actor's
/// own <c>_attributes</c> but never republished. The Script/Alarm children are created in
/// <c>PreStart</c> 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.
/// </summary>
public class InstanceActorOverrideRepublishTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site store per test.</summary>
public InstanceActorOverrideRepublishTests()
{
_localDb = TestLocalDb.CreateTemp("instance-override-republish");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
/// <summary>
/// <c>Mode</c> defaults to <c>Manual</c>; the runtime override is <c>Auto</c>. An Expression
/// alarm on <c>Mode == "Auto" &amp;&amp; Temp &gt; 100</c>.
/// </summary>
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<InstanceActor>.Instance)));
private AlarmState AlarmStateOf(IActorRef instance, string instanceName, string alarm)
{
instance.Tell(new GetAlarmSnapshotRequest("snap", instanceName, DateTimeOffset.UtcNow));
var snapshot = ExpectMsg<GetAlarmSnapshotResponse>(TimeSpan.FromSeconds(5));
return snapshot.Alarms.Single(a => a.AlarmName == alarm).State;
}
/// <summary>
/// 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
/// <c>Manual</c>, so the alarm never raised.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<GetAttributeResponse>(TimeSpan.FromSeconds(15)).Value?.ToString());
}, TimeSpan.FromSeconds(30));
instance.Tell(new SetStaticAttributeCommand("w", name, "Temp", "150", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>().Success);
AwaitAssert(() => Assert.Equal(AlarmState.Active, AlarmStateOf(instance, name, "AutoOverTemp")),
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100));
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<InstanceActor>.Instance,
null, null, null, flushWindow, gate.Task)));
instance.Tell(new SetStaticAttributeCommand("w", name, "Mode", "Maintenance", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(15)).Success);
gate.SetResult();
await Task.Delay(500);
instance.Tell(new GetAttributeRequest("g", name, "Mode", DateTimeOffset.UtcNow));
Assert.Equal("Maintenance", ExpectMsg<GetAttributeResponse>().Value?.ToString());
}
/// <summary>
/// Review 2026-10-21 01/SR-M3: stopping the instance answers every pending
/// <c>WaitAsync</c> 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.
/// </summary>
[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<GetAttributeResponse>(TimeSpan.FromSeconds(15));
Sys.Stop(instance);
var response = waiter.ExpectMsg<WaitForAttributeResponse>(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<GetAttributeResponse>(TimeSpan.FromSeconds(15)).Value?.ToString() ?? "";
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<SetStaticAttributeResponse>(TimeSpan.FromSeconds(15));
await Task.Delay(500);
instance.Tell(new SetStaticAttributeCommand("w2", name, "Level", "60", DateTimeOffset.UtcNow));
ExpectMsg<SetStaticAttributeResponse>(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));
}
}