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")] // Case variants: IsAlarmCapable uses OrdinalIgnoreCase, matching DataConnectionFactory's // own OrdinalIgnoreCase protocol-key lookup; lock the contract with non-canonical casing. [InlineData("OPCUA")] [InlineData("opcua")] [InlineData("mxgateway")] [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); } }