feat(site-runtime): stamp IsMesReportable on the Instance Actor alarm snapshot

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-11 09:59:27 -04:00
parent 735534a455
commit cbb5e83633
3 changed files with 197 additions and 2 deletions
@@ -0,0 +1,163 @@
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;
/// <summary>
/// The deployed alarm's <c>IsMesReportable</c> flag must reach the site-local alarm
/// snapshot that <c>Alarms.CurrentAsync()</c> and the Debug View query both build (the
/// flag does not cross <c>DebugAlarmStateDto</c> to central in this slice). It is stamped in ONE
/// place — <c>BuildAlarmStatesSnapshot</c> — so a computed alarm that has fired (served
/// from the retained <c>AlarmStateChanged</c>) and one that never has (served from the
/// bare Normal fallback) can never disagree about the flag.
/// </summary>
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<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) =>
ActorOf(Props.Create(() => new InstanceActor(
instanceName,
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
null)));
/// <summary>
/// One static attribute driving two computed alarms: <c>HighTemp</c> is flagged
/// MES-reportable and fires at 80; <c>TempSpike</c> is not flagged and only matches
/// the exact value 100, so it never fires in this test.
/// </summary>
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<GetAlarmSnapshotResponse>(
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<SetStaticAttributeResponse>(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);
}
}