From b472bba38424ecba35c12f636c75f6c2a4477ba1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 13:44:26 -0400 Subject: [PATCH] =?UTF-8?q?test(opcua):=20pin=20F10b=20surgical=20guard=20?= =?UTF-8?q?=E2=80=94=20alarm-bearing=20tag=20rebuilds=20+=20multi-delta=20?= =?UTF-8?q?surgical=20success?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Phase7ApplierTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 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 d3eccd8a..8d12249f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -1277,6 +1277,102 @@ public sealed class Phase7ApplierTests sink.SurgicalCalls.ShouldBeEmpty(); } + /// F10b guard — a tag that ALREADY carries an alarm (Alarm is not null on BOTH previous + /// and current, identical alarm info) is NOT surgical-eligible even when only Writable changes. + /// The explicit Alarm is null guard in TagDeltaIsSurgicalEligible prevents a false + /// positive: without it the with { Writable = … } override (which does NOT touch Alarm) + /// would leave both sides with equal Alarm values and the delta would WRONGLY look surgical. + /// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it + /// must go through a full rebuild. + [Fact] + public void Changed_alarm_bearing_tag_writable_only_still_rebuilds() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + // Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node. + var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700); + var previous = CompositionWithTags( + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", + FullName: "00001", Writable: false, Alarm: alarm)); + // Only Writable flips; alarm is unchanged (and still non-null on both sides). + var next = CompositionWithTags( + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", + FullName: "00001", Writable: true, Alarm: alarm)); + + var plan = Phase7Planner.Compute(previous, next); + + // Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else. + plan.ChangedEquipmentTags.Count.ShouldBe(1); + plan.AddedEquipmentTags.ShouldBeEmpty(); + plan.RemovedEquipmentTags.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + // The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call. + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + sink.SurgicalCalls.ShouldBeEmpty(); // NO surgical write on an alarm-bearing tag + } + + /// F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain + /// value variables, no alarm, both changing only Writable), the applier must apply BOTH in + /// place (two UpdateTagAttributes calls) without triggering any rebuild. Proves the surgical + /// path iterates all eligible deltas rather than stopping after the first. + [Fact] + public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + // Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible. + var previous = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), + new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), + }, + }; + // Only Writable flips on both tags; everything else is identical. + var next = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), + new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: true, Alarm: null), + }, + }; + + var plan = Phase7Planner.Compute(previous, next); + + // Guard the arrange: exactly two changed tags, nothing else. + plan.ChangedEquipmentTags.Count.ShouldBe(2); + plan.AddedEquipmentTags.ShouldBeEmpty(); + plan.RemovedEquipmentTags.ShouldBeEmpty(); + plan.AddedEquipment.ShouldBeEmpty(); + plan.RemovedEquipment.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + // No rebuild — both deltas are surgical-eligible. + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + + // Exactly two surgical calls — one per eligible delta. + sink.SurgicalCalls.Count.ShouldBe(2); + + // Both expected node ids must appear (order is not guaranteed). + var nodeId1 = EquipmentNodeIds.Variable("eq-1", "", "Speed"); + var nodeId2 = EquipmentNodeIds.Variable("eq-1", "", "Pressure"); + sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable); + sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable); + + outcome.ChangedNodes.ShouldBe(2); + } + /// F10b — a surgical-eligible tag delta MIXED with another change (here an added equipment) /// must still rebuild: the rebuild is forced by the OTHER change. The surgical path is taken ONLY when /// the tag deltas are the sole change. No surgical call is made (the rebuild materialises everything).