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));
}
/// 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.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700));
}
/// 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);
}
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 alarm-condition materialise calls.
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> 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 alarm-condition materialise calls.
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> 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)
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
/// 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.
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// 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) { }
/// 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.
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// No-op rebuild address space call.
public void RebuildAddressSpace() { }
}
}