using System.Linq; using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Byte-parity tests for the scripted-alarm deployment-artifact decode path /// (DeploymentArtifact.BuildEquipmentScriptedAlarmPlans) against the live compose seam /// (Phase7Composer.Compose). Both sides derive EquipmentScriptedAlarmPlan from the /// same ScriptedAlarm + Script data via the shared EquipmentScriptPaths.ExtractAlarmDependencyRefs /// helper, so the decoded plans must equal the composer's element-wise (the record has value /// equality including DependencyRefs order). Mirrors the existing EquipmentVirtualTags parity /// style in DeploymentArtifactEquipTokenTests. /// public sealed class DeploymentArtifactScriptedAlarmParityTests { private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot); /// The live composer's plan for the same scripted alarms + scripts the artifact carries /// must equal the artifact-decode plan, field-for-field including DependencyRefs order. [Fact] public void ParseComposition_scripted_alarms_are_byte_parity_with_composer() { // Two equipments, each with a scripted alarm: predicate reads a tag via ctx.GetTag("X.Y"), // and the message template carries a distinct {A.B} token — so DependencyRefs exercise the // predicate-reads-then-template-tokens merge order on both sides. var script1 = new Script { ScriptId = "s-1", Name = "hot-1", SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;", SourceHash = "h1", }; var script2 = new Script { ScriptId = "s-2", Name = "hot-2", SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach2.Temp\").Value) > 80;", SourceHash = "h2", }; var alarm1 = new ScriptedAlarm { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Overheat-1", AlarmType = "LimitAlarm", Severity = 700, MessageTemplate = "Machine 1 at {Mach1.Pressure}", PredicateScriptId = "s-1", HistorizeToAveva = true, Retain = true, Enabled = true, }; var alarm2 = new ScriptedAlarm { ScriptedAlarmId = "al-2", EquipmentId = "eq-2", Name = "Overheat-2", AlarmType = "OffNormalAlarm", Severity = 250, MessageTemplate = "Machine 2 at {Mach2.Pressure}", PredicateScriptId = "s-2", HistorizeToAveva = false, Retain = false, Enabled = false, }; var composed = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { alarm1, alarm2 }, Array.Empty(), Array.Empty(), virtualTags: Array.Empty(), scripts: new[] { script1, script2 }); // The artifact blob the write side (ConfigComposer) emits: the FULL ScriptedAlarm + Script // entities serialised Pascal-case off the EF entity. var blob = BlobOf(new { ScriptedAlarms = new[] { ToSnapshot(alarm1), ToSnapshot(alarm2), }, Scripts = new[] { new { ScriptId = script1.ScriptId, SourceCode = script1.SourceCode }, new { ScriptId = script2.ScriptId, SourceCode = script2.SourceCode }, }, }); var decoded = DeploymentArtifact.ParseComposition(blob); // Byte-parity: element-wise value equality (record equality compares DependencyRefs order). decoded.EquipmentScriptedAlarms.Count.ShouldBe(2); decoded.EquipmentScriptedAlarms.SequenceEqual(composed.EquipmentScriptedAlarms).ShouldBeTrue(); // And spot-check the merged DependencyRefs (predicate read first, then template token). var plan1 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1"); plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp", "Mach1.Pressure" }); plan1.Enabled.ShouldBeTrue(); var plan2 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2"); plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp", "Mach2.Pressure" }); plan2.Enabled.ShouldBeFalse(); plan2.HistorizeToAveva.ShouldBeFalse(); plan2.Retain.ShouldBeFalse(); } /// The cluster-scoped overload keeps only the scripted alarms whose EquipmentId belongs to /// an in-cluster driver (mirroring how EquipmentVirtualTags + EquipmentTags are filtered). [Fact] public void ParseComposition_scoped_keeps_only_my_clusters_scripted_alarms() { var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" }, new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, }, DriverInstances = new[] { new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, }, Equipment = new[] { new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" }, new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" }, }, Scripts = new[] { new { ScriptId = "scr", SourceCode = "return ctx.GetTag(\"A.X\").Value;" }, }, ScriptedAlarms = new[] { NewAlarmSnapshot("al-main", "eq-main", "scr"), NewAlarmSnapshot("al-sa", "eq-sa", "scr"), }, }); var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053"); main.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-main" }); var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053"); siteA.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-sa" }); } /// An alarm whose PredicateScriptId matches no Script row is SKIPPED on BOTH the composer /// and the artifact-decode side, so parity holds for the surviving alarm. [Fact] public void ParseComposition_skips_alarm_with_missing_predicate_script_matching_composer() { var goodScript = new Script { ScriptId = "s-ok", Name = "ok", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1", }; var goodAlarm = new ScriptedAlarm { ScriptedAlarmId = "al-ok", EquipmentId = "eq-1", Name = "Ok", AlarmType = "AlarmCondition", Severity = 500, MessageTemplate = "ok", PredicateScriptId = "s-ok", HistorizeToAveva = true, Retain = true, Enabled = true, }; var orphanAlarm = new ScriptedAlarm { ScriptedAlarmId = "al-orphan", EquipmentId = "eq-2", Name = "Orphan", AlarmType = "AlarmCondition", Severity = 500, MessageTemplate = "orphan", PredicateScriptId = "s-does-not-exist", HistorizeToAveva = true, Retain = true, Enabled = true, }; var composed = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { goodAlarm, orphanAlarm }, Array.Empty(), Array.Empty(), scripts: new[] { goodScript }); var blob = BlobOf(new { ScriptedAlarms = new[] { ToSnapshot(goodAlarm), ToSnapshot(orphanAlarm) }, Scripts = new[] { new { ScriptId = goodScript.ScriptId, SourceCode = goodScript.SourceCode } }, }); var decoded = DeploymentArtifact.ParseComposition(blob); // The orphan is dropped on both sides; the survivor's plan is byte-identical. var composedPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem(); composedPlan.ScriptedAlarmId.ShouldBe("al-ok"); var decodedPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem(); decodedPlan.ScriptedAlarmId.ShouldBe("al-ok"); decodedPlan.ShouldBe(composedPlan); } // The full Pascal-case snapshot a ScriptedAlarm EF entity serialises to (matches ConfigComposer). private static object ToSnapshot(ScriptedAlarm a) => new { a.ScriptedAlarmId, a.EquipmentId, a.Name, a.AlarmType, a.Severity, a.MessageTemplate, a.PredicateScriptId, a.HistorizeToAveva, a.Retain, a.Enabled, }; private static object NewAlarmSnapshot(string id, string equipmentId, string scriptId) => new { ScriptedAlarmId = id, EquipmentId = equipmentId, Name = id, AlarmType = "AlarmCondition", Severity = 500, MessageTemplate = $"{id} alarm", PredicateScriptId = scriptId, HistorizeToAveva = true, Retain = true, Enabled = true, }; }