feat(opcua): announce pure-add nodes via Part 3 NodeAdded model-change (R2-07 T3)
This commit is contained in:
@@ -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>(),
|
||||
|
||||
Reference in New Issue
Block a user