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()