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 AddressSpaceApplierTests
{
/// 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 AddressSpaceApplier(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 AddressSpaceApplier(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 AddressSpaceApplier(sink, NullLogger.Instance);
var plan = new AddressSpacePlan(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var plan = new AddressSpacePlan(
AddedEquipment: Array.Empty(),
RemovedEquipment: Array.Empty(),
ChangedEquipment: Array.Empty(),
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
RemovedDrivers: Array.Empty(),
ChangedDrivers: new[]
{
new AddressSpacePlan.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 AddressSpaceApplier(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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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");
}
/// Array-support Task 2 — an with IsArray: true,
/// ArrayLength: 16 flowing through must
/// forward BOTH flags verbatim to the sink's EnsureVariable. Guards against arg-order swaps or
/// accidental drops in the wire-through.
[Fact]
public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u),
},
};
applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Buffer"));
call.IsArray.ShouldBeTrue();
call.ArrayLength.ShouldBe(16u);
}
/// Array-support Task 2 — a scalar (IsArray: false,
/// ArrayLength: null) must pass isArray == false through to the sink. Guards against a
/// default flip that would silently materialise scalar tags as 1-D arrays.
[Fact]
public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
},
};
applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.IsArray.ShouldBeFalse();
call.ArrayLength.ShouldBeNull();
}
/// Review M-1 — an array equipment tag authored with Writable: true must be
/// materialised as READ-ONLY (writable == false) because array writes are out of scope
/// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash
/// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the
/// writable path for arrays.
[Fact]
public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentTags = new[]
{
// Authored ReadWrite AND IsArray — the applier must clamp to read-only.
new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u),
},
};
applier.MaterialiseEquipmentTags(composition);
// writable must be false (array writes out of scope), isArray must be true (forwarded verbatim).
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
arrCall.IsArray.ShouldBeTrue();
arrCall.ArrayLength.ShouldBe(8u);
}
/// Review M-1 regression — a scalar tag authored with Writable: true must still
/// be materialised as read/write (writable == true). The array-clamp must NOT affect
/// scalar tags.
[Fact]
public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentTags = new[]
{
// Authored ReadWrite, scalar — must pass through writable: true unchanged.
new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null),
},
};
applier.MaterialiseEquipmentTags(composition);
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
arrCall.IsArray.ShouldBeFalse();
}
/// 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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier + 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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
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 AddressSpaceApplier(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 AddressSpaceApplier(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 in a NON-surgical way (here the
/// driver-side FullName re-routes to a different point) 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.
/// (Surgically-applicable tag changes — Writable/Historizing/DataType/array-shape — take the in-place path
/// instead; those are covered by the F10b + FB-7 surgical tests.)
[Fact]
public void Changed_equipment_tags_only_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(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 the driver-side FullName flipped — a non-surgical change, so the applier rebuilds.
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.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);
}
/// F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's Expression
/// must SKIP the address-space rebuild. The vtag's materialised node is derived only from
/// {EquipmentId, FolderPath, Name, DataType}, so an Expression-only edit leaves a byte-identical node;
/// the vtag engine adopts the new expression via VirtualTagHostActor's independent respawn, not
/// the address-space path. Skipping the rebuild preserves every client's server-wide subscriptions.
/// The edit STILL counts as a change (ChangedNodes == 1) — it just no longer forces a rebuild.
/// (Supersedes the former H1a "expression edit ⇒ rebuild" behavior.)
[Fact]
public void Changed_virtual_tag_expression_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(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 = AddressSpacePlanner.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.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
outcome.ChangedNodes.ShouldBe(1); // the edit is still tallied as a change
}
/// F10b — a vtag delta differing ONLY in DependencyRefs (e.g. a new ctx.GetTag
/// literal in the script) is node-irrelevant and SKIPS the rebuild; the engine respawn picks up the
/// new dependency set.
[Fact]
public void Changed_virtual_tag_dependency_refs_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
// Same node-relevant fields; only the dependency set differs.
// Note: DependencyRefs normally tracks the Expression (derived by the ref extractor), so a
// deps-only divergence is an edge case — e.g. the extractor logic changed between two deploys —
// but it is still correctly node-irrelevant: the materialised OPC UA node is unchanged.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a", "b" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.ChangedNodes.ShouldBe(1);
}
/// F10b — a vtag delta differing ONLY in Historize (write-side flag, not materialised
/// on the node) is node-irrelevant and SKIPS the rebuild.
[Fact]
public void Changed_virtual_tag_historize_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: false));
// Same node-relevant fields; only the Historize flag flips.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: true));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.ChangedNodes.ShouldBe(1);
}
/// F10b safe-default — a vtag delta where the node-affecting DataType ALSO changed is
/// NOT node-irrelevant: the materialised node would differ, so the applier must still rebuild. Pins
/// the whitelist (Expression/DependencyRefs/Historize) against accidentally swallowing a DataType
/// edit.
[Fact]
public void Changed_virtual_tag_data_type_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));
// DataType flips AND the expression changes — DataType is node-affecting, so this must rebuild.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Int32",
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// F10b safe-default — a vtag delta where the node-affecting Name changed must
/// rebuild (the node's BrowseName/NodeId derive from Name).
[Fact]
public void Changed_virtual_tag_name_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "EfficiencyPct", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// F10b safe-default — a vtag delta where the node-affecting FolderPath changed must
/// rebuild (the folder-scoped NodeId derives from FolderPath).
[Fact]
public void Changed_virtual_tag_folder_path_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "Calc", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// F10b — the skip is ONLY for a node-irrelevant vtag edit that is the SOLE change. A
/// node-irrelevant Expression-only vtag edit MIXED with another NON-surgical change (here a changed
/// equipment tag whose driver-side FullName re-routes) must still rebuild — the rebuild is forced
/// by the OTHER change, and the running server gets its single rebuild as before.
[Fact]
public void Node_irrelevant_vtag_edit_mixed_with_another_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = new AddressSpaceComposition(
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\") * 60", DependencyRefs: new[] { "a" }),
},
};
// Expression-only vtag edit (node-irrelevant) AND a non-surgical tag change (FullName re-route).
var next = new AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }),
},
};
var plan = AddressSpacePlanner.Compute(previous, next);
// Both a node-irrelevant vtag change AND a node-affecting tag change are present.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// F10b safe-default — a vtag delta where the node-affecting EquipmentId changed
/// (admin reassigns the vtag to a different equipment) must rebuild. The materialised node lives
/// under the old equipment folder; moving it to another folder requires a full address-space
/// rebuild. EquipmentId is NOT in the node-irrelevant whitelist (Expression/DependencyRefs/Historize),
/// so the applier must not skip.
[Fact]
public void Changed_virtual_tag_equipment_id_triggers_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
// Same VirtualTagId, identical Expression/DependencyRefs/DataType/Name/FolderPath —
// but reassigned to a different equipment ("eq-2" instead of "eq-1").
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: the planner sees this as a changed vtag (same id, different EquipmentId).
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// EquipmentId is node-affecting — the node moves to a different folder — so rebuild is required.
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
outcome.ChangedNodes.ShouldBe(1);
}
/// F10b safe-default — when a plan contains TWO changed vtags and ONE is node-irrelevant
/// (Expression-only) while the OTHER is structural (DataType changed), the applier must still rebuild.
/// The Any(d => !VtagDeltaIsNodeIrrelevant(d)) predicate must catch the structural delta
/// even though its sibling vtag would individually qualify for the skip.
[Fact]
public void Changed_virtual_tags_one_irrelevant_one_structural_triggers_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = new AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentVirtualTags = new[]
{
// vt-A: will receive an Expression-only edit (node-irrelevant).
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
Expression: "ctx.GetTag(\"speed\") * 60", DependencyRefs: new[] { "speed" }),
// vt-B: will receive a DataType change (structural / node-affecting).
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Float64",
Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
},
};
var next = new AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentVirtualTags = new[]
{
// vt-A: Expression-only change — individually node-irrelevant.
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
Expression: "ctx.GetTag(\"speed\") * 120", DependencyRefs: new[] { "speed" }),
// vt-B: DataType flipped — structurally node-affecting.
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Int32",
Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
},
};
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: both vtags are diffed as changed, nothing else.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// The structural delta on vt-B must force a rebuild even though vt-A is node-irrelevant.
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
outcome.ChangedNodes.ShouldBe(2);
}
/// 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 AddressSpaceApplier(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 = AddressSpacePlanner.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 AddressSpaceApplier(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 = AddressSpacePlanner.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 removedCount — AddressSpaceApplyOutcome.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 AddressSpaceApplier(sink, NullLogger.Instance);
var previous = new AddressSpaceComposition(
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 AddressSpaceComposition(
Array.Empty(), Array.Empty(), Array.Empty());
var plan = AddressSpacePlanner.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)
}
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
/// F10b — a deploy that ONLY flips an existing equipment tag's Writable bit (a plain,
/// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change
/// IN PLACE via ISurgicalAddressSpaceSink.UpdateTagAttributes, preserving every client's
/// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as
/// a change (ChangedNodes == 1).
[Fact]
public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(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 TagId/identity; only Writable flips false → true.
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.Writable.ShouldBeTrue(); // the NEW Writable value
call.Historian.ShouldBeNull(); // not historized
outcome.ChangedNodes.ShouldBe(1);
}
/// F10b — flipping IsHistorized false → true (no override) updates in place with the
/// historian tagname defaulting to FullName; flipping true → false updates in place with a null
/// historian tagname. Both skip the rebuild.
[Fact]
public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian()
{
// false → true (no override) ⇒ historian defaults to FullName.
var sinkOn = new RecordingSink();
var applierOn = new AddressSpaceApplier(sinkOn, NullLogger.Instance);
var planOn = AddressSpacePlanner.Compute(
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)),
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)));
planOn.ChangedEquipmentTags.Count.ShouldBe(1);
var outcomeOn = applierOn.Apply(planOn);
outcomeOn.RebuildCalled.ShouldBeFalse();
sinkOn.RebuildCalls.ShouldBe(0);
sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A"); // default ⇒ FullName
// true → false ⇒ historian null.
var sinkOff = new RecordingSink();
var applierOff = new AddressSpaceApplier(sinkOff, NullLogger.Instance);
var planOff = AddressSpacePlanner.Compute(
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)),
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)));
planOff.ChangedEquipmentTags.Count.ShouldBe(1);
var outcomeOff = applierOff.Apply(planOff);
outcomeOff.RebuildCalled.ShouldBeFalse();
sinkOff.RebuildCalls.ShouldBe(0);
sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull(); // not historized ⇒ null
}
/// F10b — changing ONLY the HistorianTagname override on an already-historized tag
/// skips the rebuild and updates in place, passing the NEW override verbatim.
[Fact]
public void Changed_tag_historian_tagname_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old"));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New"); // override verbatim
}
/// FB-7 — a tag delta whose DataType changed is now surgical-eligible: the sink swaps the
/// node's DataType in place (and raises a GeneralModelChangeEvent), so the applier SKIPS the rebuild and
/// makes exactly one surgical call carrying the NEW DataType. Here Writable also flips, which the same
/// in-place update applies. Subscriptions are preserved.
[Fact]
public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
// DataType flips Float → Int32 AND Writable flips false → true — both are now surgically applied.
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.DataType.ShouldBe("Int32"); // the NEW DataType
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
call.IsArray.ShouldBeFalse();
outcome.ChangedNodes.ShouldBe(1);
}
/// FB-7 — a tag delta whose IsArray flag flips scalar → array is now surgical-eligible:
/// the sink swaps ValueRank + ArrayDimensions in place, so the applier skips the rebuild and the surgical
/// call carries the new array shape. An array tag is forced read-only (matching EnsureVariable), so the
/// surgical Writable is false even though the tag stays non-writable here.
[Fact]
public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.IsArray.ShouldBeTrue(); // the NEW array shape
call.ArrayLength.ShouldBe(16u);
call.DataType.ShouldBe("Int16"); // element type unchanged
call.Writable.ShouldBeFalse(); // array tag forced read-only
}
/// F10b safe-default — a tag delta whose driver-side FullName changed is NOT
/// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds.
[Fact]
public void Changed_tag_full_name_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty();
}
/// F10b safe-default — a tag delta whose Name changed is NOT surgical-eligible (the
/// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds.
[Fact]
public void Changed_tag_name_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty();
}
/// F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT
/// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only
/// a rebuild can materialise. No surgical call is made.
[Fact]
public void Changed_tag_alarm_presence_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
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 AddressSpaceApplier(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 = AddressSpacePlanner.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 AddressSpaceApplier(sink, NullLogger.Instance);
// Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible.
var previous = new AddressSpaceComposition(
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 AddressSpaceComposition(
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 = AddressSpacePlanner.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).
[Fact]
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = new AddressSpaceComposition(
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),
},
};
// Surgical-eligible Writable flip on the tag AND a brand-new equipment node.
var next = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty(), Array.Empty())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
},
};
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipment.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty();
}
/// F10b fallback — a sink that does NOT implement cannot
/// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full
/// rebuild (safe default).
[Fact]
public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// F10b fallback — when the surgical sink reports the node MISSING
/// (UpdateTagAttributes returns false), the applier falls back to a full rebuild. The surgical
/// call is still attempted (recorded once) before the fallback fires.
[Fact]
public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild()
{
var sink = new RecordingSink { SurgicalReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
new(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentTags = tags,
};
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
new(
Array.Empty(), Array.Empty(), Array.Empty())
{
EquipmentVirtualTags = vtags,
};
private static AddressSpaceComposition CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) =>
// ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms.
new(
Array.Empty(), Array.Empty(), alarms);
private static AddressSpaceComposition CompositionWithDrivers(params DriverInstancePlan[] drivers) =>
new(
Array.Empty(), drivers, Array.Empty());
private static AddressSpacePlan EmptyPlan => new(
Array.Empty(), Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(), Array.Empty());
private static AddressSpacePlan 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, ISurgicalAddressSpaceSink
{
/// Gets the queue of surgical in-place tag-attribute update calls (F10b + FB-7).
public ConcurrentQueue<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalQueue { get; } = new();
/// Gets the list of recorded surgical in-place tag-attribute update calls.
public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalCalls => SurgicalQueue.ToList();
/// When false, reports the node missing (returns false),
/// driving the applier's rebuild fallback. Defaults to true (node present, update succeeds).
public bool SurgicalReturns { get; init; } = true;
/// Records a surgical in-place tag-attribute update; returns .
/// The variable node ID to update in place.
/// The new Writable (AccessLevel) for the node.
/// The resolved historian tagname (null ⇒ not historized).
/// The new OPC UA data type name to apply in place.
/// The new array-ness of the node.
/// The new 1-D array length when is true.
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
{
SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
return SurgicalReturns;
}
/// 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 the isArray/arrayLength args captured per EnsureVariable
/// call, keyed by NodeId.
public ConcurrentQueue<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayQueue { 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 (NodeId, isArray, arrayLength) triples captured per EnsureVariable call.
public List<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayCalls => ArrayQueue.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, bool isArray = false, uint? arrayLength = null)
{
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname));
ArrayQueue.Enqueue((variableNodeId, isArray, arrayLength));
}
/// Records a rebuild address space call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
/// A recording sink that does NOT implement — used to
/// prove the F10b fallback: when the bound sink lacks the surgical capability, a surgical-eligible tag
/// delta still drives a full RebuildAddressSpace.
private sealed class PlainRecordingSink : IOpcUaAddressSpaceSink
{
/// Gets the number of rebuild calls made on this sink.
public int RebuildCalls;
/// Records a value write (no-op in this sink).
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// No-op alarm condition write call.
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
/// No-op alarm-condition materialise call.
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// No-op folder creation call.
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// No-op variable creation call.
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// 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, bool isArray = false, uint? arrayLength = null) { }
/// No-op rebuild address space call.
public void RebuildAddressSpace() { }
}
}