diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json index 2499e8b4..8d8e6167 100644 --- a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json +++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json @@ -19,7 +19,7 @@ { "id": "T3", "subject": "Phase 1: ComputeAddAnnouncements + AnnounceAddedNodes on the applier (dedup parent NodeAdded announces)", - "status": "pending", + "status": "completed", "blockedBy": [ "T1" ] diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 2ab88bf3..677ffb15 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -218,6 +218,63 @@ public sealed class AddressSpaceApplier return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes); } + /// + /// Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce + /// (Part 3 NodeAdded) 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 + /// FolderPath 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. + /// + /// The plan whose added nodes' parents to announce. + /// The distinct affected parent node ids, ordinal-sorted. + public IReadOnlyList ComputeAddAnnouncements(AddressSpacePlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + + var ids = new HashSet(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; + } + + /// The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs + /// under: the equipment folder when is null/empty, else the FolderPath + /// sub-folder. Mirrors the parent derivation the Materialise passes use. + /// The owning equipment folder id. + /// The tag/vtag FolderPath, or null/empty for "directly under the equipment". + /// The materialise-parent node id. + private static string MaterialiseParent(string equipmentId, string? folderPath) => + string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath); + + /// + /// Announce a Part 3 GeneralModelChangeEvent(NodeAdded) per distinct affected parent from + /// , so model-aware clients re-browse the parents the new nodes + /// were added under. Called by OpcUaPublishActor 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 + /// — no new sink member. Each call is + /// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand). + /// + /// The plan whose added nodes to announce. + 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); } + } + } + /// /// 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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index f713686d..d1bb299d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -1825,6 +1825,105 @@ public sealed class AddressSpaceApplierTests sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first } + // ----- R2-07 T3: pure-add NodeAdded announcements ----- + + /// Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the + /// equipment id. + [Fact] + public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one() + { + var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.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" }); + } + + /// A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment. + [Fact] + public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder() + { + var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.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") }); + } + + /// An added scripted alarm announces its equipment folder (where its condition node parents). + [Fact] + public void ComputeAddAnnouncements_added_alarm_announces_equipment() + { + var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); + + var plan = EmptyPlan with + { + AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") }, + }; + + applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" }); + } + + /// 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). + [Fact] + public void ComputeAddAnnouncements_added_equipment_announces_line_or_self() + { + var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.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" }); + } + + /// A plan with no additions yields no announcements. + [Fact] + public void ComputeAddAnnouncements_empty_add_plan_yields_none() + { + var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); + applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty(); + } + + /// AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected + /// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped. + [Fact] + public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent() + { + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.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(), Array.Empty(),