From d6909207a86df6f47fbc4362fee366acc6e5403f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 13:20:20 -0400 Subject: [PATCH] fix(deploy): wire native-alarm-source capability validation into flattening pipeline (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlatteningPipeline loaded data connections but never passed the alarm-capable connection set to SemanticValidator, so the native-alarm-source capability check (built but inert) never ran — a source bound to a non-alarm-capable connection deployed silently. Compute the capable set (IAlarmSubscribableConnection: OPC UA + MxGateway) and thread it through ValidationService to SemanticValidator. --- .../Protocol/AlarmCapableProtocols.cs | 31 ++++++ .../FlatteningPipeline.cs | 13 ++- .../Validation/ValidationService.cs | 14 ++- ...eningPipelineNativeAlarmCapabilityTests.cs | 96 +++++++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/AlarmCapableProtocols.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/AlarmCapableProtocols.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/AlarmCapableProtocols.cs new file mode 100644 index 00000000..e7bfc151 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/AlarmCapableProtocols.cs @@ -0,0 +1,31 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; + +/// +/// Single source of truth for which data-connection protocol strings produce an +/// adapter that implements (i.e. can +/// mirror native alarms). +/// +/// The set MUST stay in sync with the protocols registered against an +/// alarm-subscribable adapter in the DCL DataConnectionFactory: today the +/// "OpcUa" adapter (OpcUaDataConnection) and the "MxGateway" adapter +/// (MxGatewayDataConnection) both implement +/// . The runtime decision is made in +/// DataConnectionActor via _adapter is IAlarmSubscribableConnection; +/// this central-side helper lets the deploy pipeline and Central UI gate +/// native-alarm-source bindings against the same notion without instantiating an +/// adapter. Adding a new alarm-capable protocol = register the adapter in the +/// factory AND add its protocol string here. +/// +public static class AlarmCapableProtocols +{ + /// + /// Determines whether a data connection's protocol string resolves to an + /// alarm-capable adapter (one implementing ). + /// Case-insensitive; null/blank is not alarm-capable. + /// + /// The data connection protocol string (e.g. "OpcUa"). + /// true when the protocol's adapter can subscribe native alarms; otherwise false. + public static bool IsAlarmCapable(string? protocol) => + string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase) + || string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs index 6cf5be21..39ccbf68 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; @@ -111,8 +112,18 @@ public class FlatteningPipeline : IFlatteningPipeline ReturnDefinition = s.ReturnDefinition }).ToList(); + // Compute the alarm-capable connection-name set so the semantic validator + // can gate native-alarm-source bindings. "Alarm-capable" matches the DCL + // runtime decision (DataConnectionActor: _adapter is IAlarmSubscribableConnection), + // mapped from the protocol string via the shared AlarmCapableProtocols helper. + var alarmCapableConnectionNames = dataConnections.Values + .Where(c => AlarmCapableProtocols.IsAlarmCapable(c.Protocol)) + .Select(c => c.Name) + .ToHashSet(StringComparer.Ordinal); + // Validate - var validation = _validationService.Validate(config, resolvedSharedScripts); + var validation = _validationService.Validate( + config, resolvedSharedScripts, alarmCapableConnectionNames); // Compute revision hash var hash = _revisionHashService.ComputeHash(config); diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs index 039dbeb1..7093f506 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs @@ -45,8 +45,18 @@ public class ValidationService /// /// The flattened configuration to validate. /// Optional list of shared scripts for validation context. + /// + /// Optional set of site data-connection names whose protocol resolves to an + /// alarm-capable adapter (see + /// ). When supplied, + /// the semantic validator gates every native-alarm-source binding against it. + /// null skips the capability check (its absence makes the check inert). + /// /// A merged aggregating all pipeline stage outcomes. - public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null) + public ValidationResult Validate( + FlattenedConfiguration configuration, + IReadOnlyList? sharedScripts = null, + IReadOnlySet? alarmCapableConnectionNames = null) { ArgumentNullException.ThrowIfNull(configuration); @@ -59,7 +69,7 @@ public class ValidationService ValidateScriptTriggerReferences(configuration), ValidateExpressionTriggers(configuration), ValidateConnectionBindingCompleteness(configuration), - _semanticValidator.Validate(configuration, sharedScripts) + _semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames) }; return ValidationResult.Merge(results.ToArray()); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs new file mode 100644 index 00000000..b0d6a4de --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs @@ -0,0 +1,96 @@ +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; + +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; + +/// +/// M2.1 (#22): proves the FlatteningPipeline actually computes the alarm-capable +/// connection set from the loaded site data connections and threads it through +/// ValidationService → SemanticValidator. Before the fix the pipeline loaded the +/// connections but never passed the capable set, so the native-alarm-source +/// capability check (built but inert) never ran in production — a source bound to +/// a non-alarm-capable connection deployed silently. +/// +public class FlatteningPipelineNativeAlarmCapabilityTests +{ + private const int InstanceId = 1; + private const int TemplateId = 10; + private const int SiteId = 100; + + private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly FlatteningPipeline _sut; + + public FlatteningPipelineNativeAlarmCapabilityTests() + { + _sut = new FlatteningPipeline( + _templateRepo, + _siteRepo, + new FlatteningService(), + new ValidationService(), + new RevisionHashService()); + } + + /// + /// Seeds a single-template chain whose only template carries one native alarm + /// source bound to , and a site that owns a + /// single data connection of . + /// + private void Arrange(string connectionName, string connectionProtocol, string boundConnectionName) + { + var template = new Template("Tank") { Id = TemplateId }; + template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("BoilerAlarms") + { + ConnectionName = boundConnectionName, + SourceReference = "ns=2;s=Boiler", + }); + + var instance = new Instance("Tank-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId }; + + _templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any()).Returns(instance); + _templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any()).Returns(template); + _templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any()) + .Returns([]); + _templateRepo.GetAllSharedScriptsAsync(Arg.Any()) + .Returns([]); + + var connection = new DataConnection(connectionName, connectionProtocol, SiteId) { Id = 7 }; + _siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any()) + .Returns([connection]); + } + + [Fact] + public async Task FlattenAndValidate_NativeAlarmSourceOnNonAlarmCapableConnection_ReportsCapabilityError() + { + // A "Modbus" connection is NOT alarm-capable (no IAlarmSubscribableConnection adapter). + Arrange(connectionName: "PlantBus", connectionProtocol: "Modbus", boundConnectionName: "PlantBus"); + + var result = await _sut.FlattenAndValidateAsync(InstanceId); + + Assert.True(result.IsSuccess); + Assert.Contains(result.Value.Validation.Errors, + e => e.Category == ValidationCategory.NativeAlarmSourceInvalid + && e.Message.Contains("alarm-capable")); + } + + [Theory] + [InlineData("OpcUa")] + [InlineData("MxGateway")] + public async Task FlattenAndValidate_NativeAlarmSourceOnAlarmCapableConnection_NoCapabilityError(string protocol) + { + Arrange(connectionName: "Boiler", connectionProtocol: protocol, boundConnectionName: "Boiler"); + + var result = await _sut.FlattenAndValidateAsync(InstanceId); + + Assert.True(result.IsSuccess); + Assert.DoesNotContain(result.Value.Validation.Errors, + e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); + } +}