From b05c281132b23257fb8e3788bd56d0b42f41f98f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 13:11:53 -0400 Subject: [PATCH] =?UTF-8?q?test(opcua):=20pin=20F10b=20boundary=20?= =?UTF-8?q?=E2=80=94=20vtag=20EquipmentId-change=20+=20multi-vtag-mixed=20?= =?UTF-8?q?still=20rebuild?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Phase7ApplierTests.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) 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