using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class Phase7ComposerPurityTests { /// Verifies empty inputs produce empty result. [Fact] public void Empty_inputs_produce_empty_result() { var result = Phase7Composer.Compose( equipment: Array.Empty(), driverInstances: Array.Empty(), scriptedAlarms: Array.Empty()); result.EquipmentNodes.ShouldBeEmpty(); result.DriverInstancePlans.ShouldBeEmpty(); result.ScriptedAlarmPlans.ShouldBeEmpty(); } /// Verifies same inputs in different order produce structurally equal results. [Fact] public void Same_inputs_in_different_order_produce_structurally_equal_results() { var e1 = NewEquipment("eq-1"); var e2 = NewEquipment("eq-2"); var d1 = NewDriver("drv-1"); var d2 = NewDriver("drv-2"); var a1 = NewAlarm("a-1", "eq-1"); var a2 = NewAlarm("a-2", "eq-2"); var r1 = Phase7Composer.Compose( equipment: new[] { e1, e2 }, driverInstances: new[] { d1, d2 }, scriptedAlarms: new[] { a1, a2 }); var r2 = Phase7Composer.Compose( equipment: new[] { e2, e1 }, driverInstances: new[] { d2, d1 }, scriptedAlarms: new[] { a2, a1 }); r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes); r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans); r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans); } /// Verifies Compose is pure with identical repeated calls. [Fact] public void Compose_is_pure_repeated_call_returns_element_identical_output() { var equipment = new[] { NewEquipment("eq-a"), NewEquipment("eq-b") }; var drivers = new[] { NewDriver("drv-x") }; var alarms = new[] { NewAlarm("alarm-1", "eq-a") }; var r1 = Phase7Composer.Compose(equipment, drivers, alarms); var r2 = Phase7Composer.Compose(equipment, drivers, alarms); // Record equality won't help here — IReadOnlyList uses reference equality. Compare // element-wise to verify the pure-function contract. r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes); r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans); r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans); } /// Verifies output is sorted by natural key. [Fact] public void Output_is_sorted_by_natural_key() { var equipment = new[] { NewEquipment("z"), NewEquipment("a"), NewEquipment("m") }; var result = Phase7Composer.Compose(equipment, Array.Empty(), Array.Empty()); result.EquipmentNodes.Select(e => e.EquipmentId) .ShouldBe(new[] { "a", "m", "z" }); } /// Verifies UNS equipment folders browse by the friendly UNS Name segment /// (not the colloquial MachineCode, not the logical EquipmentId) while the NodeId stays the /// logical EquipmentId — so browse-path resolution + ACLs are unaffected (decision #3). [Fact] public void Equipment_node_DisplayName_is_the_UNS_Name_not_MachineCode() { var equipment = new[] { NewEquipment("filling-eq") }; // Name="filling-eq", MachineCode="FILLING-EQ" var node = Phase7Composer.Compose(equipment, Array.Empty(), Array.Empty()) .EquipmentNodes.ShouldHaveSingleItem(); node.EquipmentId.ShouldBe("filling-eq"); // NodeId stays the logical Id node.DisplayName.ShouldBe("filling-eq"); // browse name = UNS Name segment node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode } [Fact] public void Composition_carries_empty_equipment_virtualtags_by_default() { var r = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()); r.EquipmentVirtualTags.ShouldBeEmpty(); } [Fact] public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps() { var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64", "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;", new[] { "TestMachine_001.TestDouble" }); p.VirtualTagId.ShouldBe("vt-1"); p.EquipmentId.ShouldBe("eq-1"); p.Name.ShouldBe("speed-rpm"); p.DependencyRefs.ShouldHaveSingleItem(); } /// Compose joins a to its by ScriptId, /// emitting one carrying the script source as the /// Expression and the parsed ctx.GetTag("…") literals as DependencyRefs. [Fact] public void Compose_emits_equipment_virtualtag_plan_joined_to_script() { var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "speed-rpm", DataType = "Float64", ScriptId = "s-1", }; var script = new Script { ScriptId = "s-1", Name = "speed-script", SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;", SourceHash = "hash-1", }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), virtualTags: new[] { vt }, scripts: new[] { script }); var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem(); plan.VirtualTagId.ShouldBe("vt-1"); plan.EquipmentId.ShouldBe("eq-1"); plan.FolderPath.ShouldBe(""); plan.Name.ShouldBe("speed-rpm"); plan.DataType.ShouldBe("Float64"); plan.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;"); plan.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" }); } /// DependencyRefs are the distinct ctx.GetTag("…") literals in first-seen /// order — a repeated ref collapses to one. [Fact] public void Compose_extracts_distinct_dependency_refs() { var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "sum", DataType = "Float64", ScriptId = "s-1", }; var script = new Script { ScriptId = "s-1", Name = "sum-script", SourceCode = "return ctx.GetTag(\"A.X\").Value + ctx.GetTag(\"B.Y\").Value + ctx.GetTag(\"A.X\").Value;", SourceHash = "hash-1", }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), virtualTags: new[] { vt }, scripts: new[] { script }); var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem(); plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" }); } /// A whose ScriptId has no matching /// in the supplied list falls back to an empty Expression and an empty DependencyRefs — /// the plan is always emitted (never dropped) and never carries a null Expression. [Fact] public void Compose_virtualtag_with_missing_script_yields_empty_expression_and_deps() { var vt = new VirtualTag { VirtualTagId = "vt-missing", EquipmentId = "eq-1", Name = "mystery-tag", DataType = "Float64", ScriptId = "s-does-not-exist", }; var result = Phase7Composer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), virtualTags: new[] { vt }, scripts: Array.Empty