Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
T
Joseph Doherty 118933d5d5 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
2026-10-22 10:47:32 -04:00

1471 lines
81 KiB
C#

using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class AddressSpaceApplierTests
{
/// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
/// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒
/// rebuild" pin.)</summary>
[Fact]
public void Added_equipment_virtual_tags_are_pure_add_and_skip_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>R2-07 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 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
{
RemovedRawTags = new[]
{
RawTag("tag-1", "Plant/dev1/Speed"),
RawTag("tag-alm", "Plant/dev1/OverTemp", alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.ValueWrites.ShouldContain(("Plant/dev1/Speed", OpcUaStatus.Bad, AddressSpaceRealm.Raw));
var write = sink.AlarmWrites.ShouldHaveSingleItem();
write.NodeId.ShouldBe("Plant/dev1/OverTemp");
(!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue();
sink.RemoveCalls.ShouldBe(new[]
{
("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
/// falls back to a full rebuild (the one-way ratchet) and attempts no further surgical removes.</summary>
[Fact]
public void Pure_remove_with_remove_returns_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { RemoveReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A"), RawTag("tag-2", "Plant/dev1/B") },
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
// Ratchet: stopped at the FIRST failed remove — did not attempt the second.
sink.RemoveCalls.Count.ShouldBe(1);
}
/// <summary>R2-07 T11 — a PureRemove on a sink lacking the surgical capability falls back to a full
/// rebuild (safe default), same as the F10b attribute path.</summary>
[Fact]
public void Pure_remove_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with { RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A") } };
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE
/// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's
/// passes.</summary>
[Fact]
public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", "Plant/dev1/Old", AddressSpaceRealm.Raw));
outcome.AddedNodes.ShouldBe(1);
outcome.RemovedNodes.ShouldBe(1);
}
/// <summary>R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet.</summary>
[Fact]
public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { RemoveReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
/// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
[Fact]
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
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 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>
/// must SKIP the address-space rebuild. The vtag's materialised node is derived only from
/// {EquipmentId, FolderPath, Name, DataType}, so an Expression-only edit leaves a byte-identical node;
/// the vtag engine adopts the new expression via <c>VirtualTagHostActor</c>'s independent respawn, not
/// the address-space path. Skipping the rebuild preserves every client's server-wide subscriptions.
/// The edit STILL counts as a change (ChangedNodes == 1) — it just no longer forces a rebuild.
/// (Supersedes the former H1a "expression edit ⇒ rebuild" behavior.)</summary>
[Fact]
public void Changed_virtual_tag_expression_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));
// Same VirtualTag id, edited expression — the planner classifies this as a change.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: ONLY ChangedEquipmentVirtualTags is populated.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
outcome.ChangedNodes.ShouldBe(1); // the edit is still tallied as a change
}
/// <summary>F10b — a vtag delta differing ONLY in <c>DependencyRefs</c> (e.g. a new <c>ctx.GetTag</c>
/// literal in the script) is node-irrelevant and SKIPS the rebuild; the engine respawn picks up the
/// new dependency set.</summary>
[Fact]
public void Changed_virtual_tag_dependency_refs_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
// Same node-relevant fields; only the dependency set differs.
// Note: DependencyRefs normally tracks the Expression (derived by the ref extractor), so a
// deps-only divergence is an edge case — e.g. the extractor logic changed between two deploys —
// but it is still correctly node-irrelevant: the materialised OPC UA node is unchanged.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a", "b" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>F10b — a vtag delta differing ONLY in <c>Historize</c> (write-side flag, not materialised
/// on the node) is node-irrelevant and SKIPS the rebuild.</summary>
[Fact]
public void Changed_virtual_tag_historize_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: false));
// Same node-relevant fields; only the Historize flag flips.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: true));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>DataType</c> ALSO changed is
/// NOT node-irrelevant: the materialised node would differ, so the applier must still rebuild. Pins
/// the whitelist (Expression/DependencyRefs/Historize) against accidentally swallowing a DataType
/// edit.</summary>
[Fact]
public void Changed_virtual_tag_data_type_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));
// DataType flips AND the expression changes — DataType is node-affecting, so this must rebuild.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Int32",
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>Name</c> changed must
/// rebuild (the node's BrowseName/NodeId derive from Name).</summary>
[Fact]
public void Changed_virtual_tag_name_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "EfficiencyPct", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>FolderPath</c> changed must
/// rebuild (the folder-scoped NodeId derives from FolderPath).</summary>
[Fact]
public void Changed_virtual_tag_folder_path_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "Calc", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b — the skip is ONLY for a node-irrelevant vtag edit that is the SOLE change. A
/// node-irrelevant Expression-only vtag edit MIXED with another NON-surgical change (here a changed
/// equipment tag whose driver-side <c>FullName</c> re-routes) must still rebuild — the rebuild is forced
/// by the OTHER change, and the running server gets its single rebuild as before.</summary>
[Fact]
public void Node_irrelevant_vtag_edit_mixed_with_another_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-1", "One", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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 node-affecting equipment change (rename).
var next = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }),
},
};
var plan = AddressSpacePlanner.Compute(previous, next);
// Both a node-irrelevant vtag change AND a node-affecting equipment change are present.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.ChangedEquipment.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>EquipmentId</c> changed
/// (admin reassigns the vtag to a different equipment) must rebuild. The materialised node lives
/// under the old equipment folder; moving it to another folder requires a full address-space
/// rebuild. EquipmentId is NOT in the node-irrelevant whitelist (Expression/DependencyRefs/Historize),
/// so the applier must not skip.</summary>
[Fact]
public void Changed_virtual_tag_equipment_id_triggers_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
// Same VirtualTagId, identical Expression/DependencyRefs/DataType/Name/FolderPath —
// but reassigned to a different equipment ("eq-2" instead of "eq-1").
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: the planner sees this as a changed vtag (same id, different EquipmentId).
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// EquipmentId is node-affecting — the node moves to a different folder — so rebuild is required.
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>F10b safe-default — when a plan contains TWO changed vtags and ONE is node-irrelevant
/// (Expression-only) while the OTHER is structural (DataType changed), the applier must still rebuild.
/// The <c>Any(d =&gt; !VtagDeltaIsNodeIrrelevant(d))</c> predicate must catch the structural delta
/// even though its sibling vtag would individually qualify for the skip.</summary>
[Fact]
public void Changed_virtual_tags_one_irrelevant_one_structural_triggers_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
// vt-A: will receive an Expression-only edit (node-irrelevant).
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
Expression: "ctx.GetTag(\"speed\") * 60", DependencyRefs: new[] { "speed" }),
// vt-B: will receive a DataType change (structural / node-affecting).
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Float64",
Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
// vt-A: Expression-only change — individually node-irrelevant.
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
Expression: "ctx.GetTag(\"speed\") * 120", DependencyRefs: new[] { "speed" }),
// vt-B: DataType flipped — structurally node-affecting.
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Int32",
Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
},
};
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: both vtags are diffed as changed, nothing else.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// The structural delta on vt-B must force a rebuild even though vt-A is node-irrelevant.
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
outcome.ChangedNodes.ShouldBe(2);
}
/// <summary>H1a — a deploy that ONLY edits an existing scripted alarm (here its message template)
/// must rebuild the address space. The planner diffs the thin <c>ScriptedAlarmPlan</c> projection
/// into <c>ChangedAlarms</c> alone; the applier must drive exactly one rebuild so the condition node
/// reflects the edit.</summary>
[Fact]
public void Changed_alarms_only_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp high"));
// Same alarm id, edited message template — the planner classifies this as a change.
var next = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp critically high"));
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: ONLY ChangedAlarms is populated.
plan.ChangedAlarms.Count.ShouldBe(1);
plan.AddedAlarms.ShouldBeEmpty();
plan.RemovedAlarms.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.ChangedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a guard — a deploy that ONLY changes a driver instance's config must NOT rebuild the
/// address space. Driver-instance changes route through DriverHostActor's spawn-plan in Runtime, not
/// the address-space topology, so <c>ChangedDrivers</c> is intentionally excluded from
/// <c>needsRebuild</c>. This pins the exclusion against accidental inclusion. (The pre-existing
/// <see cref="Driver_only_changes_do_not_trigger_address_space_rebuild"/> uses a hand-built plan;
/// this one drives the planner end-to-end.)</summary>
[Fact]
public void Changed_drivers_only_do_not_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"));
var next = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}"));
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: ONLY ChangedDrivers is populated.
plan.ChangedDrivers.Count.ShouldBe(1);
plan.AddedDrivers.ShouldBeEmpty();
plan.RemovedDrivers.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
plan.ChangedAlarms.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
outcome.ChangedNodes.ShouldBe(1); // driver change is still tallied, just not rebuild-forcing
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>H1a (review follow-up) — a deploy that ONLY removes existing equipment tag / VirtualTag
/// nodes must rebuild AND tally the removals. Removed tags/VirtualTags are plain variable nodes (no
/// Part 9 condition to write), so before the fix they reached the rebuild path but were never added
/// to <c>removedCount</c> — <c>AddressSpaceApplyOutcome.RemovedNodes</c> reported 0, a misleading audit
/// entry. This pins both the rebuild and the accurate count.</summary>
[Fact]
public void Removed_virtual_tags_only_remove_in_place_and_are_counted()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: ONLY the removed-vtag set is populated.
plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1);
plan.RemovedEquipment.ShouldBeEmpty();
plan.RemovedAlarms.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// 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(1);
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns));
}
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
// ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) -----
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and
/// apply the new DisplayName IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateFolderDisplayName</c>,
/// preserving every client's subscriptions. Exactly one surgical folder call lands with the area's
/// NodeId (== UnsAreaId) and the NEW display name; the rename still counts as a change.</summary>
[Fact]
public void Area_rename_only_skips_rebuild_and_updates_folder_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant North"));
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant South"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
call.FolderNodeId.ShouldBe("area-1"); // folder NodeId == UnsAreaId (MaterialiseHierarchy scheme)
call.DisplayName.ShouldBe("Plant South"); // the NEW display name
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>OpcUaServer-001 — a Line rename only updates the line folder in place (NodeId == UnsLineId)
/// and skips the rebuild.</summary>
[Fact]
public void Line_rename_only_skips_rebuild_and_updates_folder_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell A"));
var next = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell B"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
call.FolderNodeId.ShouldBe("line-1");
call.DisplayName.ShouldBe("Cell B");
}
/// <summary>R2-07 T2 — a folder rename MIXED with an added equipment is now a PureAdd: the added
/// equipment is created by the Materialise passes and the rename is applied IN PLACE via the surgical
/// sink, so NO rebuild fires and exactly one folder-rename call lands. (Supersedes the pre-R2-07
/// "rename-mixed-with-add ⇒ rebuild" pin.)</summary>
[Fact]
public void Folder_rename_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "North") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
// Area renamed AND a brand-new equipment node — an add no longer forces a rebuild; the rename
// rides the in-place surgical path alongside the PureAdd.
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-new", "New", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
plan.AddedEquipment.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.FolderRenameCalls.ShouldHaveSingleItem().ShouldBe(("area-1", "South"));
}
/// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement
/// <see cref="ISurgicalAddressSpaceSink"/> cannot apply the in-place update, so it drives a full
/// rebuild (safe default).</summary>
[Fact]
public void Folder_rename_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>OpcUaServer-001 fallback — when the surgical sink reports the folder MISSING
/// (<c>UpdateFolderDisplayName</c> returns false), the applier falls back to a full rebuild. The
/// surgical call is still attempted (recorded once) before the fallback fires.</summary>
[Fact]
public void Folder_rename_surgical_returning_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { FolderRenameReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.RenamedFolders.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild
sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
// ----- R2-07 T3: pure-add NodeAdded announcements -----
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
[Fact]
public void ComputeAddAnnouncements_added_alarm_announces_equipment()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") },
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" });
}
/// <summary>An added equipment WITH a UnsLineId announces its parent line; WITHOUT one announces its own
/// new folder id (a valid Part 3 NodeAdded of the folder itself; the root has no announceable id).</summary>
[Fact]
public void ComputeAddAnnouncements_added_equipment_announces_line_or_self()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var withLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-7") } };
applier.ComputeAddAnnouncements(withLine).ShouldBe(new[] { "line-7" });
var noLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "") } };
applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" });
}
/// <summary>
/// 2026-08-15 review I5 — an added UNS reference variable announces its EQUIPMENT folder, which is the
/// parent <c>MaterialiseUnsReferences</c> hangs it under and a UNS-realm node (the realm
/// <c>AnnounceAddedNodes</c> hardcodes). Before the surgical-deploy branch a plan carrying adds went
/// through a full rebuild, so every client re-browsed regardless and the missing announcement was
/// invisible; now the add is surgical and an unannounced node stays unseen until some later browse.
/// Two references under one equipment dedup to a single announcement.
/// </summary>
[Fact]
public void ComputeAddAnnouncements_added_uns_reference_announces_its_equipment()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedUnsReferenceVariables = new[]
{
UnsRef("filling/line-1/station-1/speed", "speed"),
UnsRef("filling/line-1/station-1/temp", "temp"),
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
}
/// <summary>A plan with no additions yields no announcements.</summary>
[Fact]
public void ComputeAddAnnouncements_empty_add_plan_yields_none()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty();
}
/// <summary>AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected
/// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped.</summary>
[Fact]
public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
AddedEquipmentVirtualTags = new[]
{
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>()),
},
};
applier.AnnounceAddedNodes(plan);
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
}
/// <summary>
/// Gitea #599 (archreview 01/U-8 = 03/P6) — surgically-added RAW nodes are announced in the RAW realm.
/// A new tag under an existing device announces that device; a new TagGroup carrying a new tag under a
/// new Device announces only the Device's existing parent — the inner parents are themselves new, and a
/// client re-browsing the outer one discovers the whole new subtree. Before the fix only Uns-realm
/// parents were announced, so these adds were invisible to connected model-aware clients.
/// </summary>
[Fact]
public void AnnounceAddedNodes_announces_added_raw_nodes_in_the_raw_realm()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedRawContainers = new[]
{
new RawContainerNode("Plant/Modbus/dev2", "Plant/Modbus", "dev2", RawNodeKind.Device),
new RawContainerNode("Plant/Modbus/dev2/grp", "Plant/Modbus/dev2", "grp", RawNodeKind.TagGroup),
},
AddedRawTags = new[]
{
RawTag("Plant/Modbus/dev2/grp/speed") with { ParentNodeId = "Plant/Modbus/dev2/grp" },
RawTag("Plant/Modbus/dev1/temp"), // parent Plant/Modbus/dev1 already exists
},
// A UNS add in the same plan still announces in the Uns realm.
AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/speed", "speed") },
};
applier.AnnounceAddedNodes(plan);
sink.ModelChangeRealmQueue.OrderBy(x => x.NodeId, StringComparer.Ordinal).ToArray().ShouldBe(new[]
{
("Plant/Modbus", AddressSpaceRealm.Raw),
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
("eq-1", AddressSpaceRealm.Uns),
});
}
/// <summary>A top-level raw container (no parent: it hangs under the shared root, which has no
/// realm-qualified id) announces itself; duplicates collapse; the Uns-side computation is unaffected.</summary>
[Fact]
public void ComputeRawAddAnnouncements_top_level_container_announces_itself_and_dedups()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedRawContainers = new[] { new RawContainerNode("Plant2", null, "Plant2", RawNodeKind.Folder) },
AddedRawTags = new[] { RawTag("Plant/Modbus/dev1/a"), RawTag("Plant/Modbus/dev1/b") },
};
applier.ComputeRawAddAnnouncements(plan).ShouldBe(new[] { "Plant/Modbus/dev1", "Plant2" });
applier.ComputeAddAnnouncements(plan).ShouldBeEmpty();
}
// ----- Surgical v3 deltas: raw-tag attribute edits (Raw realm) + UNS-reference remove -----
// Task 4 taught AddressSpaceChangeClassifier that a non-alarm RawTagDelta and any UnsReferenceDelta are
// surgical-eligible; these pin the applier actually APPLYING them (before this, such a plan classified
// AttributeOnly and fell through the surgical branch as a silent no-op).
/// <summary>A non-alarm raw-tag delta (historize flipped on) is applied IN PLACE on the raw node via
/// <c>ISurgicalAddressSpaceSink.UpdateTagAttributes</c> in the <b>Raw</b> realm — no rebuild, so every
/// client subscription survives. The arguments match <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/>
/// exactly: a blank historian override resolves to the RawPath (== NodeId).</summary>
[Fact]
public void Surgical_raw_tag_delta_updates_in_place_raw_realm()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = RawTag("Plant/Modbus/dev1/speed");
var current = previous with { IsHistorized = true };
var plan = AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current));
plan.ChangedRawTags.Count.ShouldBe(1);
plan.AddedRawTags.ShouldBeEmpty();
plan.RemovedRawTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
call.Writable.ShouldBeTrue(); // Writable && !IsArray
call.Historian.ShouldBe("Plant/Modbus/dev1/speed"); // blank override ⇒ the RawPath
call.DataType.ShouldBe("Float");
call.IsArray.ShouldBeFalse();
call.ArrayLength.ShouldBeNull();
call.Realm.ShouldBe(AddressSpaceRealm.Raw); // the raw node, NOT a UNS projection
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>MaterialiseRawSubtree parity for the two computed arguments: an ARRAY tag is forced read-only
/// (the driver write path can't handle arrays) and a non-blank <c>HistorianTagname</c> override wins over
/// the RawPath default.</summary>
[Fact]
public void Surgical_raw_tag_delta_forces_array_read_only_and_honours_historian_override()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = RawTag("Plant/Modbus/dev1/block") with
{
Writable = true, IsArray = true, ArrayLength = 8, IsHistorized = true, HistorianTagname = "WW.Old",
};
var current = previous with { HistorianTagname = "WW.New", ArrayLength = 16 };
var plan = AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current));
plan.ChangedRawTags.Count.ShouldBe(1);
applier.Apply(plan);
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.Writable.ShouldBeFalse(); // Writable && !IsArray ⇒ an array tag is read-only
call.Historian.ShouldBe("WW.New"); // override verbatim
call.IsArray.ShouldBeTrue();
call.ArrayLength.ShouldBe(16u);
call.Realm.ShouldBe(AddressSpaceRealm.Raw);
}
/// <summary>The headline UNS-reference case: adding/dropping ONE <c>UnsTagReference</c> flips its backing
/// raw tag to Changed purely through <c>ReferencingEquipmentPaths</c>, which is node-INERT for a value tag.
/// The applier must recognise that and make NO sink call at all — not a rebuild, and not even an
/// idempotent in-place update.</summary>
[Fact]
public void Reference_only_raw_tag_delta_skips_the_sink_call()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = RawTag("Plant/Modbus/dev1/speed");
var current = previous with { ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" } };
var plan = AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current));
plan.ChangedRawTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.SurgicalCalls.ShouldBeEmpty(); // node-inert ⇒ nothing to say to the sink
}
/// <summary>A UNS-reference delta (here a display-name-override edit, which moves the NodeId) tears the
/// PREVIOUS node down in place: a terminal Bad so an in-flight MonitoredItem observes the removal, then
/// RemoveVariableNode — both in the Uns realm, no rebuild. The RECREATE of the current node is NOT the
/// applier's job (it rides the materialise pass after Apply returns).</summary>
[Fact]
public void Uns_reference_delta_removes_previous_node()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = UnsRef("filling/line-1/station-1/speed", "speed");
var current = previous with { NodeId = "filling/line-1/station-1/Line Speed", EffectiveName = "Line Speed" };
var plan = AddressSpacePlanner.Compute(CompositionWithUnsRefs(previous), CompositionWithUnsRefs(current));
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.ValueWrites.ShouldContain((previous.NodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", previous.NodeId, AddressSpaceRealm.Uns));
// The applier never recreates — no EnsureVariable for the new NodeId here.
sink.VariableCalls.ShouldBeEmpty();
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>
/// 2026-08-15 review — the combined plan a backing-tag HISTORIAN edit produces: a surgical raw-tag
/// delta AND the coincident UNS-reference delta (the reference record carries the backing historian
/// intent, so it goes unequal too). Both are non-rebuild kinds, so ONE apply must update the raw node
/// in place AND repair the stale UNS mirror — with NO rebuild. Without the coincident delta the UNS
/// mirror kept the OLD historian tagname and HistoryRead through the UNS NodeId served a different
/// series than through the raw NodeId.
/// <para><b>The mirror is repaired IN PLACE (review I2), not by remove+recreate.</b> This delta's
/// IDENTITY is unchanged (same NodeId, same EquipmentId, same BackingRawPath) — only the inherited
/// Backing* attributes moved — so <c>UpdateTagAttributes</c> in the Uns realm says everything that
/// needs saying and the mirror's client MonitoredItems survive. Remove+recreate stays for the deltas
/// that genuinely change identity (a re-point or an effective-name edit).</para>
/// </summary>
[Fact]
public void Backing_historian_edit_updates_both_the_raw_node_and_the_uns_mirror_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var rawBefore = RawTag("Plant/Modbus/dev1/speed") with { IsHistorized = true, HistorianTagname = "WW.Old" };
var rawAfter = rawBefore with { HistorianTagname = "WW.New" };
var unsBefore = UnsRef("filling/line-1/station-1/speed", "speed") with
{
BackingIsHistorized = true, BackingHistorianTagname = "WW.Old",
};
var unsAfter = unsBefore with { BackingHistorianTagname = "WW.New" };
var previous = CompositionWith(rawBefore, unsBefore);
var current = CompositionWith(rawAfter, unsAfter);
var plan = AddressSpacePlanner.Compute(previous, current);
// The plan shape the fix produces: one delta in EACH realm, nothing structural.
plan.ChangedRawTags.Count.ShouldBe(1);
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// Raw node: updated in place with the NEW tagname, in the Raw realm.
var raw = sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Raw);
raw.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
raw.Historian.ShouldBe("WW.New");
// UNS mirror: the SAME in-place update, in the Uns realm, carrying the new inherited tagname.
var uns = sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Uns);
uns.NodeId.ShouldBe(unsAfter.NodeId);
uns.Historian.ShouldBe("WW.New");
// …and NOT torn down: no remove, and no terminal Bad on the mirror's subscribers.
sink.RemoveCalls.ShouldBeEmpty();
sink.ValueWrites.ShouldNotContain((unsBefore.NodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
}
/// <summary>The same combined-plan repair for a scalar→ARRAY flip on the backing tag: without it the UNS
/// mirror stays scalar AND writable, bypassing the array read-only write gate through the UNS NodeId.
/// Identity is unchanged, so the mirror is repaired in place too (review I2) — and the in-place call must
/// carry the array shape AND the forced read-only, exactly as a recreate would have.</summary>
[Fact]
public void Backing_shape_flip_updates_the_uns_mirror_in_place_without_rebuilding()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var rawBefore = RawTag("Plant/Modbus/dev1/block");
var rawAfter = rawBefore with { IsArray = true, ArrayLength = 8 };
var unsBefore = UnsRef("filling/line-1/station-1/block", "block");
var unsAfter = unsBefore with { BackingIsArray = true, BackingArrayLength = 8 };
var plan = AddressSpacePlanner.Compute(
CompositionWith(rawBefore, unsBefore), CompositionWith(rawAfter, unsAfter));
plan.ChangedRawTags.Count.ShouldBe(1);
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Raw).IsArray.ShouldBeTrue();
var uns = sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Uns);
uns.NodeId.ShouldBe(unsAfter.NodeId);
uns.IsArray.ShouldBeTrue();
uns.ArrayLength.ShouldBe(8u);
// The reference row is authored Writable, but an array node is forced read-only — the same
// `Writable && !BackingIsArray` rule MaterialiseUnsReferences applies when it creates the node.
uns.Writable.ShouldBeFalse();
sink.RemoveCalls.ShouldBeEmpty();
}
/// <summary>Gitea #610: the array-write capability flipping on an already-array tag (its driver gained an
/// array encoder, or the element type moved into the supported set) is applied IN PLACE on the raw node AND
/// its UNS mirror — both become writable — with no rebuild. Without ArrayWritable on the classifier whitelist
/// this edit would tear down every node; without BackingArrayWritable the mirror would stay read-only.</summary>
[Fact]
public void Array_write_capability_flip_updates_raw_and_uns_writability_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var rawBefore = RawTag("Plant/Modbus/dev1/block") with { Writable = true, IsArray = true, ArrayLength = 8 };
var rawAfter = rawBefore with { ArrayWritable = true };
var unsBefore = UnsRef("filling/line-1/station-1/block", "block") with { BackingIsArray = true, BackingArrayLength = 8 };
var unsAfter = unsBefore with { BackingArrayWritable = true };
var plan = AddressSpacePlanner.Compute(
CompositionWith(rawBefore, unsBefore), CompositionWith(rawAfter, unsAfter));
plan.ChangedRawTags.Count.ShouldBe(1);
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.RemoveCalls.ShouldBeEmpty();
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Raw).Writable.ShouldBeTrue();
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Uns).Writable.ShouldBeTrue();
}
/// <summary>The one-way rebuild ratchet holds for the new raw-tag path: a surgical update that reports the
/// node missing (false) falls back to a full rebuild.</summary>
[Fact]
public void Surgical_raw_tag_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { SurgicalReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = RawTag("Plant/Modbus/dev1/speed");
var current = previous with { IsHistorized = true };
var outcome = applier.Apply(
AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current)));
sink.SurgicalCalls.ShouldHaveSingleItem(); // attempted in place first
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>The same ratchet for the UNS-reference remove: a remove reporting the node missing (false)
/// falls back to a full rebuild.</summary>
[Fact]
public void Uns_reference_remove_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { RemoveReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = UnsRef("filling/line-1/station-1/speed", "speed");
var current = previous with { BackingRawPath = "Plant/Modbus/dev1/speed2" };
var outcome = applier.Apply(
AddressSpacePlanner.Compute(CompositionWithUnsRefs(previous), CompositionWithUnsRefs(current)));
sink.RemoveCalls.ShouldHaveSingleItem(); // attempted in place first
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>A sink lacking the surgical capability cannot apply a raw-tag delta in place, so it must
/// REBUILD. This is what closes the Task-4 gap for the fallback case: before the branch trigger learned
/// about raw-tag deltas, such a plan classified AttributeOnly and applied nothing at all.</summary>
[Fact]
public void Raw_tag_delta_with_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = RawTag("Plant/Modbus/dev1/speed");
var current = previous with { IsHistorized = true };
var outcome = applier.Apply(
AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current)));
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
private static RawTagPlan RawTag(string rawPath) =>
new("tag-" + rawPath, rawPath, ParentNodeId: "Plant/Modbus/dev1", DriverInstanceId: "drv-1",
Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float", Writable: true, Alarm: null,
ReferencingEquipmentPaths: Array.Empty<string>());
private static UnsReferenceVariable UnsRef(string nodeId, string effectiveName) =>
new("ref-1", "eq-1", nodeId, effectiveName, "Plant/Modbus/dev1/speed", "Float", Writable: true);
private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = tags,
};
// Both realms in one composition — the shape a backing-tag edit produces (a raw delta AND its coincident
// UNS-reference delta) needs the raw tag and the reference to it side by side.
private static AddressSpaceComposition CompositionWith(RawTagPlan tag, UnsReferenceVariable reference) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[] { tag },
UnsReferenceVariables = new[] { reference },
};
private static AddressSpaceComposition CompositionWithUnsRefs(params UnsReferenceVariable[] refs) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
UnsReferenceVariables = refs,
};
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
new(
areas, Array.Empty<UnsLineProjection>(), Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
private static AddressSpaceComposition CompositionWithLines(params UnsLineProjection[] lines) =>
new(
Array.Empty<UnsAreaProjection>(), lines, Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = vtags,
};
private static AddressSpaceComposition CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) =>
// ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms.
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), alarms);
private static AddressSpaceComposition CompositionWithDrivers(params DriverInstancePlan[] drivers) =>
new(
Array.Empty<EquipmentNode>(), drivers, Array.Empty<ScriptedAlarmPlan>());
private static 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>(),
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<AddressSpacePlan.AlarmDelta>());
private static AddressSpacePlan WithEquipmentRemoval(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
/// <summary>Gets the queue of surgical in-place tag-attribute update calls (F10b + FB-7).</summary>
public ConcurrentQueue<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength, AddressSpaceRealm Realm)> SurgicalQueue { get; } = new();
/// <summary>Gets the list of recorded surgical in-place tag-attribute update calls.</summary>
public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength, AddressSpaceRealm Realm)> SurgicalCalls => SurgicalQueue.ToList();
/// <summary>When false, <see cref="UpdateTagAttributes"/> reports the node missing (returns false),
/// driving the applier's rebuild fallback. Defaults to true (node present, update succeeds).</summary>
public bool SurgicalReturns { get; init; } = true;
/// <summary>Records a surgical in-place tag-attribute update; returns <see cref="SurgicalReturns"/>.</summary>
/// <param name="variableNodeId">The variable node ID to update in place.</param>
/// <param name="writable">The new Writable (AccessLevel) for the node.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
/// <param name="dataType">The new OPC UA data type name to apply in place.</param>
/// <param name="isArray">The new array-ness of the node.</param>
/// <param name="arrayLength">The new 1-D array length when <paramref name="isArray"/> is true.</param>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm));
return SurgicalReturns;
}
/// <summary>Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001).</summary>
public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new();
/// <summary>Gets the list of recorded surgical folder display-name update calls.</summary>
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls => FolderRenameQueue.ToList();
/// <summary>When false, <see cref="UpdateFolderDisplayName"/> reports the folder missing (returns
/// false), driving the applier's rebuild fallback. Defaults to true.</summary>
public bool FolderRenameReturns { get; init; } = true;
/// <summary>Records a surgical in-place folder display-name update; returns <see cref="FolderRenameReturns"/>.</summary>
/// <param name="folderNodeId">The folder node ID to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
FolderRenameQueue.Enqueue((folderNodeId, displayName));
return FolderRenameReturns;
}
/// <summary>Gets the queue of surgical remove calls (kind + node id) in call order (R2-07 Phase 2).</summary>
public ConcurrentQueue<(string Kind, string NodeId, AddressSpaceRealm Realm)> RemoveQueue { get; } = new();
/// <summary>Gets the list of recorded surgical remove calls.</summary>
public List<(string Kind, string NodeId, AddressSpaceRealm Realm)> RemoveCalls => RemoveQueue.ToList();
/// <summary>When false, the Remove* members report the node missing (return false), driving the
/// applier's rebuild fallback. Defaults to true (node present, removal succeeds).</summary>
public bool RemoveReturns { get; init; } = true;
/// <summary>Records a surgical variable-node removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
RemoveQueue.Enqueue(("var", variableNodeId, realm));
return RemoveReturns;
}
/// <summary>Records a surgical alarm-condition-node removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
RemoveQueue.Enqueue(("alarm", alarmNodeId, realm));
return RemoveReturns;
}
/// <summary>Records a surgical equipment-subtree removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
RemoveQueue.Enqueue(("equipment", equipmentNodeId, realm));
return RemoveReturns;
}
/// <summary>Gets the queue of alarm condition write calls.</summary>
public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
/// <summary>Gets the queue of folder creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// <summary>Gets the queue of variable creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new();
/// <summary>Gets the queue of the historian-tagname arg captured per <c>EnsureVariable</c> call,
/// keyed by NodeId (null ⇒ that call passed not-historized).</summary>
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
/// <summary>Gets the queue of the isArray/arrayLength args captured per <c>EnsureVariable</c>
/// call, keyed by NodeId.</summary>
public ConcurrentQueue<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayQueue { get; } = new();
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionQueue { get; } = new();
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
public int RebuildCalls;
/// <summary>Gets the list of recorded alarm writes.</summary>
public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList();
/// <summary>Gets the list of recorded folder creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// <summary>Gets the list of recorded variable creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList();
/// <summary>Gets the list of recorded (NodeId, historian-tagname) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <summary>Gets the list of recorded (NodeId, isArray, arrayLength) triples captured per EnsureVariable call.</summary>
public List<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayCalls => ArrayQueue.ToList();
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// <summary>Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad.</summary>
public ConcurrentQueue<(string NodeId, uint Quality, AddressSpaceRealm Realm)> ValueWriteQueue { get; } = new();
/// <summary>Gets the list of recorded value writes.</summary>
public List<(string NodeId, uint Quality, AddressSpaceRealm Realm)> ValueWrites => ValueWriteQueue.ToList();
/// <summary>Records a value write (NodeId + quality).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="statusCode">The OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> ValueWriteQueue.Enqueue((nodeId, statusCode, realm));
/// <summary>Records an alarm condition write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> AlarmQueue.Enqueue((alarmNodeId, state));
/// <summary>Records an alarm-condition materialise call.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
/// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records a variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname));
ArrayQueue.Enqueue((variableNodeId, isArray, arrayLength));
}
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Gets the queue of NodeAdded model-change announcements (discovered-node injection).</summary>
public ConcurrentQueue<string> ModelChangeQueue { get; } = new();
/// <summary>Gets the list of recorded NodeAdded model-change announcements (discovered-node injection).</summary>
public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
ModelChangeQueue.Enqueue(affectedNodeId);
ModelChangeRealmQueue.Enqueue((affectedNodeId, realm));
}
/// <summary>Gets the NodeAdded announcements WITH the realm each was raised in (Gitea #599).</summary>
public ConcurrentQueue<(string NodeId, AddressSpaceRealm Realm)> ModelChangeRealmQueue { get; } = new();
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
/// prove the F10b fallback: when the bound sink lacks the surgical capability, a surgical-eligible tag
/// delta still drives a full <c>RebuildAddressSpace</c>.</summary>
private sealed class PlainRecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
public int RebuildCalls;
/// <summary>Records a value write (no-op in this sink).</summary>
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm condition write call.</summary>
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm-condition materialise call.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>No-op folder creation call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
{
private readonly bool _throwOnAlarmWrite;
/// <summary>Initializes a new instance of the ThrowingSink class.</summary>
/// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
/// <summary>Records a value write (no-op in this sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="statusCode">The OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
/// <summary>No-op alarm-condition materialise call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}