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:
@@ -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()
|
||||
|
||||
+19
@@ -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()
|
||||
|
||||
+19
-2
@@ -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()
|
||||
|
||||
+23
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user