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" }); sink.AlarmWrites.All(a => a.Active == false && a.Acknowledged == false).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(), AddedGalaxyTags: Array.Empty(), RemovedGalaxyTags: Array.Empty(), ChangedGalaxyTags: 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(), AddedGalaxyTags: Array.Empty(), RemovedGalaxyTags: Array.Empty(), ChangedGalaxyTags: Array.Empty()); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); } /// Verifies that sink exceptions in WriteAlarmState do not propagate and rebuild still fires. [Fact] public void Sink_exception_in_WriteAlarmState_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 MaterialiseGalaxyTags creates one folder per distinct FolderPath and one /// variable per tag, with root-level tags hung directly under the namespace root. [Fact] public void MaterialiseGalaxyTags_creates_folder_per_distinct_path_and_variable_per_tag() { 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(), GalaxyTags: new[] { new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"), new GalaxyTagPlan("t2", "drv", "", "Pressure", "Int32", "Pressure"), }); applier.MaterialiseGalaxyTags(composition); // One folder for the single distinct non-empty FolderPath; the root-level tag adds none. sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("section.area", null, "section.area")); // Foldered tag → NodeId is its MxAccessRef under the FolderPath parent. // Root-level tag → NodeId is its DisplayName under the root (null parent). sink.VariableCalls.ShouldContain(("section.area.Temperature", "section.area", "Temperature", "Float")); sink.VariableCalls.ShouldContain(("Pressure", (string?)null, "Pressure", "Int32")); sink.VariableCalls.Count.ShouldBe(2); } /// Verifies that two tags sharing a FolderPath produce a single EnsureFolder call /// (deduped) but one EnsureVariable per tag. [Fact] public void MaterialiseGalaxyTags_dedupes_folders_for_tags_sharing_a_path() { 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(), GalaxyTags: new[] { new GalaxyTagPlan("t1", "drv", "line.cell", "Speed", "Float", "line.cell.Speed"), new GalaxyTagPlan("t2", "drv", "line.cell", "Torque", "Float", "line.cell.Torque"), }); applier.MaterialiseGalaxyTags(composition); sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("line.cell", null, "line.cell")); sink.VariableCalls.Count.ShouldBe(2); sink.VariableCalls.ShouldContain(("line.cell.Speed", "line.cell", "Speed", "Float")); sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float")); } /// 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(), GalaxyTags: Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"), }, }; applier.MaterialiseEquipmentTags(composition); sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float")); } /// 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"), }, }; applier.MaterialiseEquipmentTags(composition); sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float")); } /// 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"), new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"), }, }; applier.MaterialiseEquipmentTags(composition); sink.VariableCalls.Count.ShouldBe(2); sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float")); sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float")); } /// 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 sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64")); } /// 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")); sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64")); } /// Verifies that added equipment tags in an otherwise-empty plan trigger an /// address-space rebuild (parity with the Galaxy-tag path — 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"), }, }; 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); } /// Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild. [Fact] public void Added_galaxy_tags_trigger_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: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty(), AddedGalaxyTags: new[] { new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"), }, RemovedGalaxyTags: Array.Empty(), ChangedGalaxyTags: Array.Empty()); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); outcome.AddedNodes.ShouldBe(1); sink.RebuildCalls.ShouldBe(1); } private static Phase7Plan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), 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(), AddedGalaxyTags: Array.Empty(), RemovedGalaxyTags: Array.Empty(), ChangedGalaxyTags: Array.Empty()); private sealed class RecordingSink : IOpcUaAddressSpaceSink { /// Gets the queue of alarm state write calls. public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> 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)> VariableQueue { 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, bool Active, bool Acknowledged)> 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)> VariableCalls => VariableQueue.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 state write call. /// The alarm node ID. /// Whether the alarm is active. /// Whether the alarm is acknowledged. /// The source timestamp in UTC. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => AlarmQueue.Enqueue((alarmNodeId, active, acknowledged)); /// 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. public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) => VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType)); /// 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. /// Whether the alarm is active. /// Whether the alarm is acknowledged. /// The source timestamp in UTC. /// Thrown when configured to throw on alarm write. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); } /// 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. public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { } /// No-op rebuild address space call. public void RebuildAddressSpace() { } } }