using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Verifies the live-edit compose seam () builds the richer /// per-equipment scripted-alarm projection (): each alarm /// is joined to its predicate for the source, carries the parsed /// ctx.GetTag("…") read refs UNION the {TagPath} tokens referenced in its /// MessageTemplate as DependencyRefs, and preserves every flag the host decides on /// (including disabled alarms). The projection is deterministic so the upcoming /// artifact-byte-parity test (sibling task) is reliable. /// public sealed class Phase7ComposerScriptedAlarmTests { /// Two equipments, each with a scripted alarm whose predicate script reads one tag via /// ctx.GetTag("X.Y"). Each plan must carry the resolved PredicateSource, DependencyRefs /// containing the read path, and every scalar field copied through faithfully. [Fact] public void Compose_emits_richer_scripted_alarm_plan_per_equipment_joined_to_script() { 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 overheating", 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 overheating", PredicateScriptId = "s-2", HistorizeToAveva = false, Retain = false, Enabled = true, }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { alarm1, alarm2 }, Array.Empty(), Array.Empty(), virtualTags: Array.Empty(), scripts: new[] { script1, script2 }); result.EquipmentScriptedAlarms.Count.ShouldBe(2); var plan1 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1"); plan1.EquipmentId.ShouldBe("eq-1"); plan1.Name.ShouldBe("Overheat-1"); plan1.AlarmType.ShouldBe("LimitAlarm"); plan1.Severity.ShouldBe(700); plan1.MessageTemplate.ShouldBe("Machine 1 overheating"); plan1.PredicateScriptId.ShouldBe("s-1"); plan1.PredicateSource.ShouldBe(script1.SourceCode); plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp" }); plan1.HistorizeToAveva.ShouldBeTrue(); plan1.Retain.ShouldBeTrue(); plan1.Enabled.ShouldBeTrue(); var plan2 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2"); plan2.EquipmentId.ShouldBe("eq-2"); plan2.AlarmType.ShouldBe("OffNormalAlarm"); plan2.Severity.ShouldBe(250); plan2.PredicateSource.ShouldBe(script2.SourceCode); plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp" }); plan2.HistorizeToAveva.ShouldBeFalse(); plan2.Retain.ShouldBeFalse(); } /// The {TagPath} tokens in the MessageTemplate are merged into DependencyRefs /// alongside the predicate's ctx.GetTag reads, so the host can subscribe to every signal /// the alarm references for either the predicate or the message. [Fact] public void Compose_merges_message_template_token_paths_into_dependency_refs() { var script = new Script { ScriptId = "s-1", Name = "hot", SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Line.Pressure\").Value) > 5;", SourceHash = "h1", }; var alarm = new ScriptedAlarm { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "TempHigh", AlarmType = "LimitAlarm", Severity = 500, MessageTemplate = "Temp {Line.Temp} high", PredicateScriptId = "s-1", }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { alarm }, Array.Empty(), Array.Empty(), scripts: new[] { script }); var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); // Predicate read + message token are both present (predicate reads first, then template tokens). plan.DependencyRefs.ShouldBe(new[] { "Line.Pressure", "Line.Temp" }); } /// A template token that duplicates a predicate read collapses to one ref, and a /// repeated template token collapses too — DependencyRefs are distinct (predicate reads first, /// then first-seen template tokens), and the reserved {{equip}} double-brace form is not /// harvested as a token. [Fact] public void Compose_dependency_refs_are_distinct_and_exclude_the_reserved_equip_token() { var script = new Script { ScriptId = "s-1", Name = "dup", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1", }; var alarm = new ScriptedAlarm { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Dup", AlarmType = "AlarmCondition", Severity = 500, // {A.X} duplicates the predicate read; {B.Y} appears twice; {{equip}}.Z must NOT be a token. MessageTemplate = "v={A.X} w={B.Y} x={B.Y} base={{equip}}.Z", PredicateScriptId = "s-1", }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { alarm }, Array.Empty(), Array.Empty(), scripts: new[] { script }); var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" }); plan.DependencyRefs.ShouldNotContain("equip"); } /// A scripted alarm whose PredicateScriptId matches no Script row is SKIPPED (not in the /// result) and compose does NOT throw — the bad row is dropped, the rest of the projection stands. [Fact] public void Compose_skips_scripted_alarm_with_missing_predicate_script_without_throwing() { 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", }; var orphanAlarm = new ScriptedAlarm { ScriptedAlarmId = "al-orphan", EquipmentId = "eq-2", Name = "Orphan", AlarmType = "AlarmCondition", Severity = 500, MessageTemplate = "orphan", PredicateScriptId = "s-does-not-exist", }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { goodAlarm, orphanAlarm }, Array.Empty(), Array.Empty(), scripts: new[] { goodScript }); var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); plan.ScriptedAlarmId.ShouldBe("al-ok"); result.EquipmentScriptedAlarms.ShouldNotContain(p => p.ScriptedAlarmId == "al-orphan"); } /// A disabled alarm (Enabled=false) is STILL present in the projection — the host /// decides whether to host it, the composer never drops it. The flag is carried through. [Fact] public void Compose_keeps_disabled_scripted_alarm_with_enabled_flag_carried() { var script = new Script { ScriptId = "s-1", Name = "off", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1", }; var alarm = new ScriptedAlarm { ScriptedAlarmId = "al-off", EquipmentId = "eq-1", Name = "Disabled", AlarmType = "AlarmCondition", Severity = 500, MessageTemplate = "off", PredicateScriptId = "s-1", Enabled = false, }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { alarm }, Array.Empty(), Array.Empty(), scripts: new[] { script }); var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); plan.ScriptedAlarmId.ShouldBe("al-off"); plan.Enabled.ShouldBeFalse(); } /// Determinism: the same inputs in different order produce identical ordered output /// (stable sort by EquipmentId then ScriptedAlarmId) — this is the contract the upcoming /// artifact byte-parity test relies on. [Fact] public void Compose_scripted_alarm_projection_is_deterministic_regardless_of_input_order() { var script1 = new Script { ScriptId = "s-1", Name = "a", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1" }; var script2 = new Script { ScriptId = "s-2", Name = "b", SourceCode = "return ctx.GetTag(\"B.Y\").Value;", SourceHash = "h2" }; var script3 = new Script { ScriptId = "s-3", Name = "c", SourceCode = "return ctx.GetTag(\"C.Z\").Value;", SourceHash = "h3" }; // Two alarms on the same equipment to exercise the secondary (ScriptedAlarmId) sort key. var a1 = NewAlarm("al-1", "eq-2", "s-1"); var a2 = NewAlarm("al-2", "eq-1", "s-2"); var a3 = NewAlarm("al-3", "eq-1", "s-3"); var scripts = new[] { script1, script2, script3 }; var r1 = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { a1, a2, a3 }, Array.Empty(), Array.Empty(), scripts: scripts); var r2 = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { a3, a1, a2 }, Array.Empty(), Array.Empty(), scripts: scripts); // Sorted by EquipmentId then ScriptedAlarmId: (eq-1,al-2), (eq-1,al-3), (eq-2,al-1). r1.EquipmentScriptedAlarms.Select(p => p.ScriptedAlarmId) .ShouldBe(new[] { "al-2", "al-3", "al-1" }); r1.EquipmentScriptedAlarms.ShouldBe(r2.EquipmentScriptedAlarms); } /// The new projection defaults to empty when no scripts/alarms are supplied (init-only /// member, no positional-parameter break for existing callers). [Fact] public void Composition_carries_empty_scripted_alarms_by_default() { var r = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()); r.EquipmentScriptedAlarms.ShouldBeEmpty(); } private static ScriptedAlarm NewAlarm(string id, string equipmentId, string scriptId) => new() { ScriptedAlarmId = id, EquipmentId = equipmentId, Name = id, AlarmType = "AlarmCondition", Severity = 500, MessageTemplate = $"{id} alarm", PredicateScriptId = scriptId, }; }