using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class AddressSpacePlannerTests { /// Verifies that empty inputs produce an empty plan. [Fact] public void Empty_inputs_produce_empty_plan() { var prev = new AddressSpaceComposition(Array.Empty(), Array.Empty(), Array.Empty()); var next = prev; var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeTrue(); } /// Verifies that identical compositions produce an empty plan. [Fact] public void Identical_compositions_produce_empty_plan() { var eq = new EquipmentNode("eq-1", "Eq 1", "line-1"); var prev = new AddressSpaceComposition(new[] { eq }, Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition(new[] { eq }, Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeTrue(); } /// Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change) /// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the /// IsEmpty gate before materialising the new equipment variables. [Fact] public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), }, }; var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1"); plan.RemovedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipmentTags.ShouldBeEmpty(); } /// Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change) /// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that /// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate. [Fact] public void Equipment_virtual_tag_only_change_yields_non_empty_plan_with_added_tag() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "a + b", DependencyRefs: new[] { "a", "b" }), }, }; var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); plan.AddedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1"); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); } /// Verifies a disappeared VirtualTag routes to RemovedEquipmentVirtualTags. [Fact] public void Disappeared_virtual_tag_goes_to_RemovedEquipmentVirtualTags() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "a + b", DependencyRefs: new[] { "a", "b" }), }, }; var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); plan.RemovedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1"); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); } /// Verifies a VirtualTag with the same id but a different Expression routes to /// ChangedEquipmentVirtualTags (the diff identity is VirtualTagId; any field difference, /// including the evaluated Expression, moves it from stable to changed). [Fact] public void Same_id_with_different_expression_routes_to_ChangedEquipmentVirtualTags() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "a + b", DependencyRefs: new[] { "a", "b" }), }, }; var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "a - b", DependencyRefs: new[] { "a", "b" }), }, }; var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); plan.ChangedEquipmentVirtualTags.Single().Previous.Expression.ShouldBe("a + b"); plan.ChangedEquipmentVirtualTags.Single().Current.Expression.ShouldBe("a - b"); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } /// H5a — a VirtualTag with the same id but a toggled Historize flag (and otherwise /// identical fields) must route to ChangedEquipmentVirtualTags. This pins that Historize is /// part of so a Historize-only deploy is not a silent /// no-op at the diff/IsEmpty gate. [Fact] public void Same_id_with_toggled_historize_routes_to_ChangedEquipmentVirtualTags() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "a + b", DependencyRefs: new[] { "a", "b" }, Historize: false), }, }; var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "a + b", DependencyRefs: new[] { "a", "b" }, Historize: true), }, }; var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); plan.ChangedEquipmentVirtualTags.Single().Previous.Historize.ShouldBeFalse(); plan.ChangedEquipmentVirtualTags.Single().Current.Historize.ShouldBeTrue(); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } /// An equipment Tag with the same id but a toggled IsArray flag (and otherwise /// identical fields) must route to ChangedEquipmentTags. This pins that IsArray is part of /// (record value-equality) so an array-flag-only deploy is not /// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag. [Fact] public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), }, }; var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16), }, }; var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse(); plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue(); plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16); plan.AddedEquipmentTags.ShouldBeEmpty(); plan.RemovedEquipmentTags.ShouldBeEmpty(); } /// Regression guard for structural equality on : /// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan /// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with /// dependencies would be wrongly flagged "Changed" on every parse, preventing IsEmpty short-circuits). [Fact] public void Identical_virtualtag_snapshots_diff_to_empty_plan() { // Two separate list instances with identical contents — proves structural (not reference) equality. var refsA = new[] { "EQ1.Speed", "EQ1.Torque" }; var refsB = new[] { "EQ1.Speed", "EQ1.Torque" }; var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsA), }, }; var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsB), }, }; var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeTrue(); plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } /// Verifies that new equipment goes to the AddedEquipment list. [Fact] public void New_equipment_goes_to_AddedEquipment() { var prev = new AddressSpaceComposition(Array.Empty(), Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition( new[] { new EquipmentNode("eq-1", "A", "line-1") }, Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-1"); plan.RemovedEquipment.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); } /// Verifies that disappeared equipment goes to the RemovedEquipment list. [Fact] public void Disappeared_equipment_goes_to_RemovedEquipment() { var prev = new AddressSpaceComposition( new[] { new EquipmentNode("eq-1", "A", "line-1") }, Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition(Array.Empty(), Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-1"); plan.AddedEquipment.ShouldBeEmpty(); } /// Verifies that equipment with same id but different display name routes to ChangedEquipment. [Fact] public void Same_id_with_different_display_name_routes_to_ChangedEquipment() { var prev = new AddressSpaceComposition( new[] { new EquipmentNode("eq-1", "Old", "line-1") }, Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition( new[] { new EquipmentNode("eq-1", "New", "line-1") }, Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.ChangedEquipment.Single().Previous.DisplayName.ShouldBe("Old"); plan.ChangedEquipment.Single().Current.DisplayName.ShouldBe("New"); plan.AddedEquipment.ShouldBeEmpty(); plan.RemovedEquipment.ShouldBeEmpty(); } /// Verifies that driver config changes route to ChangedDrivers. [Fact] public void Driver_config_change_routes_to_ChangedDrivers() { var prev = new AddressSpaceComposition( Array.Empty(), new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"old\"}") }, Array.Empty()); var next = new AddressSpaceComposition( Array.Empty(), new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"new\"}") }, Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.ChangedDrivers.Single().Current.ConfigJson.ShouldContain("new"); } /// Verifies that alarm message template changes route to ChangedAlarms. [Fact] public void Alarm_message_template_change_routes_to_ChangedAlarms() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "old") }); var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "new") }); var plan = AddressSpacePlanner.Compute(prev, next); plan.ChangedAlarms.Single().Current.MessageTemplate.ShouldBe("new"); } /// Verifies that added and removed lists are sorted by id for deterministic ordering. [Fact] public void Added_and_removed_lists_are_sorted_by_id_for_deterministic_ordering() { var prev = new AddressSpaceComposition( new[] { new EquipmentNode("z", "Z", "line-1"), new EquipmentNode("a", "A", "line-1") }, Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition(Array.Empty(), Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.RemovedEquipment.Select(e => e.EquipmentId).ShouldBe(new[] { "a", "z" }); } /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area's /// DisplayName differs; same id, no equipment/driver/alarm/tag/vtag delta) must yield a /// NON-empty plan with the rename in , so /// OpcUaPublishActor.HandleRebuild no longer short-circuits at the IsEmpty gate before the /// folder's DisplayName is refreshed. [Fact] public void Area_rename_only_yields_non_empty_plan_with_renamed_folder() { var prev = new AddressSpaceComposition( UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") }, UnsLines: Array.Empty(), EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var next = new AddressSpaceComposition( UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") }, UnsLines: Array.Empty(), EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); var rename = plan.RenamedFolders.ShouldHaveSingleItem(); rename.FolderNodeId.ShouldBe("area-1"); // folder NodeId == the area id (MaterialiseHierarchy scheme) rename.NewDisplayName.ShouldBe("Plant South"); } /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Line rename yields a NON-empty plan /// with the rename in (folder NodeId == the line id). [Fact] public void Line_rename_only_yields_non_empty_plan_with_renamed_folder() { var prev = new AddressSpaceComposition( UnsAreas: Array.Empty(), UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }, EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var next = new AddressSpaceComposition( UnsAreas: Array.Empty(), UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") }, EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeFalse(); var rename = plan.RenamedFolders.ShouldHaveSingleItem(); rename.FolderNodeId.ShouldBe("line-1"); rename.NewDisplayName.ShouldBe("Cell B"); } /// OpcUaServer-001 — no false positives: two compositions with identical Area + Line topology /// (same ids, same DisplayNames) produce NO rename and an empty plan. Pins that the rename diff fires /// ONLY on an actual DisplayName change, not on every redeploy. [Fact] public void Identical_area_line_topology_yields_empty_plan_no_renames() { var areas = new[] { new UnsAreaProjection("area-1", "Plant North") }; var lines = new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }; var prev = new AddressSpaceComposition(areas, lines, Array.Empty(), Array.Empty(), Array.Empty()); // Fresh projection instances, identical contents — proves value (not reference) comparison. var next = new AddressSpaceComposition( new[] { new UnsAreaProjection("area-1", "Plant North") }, new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }, Array.Empty(), Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.IsEmpty.ShouldBeTrue(); plan.RenamedFolders.ShouldBeEmpty(); } /// OpcUaServer-001 — a brand-new Area (added, not renamed) is NOT a rename: an area present /// only in next is materialised by the hierarchy pass, not refreshed in place. The rename diff /// must only flag folders present in BOTH snapshots whose DisplayName changed. [Fact] public void Added_area_is_not_a_rename() { var prev = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()); var next = new AddressSpaceComposition( UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") }, UnsLines: Array.Empty(), EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.RenamedFolders.ShouldBeEmpty(); } /// OpcUaServer-001 — when BOTH an Area and a Line are renamed in one deploy, both renames /// appear in , ordered deterministically by folder id /// (areas + lines concatenated, each sorted by id). [Fact] public void Area_and_line_renames_both_captured_and_ordered_by_id() { var prev = new AddressSpaceComposition( UnsAreas: new[] { new UnsAreaProjection("area-1", "North") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }, EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var next = new AddressSpaceComposition( UnsAreas: new[] { new UnsAreaProjection("area-1", "South") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") }, EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var plan = AddressSpacePlanner.Compute(prev, next); plan.RenamedFolders.Count.ShouldBe(2); // "area-1" sorts before "line-1" (areas first, then lines, each id-sorted) — deterministic order. plan.RenamedFolders.Select(r => r.FolderNodeId).ShouldBe(new[] { "area-1", "line-1" }); plan.RenamedFolders.Single(r => r.FolderNodeId == "area-1").NewDisplayName.ShouldBe("South"); plan.RenamedFolders.Single(r => r.FolderNodeId == "line-1").NewDisplayName.ShouldBe("Cell B"); } /// Verifies that mixed changes across all three classes are captured in one pass. [Fact] public void Mixed_changes_across_all_three_classes_are_captured_in_one_pass() { var prev = new AddressSpaceComposition( new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-drop", "Drop", "line-1") }, new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":1}") }, new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1") }); var next = new AddressSpaceComposition( new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-new", "New", "line-1") }, new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":2}") }, new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1"), new ScriptedAlarmPlan("a-new", "eq-new", "s2", "t2") }); var plan = AddressSpacePlanner.Compute(prev, next); plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-new"); plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-drop"); plan.ChangedEquipment.ShouldBeEmpty(); plan.ChangedDrivers.Single().Current.DriverInstanceId.ShouldBe("drv-change"); plan.AddedAlarms.Single().ScriptedAlarmId.ShouldBe("a-new"); } }