fix(v3-batch4-wp4): alarm fan-out hardening — event-delivery test, resolution-failure meter, teardown guards (Wave C review M1/M2/M3/L1/L3)
M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two equipment folders, fires ONE transition, and asserts a Server-object subscriber gets EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping Server + equipment-folder subscribers each get exactly one copy. Captures via the subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents path delivers nothing for these conditions in the SDK client — the working capture is the fast callback). M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented the DisplayName==Name coupling as a binding invariant. Tests: Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed. M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set, bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced equipment receiving the alarm; binding guard comment added on the classifier's ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set. L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry). L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision (defense-in-depth; UNS uniqueness prevents it). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
@@ -152,6 +154,88 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name
|
||||
/// coupling). The composer builds a native alarm's <c>ReferencingEquipmentPaths</c> from the
|
||||
/// Area/Line/Equipment <c>Name</c>s; the applier's <c>BuildEquipmentIdByFolderPath</c> inverts those
|
||||
/// paths back to the <c>EquipmentId</c> via the projected DisplayName. Feed a REAL composer output
|
||||
/// (not a hand-built plan) through <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> and assert
|
||||
/// the notifier wired to exactly the backing equipment's id — proving the two path constructions agree
|
||||
/// end-to-end. If a future editable Area/Line display-name feature made <c>DisplayName != Name</c>, this
|
||||
/// inversion (and thus the native-alarm fan-out) would break with no deploy error; this test trips first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier()
|
||||
{
|
||||
// Real config: a raw alarm tag (Speed) referenced by one equipment (eq-1 under Area "filling" /
|
||||
// Line "line-1" / Equipment "station-1").
|
||||
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
||||
};
|
||||
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
||||
var speed = new Tag
|
||||
{
|
||||
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
||||
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
|
||||
|
||||
var composition = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: new[] { refSpeed },
|
||||
tags: new[] { speed },
|
||||
rawFolders: new[] { folder },
|
||||
devices: new[] { device },
|
||||
tagGroups: Array.Empty<TagGroup>());
|
||||
|
||||
// The composer emitted the alarm tag's referencing-equipment path from the Area/Line/Equipment Names.
|
||||
var alarmTag = composition.RawTags.Single(t => t.Alarm is not null);
|
||||
alarmTag.ReferencingEquipmentPaths.ShouldBe(new[] { V3NodeIds.Uns("filling", "line-1", "station-1") });
|
||||
|
||||
// The applier inverts that path back to the EquipmentId folder and wires the notifier there.
|
||||
var sink = new RealmRecordingSink();
|
||||
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
var w = sink.NotifierWirings.ShouldHaveSingleItem();
|
||||
w.AlarmNodeId.ShouldBe(alarmTag.NodeId);
|
||||
w.FolderNodeIds.ShouldBe(new[] { "eq-1" }); // resolved path → EquipmentId
|
||||
}
|
||||
|
||||
/// <summary>Wave C review M2 — when a native alarm HAS referencing equipment paths but NONE resolve to an
|
||||
/// equipment folder (broken ancestry / the latent coupling breaking), the applier surfaces it as a failed
|
||||
/// node (so the degraded-apply meter fires) instead of silently skipping — no notifier is wired, but the
|
||||
/// condition itself is still materialised.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_alarm_with_unresolvable_referencing_equipment_counts_as_failed()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
// No equipment nodes in the composition → the referencing path can't resolve to an EquipmentId folder.
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||
ReferencingEquipmentPaths: new[] { "filling/line1/ghost" }),
|
||||
},
|
||||
};
|
||||
|
||||
var failed = NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
failed.ShouldBeGreaterThanOrEqualTo(1); // resolution failure surfaced (not a silent skip)
|
||||
sink.NotifierWirings.ShouldBeEmpty(); // nothing wired
|
||||
sink.Conditions.ShouldHaveSingleItem(); // the condition itself still materialised
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian()
|
||||
{
|
||||
|
||||
+22
@@ -177,6 +177,28 @@ public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Wave C review M3 — <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> is a RECONCILE: re-wiring
|
||||
/// with a SHRUNK folder set unwires (bidirectionally) the dropped equipment folder, so a de-referenced
|
||||
/// equipment stops receiving the alarm. (Guards a future surgical ChangedRawTags path; today the shrink
|
||||
/// arrives as a full rebuild.)</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set()
|
||||
{
|
||||
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||
|
||||
// Re-wire with only Equip1 (Equip2 de-referenced) — reconcile unwires Equip2 bidirectionally.
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
|
||||
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
|
||||
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0); // no dangling inverse to the condition
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
|
||||
Reference in New Issue
Block a user