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 3bfbc728..e85e15e1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -696,6 +696,9 @@ public sealed class Phase7ApplierTests
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
// Same node-relevant fields; only the dependency set differs.
+ // Note: DependencyRefs normally tracks the Expression (derived by the ref extractor), so a
+ // deps-only divergence is an edge case — e.g. the extractor logic changed between two deploys —
+ // but it is still correctly node-irrelevant: the materialised OPC UA node is unchanged.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a", "b" }));
@@ -861,6 +864,95 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
+ /// F10b safe-default — a vtag delta where the node-affecting EquipmentId changed
+ /// (admin reassigns the vtag to a different equipment) must rebuild. The materialised node lives
+ /// under the old equipment folder; moving it to another folder requires a full address-space
+ /// rebuild. EquipmentId is NOT in the node-irrelevant whitelist (Expression/DependencyRefs/Historize),
+ /// so the applier must not skip.
+ [Fact]
+ public void Changed_virtual_tag_equipment_id_triggers_rebuild()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var previous = CompositionWithVirtualTags(
+ new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
+ Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
+ // Same VirtualTagId, identical Expression/DependencyRefs/DataType/Name/FolderPath —
+ // but reassigned to a different equipment ("eq-2" instead of "eq-1").
+ var next = CompositionWithVirtualTags(
+ new EquipmentVirtualTagPlan("vt-1", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
+ Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
+
+ var plan = Phase7Planner.Compute(previous, next);
+
+ // Guard the arrange: the planner sees this as a changed vtag (same id, different EquipmentId).
+ plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
+ plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
+ plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
+
+ var outcome = applier.Apply(plan);
+
+ // EquipmentId is node-affecting — the node moves to a different folder — so rebuild is required.
+ outcome.RebuildCalled.ShouldBeTrue();
+ sink.RebuildCalls.ShouldBe(1);
+ outcome.ChangedNodes.ShouldBe(1);
+ }
+
+ /// F10b safe-default — when a plan contains TWO changed vtags and ONE is node-irrelevant
+ /// (Expression-only) while the OTHER is structural (DataType changed), the applier must still rebuild.
+ /// The Any(d => !VtagDeltaIsNodeIrrelevant(d)) predicate must catch the structural delta
+ /// even though its sibling vtag would individually qualify for the skip.
+ [Fact]
+ public void Changed_virtual_tags_one_irrelevant_one_structural_triggers_rebuild()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var previous = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentVirtualTags = new[]
+ {
+ // vt-A: will receive an Expression-only edit (node-irrelevant).
+ new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
+ Expression: "ctx.GetTag(\"speed\") * 60", DependencyRefs: new[] { "speed" }),
+ // vt-B: will receive a DataType change (structural / node-affecting).
+ new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Float64",
+ Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
+ },
+ };
+ var next = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentVirtualTags = new[]
+ {
+ // vt-A: Expression-only change — individually node-irrelevant.
+ new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
+ Expression: "ctx.GetTag(\"speed\") * 120", DependencyRefs: new[] { "speed" }),
+ // vt-B: DataType flipped — structurally node-affecting.
+ new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Int32",
+ Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
+ },
+ };
+
+ var plan = Phase7Planner.Compute(previous, next);
+
+ // Guard the arrange: both vtags are diffed as changed, nothing else.
+ plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2);
+ plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
+ plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
+ plan.ChangedEquipmentTags.ShouldBeEmpty();
+ plan.ChangedEquipment.ShouldBeEmpty();
+
+ var outcome = applier.Apply(plan);
+
+ // The structural delta on vt-B must force a rebuild even though vt-A is node-irrelevant.
+ outcome.RebuildCalled.ShouldBeTrue();
+ sink.RebuildCalls.ShouldBe(1);
+ outcome.ChangedNodes.ShouldBe(2);
+ }
+
/// H1a — a deploy that ONLY edits an existing scripted alarm (here its message template)
/// must rebuild the address space. The planner diffs the thin ScriptedAlarmPlan projection
/// into ChangedAlarms alone; the applier must drive exactly one rebuild so the condition node