using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// R2-07 T1 — table-driven tests over . The /// classifier is a pure function over an : it names the delta class the /// applier routes each plan through (Empty / AttributeOnly / PureAdd / PureRemove / AddRemoveMix / /// Rebuild). First-match-wins per the plan's classification table, with the default-closed /// Rebuild safety valve catching every node-affecting change. /// public sealed class AddressSpaceChangeClassifierTests { [Fact] public void Empty_plan_classifies_as_Empty() { AddressSpaceChangeClassifier.Classify(Plan()).ShouldBe(AddressSpaceChangeKind.Empty); } // ----- single add class ⇒ PureAdd ----- [Fact] public void Added_equipment_only_is_PureAdd() { var plan = Plan(addedEquipment: new[] { Eq("eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } [Fact] public void Added_alarm_only_is_PureAdd() { var plan = Plan(addedAlarms: new[] { Alarm("alm-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } [Fact] public void Added_equipment_tag_only_is_PureAdd() { var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } [Fact] public void Added_virtual_tag_only_is_PureAdd() { var plan = Plan(addedVtags: new[] { Vtag("vt-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } // ----- single remove class ⇒ PureRemove ----- [Fact] public void Removed_equipment_only_is_PureRemove() { var plan = Plan(removedEquipment: new[] { Eq("eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); } [Fact] public void Removed_alarm_only_is_PureRemove() { var plan = Plan(removedAlarms: new[] { Alarm("alm-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); } [Fact] public void Removed_equipment_tag_only_is_PureRemove() { var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); } [Fact] public void Removed_virtual_tag_only_is_PureRemove() { var plan = Plan(removedVtags: new[] { Vtag("vt-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); } // ----- add AND remove ⇒ AddRemoveMix ----- [Fact] public void Add_and_remove_is_AddRemoveMix() { var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") }, removedTags: new[] { Tag("tag-2", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix); } [Fact] public void Add_equipment_and_remove_vtag_is_AddRemoveMix() { var plan = Plan(addedEquipment: new[] { Eq("eq-2") }, removedVtags: new[] { Vtag("vt-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix); } // ----- node-affecting change ⇒ Rebuild ----- [Fact] public void Changed_equipment_is_Rebuild() { var plan = Plan(changedEquipment: new[] { new AddressSpacePlan.EquipmentDelta(Eq("eq-1"), Eq("eq-1") with { DisplayName = "New" }), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } [Fact] public void Changed_alarm_is_Rebuild() { var plan = Plan(changedAlarms: new[] { new AddressSpacePlan.AlarmDelta(Alarm("alm-1"), Alarm("alm-1") with { MessageTemplate = "changed" }), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } [Fact] public void Non_surgical_changed_tag_is_Rebuild() { // FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild. var plan = Plan(changedTags: new[] { new AddressSpacePlan.EquipmentTagDelta( Tag("tag-1", "eq-1", fullName: "40001"), Tag("tag-1", "eq-1", fullName: "40002")), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } [Fact] public void Node_relevant_changed_vtag_is_Rebuild() { // Name change re-derives the NodeId ⇒ node-relevant ⇒ Rebuild. var plan = Plan(changedVtags: new[] { new AddressSpacePlan.EquipmentVirtualTagDelta( Vtag("vt-1", "eq-1", name: "A"), Vtag("vt-1", "eq-1", name: "B")), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } // ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly ----- [Fact] public void Surgical_eligible_changed_tag_only_is_AttributeOnly() { // Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes). var plan = Plan(changedTags: new[] { new AddressSpacePlan.EquipmentTagDelta( Tag("tag-1", "eq-1", writable: false), Tag("tag-1", "eq-1", writable: true)), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } [Fact] public void Node_irrelevant_changed_vtag_only_is_AttributeOnly() { // Only Expression differs ⇒ node-irrelevant ⇒ AttributeOnly. var plan = Plan(changedVtags: new[] { new AddressSpacePlan.EquipmentVirtualTagDelta( Vtag("vt-1", "eq-1", expression: "a"), Vtag("vt-1", "eq-1", expression: "a * 2")), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } [Fact] public void Folder_renames_only_is_AttributeOnly() { var plan = Plan(renamedFolders: new[] { new AddressSpacePlan.FolderRename("area-1", "New Area") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } [Fact] public void Driver_only_deltas_are_AttributeOnly_node_inert() { // Driver adds/removes/changes never touch the address space — excluded from hasAdds/hasRemoves. var plan = Plan( addedDrivers: new[] { new DriverInstancePlan("d-1", "Modbus", "{}") }, removedDrivers: new[] { new DriverInstancePlan("d-2", "Modbus", "{}") }, changedDrivers: new[] { new AddressSpacePlan.DriverDelta( new DriverInstancePlan("d-3", "Modbus", "{\"v\":1}"), new DriverInstancePlan("d-3", "Modbus", "{\"v\":2}")), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } // ----- mixed-with-surgical: adds still win over coincident surgical changes ----- [Fact] public void Adds_with_coincident_surgical_change_is_PureAdd() { var plan = Plan( addedTags: new[] { Tag("tag-new", "eq-1") }, changedTags: new[] { new AddressSpacePlan.EquipmentTagDelta( Tag("tag-1", "eq-1", writable: false), Tag("tag-1", "eq-1", writable: true)), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } [Fact] public void Adds_with_coincident_non_surgical_change_is_Rebuild() { // Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical // change forces Rebuild even alongside pure adds. var plan = Plan( addedTags: new[] { Tag("tag-new", "eq-1") }, changedTags: new[] { new AddressSpacePlan.EquipmentTagDelta( Tag("tag-1", "eq-1", fullName: "40001"), Tag("tag-1", "eq-1", fullName: "40002")), }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } [Fact] public void Adds_with_coincident_folder_rename_is_PureAdd() { var plan = Plan( addedTags: new[] { Tag("tag-new", "eq-1") }, renamedFolders: new[] { new AddressSpacePlan.FolderRename("area-1", "New Area") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } // ----- construction helpers ----- private static EquipmentNode Eq(string id) => new(id, id, "line-1"); private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg"); private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) => new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null); private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") => new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" }); private static AddressSpacePlan Plan( IReadOnlyList? addedEquipment = null, IReadOnlyList? removedEquipment = null, IReadOnlyList? changedEquipment = null, IReadOnlyList? addedDrivers = null, IReadOnlyList? removedDrivers = null, IReadOnlyList? changedDrivers = null, IReadOnlyList? addedAlarms = null, IReadOnlyList? removedAlarms = null, IReadOnlyList? changedAlarms = null, IReadOnlyList? addedTags = null, IReadOnlyList? removedTags = null, IReadOnlyList? changedTags = null, IReadOnlyList? addedVtags = null, IReadOnlyList? removedVtags = null, IReadOnlyList? changedVtags = null, IReadOnlyList? renamedFolders = null) => new( addedEquipment ?? Array.Empty(), removedEquipment ?? Array.Empty(), changedEquipment ?? Array.Empty(), addedDrivers ?? Array.Empty(), removedDrivers ?? Array.Empty(), changedDrivers ?? Array.Empty(), addedAlarms ?? Array.Empty(), removedAlarms ?? Array.Empty(), changedAlarms ?? Array.Empty()) { AddedEquipmentTags = addedTags ?? Array.Empty(), RemovedEquipmentTags = removedTags ?? Array.Empty(), ChangedEquipmentTags = changedTags ?? Array.Empty(), AddedEquipmentVirtualTags = addedVtags ?? Array.Empty(), RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty(), ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty(), RenamedFolders = renamedFolders ?? Array.Empty(), }; }