refactor(opcua): delete the dead v3 equipment-tag materialisation (B5)

Review 01 U2. The composer never emits EquipmentTags in v3 (device values
live in the Raw subtree and reach equipment as UnsReferenceVariables), so
the equipment-tag plan diff sets (Added/Removed/ChangedEquipmentTags,
EquipmentTagDelta), the planner's diff over them, the classifier's
TagDeltaIsSurgicalEligible and its Rebuild clause, the applier's
MaterialiseEquipmentTags pass, surgical ChangedEquipmentTags loop,
removed-tag teardown loop and add-announcement term, and the publish
actor's equipment-tag pass were unreachable, maintained dead weight.
IsEmpty keeps its semantics (an otherwise-empty plan is still Empty).

Kept, deliberately: AddressSpaceComposition.EquipmentTags and the
EquipmentTagPlan record — Runtime's DeploymentArtifact (outside this
lane) still assigns the empty set and the artifact parity tests assert
it; follow-up F2 removes both together.

Tests: the equipment-tag vehicle tests for PureRemove / AddRemoveMix /
the rebuild ratchet are re-expressed over raw tags (same applier paths);
the equipment-tag surgical and classifier tests are deleted (their raw /
UNS-reference equivalents already exist); SubscriptionSurvivalTests now
materialises its tags through MaterialiseRawSubtree.

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 10:29:54 -04:00
parent 1578efd4a9
commit 118933d5d5
10 changed files with 159 additions and 1139 deletions
@@ -105,23 +105,21 @@ public sealed class AddressSpaceApplier
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++;
}
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
// removed raw containers/tags + UNS reference variables are likewise real removals.
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
// Removed VirtualTags are plain variable nodes (no Part 9 condition to write before tear-down), but
// they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes is accurate on a
// removed-vtag-only deploy. v3 Batch 4: removed raw containers/tags + UNS reference variables are
// likewise real removals.
removedCount += plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count +
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
// A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
@@ -143,7 +141,7 @@ public sealed class AddressSpaceApplier
// order even when an id is reused across the remove + add sets (the recreated node is a fresh
// NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback.
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
// ChangedAlarms / node-relevant ChangedEquipmentVirtualTags / non-surgical raw-tag deltas)
// — the classifier's rule 2, which also catches any future plan field that makes a changed
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
@@ -154,7 +152,6 @@ public sealed class AddressSpaceApplier
// afterward by the publish actor's Materialise passes + announce).
var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix;
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
@@ -174,7 +171,7 @@ public sealed class AddressSpaceApplier
rebuildFailed = !SafeRebuild();
rebuilt = true;
}
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass ||
else if (renamedFolders.Count > 0 || hasRemovePass ||
surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0)
{
if (_sink is ISurgicalAddressSpaceSink surgical)
@@ -192,25 +189,6 @@ public sealed class AddressSpaceApplier
}
if (!ok) { allApplied = false; break; }
}
foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
{
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
// would so the in-place update matches what a rebuild would have produced. Array tags are
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
var writable = d.Current.Writable && !d.Current.IsArray;
var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
: null;
bool ok;
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
ok = false;
}
if (!ok) { allApplied = false; break; }
}
// v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE
// UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths,
// and before this path existed that tore down every node on the server for an edit that creates
@@ -318,8 +296,8 @@ public sealed class AddressSpaceApplier
}
_logger.LogInformation(
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count,
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
kind, addedCount, removedCount, changedCount,
rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count,
rebuilt ? 0 : renamedFolders.Count, rebuilt);
@@ -340,10 +318,9 @@ public sealed class AddressSpaceApplier
/// Apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
/// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose
/// <c>EquipmentId</c> is itself in <see cref="AddressSpacePlan.RemovedEquipment"/> is SUBSUMED by the
/// subtree removal and skipped individually. For surviving-equipment removals: a removed value
/// subtree removal and skipped individually. For surviving-equipment removals: a removed VirtualTag
/// variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
/// a removed alarm-bearing tag (previously it got no terminal condition write) and a
/// removed scripted alarm get a terminal <see cref="RemovedConditionState"/> write then
/// a removed scripted alarm gets a terminal <see cref="RemovedConditionState"/> write then
/// <see cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>; each removed equipment gets one
/// <see cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>. The terminal write is what an
/// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets
@@ -361,25 +338,6 @@ public sealed class AddressSpaceApplier
plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal);
bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId);
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
{
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
// closed), then remove the condition in place.
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
else
{
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
}
// Removed VirtualTags (always plain value variables) for surviving equipment.
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
{
@@ -548,7 +506,6 @@ public sealed class AddressSpaceApplier
ArgumentNullException.ThrowIfNull(plan);
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
// v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its
@@ -1072,96 +1029,9 @@ public sealed class AddressSpaceApplier
return failed;
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot.
/// For each <see cref="EquipmentTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
/// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
/// materialised to a plan's added tags; <paramref name="composition"/> stays the FULL composition for
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
/// </summary>
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
/// <param name="only">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
public int MaterialiseEquipmentTags(
AddressSpaceComposition composition,
IReadOnlyList<EquipmentTagPlan>? only = null)
{
ArgumentNullException.ThrowIfNull(composition);
var equipmentTags = only ?? composition.EquipmentTags;
if (equipmentTags.Count == 0) return 0;
var failed = 0;
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (never re-create
// the equipment folder here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in equipmentTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
// ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
// Per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in equipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
}
else
{
// Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective
// historian tagname HERE (default-vs-override): a null/blank override falls back to the
// driver-side FullName; null means the tag is not historized at all.
string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
// Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
// here. The live array-write capability is carried on RawTagPlan /
// UnsReferenceVariable only — see RawNodeWritable.
var writable = tag.Writable && !tag.IsArray;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
}
}
_logger.LogInformation(
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
equipmentTags.Count,
equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
only is not null,
failed);
return failed;
}
/// <summary>
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
/// analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
/// computed-signal pass. For each <see cref="EquipmentVirtualTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
/// practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
/// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
@@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
Empty,
/// <summary>Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas,
/// <summary>Only surgical-eligible changed raw tags, surgical UNS-reference deltas,
/// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b
/// in-place path. No adds, no removes.</summary>
AttributeOnly,
@@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind
AddRemoveMix,
/// <summary>Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers,
/// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant
/// alarm-bearing/identity-changing ChangedRawTags, node-relevant
/// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild.</summary>
Rebuild,
}
@@ -53,8 +53,7 @@ public static class AddressSpaceChangeClassifier
if (plan.IsEmpty) return AddressSpaceChangeKind.Empty;
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is
// node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is
// RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the
// add/remove split so a non-surgical change alongside pure adds still rebuilds.
//
@@ -94,7 +93,6 @@ public static class AddressSpaceChangeClassifier
plan.ChangedAlarms.Count > 0 ||
plan.ChangedRawContainers.Count > 0 ||
plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) ||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
{
return AddressSpaceChangeKind.Rebuild;
@@ -105,12 +103,12 @@ public static class AddressSpaceChangeClassifier
// add/remove split below and leave a driver-only plan classified AttributeOnly.
var hasAdds =
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
plan.AddedUnsReferenceVariables.Count > 0;
var hasRemoves =
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
plan.RemovedUnsReferenceVariables.Count > 0;
@@ -141,36 +139,6 @@ public static class AddressSpaceChangeClassifier
Historize = d.Current.Historize,
}).Equals(d.Current);
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
// override-unequal default also covers any future field.
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
/// <summary>Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding.</summary>
/// <param name="d">The equipment-tag delta to classify.</param>
/// <returns><see langword="true"/> when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise <see langword="false"/>.</returns>
internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
d.Previous.Alarm is null && d.Current.Alarm is null &&
(d.Previous with
{
Writable = d.Current.Writable,
IsHistorized = d.Current.IsHistorized,
HistorianTagname = d.Current.HistorianTagname,
DataType = d.Current.DataType,
IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength,
}).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
@@ -23,28 +23,15 @@ public sealed record AddressSpacePlan(
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
IReadOnlyList<AddressSpacePlan.AlarmDelta> ChangedAlarms)
{
/// <summary>
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
/// init-only members (defaulting empty) rather than positional parameters so existing
/// <c>AddressSpacePlan</c> construction sites compile unchanged — consistent with how
/// <see cref="AddressSpaceComposition.EquipmentTags"/> was added. Without these, an
/// incremental deploy that changes ONLY equipment tags produced an empty plan and
/// <c>OpcUaPublishActor.HandleRebuild</c> short-circuited before materialising them.
/// </summary>
public IReadOnlyList<EquipmentTagPlan> AddedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagPlan> RemovedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
/// <summary>
/// Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
/// The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
/// A VirtualTag carries an
/// <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
/// compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) so existing
/// construction sites compile unchanged. (The retired v3 equipment-tag diff sets — review 01 U2 — are gone:
/// device values live in the Raw subtree and reach equipment as <see cref="UnsReferenceVariable"/>s.)
/// </summary>
public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
@@ -65,7 +52,7 @@ public sealed record AddressSpacePlan(
/// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
/// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
/// every existing <c>AddressSpacePlan</c> construction site compiles unchanged — consistent with
/// the EquipmentTag / EquipmentVirtualTag diff sets above.
/// the EquipmentVirtualTag diff sets above.
/// </summary>
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
@@ -74,7 +61,7 @@ public sealed record AddressSpacePlan(
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
/// unchanged (consistent with the EquipmentTag diff sets above).
/// unchanged.
/// </summary>
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
/// <inheritdoc cref="AddedRawContainers"/>
@@ -114,7 +101,6 @@ public sealed record AddressSpacePlan(
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
@@ -124,7 +110,6 @@ public sealed record AddressSpacePlan(
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
@@ -177,12 +162,7 @@ public static class AddressSpacePlanner
a => a.ScriptedAlarmId,
(a, b) => new AddressSpacePlan.AlarmDelta(a, b));
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
previous.EquipmentTags, next.EquipmentTags,
t => t.TagId,
(a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b));
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
// VirtualTags diff by VirtualTagId. EquipmentVirtualTagPlan
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
// list instances, identical contents) correctly diffs to empty; only a real content change is
@@ -232,9 +212,6 @@ public static class AddressSpacePlanner
addedDrv, removedDrv, changedDrv,
addedAlarm, removedAlarm, changedAlarm)
{
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,
ChangedEquipmentTags = changedEqTags,
AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags,
@@ -725,8 +725,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
/// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over
/// Run the four node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
/// equipment VirtualTags — either over the FULL composition (after a rebuild) or over
/// just this plan's delta (surgical deploys). <c>MaterialiseHierarchy</c> is NOT one of them:
/// the caller always runs it in full, because parents must exist before any add.
/// </summary>
@@ -809,21 +809,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms);
}
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
// FolderPath sub-folder) exists under its already-materialised equipment folder so
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
// each subscription cycle; variables show BadWaitingForInitialData only until the first
// publish interval fires.
if (fullComposition)
{
failedNodes += applier.MaterialiseEquipmentTags(composition);
}
else if (plan.AddedEquipmentTags.Count > 0)
{
failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags);
}
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
// Equipment-namespace VirtualTags get their own pass right after the scripted alarms:
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
// equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live
// values once the first dependency update arrives; until then variables show BadWaitingForInitialData.
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// announce (T4b) land.
/// <para><b>Run note:</b> this is a heavy in-process server+client integration test — run it in the
/// serial integration pass (it is NOT part of the lightweight unit sweep). The <see cref="OtOpcUaNodeManager"/>
/// namespace URI is <see cref="OtOpcUaNodeManager.DefaultNamespaceUri"/>; the client resolves the
/// namespace URI is <see cref="V3NodeIds.RawNamespaceUri"/>; the client resolves the
/// folder-scoped variable NodeIds against it.</para>
/// </summary>
public sealed class SubscriptionSurvivalTests
@@ -68,15 +68,15 @@ public sealed class SubscriptionSurvivalTests
// --- Materialise tag A (equipment eq-1, tag "A") and seed it a Good value. ---
var withA = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
RawTag("tag-a", "A"));
applier.MaterialiseHierarchy(withA);
applier.MaterialiseEquipmentTags(withA);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
applier.MaterialiseRawSubtree(withA);
var nodeIdAString = "Plant/dev1/A";
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
// --- Open a client session + subscription + monitored item on A. ---
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
ns.ShouldBeGreaterThan((ushort)0);
var nodeIdA = new NodeId(nodeIdAString, ns);
@@ -106,22 +106,22 @@ public sealed class SubscriptionSurvivalTests
// --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). ---
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-b", "B"));
var plan = AddressSpacePlanner.Compute(withA, withAB);
plan.AddedEquipmentTags.Count.ShouldBe(1);
plan.AddedRawTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown
// Materialise passes (as the publish actor runs them) then announce — over the FULL composition.
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
applier.MaterialiseRawSubtree(withAB);
applier.AnnounceAddedNodes(plan);
// --- A's monitored item must still be alive: a new write to A is delivered. ---
lock (gate) received.Clear();
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
lock (gate)
@@ -131,8 +131,8 @@ public sealed class SubscriptionSurvivalTests
}
// --- B is browsable + readable (the add landed). ---
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var nodeIdB = new NodeId("Plant/dev1/B", ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var bValue = await session.ReadValueAsync(nodeIdB, ct);
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
bValue.Value.ShouldBe(7);
@@ -180,17 +180,17 @@ public sealed class SubscriptionSurvivalTests
// Materialise A + B and seed both Good.
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-b", "B"));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
applier.MaterialiseRawSubtree(withAB);
var nodeIdAString = "Plant/dev1/A";
var nodeIdBString = "Plant/dev1/B";
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var nodeIdA = new NodeId(nodeIdAString, ns);
var nodeIdB = new NodeId(nodeIdBString, ns);
@@ -211,9 +211,9 @@ public sealed class SubscriptionSurvivalTests
// Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}).
var withA = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
RawTag("tag-a", "A"));
var plan = AddressSpacePlanner.Compute(withAB, withA);
plan.RemovedEquipmentTags.Count.ShouldBe(1);
plan.RemovedRawTags.Count.ShouldBe(1);
lock (gate) { receivedA.Clear(); receivedB.Clear(); }
var outcome = applier.Apply(plan);
@@ -223,7 +223,7 @@ public sealed class SubscriptionSurvivalTests
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
// A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
@@ -273,15 +273,15 @@ public sealed class SubscriptionSurvivalTests
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-b", "B"));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
applier.MaterialiseRawSubtree(withAB);
var nodeIdAString = "Plant/dev1/A";
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var nodeIdA = new NodeId(nodeIdAString, ns);
var receivedA = new List<DataValue>();
@@ -297,32 +297,32 @@ public sealed class SubscriptionSurvivalTests
// MIXED apply: remove B, add C (previous={A,B}, next={A,C}).
var withAC = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-c", "C"));
var plan = AddressSpacePlanner.Compute(withAB, withAC);
plan.RemovedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.Count.ShouldBe(1);
plan.RemovedRawTags.Count.ShouldBe(1);
plan.AddedRawTags.Count.ShouldBe(1);
lock (gate) receivedA.Clear();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild
// Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce.
applier.MaterialiseEquipmentTags(withAC);
applier.MaterialiseRawSubtree(withAC);
applier.AnnounceAddedNodes(plan);
// A stays alive.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
var nodeIdB = new NodeId("Plant/dev1/B", ns);
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
// C is browsable + readable.
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var nodeIdC = new NodeId("Plant/dev1/C", ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var cRead = await session.ReadValueAsync(nodeIdC, ct);
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
cRead.Value.ShouldBe(9);
@@ -333,7 +333,11 @@ public sealed class SubscriptionSurvivalTests
}
}
private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) =>
private static RawTagPlan RawTag(string tagId, string name) =>
new(tagId, $"Plant/dev1/{name}", ParentNodeId: "Plant/dev1", "drv", Name: name, DataType: "Int32", Writable: false,
Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
private static AddressSpaceComposition CompositionWith(params RawTagPlan[] tags) =>
new(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
@@ -341,7 +345,8 @@ public sealed class SubscriptionSurvivalTests
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
RawContainers = new[] { new RawContainerNode("Plant/dev1", null, "dev1", RawNodeKind.Device) },
RawTags = tags,
};
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
@@ -75,30 +75,26 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
// ---------------- T3: Materialise* passes return failed-node counts ----------------
/// <summary>Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return.</summary>
/// <summary>Every EnsureVariable throwing in MaterialiseRawSubtree is tallied into the pass's int return.</summary>
[Fact]
public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount()
public void MaterialiseRawSubtree_WhenEnsureVariableThrows_ReturnsFailedCount()
{
var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = CompositionWithTags(
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null));
var composition = CompositionWithRawTags(RawTag("t1", "A"), RawTag("t2", "B"), RawTag("t3", "C"));
applier.MaterialiseEquipmentTags(composition).ShouldBe(3);
applier.MaterialiseRawSubtree(composition).ShouldBe(3);
}
/// <summary>All five Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
/// <summary>All four Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
[Fact]
public void Materialise_HappyPath_AllPassesReturnZero()
{
var sink = new ConfigurableThrowingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var tags = CompositionWithTags(
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var tags = CompositionWithRawTags(RawTag("t1", "A"));
var vtags = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>(), Historize: false));
var alarms = CompositionWithScriptedAlarms(
@@ -106,17 +102,21 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true));
applier.MaterialiseHierarchy(tags).ShouldBe(0);
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
applier.MaterialiseRawSubtree(tags).ShouldBe(0);
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
}
// ---------------- fixtures ----------------
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
private static RawTagPlan RawTag(string id, string name) =>
new(id, $"Plant/dev1/{name}", ParentNodeId: null, "drv", Name: name, DataType: "Float", Writable: false, Alarm: null,
ReferencingEquipmentPaths: Array.Empty<string>());
private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) =>
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
RawTags = tags,
};
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
@@ -110,13 +110,13 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
}
/// <summary>Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
/// <summary>Verifies MaterialiseRawSubtree is idempotent against a real SDK node manager:
/// applying the same composition twice yields a single Variable node (no duplicates). This is the
/// restart-safety guarantee — HandleRebuild runs on both the apply path and the
/// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
/// restart re-runs this pass and must not double-materialise.</summary>
[Fact]
public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
public async Task MaterialiseRawSubtree_against_real_SDK_node_manager_is_idempotent()
{
var applicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}";
var started = await OpcUaHostStart.StartAsync(
@@ -145,16 +145,17 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
RawTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new RawTagPlan("tag-1", "Plant/dev1/Speed", ParentNodeId: null, "drv", Name: "Speed", DataType: "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
// Equipment folder first (the variable's parent), then the tag pass — applied twice.
// The raw pass — applied twice.
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseRawSubtree(composition);
applier.MaterialiseRawSubtree(composition);
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
}
@@ -12,30 +12,6 @@ public sealed class AddressSpaceApplierTests
/// <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>
@@ -61,27 +37,22 @@ public sealed class AddressSpaceApplierTests
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>
/// <summary>R2-07 T11 (raw-tag vehicle, review 01 U2 retired the equipment-tag one) — a remove-one-raw-tag
/// plan is a PureRemove: the applier writes a terminal Bad to the removed variable then removes it in place
/// via RemoveVariableNode in the RAW realm — NO rebuild. An alarm-bearing raw tag gets the terminal
/// RemovedConditionState and a condition-node removal instead.</summary>
[Fact]
public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place()
public void Removed_raw_tags_write_terminal_state_and_remove_in_place_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
RemovedRawTags = 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)),
RawTag("tag-1", "Plant/dev1/Speed"),
RawTag("tag-alm", "Plant/dev1/OverTemp", alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
},
};
@@ -89,125 +60,16 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(V3NodeIds.Uns("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 = V3NodeIds.Uns("eq-1", "Speed");
// Terminal Bad written to the removed variable BEFORE the removal.
sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns));
}
/// <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 = V3NodeIds.Uns("eq-1", "OverTemp");
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
sink.ValueWrites.ShouldContain(("Plant/dev1/Speed", OpcUaStatus.Bad, AddressSpaceRealm.Raw));
var write = sink.AlarmWrites.ShouldHaveSingleItem();
write.NodeId.ShouldBe(nodeId);
write.NodeId.ShouldBe("Plant/dev1/OverTemp");
(!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, AddressSpaceRealm.Uns));
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
sink.RemoveCalls.ShouldBe(new[]
{
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", AddressSpaceRealm.Uns));
("var", "Plant/dev1/Speed", AddressSpaceRealm.Raw),
("alarm", "Plant/dev1/OverTemp", AddressSpaceRealm.Raw),
});
outcome.RemovedNodes.ShouldBe(2);
}
/// <summary>R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier
@@ -220,11 +82,7 @@ public sealed class AddressSpaceApplierTests
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),
},
RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A"), RawTag("tag-2", "Plant/dev1/B") },
};
var outcome = applier.Apply(plan);
@@ -243,13 +101,7 @@ public sealed class AddressSpaceApplierTests
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 plan = EmptyPlan with { RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A") } };
var outcome = applier.Apply(plan);
@@ -259,7 +111,7 @@ public sealed class AddressSpaceApplierTests
/// <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>
/// passes.</summary>
[Fact]
public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
{
@@ -268,14 +120,8 @@ public sealed class AddressSpaceApplierTests
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),
},
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
};
var outcome = applier.Apply(plan);
@@ -283,40 +129,11 @@ public sealed class AddressSpaceApplierTests
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", V3NodeIds.Uns("eq-1", "Old"), AddressSpaceRealm.Uns));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", "Plant/dev1/Old", AddressSpaceRealm.Raw));
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 = V3NodeIds.Uns("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, AddressSpaceRealm.Uns));
}
/// <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()
@@ -326,14 +143,8 @@ public sealed class AddressSpaceApplierTests
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),
},
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
};
var outcome = applier.Apply(plan);
@@ -342,38 +153,28 @@ public sealed class AddressSpaceApplierTests
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>
/// <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 Changed_equipment_tags_only_trigger_rebuild()
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 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 plan = EmptyPlan with
{
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
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.ShouldBeTrue();
outcome.ChangedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
outcome.RebuildCalled.ShouldBeFalse();
// ONLY the subtree removal fires — no individual var removal for the subsumed child vtag.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns));
}
/// <summary>F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's <c>Expression</c>
@@ -553,26 +354,18 @@ public sealed class AddressSpaceApplierTests
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
new[] { new EquipmentNode("eq-1", "One", "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: 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).
// Expression-only vtag edit (node-irrelevant) AND a node-affecting equipment change (rename).
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, 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",
@@ -582,9 +375,9 @@ public sealed class AddressSpaceApplierTests
var plan = AddressSpacePlanner.Compute(previous, next);
// Both a node-irrelevant vtag change AND a node-affecting tag change are present.
// Both a node-irrelevant vtag change AND a node-affecting equipment change are present.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.ChangedEquipment.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
@@ -670,7 +463,6 @@ public sealed class AddressSpaceApplierTests
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
var outcome = applier.Apply(plan);
@@ -732,7 +524,6 @@ public sealed class AddressSpaceApplierTests
plan.RemovedDrivers.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
plan.ChangedAlarms.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
@@ -748,7 +539,7 @@ public sealed class AddressSpaceApplierTests
/// 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()
public void Removed_virtual_tags_only_remove_in_place_and_are_counted()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -756,10 +547,6 @@ public sealed class AddressSpaceApplierTests
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",
@@ -771,440 +558,23 @@ public sealed class AddressSpaceApplierTests
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: ONLY the two Removed sets are populated.
plan.RemovedEquipmentTags.Count.ShouldBe(1);
// Guard the arrange: ONLY the removed-vtag set is populated.
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.
// R2-07 T11 — a removed-vtag plan is a PureRemove: the variable is torn down in place
// (RemoveVariableNode), NO full rebuild; the removal is 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", V3NodeIds.Uns("eq-1", "Speed"), AddressSpaceRealm.Uns));
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns));
outcome.RemovedNodes.ShouldBe(1);
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns));
}
// ----- 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(V3NodeIds.Uns("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(V3NodeIds.Uns("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 = V3NodeIds.Uns("eq-1", "Speed");
var nodeId2 = V3NodeIds.Uns("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
@@ -1337,42 +707,6 @@ public sealed class AddressSpaceApplierTests
// ----- 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[] { V3NodeIds.Uns("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()
@@ -1445,11 +779,11 @@ public sealed class AddressSpaceApplierTests
var plan = EmptyPlan with
{
AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
AddedEquipmentTags = new[]
AddedEquipmentVirtualTags = 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),
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "A", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>()),
new EquipmentVirtualTagPlan("vt-2", "eq-1", FolderPath: "", Name: "B", DataType: "Float", Expression: "2", DependencyRefs: Array.Empty<string>()),
new EquipmentVirtualTagPlan("vt-3", "eq-1", FolderPath: "Diag", Name: "C", DataType: "Float", Expression: "3", DependencyRefs: Array.Empty<string>()),
},
};
@@ -1855,13 +1189,6 @@ public sealed class AddressSpaceApplierTests
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>())
@@ -1878,6 +1205,10 @@ public sealed class AddressSpaceApplierTests
new(
Array.Empty<EquipmentNode>(), drivers, Array.Empty<ScriptedAlarmPlan>());
private static RawTagPlan RawTag(string tagId, string rawPath, EquipmentTagAlarmInfo? alarm = null) =>
new(tagId, rawPath, ParentNodeId: null, "drv", Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float",
Writable: false, Alarm: alarm, ReferencingEquipmentPaths: Array.Empty<string>());
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>(),
@@ -35,7 +35,7 @@ public sealed class AddressSpaceChangeClassifierTests
}
[Fact]
public void Added_equipment_tag_only_is_PureAdd()
public void Added_raw_tag_only_is_PureAdd()
{
var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") });
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
@@ -65,7 +65,7 @@ public sealed class AddressSpaceChangeClassifierTests
}
[Fact]
public void Removed_equipment_tag_only_is_PureRemove()
public void Removed_raw_tag_only_is_PureRemove()
{
var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") });
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
@@ -116,19 +116,6 @@ public sealed class AddressSpaceChangeClassifierTests
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
[Fact]
public void Non_surgical_changed_tag_is_Rebuild()
{
// FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild.
var plan = Plan(changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", fullName: "40001"),
Tag("tag-1", "eq-1", fullName: "40002")),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
[Fact]
public void Node_relevant_changed_vtag_is_Rebuild()
{
@@ -144,19 +131,6 @@ public sealed class AddressSpaceChangeClassifierTests
// ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly -----
[Fact]
public void Surgical_eligible_changed_tag_only_is_AttributeOnly()
{
// Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes).
var plan = Plan(changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", writable: false),
Tag("tag-1", "eq-1", writable: true)),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
}
[Fact]
public void Node_irrelevant_changed_vtag_only_is_AttributeOnly()
{
@@ -195,36 +169,6 @@ public sealed class AddressSpaceChangeClassifierTests
// ----- mixed-with-surgical: adds still win over coincident surgical changes -----
[Fact]
public void Adds_with_coincident_surgical_change_is_PureAdd()
{
var plan = Plan(
addedTags: new[] { Tag("tag-new", "eq-1") },
changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", writable: false),
Tag("tag-1", "eq-1", writable: true)),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
}
[Fact]
public void Adds_with_coincident_non_surgical_change_is_Rebuild()
{
// Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical
// change forces Rebuild even alongside pure adds.
var plan = Plan(
addedTags: new[] { Tag("tag-new", "eq-1") },
changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", fullName: "40001"),
Tag("tag-1", "eq-1", fullName: "40002")),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
[Fact]
public void Adds_with_coincident_folder_rename_is_PureAdd()
{
@@ -240,8 +184,9 @@ public sealed class AddressSpaceChangeClassifierTests
private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg");
private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) =>
new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null);
private static RawTagPlan Tag(string id, string device) =>
new(id, $"{device}/{id}", ParentNodeId: device, "drv", Name: id, DataType: "Float", Writable: false, Alarm: null,
ReferencingEquipmentPaths: Array.Empty<string>());
private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") =>
new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" });
@@ -256,9 +201,8 @@ public sealed class AddressSpaceChangeClassifierTests
IReadOnlyList<ScriptedAlarmPlan>? addedAlarms = null,
IReadOnlyList<ScriptedAlarmPlan>? removedAlarms = null,
IReadOnlyList<AddressSpacePlan.AlarmDelta>? changedAlarms = null,
IReadOnlyList<EquipmentTagPlan>? addedTags = null,
IReadOnlyList<EquipmentTagPlan>? removedTags = null,
IReadOnlyList<AddressSpacePlan.EquipmentTagDelta>? changedTags = null,
IReadOnlyList<RawTagPlan>? addedTags = null,
IReadOnlyList<RawTagPlan>? removedTags = null,
IReadOnlyList<EquipmentVirtualTagPlan>? addedVtags = null,
IReadOnlyList<EquipmentVirtualTagPlan>? removedVtags = null,
IReadOnlyList<AddressSpacePlan.EquipmentVirtualTagDelta>? changedVtags = null,
@@ -274,9 +218,8 @@ public sealed class AddressSpaceChangeClassifierTests
removedAlarms ?? Array.Empty<ScriptedAlarmPlan>(),
changedAlarms ?? Array.Empty<AddressSpacePlan.AlarmDelta>())
{
AddedEquipmentTags = addedTags ?? Array.Empty<EquipmentTagPlan>(),
RemovedEquipmentTags = removedTags ?? Array.Empty<EquipmentTagPlan>(),
ChangedEquipmentTags = changedTags ?? Array.Empty<AddressSpacePlan.EquipmentTagDelta>(),
AddedRawTags = addedTags ?? Array.Empty<RawTagPlan>(),
RemovedRawTags = removedTags ?? Array.Empty<RawTagPlan>(),
AddedEquipmentVirtualTags = addedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty<AddressSpacePlan.EquipmentVirtualTagDelta>(),
@@ -30,31 +30,6 @@ public sealed class AddressSpacePlannerTests
plan.IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change)
/// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the
/// IsEmpty gate before materialising the new equipment variables.</summary>
[Fact]
public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
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: false, Alarm: null),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1");
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change)
/// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that
/// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate.</summary>
@@ -174,42 +149,6 @@ public sealed class AddressSpacePlannerTests
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>An equipment Tag with the same id but a toggled <c>IsArray</c> flag (and otherwise
/// identical fields) must route to ChangedEquipmentTags. This pins that <c>IsArray</c> is part of
/// <see cref="EquipmentTagPlan.Equals"/> (record value-equality) so an array-flag-only deploy is not
/// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag.</summary>
[Fact]
public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue();
plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with