using System.Collections.Concurrent; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class Phase7ApplierTests { /// Verifies that an empty plan does not call the sink or trigger a rebuild. [Fact] public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var outcome = applier.Apply(EmptyPlan); outcome.RebuildCalled.ShouldBeFalse(); outcome.AddedNodes.ShouldBe(0); outcome.RemovedNodes.ShouldBe(0); outcome.ChangedNodes.ShouldBe(0); sink.RebuildCalls.ShouldBe(0); sink.AlarmWrites.ShouldBeEmpty(); } /// Verifies that removed equipment writes inactive alarm state and triggers rebuild. [Fact] public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var plan = WithEquipmentRemoval("eq-1", "eq-2"); var outcome = applier.Apply(plan); outcome.RemovedNodes.ShouldBe(2); outcome.RebuildCalled.ShouldBeTrue(); sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" }); // Removed nodes are reset to the "no-event" state: inactive + acked + confirmed + enabled. sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } /// Verifies that added equipment triggers rebuild without writing alarm state. [Fact] public void Added_equipment_triggers_rebuild_without_alarm_writes() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var plan = new Phase7Plan( AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") }, RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); outcome.AddedNodes.ShouldBe(1); sink.AlarmWrites.ShouldBeEmpty(); sink.RebuildCalls.ShouldBe(1); } /// Verifies that driver-only changes do not trigger address space rebuild. [Fact] public void Driver_only_changes_do_not_trigger_address_space_rebuild() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var plan = new Phase7Plan( AddedEquipment: Array.Empty(), RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") }, RemovedDrivers: Array.Empty(), ChangedDrivers: new[] { new Phase7Plan.DriverDelta( new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"), new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")), }, AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); } /// Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires. [Fact] public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires() { var sink = new ThrowingSink(throwOnAlarmWrite: true); var applier = new Phase7Applier(sink, NullLogger.Instance); var plan = WithEquipmentRemoval("eq-1"); var outcome = applier.Apply(plan); // should not throw outcome.RemovedNodes.ShouldBe(1); outcome.RebuildCalled.ShouldBeTrue(); } /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment /// folder (decision #4). [Fact] public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), }, }; applier.MaterialiseEquipmentTags(composition); sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed // A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite). sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true)); // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath). sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); } /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment /// folder (not the namespace root), with the variable parented to that sub-folder and a /// folder-scoped NodeId. [Fact] public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), }, }; applier.MaterialiseEquipmentTags(composition); sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); // A Read plan threads Writable: false (the node stays CurrentRead). sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false)); // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath). sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); } /// Regression for the FullName-as-NodeId collision: two identical machines exposing the /// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one /// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName. [Fact] public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), }, }; applier.MaterialiseEquipmentTags(composition); sink.VariableCalls.Count.ShouldBe(2); sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false)); sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false)); } /// Phase B WS-3 — an alarm-bearing equipment tag (Alarm is not null) materialises a /// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value /// variable; a plain tag (Alarm == null) stays a value variable. The alarm tag's condition /// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's /// AlarmType/Severity. Proves BOTH branches in one composition. [Fact] public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), }, }; applier.MaterialiseEquipmentTags(composition); // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition. var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed"); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true)); sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId); // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent, // matching display/type/severity) and did NOT drive EnsureVariable. var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp"); // A native equipment-tag alarm: the call-site threads isNative: true. sink.AlarmConditionCalls.ShouldHaveSingleItem() .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true)); sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId); } /// Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its /// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder), /// using the folder-scoped NodeId. [Fact] public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)), }, }; applier.MaterialiseEquipmentTags(composition); // The sub-folder is still created for an alarm tag with a FolderPath. sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable. var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp"); // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true. sink.AlarmConditionCalls.ShouldHaveSingleItem() .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true)); sink.VariableCalls.ShouldBeEmpty(); } /// Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it /// to EnsureVariable: a historized tag with NO override falls back to its FullName; a /// historized tag WITH an override passes the override verbatim; a non-historized tag passes null. [Fact] public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { // Historized, no override ⇒ tagname defaults to FullName ("T.A"). new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float", FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null), // Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName. new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float", FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"), // Not historized ⇒ tagname is null. new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float", FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null), }, }; applier.MaterialiseEquipmentTags(composition); var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname); byNode[EquipmentNodeIds.Variable("eq-1", "", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName byNode[EquipmentNodeIds.Variable("eq-1", "", "BOverride")].ShouldBe("WW.Override"); // override verbatim byNode[EquipmentNodeIds.Variable("eq-1", "", "CPlain")].ShouldBeNull(); // not historized ⇒ null } /// Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to /// FullName (the resolve uses string.IsNullOrWhiteSpace, not just null). [Fact] public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "), }, }; applier.MaterialiseEquipmentTags(composition); var call = sink.HistorianCalls.ShouldHaveSingleItem(); call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); call.HistorianTagname.ShouldBe("40001"); } /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create /// the equipment folder (no sub-folder when FolderPath is empty). [Fact] public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }), }, }; applier.MaterialiseEquipmentVirtualTags(composition); sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed // VirtualTags are computed outputs — always read-only (Writable: false). sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula. sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm")); } /// Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and /// the equipment-VirtualTag pass is byte-identical to — the /// single source of truth Phase7Applier + VirtualTagHostActor both point at. Covers null/empty /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the /// shared helper fails here. [Fact] public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64", Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), }, }; applier.MaterialiseEquipmentTags(composition); applier.MaterialiseEquipmentVirtualTags(composition); var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList(); nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed")); nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency")); nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg")); } /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables /// (one EnsureVariable each, no NodeId collision), parented to the equipment folder. [Fact] public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64", Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), }, }; applier.MaterialiseEquipmentVirtualTags(composition); sink.FolderCalls.ShouldBeEmpty(); sink.VariableCalls.Count.ShouldBe(2); sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false)); } /// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS /// disabled alarms. [Fact] public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentScriptedAlarms = new[] { new EquipmentScriptedAlarmPlan( ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm", Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;", DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true), new EquipmentScriptedAlarmPlan( ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition", Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;", DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: false), }, }; applier.MaterialiseScriptedAlarms(composition); // Only the enabled alarm is materialised; the disabled one is skipped entirely. // A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap). sink.AlarmConditionCalls.ShouldHaveSingleItem() .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false)); } /// Verifies that added equipment tags in an otherwise-empty plan trigger an /// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no /// longer a silent no-op). [Fact] public void Added_equipment_tags_trigger_rebuild() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var plan = EmptyPlan with { AddedEquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), }, }; var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); outcome.AddedNodes.ShouldBe(1); 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); } /// 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); } /// H1a (review follow-up) — a deploy that ONLY removes existing equipment tag / VirtualTag /// nodes must rebuild AND tally the removals. Removed tags/VirtualTags are plain variable nodes (no /// Part 9 condition to write), so before the fix they reached the rebuild path but were never added /// to removedCountPhase7ApplyOutcome.RemovedNodes reported 0, a misleading audit /// entry. This pins both the rebuild and the accurate count. [Fact] public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted() { var sink = new RecordingSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); 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), }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), }, }; var next = new Phase7CompositionResult( Array.Empty(), Array.Empty(), Array.Empty()); var plan = Phase7Planner.Compute(previous, next); // Guard the arrange: ONLY the two Removed sets are populated. plan.RemovedEquipmentTags.Count.ShouldBe(1); plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1); plan.AddedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.RemovedEquipment.ShouldBeEmpty(); plan.RemovedAlarms.ShouldBeEmpty(); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); outcome.RemovedNodes.ShouldBe(2); // both removals counted (was 0 before the fix) } 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(), Array.Empty(), Array.Empty(), Array.Empty()); private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new( AddedEquipment: Array.Empty(), RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); private sealed class RecordingSink : IOpcUaAddressSpaceSink { /// Gets the queue of alarm condition write calls. public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new(); /// Gets the queue of folder creation calls. public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new(); /// Gets the queue of variable creation calls. public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new(); /// Gets the queue of the historian-tagname arg captured per EnsureVariable call, /// keyed by NodeId (null ⇒ that call passed not-historized). public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new(); /// Gets the queue of alarm-condition materialise calls. public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionQueue { get; } = new(); /// Gets the number of rebuild calls made on this sink. public int RebuildCalls; /// Gets the list of recorded alarm writes. public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList(); /// Gets the list of recorded folder creation calls. public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList(); /// Gets the list of recorded variable creation calls. public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList(); /// Gets the list of recorded (NodeId, historian-tagname) pairs captured per EnsureVariable call. public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList(); /// Gets the list of recorded alarm-condition materialise calls. public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList(); /// Records a value write (no-op in this recording sink). /// The node ID. /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) => AlarmQueue.Enqueue((alarmNodeId, state)); /// Records an alarm-condition materialise call. /// The alarm node ID (== ScriptedAlarmId). /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative)); /// Records a folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records a variable creation call. /// The variable node ID. /// The parent folder node ID, if any. /// The display name for the variable. /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); HistorianQueue.Enqueue((variableNodeId, historianTagname)); } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); } private sealed class ThrowingSink : IOpcUaAddressSpaceSink { private readonly bool _throwOnAlarmWrite; /// Initializes a new instance of the ThrowingSink class. /// Whether to throw on alarm state writes. public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; } /// Records a value write (no-op in this sink). /// The node ID. /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } /// Throws an exception if configured to do so. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. /// Thrown when configured to throw on alarm write. public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); } /// No-op alarm-condition materialise call. /// The alarm node ID. /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } /// No-op folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } /// No-op variable creation call. /// The variable node ID. /// The parent folder node ID, if any. /// The display name for the variable. /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { } /// No-op rebuild address space call. public void RebuildAddressSpace() { } } }