Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
T

2384 lines
127 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
[Fact]
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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();
}
/// <summary>R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal
/// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via
/// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the
/// pre-R2-07 "removed equipment ⇒ rebuild" pin.)</summary>
[Fact]
public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = WithEquipmentRemoval("eq-1", "eq-2");
var outcome = applier.Apply(plan);
outcome.RemovedNodes.ShouldBe(2);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// Terminal "no-event" condition state written per id (inactive + acked + confirmed).
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
// Each equipment torn down as a subtree.
sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") });
}
/// <summary>R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
/// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm
/// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.)</summary>
[Fact]
public void Added_equipment_is_pure_add_and_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = new AddressSpacePlan(
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
outcome.AddedNodes.ShouldBe(1);
sink.AlarmWrites.ShouldBeEmpty();
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
[Fact]
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = new AddressSpacePlan(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: new[]
{
new AddressSpacePlan.DriverDelta(
new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
},
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.</summary>
[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<AddressSpaceApplier>.Instance);
var plan = WithEquipmentRemoval("eq-1");
var outcome = applier.Apply(plan); // should not throw
outcome.RemovedNodes.ShouldBe(1);
outcome.RebuildCalled.ShouldBeTrue();
}
/// <summary>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).</summary>
[Fact]
public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
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"));
}
/// <summary>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.</summary>
[Fact]
public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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"));
}
/// <summary>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.</summary>
[Fact]
public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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));
}
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag (<c>Alarm is not null</c>) materialises a
/// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value
/// variable; a plain tag (<c>Alarm == null</c>) 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.</summary>
[Fact]
public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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);
}
/// <summary>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.</summary>
[Fact]
public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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();
}
/// <summary>Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it
/// to <c>EnsureVariable</c>: a historized tag with NO override falls back to its <c>FullName</c>; a
/// historized tag WITH an override passes the override verbatim; a non-historized tag passes null.</summary>
[Fact]
public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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
}
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
/// <c>FullName</c> (the resolve uses <c>string.IsNullOrWhiteSpace</c>, not just null).</summary>
[Fact]
public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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");
}
/// <summary>Array-support Task 2 — an <see cref="EquipmentTagPlan"/> with <c>IsArray: true,
/// ArrayLength: 16</c> flowing through <see cref="AddressSpaceApplier.MaterialiseEquipmentTags"/> must
/// forward BOTH flags verbatim to the sink's <c>EnsureVariable</c>. Guards against arg-order swaps or
/// accidental drops in the wire-through.</summary>
[Fact]
public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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);
}
/// <summary>Array-support Task 2 — a scalar <see cref="EquipmentTagPlan"/> (<c>IsArray: false</c>,
/// <c>ArrayLength: null</c>) must pass <c>isArray == false</c> through to the sink. Guards against a
/// default flip that would silently materialise scalar tags as 1-D arrays.</summary>
[Fact]
public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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();
}
/// <summary>Review M-1 — an array equipment tag authored with <c>Writable: true</c> must be
/// materialised as READ-ONLY (<c>writable == false</c>) 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.</summary>
[Fact]
public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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);
}
/// <summary>Review M-1 regression — a scalar tag authored with <c>Writable: true</c> must still
/// be materialised as read/write (<c>writable == true</c>). The array-clamp must NOT affect
/// scalar tags.</summary>
[Fact]
public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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();
}
/// <summary>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).</summary>
[Fact]
public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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"));
}
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
/// the equipment-VirtualTag pass is byte-identical to <see cref="EquipmentNodeIds.Variable"/> — 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.</summary>
[Fact]
public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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"));
}
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
/// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.</summary>
[Fact]
public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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));
}
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
/// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
/// disabled alarms.</summary>
[Fact]
public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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<string>(), 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<string>(), 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));
}
/// <summary>Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by
/// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables
/// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the
/// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them
/// parent-first before ensuring (a child folder's parent must exist first).</summary>
[Fact]
public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Child folder listed BEFORE its parent — the applier must re-order parent-first.
var folders = new[]
{
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
};
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
"String", Writable: false, IsArray: false, ArrayLength: null),
};
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
// Folders ensured parent-first regardless of input order (shallowest depth first).
sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS"));
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"));
// Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY.
sink.VariableCalls.ShouldHaveSingleItem()
.ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false));
// Exactly one NodeAdded model-change, announced under the equipment root.
sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1");
}
/// <summary>Task 4 — a discovered array variable (rare) authored <c>Writable: true</c> is forced
/// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the
/// IsArray / ArrayLength flags are forwarded verbatim to the sink.</summary>
[Fact]
public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16",
Writable: true, IsArray: true, ArrayLength: 8u),
};
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), variables);
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);
}
/// <summary>Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the
/// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on
/// existing nodes), and a model-change is raised once PER call (twice across two calls).</summary>
[Fact]
public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var folders = new[]
{
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
};
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
"String", Writable: false, IsArray: false, ArrayLength: null),
};
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
// Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the
// DISTINCT set the applier produces is stable across re-applies.
sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" });
// One model-change per call ⇒ two across two calls.
sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" });
}
/// <summary>Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no
/// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change.</summary>
[Fact]
public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), Array.Empty<DiscoveredVariable>());
sink.FolderCalls.ShouldBeEmpty();
sink.VariableCalls.ShouldBeEmpty();
sink.ModelChangeCalls.ShouldBeEmpty();
}
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
/// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.)</summary>
[Fact]
public void Added_equipment_tags_are_pure_add_and_skip_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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.ShouldBeFalse();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
/// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒
/// rebuild" pin.)</summary>
[Fact]
public void Added_equipment_virtual_tags_are_pure_add_and_skip_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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.ShouldBeFalse();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on
/// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The
/// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is
/// surgical-eligible); the applier still runs the F10b surgical pass.</summary>
[Fact]
public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
ChangedEquipmentTags = new[]
{
new AddressSpacePlan.EquipmentTagDelta(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.Writable.ShouldBeTrue();
}
/// <summary>R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node
/// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is
/// preserved through the classifier routing).</summary>
[Fact]
public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { SurgicalReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
ChangedEquipmentTags = new[]
{
new AddressSpacePlan.EquipmentTagDelta(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
/// <summary>R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the
/// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2
/// phase pin.)</summary>
[Fact]
public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = 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.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
// Terminal Bad written to the removed variable BEFORE the removal.
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
}
/// <summary>R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing
/// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION
/// node in place (not a value variable).</summary>
[Fact]
public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
var write = sink.AlarmWrites.ShouldHaveSingleItem();
write.NodeId.ShouldBe(nodeId);
(!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue();
// Removed as a condition node, not a value variable.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId));
sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId);
}
/// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
/// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
[Fact]
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
RemovedEquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
// ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1"));
}
/// <summary>R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier
/// falls back to a full rebuild (the one-way ratchet) and attempts no further surgical removes.</summary>
[Fact]
public void Pure_remove_with_remove_returns_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { RemoveReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
// Ratchet: stopped at the FIRST failed remove — did not attempt the second.
sink.RemoveCalls.Count.ShouldBe(1);
}
/// <summary>R2-07 T11 — a PureRemove on a sink lacking the surgical capability falls back to a full
/// rebuild (safe default), same as the F10b attribute path.</summary>
[Fact]
public void Pure_remove_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE
/// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's
/// passes. (Supersedes the T2 phase pin.)</summary>
[Fact]
public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", EquipmentNodeIds.Variable("eq-1", "", "Old")));
outcome.AddedNodes.ShouldBe(1);
outcome.RemovedNodes.ShouldBe(1);
}
/// <summary>R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the
/// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the
/// recreate is the publish actor's job, a fresh NodeState). No rebuild.</summary>
[Fact]
public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Slot");
var plan = EmptyPlan with
{
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
}
/// <summary>R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet.</summary>
[Fact]
public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { RemoveReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the
/// driver-side <c>FullName</c> re-routes to a different point) must rebuild the address space. The planner
/// diffs the tag into <c>ChangedEquipmentTags</c> 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.)</summary>
[Fact]
public void Changed_equipment_tags_only_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's <c>Expression</c>
/// 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 <c>VirtualTagHostActor</c>'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.)</summary>
[Fact]
public void Changed_virtual_tag_expression_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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
}
/// <summary>F10b — a vtag delta differing ONLY in <c>DependencyRefs</c> (e.g. a new <c>ctx.GetTag</c>
/// literal in the script) is node-irrelevant and SKIPS the rebuild; the engine respawn picks up the
/// new dependency set.</summary>
[Fact]
public void Changed_virtual_tag_dependency_refs_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>F10b — a vtag delta differing ONLY in <c>Historize</c> (write-side flag, not materialised
/// on the node) is node-irrelevant and SKIPS the rebuild.</summary>
[Fact]
public void Changed_virtual_tag_historize_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>DataType</c> 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.</summary>
[Fact]
public void Changed_virtual_tag_data_type_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>Name</c> changed must
/// rebuild (the node's BrowseName/NodeId derive from Name).</summary>
[Fact]
public void Changed_virtual_tag_name_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>FolderPath</c> changed must
/// rebuild (the folder-scoped NodeId derives from FolderPath).</summary>
[Fact]
public void Changed_virtual_tag_folder_path_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>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 <c>FullName</c> re-routes) must still rebuild — the rebuild is forced
/// by the OTHER change, and the running server gets its single rebuild as before.</summary>
[Fact]
public void Node_irrelevant_vtag_edit_mixed_with_another_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>EquipmentId</c> 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.</summary>
[Fact]
public void Changed_virtual_tag_equipment_id_triggers_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>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 <c>Any(d =&gt; !VtagDeltaIsNodeIrrelevant(d))</c> predicate must catch the structural delta
/// even though its sibling vtag would individually qualify for the skip.</summary>
[Fact]
public void Changed_virtual_tags_one_irrelevant_one_structural_triggers_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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);
}
/// <summary>H1a — a deploy that ONLY edits an existing scripted alarm (here its message template)
/// must rebuild the address space. The planner diffs the thin <c>ScriptedAlarmPlan</c> projection
/// into <c>ChangedAlarms</c> alone; the applier must drive exactly one rebuild so the condition node
/// reflects the edit.</summary>
[Fact]
public void Changed_alarms_only_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>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 <c>ChangedDrivers</c> is intentionally excluded from
/// <c>needsRebuild</c>. This pins the exclusion against accidental inclusion. (The pre-existing
/// <see cref="Driver_only_changes_do_not_trigger_address_space_rebuild"/> uses a hand-built plan;
/// this one drives the planner end-to-end.)</summary>
[Fact]
public void Changed_drivers_only_do_not_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>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 <c>removedCount</c> — <c>AddressSpaceApplyOutcome.RemovedNodes</c> reported 0, a misleading audit
/// entry. This pins both the rebuild and the accurate count.</summary>
[Fact]
public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
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);
// R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in
// place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted.
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.RemovedNodes.ShouldBe(2); // both removals counted
sink.RemoveCalls.Count.ShouldBe(2);
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed")));
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency")));
}
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
/// <summary>F10b — a deploy that ONLY flips an existing equipment tag's <c>Writable</c> bit (a plain,
/// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change
/// IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateTagAttributes</c>, preserving every client's
/// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as
/// a change (ChangedNodes == 1).</summary>
[Fact]
public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>F10b — flipping <c>IsHistorized</c> false → true (no override) updates in place with the
/// historian tagname defaulting to <c>FullName</c>; flipping true → false updates in place with a null
/// historian tagname. Both skip the rebuild.</summary>
[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<AddressSpaceApplier>.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<AddressSpaceApplier>.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
}
/// <summary>F10b — changing ONLY the <c>HistorianTagname</c> override on an already-historized tag
/// skips the rebuild and updates in place, passing the NEW override verbatim.</summary>
[Fact]
public void Changed_tag_historian_tagname_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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
}
/// <summary>FB-7 — a tag delta whose <c>DataType</c> 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.</summary>
[Fact]
public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>FB-7 — a tag delta whose <c>IsArray</c> 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.</summary>
[Fact]
public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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
}
/// <summary>F10b safe-default — a tag delta whose driver-side <c>FullName</c> changed is NOT
/// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds.</summary>
[Fact]
public void Changed_tag_full_name_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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();
}
/// <summary>F10b safe-default — a tag delta whose <c>Name</c> changed is NOT surgical-eligible (the
/// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds.</summary>
[Fact]
public void Changed_tag_name_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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();
}
/// <summary>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.</summary>
[Fact]
public void Changed_tag_alarm_presence_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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();
}
/// <summary>F10b guard — a tag that ALREADY carries an alarm (<c>Alarm is not null</c> on BOTH previous
/// and current, identical alarm info) is NOT surgical-eligible even when only <c>Writable</c> changes.
/// The explicit <c>Alarm is null</c> guard in <c>TagDeltaIsSurgicalEligible</c> prevents a false
/// positive: without it the <c>with { Writable = … }</c> override (which does NOT touch <c>Alarm</c>)
/// would leave both sides with equal <c>Alarm</c> 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.</summary>
[Fact]
public void Changed_alarm_bearing_tag_writable_only_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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
}
/// <summary>F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain
/// value variables, no alarm, both changing only <c>Writable</c>), the applier must apply BOTH in
/// place (two <c>UpdateTagAttributes</c> calls) without triggering any rebuild. Proves the surgical
/// path iterates all eligible deltas rather than stopping after the first.</summary>
[Fact]
public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible.
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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);
}
/// <summary>R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd:
/// the added equipment is created by the Materialise passes and the surgical tag change is applied IN
/// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07
/// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.)</summary>
[Fact]
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue();
}
/// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot
/// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full
/// rebuild (safe default).</summary>
[Fact]
public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>F10b fallback — when the surgical sink reports the node MISSING
/// (<c>UpdateTagAttributes</c> returns false), the applier falls back to a full rebuild. The surgical
/// call is still attempted (recorded once) before the fallback fires.</summary>
[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<AddressSpaceApplier>.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
}
// ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) -----
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and
/// apply the new DisplayName IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateFolderDisplayName</c>,
/// preserving every client's subscriptions. Exactly one surgical folder call lands with the area's
/// NodeId (== UnsAreaId) and the NEW display name; the rename still counts as a change.</summary>
[Fact]
public void Area_rename_only_skips_rebuild_and_updates_folder_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant North"));
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant South"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
call.FolderNodeId.ShouldBe("area-1"); // folder NodeId == UnsAreaId (MaterialiseHierarchy scheme)
call.DisplayName.ShouldBe("Plant South"); // the NEW display name
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>OpcUaServer-001 — a Line rename only updates the line folder in place (NodeId == UnsLineId)
/// and skips the rebuild.</summary>
[Fact]
public void Line_rename_only_skips_rebuild_and_updates_folder_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell A"));
var next = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell B"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
call.FolderNodeId.ShouldBe("line-1");
call.DisplayName.ShouldBe("Cell B");
}
/// <summary>R2-07 T2 — a folder rename MIXED with an added equipment is now a PureAdd: the added
/// equipment is created by the Materialise passes and the rename is applied IN PLACE via the surgical
/// sink, so NO rebuild fires and exactly one folder-rename call lands. (Supersedes the pre-R2-07
/// "rename-mixed-with-add ⇒ rebuild" pin.)</summary>
[Fact]
public void Folder_rename_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "North") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
// Area renamed AND a brand-new equipment node — an add no longer forces a rebuild; the rename
// rides the in-place surgical path alongside the PureAdd.
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-new", "New", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
plan.AddedEquipment.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.FolderRenameCalls.ShouldHaveSingleItem().ShouldBe(("area-1", "South"));
}
/// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement
/// <see cref="ISurgicalAddressSpaceSink"/> cannot apply the in-place update, so it drives a full
/// rebuild (safe default).</summary>
[Fact]
public void Folder_rename_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>OpcUaServer-001 fallback — when the surgical sink reports the folder MISSING
/// (<c>UpdateFolderDisplayName</c> returns false), the applier falls back to a full rebuild. The
/// surgical call is still attempted (recorded once) before the fallback fires.</summary>
[Fact]
public void Folder_rename_surgical_returning_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { FolderRenameReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild
sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
// ----- R2-07 T3: pure-add NodeAdded announcements -----
/// <summary>Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the
/// equipment id.</summary>
[Fact]
public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
}
/// <summary>A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment.</summary>
[Fact]
public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") });
}
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
[Fact]
public void ComputeAddAnnouncements_added_alarm_announces_equipment()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") },
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" });
}
/// <summary>An added equipment WITH a UnsLineId announces its parent line; WITHOUT one announces its own
/// new folder id (a valid Part 3 NodeAdded of the folder itself; the root has no announceable id).</summary>
[Fact]
public void ComputeAddAnnouncements_added_equipment_announces_line_or_self()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var withLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-7") } };
applier.ComputeAddAnnouncements(withLine).ShouldBe(new[] { "line-7" });
var noLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "") } };
applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" });
}
/// <summary>A plan with no additions yields no announcements.</summary>
[Fact]
public void ComputeAddAnnouncements_empty_add_plan_yields_none()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty();
}
/// <summary>AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected
/// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped.</summary>
[Fact]
public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null),
},
};
applier.AnnounceAddedNodes(plan);
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
}
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
new(
areas, Array.Empty<UnsLineProjection>(), Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
private static AddressSpaceComposition CompositionWithLines(params UnsLineProjection[] lines) =>
new(
Array.Empty<UnsAreaProjection>(), lines, Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
};
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = vtags,
};
private static AddressSpaceComposition CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) =>
// ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms.
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), alarms);
private static AddressSpaceComposition CompositionWithDrivers(params DriverInstancePlan[] drivers) =>
new(
Array.Empty<EquipmentNode>(), drivers, Array.Empty<ScriptedAlarmPlan>());
private static AddressSpacePlan EmptyPlan => new(
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<AddressSpacePlan.EquipmentDelta>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<AddressSpacePlan.DriverDelta>(),
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<AddressSpacePlan.AlarmDelta>());
private static AddressSpacePlan WithEquipmentRemoval(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
/// <summary>Gets the queue of surgical in-place tag-attribute update calls (F10b + FB-7).</summary>
public ConcurrentQueue<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalQueue { get; } = new();
/// <summary>Gets the list of recorded surgical in-place tag-attribute update calls.</summary>
public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalCalls => SurgicalQueue.ToList();
/// <summary>When false, <see cref="UpdateTagAttributes"/> reports the node missing (returns false),
/// driving the applier's rebuild fallback. Defaults to true (node present, update succeeds).</summary>
public bool SurgicalReturns { get; init; } = true;
/// <summary>Records a surgical in-place tag-attribute update; returns <see cref="SurgicalReturns"/>.</summary>
/// <param name="variableNodeId">The variable node ID to update in place.</param>
/// <param name="writable">The new Writable (AccessLevel) for the node.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
/// <param name="dataType">The new OPC UA data type name to apply in place.</param>
/// <param name="isArray">The new array-ness of the node.</param>
/// <param name="arrayLength">The new 1-D array length when <paramref name="isArray"/> is true.</param>
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;
}
/// <summary>Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001).</summary>
public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new();
/// <summary>Gets the list of recorded surgical folder display-name update calls.</summary>
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls => FolderRenameQueue.ToList();
/// <summary>When false, <see cref="UpdateFolderDisplayName"/> reports the folder missing (returns
/// false), driving the applier's rebuild fallback. Defaults to true.</summary>
public bool FolderRenameReturns { get; init; } = true;
/// <summary>Records a surgical in-place folder display-name update; returns <see cref="FolderRenameReturns"/>.</summary>
/// <param name="folderNodeId">The folder node ID to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
{
FolderRenameQueue.Enqueue((folderNodeId, displayName));
return FolderRenameReturns;
}
/// <summary>Gets the queue of surgical remove calls (kind + node id) in call order (R2-07 Phase 2).</summary>
public ConcurrentQueue<(string Kind, string NodeId)> RemoveQueue { get; } = new();
/// <summary>Gets the list of recorded surgical remove calls.</summary>
public List<(string Kind, string NodeId)> RemoveCalls => RemoveQueue.ToList();
/// <summary>When false, the Remove* members report the node missing (return false), driving the
/// applier's rebuild fallback. Defaults to true (node present, removal succeeds).</summary>
public bool RemoveReturns { get; init; } = true;
/// <summary>Records a surgical variable-node removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveVariableNode(string variableNodeId)
{
RemoveQueue.Enqueue(("var", variableNodeId));
return RemoveReturns;
}
/// <summary>Records a surgical alarm-condition-node removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveAlarmConditionNode(string alarmNodeId)
{
RemoveQueue.Enqueue(("alarm", alarmNodeId));
return RemoveReturns;
}
/// <summary>Records a surgical equipment-subtree removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveEquipmentSubtree(string equipmentNodeId)
{
RemoveQueue.Enqueue(("equipment", equipmentNodeId));
return RemoveReturns;
}
/// <summary>Gets the queue of alarm condition write calls.</summary>
public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
/// <summary>Gets the queue of folder creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// <summary>Gets the queue of variable creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new();
/// <summary>Gets the queue of the historian-tagname arg captured per <c>EnsureVariable</c> call,
/// keyed by NodeId (null ⇒ that call passed not-historized).</summary>
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
/// <summary>Gets the queue of the isArray/arrayLength args captured per <c>EnsureVariable</c>
/// call, keyed by NodeId.</summary>
public ConcurrentQueue<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayQueue { get; } = new();
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionQueue { get; } = new();
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
public int RebuildCalls;
/// <summary>Gets the list of recorded alarm writes.</summary>
public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList();
/// <summary>Gets the list of recorded folder creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// <summary>Gets the list of recorded variable creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList();
/// <summary>Gets the list of recorded (NodeId, historian-tagname) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <summary>Gets the list of recorded (NodeId, isArray, arrayLength) triples captured per EnsureVariable call.</summary>
public List<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayCalls => ArrayQueue.ToList();
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// <summary>Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad.</summary>
public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new();
/// <summary>Gets the list of recorded value writes.</summary>
public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList();
/// <summary>Records a value write (NodeId + quality).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> ValueWriteQueue.Enqueue((nodeId, quality));
/// <summary>Records an alarm condition write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, state));
/// <summary>Records an alarm-condition materialise call.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
/// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records a variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
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));
}
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Gets the queue of NodeAdded model-change announcements (discovered-node injection).</summary>
public ConcurrentQueue<string> ModelChangeQueue { get; } = new();
/// <summary>Gets the list of recorded NodeAdded model-change announcements (discovered-node injection).</summary>
public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId);
}
/// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
/// prove the F10b fallback: when the bound sink lacks the surgical capability, a surgical-eligible tag
/// delta still drives a full <c>RebuildAddressSpace</c>.</summary>
private sealed class PlainRecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
public int RebuildCalls;
/// <summary>Records a value write (no-op in this sink).</summary>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>No-op alarm condition write call.</summary>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
/// <summary>No-op alarm-condition materialise call.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// <summary>No-op folder creation call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>No-op variable creation call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
{
private readonly bool _throwOnAlarmWrite;
/// <summary>Initializes a new instance of the ThrowingSink class.</summary>
/// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
/// <summary>Records a value write (no-op in this sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
/// <summary>No-op alarm-condition materialise call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>No-op variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
}