feat(native-alarms): per-binding IsMesReportable on native alarm source bindings (ScadaBridge#59)

TemplateNativeAlarmSource.IsMesReportable + tri-state InstanceNativeAlarmSourceOverride
.IsMesReportableOverride flatten onto ResolvedNativeAlarmSource; NativeAlarmActor stamps every
mirrored condition and InstanceActor the quiet-binding placeholder, so ScriptAlarm,
Alarms.MesReportableAsync() and the #60 wire fields carry it. Additive to the 900-999 severity band.
Migration AddNativeAlarmSourceMesReportable. Management commands, CLI --mes-reportable, Central UI
(template dialog checkbox, instance override select), Transport DTOs/importer/diff and the revision
hash (true-only, so existing hashes are unchanged) carry it.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:09:54 -05:00
parent 69a2017000
commit f98369a786
38 changed files with 2579 additions and 43 deletions
@@ -33,8 +33,8 @@ public class EditingParityTests
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
[InlineData("template move --id 12", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":null}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source --filter Active --description Updated --locked", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":\"Active\",\"description\":\"Updated\",\"isLocked\":true}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":null,\"description\":null,\"isLocked\":false}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source --filter Active --description Updated --locked --mes-reportable", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":\"Active\",\"description\":\"Updated\",\"isLocked\":true,\"isMesReportable\":true}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":null,\"description\":null,\"isLocked\":false,\"isMesReportable\":false}")]
[InlineData("instance attribute-override delete --instance-id 6 --attribute Module.Value", "DeleteInstanceAttributeOverride", "{\"instanceId\":6,\"attributeName\":\"Module.Value\"}")]
[InlineData("instance native-alarm-source list --instance-id 6", "ListInstanceNativeAlarmSourceOverrides", "{\"instanceId\":6}")]
[InlineData("instance alarm-override list --instance-id 6", "ListInstanceAlarmOverrides", "{\"instanceId\":6}")]
@@ -165,6 +165,36 @@ public class SchemaConfigurationTests : IDisposable
Assert.True((await _context.TemplateAlarms.SingleAsync(a => a.Id == alarm.Id)).IsMesReportable);
Assert.False((await _context.InstanceAlarmOverrides.SingleAsync(o => o.Id == ovr.Id)).IsMesReportableOverride);
}
// Gitea #59: per-binding flag on native alarm sources. Backed on SQL Server by the
// AddNativeAlarmSourceMesReportable migration; pinned here on the model/persist path.
/// <summary>Verifies that native alarm sources and their overrides persist the MES-reportable flags.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NativeAlarmSource_And_Override_PersistMesReportableFlags()
{
var site = new Site("Site59", "S-059");
var template = new Template("MesNativeT1");
_context.Sites.Add(site);
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var instance = new Instance("MesNativeInst1") { SiteId = site.Id, TemplateId = template.Id };
_context.Instances.Add(instance);
await _context.SaveChangesAsync();
var src = new TemplateNativeAlarmSource("Pressure")
{ TemplateId = template.Id, ConnectionName = "Opc", SourceReference = "ns=2;s=P", IsMesReportable = true };
var ovr = new InstanceNativeAlarmSourceOverride("Pressure")
{ InstanceId = instance.Id, IsMesReportableOverride = false };
_context.Add(src);
_context.Add(ovr);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
Assert.True((await _context.Set<TemplateNativeAlarmSource>().SingleAsync(a => a.Id == src.Id)).IsMesReportable);
Assert.False((await _context.Set<InstanceNativeAlarmSourceOverride>().SingleAsync(o => o.Id == ovr.Id)).IsMesReportableOverride);
}
}
public class SplitQueryBehaviourTests : IDisposable
@@ -4706,6 +4706,37 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
}
/// <summary>Gitea #59: the instance override command carries IsMesReportableOverride onto the persisted row.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverride_CarriesIsMesReportableOverride()
{
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
InstanceNativeAlarmSourceOverride? added = null;
_templateRepo
.When(r => r.AddInstanceNativeAlarmSourceOverrideAsync(Arg.Any<InstanceNativeAlarmSourceOverride>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<InstanceNativeAlarmSourceOverride>());
CreateActor().Tell(Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", null, null, null, IsMesReportableOverride: true),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.True(added!.IsMesReportableOverride);
}
/// <summary>Verifies that set instance native alarm source override, when with design role, returns unauthorized.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverride_WithDesignRole_ReturnsUnauthorized()
@@ -50,6 +50,25 @@ public class NativeAlarmSourceNameValidationTests : TestKit
_repo.DidNotReceive().AddTemplateNativeAlarmSourceAsync(Arg.Any<TemplateNativeAlarmSource>(), Arg.Any<CancellationToken>());
}
/// <summary>Gitea #59: the update command carries IsMesReportable onto the persisted binding.</summary>
[Fact]
public void Update_CarriesIsMesReportable()
{
TemplateNativeAlarmSource? updated = null;
_repo.When(r => r.UpdateTemplateNativeAlarmSourceAsync(Arg.Any<TemplateNativeAlarmSource>(), Arg.Any<CancellationToken>()))
.Do(ci => updated = ci.Arg<TemplateNativeAlarmSource>());
_services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.TemplateService>();
CreateActor().Tell(Designer(new UpdateTemplateNativeAlarmSourceCommand(
4, "Pressure", "Opc", "ns=2;s=T01", null, null, false, IsMesReportable: true)));
// The reconcile that follows needs TemplateService collaborators this harness does not
// wire, so only the persisted binding is asserted (the Update call precedes the reconcile).
ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.NotNull(updated);
Assert.True(updated!.IsMesReportable);
}
/// <summary>Renaming a source to an illegal actor name is rejected before any write.</summary>
[Fact]
public void Update_RenameToInvalidActorName_IsRejected()
@@ -51,7 +51,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
NullLogger<InstanceActor>.Instance,
dclManager)));
private static FlattenedConfiguration ConfigWithNativeSource(string instanceName) => new()
private static FlattenedConfiguration ConfigWithNativeSource(string instanceName, bool mesReportable = false) => new()
{
InstanceUniqueName = instanceName,
NativeAlarmSources =
@@ -60,7 +60,8 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
{
CanonicalName = "Pressure",
ConnectionName = "Opc",
SourceReference = "ns=2;s=T01"
SourceReference = "ns=2;s=T01",
IsMesReportable = mesReportable
}
]
};
@@ -100,6 +101,22 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800);
}
/// <summary>Gitea #59: the quiet-binding placeholder row carries the binding's MES flag.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Snapshot_QuietNativeBinding_PlaceholderCarriesBindingMesFlag(bool flag)
{
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst", flag), dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new SubscribeDebugViewRequest("inst", "c"));
var snap = ExpectMsg<DebugViewSnapshot>();
Assert.Equal(flag, Assert.Single(snap.AlarmStates, a => a.IsConfiguredPlaceholder).IsMesReportable);
}
/// <summary>Verifies that a quiet native binding emits a placeholder in the snapshot.</summary>
[Fact]
public void Snapshot_QuietNativeBinding_EmitsPlaceholder()
@@ -36,11 +36,12 @@ public class NativeAlarmActorTests : TestKit, IDisposable
_storage.InitializeAsync().GetAwaiter().GetResult();
}
private static ResolvedNativeAlarmSource Source() => new()
private static ResolvedNativeAlarmSource Source(bool mesReportable = false) => new()
{
CanonicalName = "Pressure",
ConnectionName = "Opc",
SourceReference = "ns=2;s=T01"
SourceReference = "ns=2;s=T01",
IsMesReportable = mesReportable
};
private static NativeAlarmTransition Transition(
@@ -48,6 +49,11 @@ public class NativeAlarmActorTests : TestKit, IDisposable
new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition,
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
private IActorRef SpawnMes(IActorRef instanceActor, IActorRef dclManager, bool mesReportable) =>
ActorOf(Props.Create(() => new NativeAlarmActor(
Source(mesReportable), "inst", instanceActor, dclManager, _storage, _options,
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, null)));
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, IServiceProvider? serviceProvider = null) =>
Spawn(instanceActor, dclManager, _options, serviceProvider);
@@ -157,6 +163,25 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal("Pressure", emitted.NativeSourceCanonicalName);
}
/// <summary>Gitea #59: a binding flagged MES-reportable stamps every condition it mirrors; an unflagged one does not.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Raise_StampsIsMesReportable_FromBindingFlag(bool bindingFlag)
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = SpawnMes(instance.Ref, dcl.Ref, bindingFlag);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
// Severity 100 is OUTSIDE the 900-999 band, so only the binding flag can set it.
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 100))));
Assert.Equal(bindingFlag, instance.ExpectMsg<AlarmStateChanged>().IsMesReportable);
}
/// <summary>Verifies that a snapshot completing with a missing prior alarm emits a return to normal.</summary>
[Fact]
public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal()
@@ -952,6 +952,31 @@ public class FlatteningServiceTests
Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source);
}
/// <summary>Gitea #59: the binding flag flattens from the template and a tri-state instance override wins (null inherits).</summary>
[Theory]
[InlineData(true, null, true)]
[InlineData(false, null, false)]
[InlineData(false, true, true)]
[InlineData(true, false, false)]
public void Flatten_NativeAlarmSource_IsMesReportable_TemplateFlagAndOverride(bool templateFlag, bool? ovr, bool expected)
{
var template = CreateTemplate(1, "Base");
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure")
{ ConnectionName = "Opc", SourceReference = "ns=2;s=P1", IsMesReportable = templateFlag });
var instance = CreateInstance();
if (ovr.HasValue)
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Pressure")
{ IsMesReportableOverride = ovr });
var result = _sut.Flatten(instance, [template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
Assert.Equal(expected, result.Value.NativeAlarmSources[0].IsMesReportable);
}
/// <summary>Verifies that a locked native alarm source ignores an instance override.</summary>
[Fact]
public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride()
@@ -413,6 +413,29 @@ public class RevisionHashServiceTests
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
/// <summary>Gitea #59: the binding's MES-reportable flag participates in the hash; false hashes as before the field existed.</summary>
[Fact]
public void ComputeHash_NativeAlarmSourceIsMesReportableChange_ChangesHash()
{
var baseSource = new ResolvedNativeAlarmSource
{
CanonicalName = "Boiler.Alarms",
ConnectionName = "plc1",
SourceReference = "ns=2;s=Boiler"
};
var configFalse = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1", TemplateId = 1, SiteId = 1,
NativeAlarmSources = [baseSource]
};
var configTrue = configFalse with { NativeAlarmSources = [baseSource with { IsMesReportable = true }] };
Assert.NotEqual(_sut.ComputeHash(configFalse), _sut.ComputeHash(configTrue));
// false is omitted from the canonical JSON, so adding the field cannot invalidate stored hashes.
Assert.Equal(_sut.ComputeHash(configFalse),
_sut.ComputeHash(configFalse with { NativeAlarmSources = [baseSource with { IsMesReportable = false }] }));
}
/// <summary>Verifies that changing a native alarm source's locked flag changes the hash.</summary>
[Fact]
public void ComputeHash_NativeAlarmSourceIsLockedChange_ChangesHash()
@@ -227,6 +227,7 @@ public sealed class EntitySerializerTests
IsLocked = true,
IsInherited = true,
LockedInDerived = true,
IsMesReportable = true,
});
var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };
@@ -242,6 +243,7 @@ public sealed class EntitySerializerTests
Assert.True(dtoSrc.IsLocked);
Assert.True(dtoSrc.IsInherited);
Assert.True(dtoSrc.LockedInDerived);
Assert.True(dtoSrc.IsMesReportable);
var rtSrc = Assert.Single(Assert.Single(sut.FromBundleContent(dto).Templates).NativeAlarmSources);
Assert.Equal("Boiler", rtSrc.Name);
@@ -252,6 +254,27 @@ public sealed class EntitySerializerTests
Assert.True(rtSrc.IsLocked);
Assert.True(rtSrc.IsInherited);
Assert.True(rtSrc.LockedInDerived);
Assert.True(rtSrc.IsMesReportable);
}
/// <summary>Gitea #59: an instance native-source override round-trips its tri-state MES flag.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
[InlineData(null)]
public void Roundtrip_instance_NativeAlarmSourceOverride_preserves_IsMesReportableOverride(bool? flag)
{
var inst = new Instance("I1") { Id = 1, TemplateId = 1, SiteId = 1 };
inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Boiler")
{ InstanceId = 1, IsMesReportableOverride = flag });
var aggregate = MakeEmptyAggregate() with { Instances = new[] { inst } };
var sut = new EntitySerializer();
var dto = sut.ToBundleContent(aggregate);
Assert.Equal(flag, Assert.Single(Assert.Single(dto.Instances).NativeAlarmSourceOverrides).IsMesReportableOverride);
var rt = Assert.Single(sut.FromBundleContent(dto).Instances);
Assert.Equal(flag, Assert.Single(rt.NativeAlarmSourceOverrides).IsMesReportableOverride);
}
/// <summary>Verifies that old-form bundle JSON without the locked-in-derived field deserializes it as false.</summary>