feat(opcua): announce pure-add nodes via Part 3 NodeAdded model-change (R2-07 T3)

This commit is contained in:
Joseph Doherty
2026-07-13 11:48:02 -04:00
parent 0d717e4785
commit 9dbc363ef0
3 changed files with 157 additions and 1 deletions
@@ -19,7 +19,7 @@
{
"id": "T3",
"subject": "Phase 1: ComputeAddAnnouncements + AnnounceAddedNodes on the applier (dedup parent NodeAdded announces)",
"status": "pending",
"status": "completed",
"blockedBy": [
"T1"
]
@@ -218,6 +218,63 @@ public sealed class AddressSpaceApplier
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes);
}
/// <summary>
/// Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce
/// (Part 3 <c>NodeAdded</c>) after a pure-add apply's Materialise passes have created the new nodes.
/// For each added tag/vtag the parent is its materialise folder (the equipment folder, or the
/// <c>FolderPath</c> sub-folder — the exact ids the Materialise passes place children at); for each
/// added scripted alarm the parent is its equipment folder (where the condition node parents); for
/// each added equipment the parent is its UNS line folder when set, else the equipment's own new
/// folder id (a valid Part 3 announcement of the folder itself — the root has no announceable id).
/// Pure — no sink interaction — so it is unit-testable in isolation and safe to call before the
/// nodes exist. Ordinal-sorted so the announce sequence is stable.
/// </summary>
/// <param name="plan">The plan whose added nodes' parents to announce.</param>
/// <returns>The distinct affected parent node ids, ordinal-sorted.</returns>
public IReadOnlyList<string> ComputeAddAnnouncements(AddressSpacePlan plan)
{
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);
foreach (var eq in plan.AddedEquipment)
ids.Add(string.IsNullOrWhiteSpace(eq.UnsLineId) ? eq.EquipmentId : eq.UnsLineId);
var list = ids.ToList();
list.Sort(StringComparer.Ordinal);
return list;
}
/// <summary>The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs
/// under: the equipment folder when <paramref name="folderPath"/> is null/empty, else the FolderPath
/// sub-folder. Mirrors the parent derivation the Materialise passes use.</summary>
/// <param name="equipmentId">The owning equipment folder id.</param>
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <returns>The materialise-parent node id.</returns>
private static string MaterialiseParent(string equipmentId, string? folderPath) =>
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath);
/// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
/// <see cref="ComputeAddAnnouncements"/>, so model-aware clients re-browse the parents the new nodes
/// were added under. Called by <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist
/// by then) and ONLY on a non-rebuild apply — after a full rebuild the announcement is moot
/// (subscriptions are dead anyway). Uses the existing, guard-covered
/// <see cref="IOpcUaAddressSpaceSink.RaiseNodesAddedModelChange"/> — no new sink member. Each call is
/// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
/// </summary>
/// <param name="plan">The plan whose added nodes to announce.</param>
public void AnnounceAddedNodes(AddressSpacePlan plan)
{
foreach (var id in ComputeAddAnnouncements(plan))
{
try { _sink.RaiseNodesAddedModelChange(id); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
}
}
/// <summary>
/// Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request
@@ -1825,6 +1825,105 @@ public sealed class AddressSpaceApplierTests
sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
// ----- R2-07 T3: pure-add NodeAdded announcements -----
/// <summary>Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the
/// equipment id.</summary>
[Fact]
public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
}
/// <summary>A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment.</summary>
[Fact]
public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") });
}
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
[Fact]
public void ComputeAddAnnouncements_added_alarm_announces_equipment()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") },
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" });
}
/// <summary>An added equipment WITH a UnsLineId announces its parent line; WITHOUT one announces its own
/// new folder id (a valid Part 3 NodeAdded of the folder itself; the root has no announceable id).</summary>
[Fact]
public void ComputeAddAnnouncements_added_equipment_announces_line_or_self()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var withLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-7") } };
applier.ComputeAddAnnouncements(withLine).ShouldBe(new[] { "line-7" });
var noLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "") } };
applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" });
}
/// <summary>A plan with no additions yields no announcements.</summary>
[Fact]
public void ComputeAddAnnouncements_empty_add_plan_yields_none()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty();
}
/// <summary>AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected
/// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped.</summary>
[Fact]
public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null),
},
};
applier.AnnounceAddedNodes(plan);
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
}
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
new(
areas, Array.Empty<UnsLineProjection>(), Array.Empty<EquipmentNode>(),