diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index bda644cd..eda6f98f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -77,15 +77,19 @@ public sealed class Phase7Applier plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count; - // Any add/remove of Equipment, ScriptedAlarm, 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. - // TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild. + // Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment + // VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive + // every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a + // re-severitied alarm, a flipped tag dataType/Writable, or an edited VirtualTag expression) must + // still rebuild or the running server keeps the stale node. + // ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the + // address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which + // re-spawns the affected driver actor without re-materialising any nodes. var needsRebuild = - plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || - plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || - plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || - plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0; + plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 || + plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 || + plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.ChangedEquipmentTags.Count > 0 || + plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Count > 0; if (needsRebuild) { 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 b30c6403..c794bfce 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -498,6 +498,153 @@ public sealed class Phase7ApplierTests sink.RebuildCalls.ShouldBe(1); } + /// H1a — a deploy that ONLY changes an existing equipment tag (e.g. flips its dataType or + /// Writable bit) must rebuild the address space. The planner diffs the tag into + /// ChangedEquipmentTags with no Added/Removed of anything else; the applier must still drive + /// exactly one rebuild so the running server drops the stale node and re-materialises it. + [Fact] + public void Changed_equipment_tags_only_trigger_rebuild() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var previous = CompositionWithTags( + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); + // Same tag id, but DataType + Writable flipped — the planner classifies this as a change. + var next = CompositionWithTags( + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null)); + + var plan = Phase7Planner.Compute(previous, next); + + // Guard the arrange: ONLY ChangedEquipmentTags is populated. + plan.ChangedEquipmentTags.Count.ShouldBe(1); + plan.AddedEquipmentTags.ShouldBeEmpty(); + plan.RemovedEquipmentTags.ShouldBeEmpty(); + plan.AddedEquipment.ShouldBeEmpty(); + plan.RemovedEquipment.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeTrue(); + outcome.ChangedNodes.ShouldBe(1); + sink.RebuildCalls.ShouldBe(1); + } + + /// H1a — a deploy that ONLY edits an existing VirtualTag's expression must rebuild the + /// address space. The planner diffs it into ChangedEquipmentVirtualTags alone; the applier + /// must drive exactly one rebuild. + [Fact] + public void Changed_equipment_virtual_tags_only_trigger_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\") * 60", DependencyRefs: new[] { "a" })); + // Same VirtualTag id, edited expression — the planner classifies this as a change. + var next = CompositionWithVirtualTags( + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", + Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" })); + + var plan = Phase7Planner.Compute(previous, next); + + // Guard the arrange: ONLY ChangedEquipmentVirtualTags is populated. + plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); + plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); + plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeTrue(); + outcome.ChangedNodes.ShouldBe(1); + sink.RebuildCalls.ShouldBe(1); + } + + /// 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 + /// reflects the edit. + [Fact] + public void Changed_alarms_only_trigger_rebuild() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var previous = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp high")); + // Same alarm id, edited message template — the planner classifies this as a change. + var next = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp critically high")); + + var plan = Phase7Planner.Compute(previous, next); + + // Guard the arrange: ONLY ChangedAlarms is populated. + plan.ChangedAlarms.Count.ShouldBe(1); + plan.AddedAlarms.ShouldBeEmpty(); + plan.RemovedAlarms.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeTrue(); + outcome.ChangedNodes.ShouldBe(1); + sink.RebuildCalls.ShouldBe(1); + } + + /// H1a guard — a deploy that ONLY changes a driver instance's config must NOT rebuild the + /// address space. Driver-instance changes route through DriverHostActor's spawn-plan in Runtime, not + /// the address-space topology, so ChangedDrivers is intentionally excluded from + /// needsRebuild. This pins the exclusion against accidental inclusion. (The pre-existing + /// uses a hand-built plan; + /// this one drives the planner end-to-end.) + [Fact] + public void Changed_drivers_only_do_not_trigger_rebuild() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var previous = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}")); + var next = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")); + + var plan = Phase7Planner.Compute(previous, next); + + // Guard the arrange: ONLY ChangedDrivers is populated. + plan.ChangedDrivers.Count.ShouldBe(1); + plan.AddedDrivers.ShouldBeEmpty(); + plan.RemovedDrivers.ShouldBeEmpty(); + plan.ChangedEquipment.ShouldBeEmpty(); + plan.ChangedAlarms.ShouldBeEmpty(); + plan.ChangedEquipmentTags.ShouldBeEmpty(); + plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeFalse(); + outcome.ChangedNodes.ShouldBe(1); // driver change is still tallied, just not rebuild-forcing + sink.RebuildCalls.ShouldBe(0); + } + + private static Phase7CompositionResult CompositionWithTags(params EquipmentTagPlan[] tags) => + new( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = tags, + }; + + private static Phase7CompositionResult CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => + new( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentVirtualTags = vtags, + }; + + private static Phase7CompositionResult CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) => + // ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms. + new( + Array.Empty(), Array.Empty(), alarms); + + private static Phase7CompositionResult CompositionWithDrivers(params DriverInstancePlan[] drivers) => + new( + Array.Empty(), drivers, Array.Empty()); + private static Phase7Plan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(),