diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 9c991847..4f3ca2e6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -70,19 +70,23 @@ public sealed class Phase7Applier var changedCount = plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + - plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count; + plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count + + plan.ChangedEquipmentVirtualTags.Count; var addedCount = plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + - plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count; + plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count + + plan.AddedEquipmentVirtualTags.Count; - // Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, or Equipment tag topology requires - // a real address-space rebuild. Driver-instance changes don't touch the address-space - // topology directly — they go through DriverHostActor's spawn-plan in Runtime. + // Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, Equipment tag, or Equipment + // VirtualTag topology requires a real address-space rebuild. Driver-instance changes don't + // touch the address-space topology directly — they go through DriverHostActor's spawn-plan + // in Runtime. var needsRebuild = plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0 || - plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0; + plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || + plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0; if (needsRebuild) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs index 16f09e3b..cae06653 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs @@ -40,19 +40,36 @@ public sealed record Phase7Plan( /// public IReadOnlyList ChangedEquipmentTags { get; init; } = Array.Empty(); + /// + /// Equipment-namespace VirtualTag diff sets, keyed by . + /// The value-side analogue of : a VirtualTag carries an + /// Expression evaluated over DependencyRefs, so a deploy that changes ONLY + /// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a + /// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and + /// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same + /// compile-compatibility reason as . + /// + public IReadOnlyList AddedEquipmentVirtualTags { get; init; } = Array.Empty(); + /// + public IReadOnlyList RemovedEquipmentVirtualTags { get; init; } = Array.Empty(); + /// + public IReadOnlyList ChangedEquipmentVirtualTags { get; init; } = Array.Empty(); + /// Gets a value indicating whether the composition plan contains no changes. public bool IsEmpty => AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 && AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0 && - AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0; + AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && + AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0; public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current); public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current); public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current); public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current); + public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current); } public static class Phase7Planner @@ -95,6 +112,15 @@ public static class Phase7Planner t => t.TagId, (a, b) => new Phase7Plan.EquipmentTagDelta(a, b)); + // VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. Element equality on + // EquipmentVirtualTagPlan compares its scalar fields (Expression/DataType/Name/FolderPath/…) + // by value; DependencyRefs (an IReadOnlyList) compares by reference, so a fresh list + // instance is conservatively treated as changed — fine for a rebuild trigger. + var (addedVTags, removedVTags, changedVTags) = DiffById( + previous.EquipmentVirtualTags, next.EquipmentVirtualTags, + t => t.VirtualTagId, + (a, b) => new Phase7Plan.EquipmentVirtualTagDelta(a, b)); + return new Phase7Plan( addedEq, removedEq, changedEq, addedDrv, removedDrv, changedDrv, @@ -104,6 +130,9 @@ public static class Phase7Planner AddedEquipmentTags = addedEqTags, RemovedEquipmentTags = removedEqTags, ChangedEquipmentTags = changedEqTags, + AddedEquipmentVirtualTags = addedVTags, + RemovedEquipmentVirtualTags = removedVTags, + ChangedEquipmentVirtualTags = changedVTags, }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 1891680a..1136fdba 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -281,6 +281,31 @@ public sealed class Phase7ApplierTests sink.RebuildCalls.ShouldBe(1); } + /// Verifies that added Equipment VirtualTags in an otherwise-empty plan trigger an + /// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags, + /// so a VirtualTag-only deploy is no longer a silent no-op). + [Fact] + public void Added_equipment_virtual_tags_trigger_rebuild() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var plan = EmptyPlan with + { + AddedEquipmentVirtualTags = new[] + { + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", + Expression: "a + b", DependencyRefs: new[] { "a", "b" }), + }, + }; + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeTrue(); + outcome.AddedNodes.ShouldBe(1); + sink.RebuildCalls.ShouldBe(1); + } + /// Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild. [Fact] public void Added_galaxy_tags_trigger_rebuild() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs index 96c99bc5..8b399b0b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs @@ -55,6 +55,90 @@ public sealed class Phase7PlannerTests 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 Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()); + var next = new Phase7CompositionResult( + 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 = Phase7Planner.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 Phase7CompositionResult( + 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 Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()); + + var plan = Phase7Planner.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 Phase7CompositionResult( + 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 Phase7CompositionResult( + 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 = Phase7Planner.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(); + } + /// Verifies that new equipment goes to the AddedEquipment list. [Fact] public void New_equipment_goes_to_AddedEquipment()