Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
T
Joseph Doherty db751d12a5
v2-ci / build (pull_request) Successful in 4m49s
v2-ci / unit-tests (pull_request) Has started running
feat(alarms): condition Quality tracks source connectivity (#477)
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good
so every native + scripted condition reported Good unconditionally — a
comms-lost device still showed a healthy, inactive, Good condition (a
wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs
bucketing on IsGood) could not tell "genuinely inactive" from "lost contact".

Layer 1 — make Quality a real, plumbed field:
- AlarmConditionSnapshot gains OpcUaQuality Quality (default Good).
- MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good).
- WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality
  member so a quality-bucket change fires a Part 9 event.

Layer 2 — drive native quality from driver connectivity (a comms-lost driver
emits no alarm transitions, and an alarm-bearing raw tag has no value variable,
so quality can't come from either existing channel):
- DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting.
- DriverHostActor fans it to every native condition the driver owns as
  OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect).
- New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and
  fires only on a bucket change — never touches Active/Acked/Retain (an active
  alarm that loses comms stays active). Not a full-snapshot re-projection, so it
  can't clobber severity/message and works for a never-fired condition.
  Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the
  reflection forwarding guard). Ungated by redundancy role; no /alerts row.

Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3).

Tests: node-level (materialise/project/no-clobber/unknown-node no-op),
NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor
fan-out, OpcUaPublishActor routing, and the wire-level guard
(Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified
against a simulated pre-fix always-Good server. Existing DriverInstanceActor
parent probes ignore the new ConnectivityChanged.

Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)";
design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
2026-07-17 15:10:04 -04:00

310 lines
17 KiB
C#

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;
/// <summary>
/// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes.
/// <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> lights up the <c>ns=Raw</c> device tree
/// (containers as folders + tags as variables, each keyed by its RawPath, all in
/// <see cref="AddressSpaceRealm.Raw"/>); <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> lights
/// up the <c>ns=UNS</c> reference variables (each under its equipment folder in
/// <see cref="AddressSpaceRealm.Uns"/>) and wires an <c>Organizes</c> reference UNS→Raw. A historized raw
/// tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries
/// its realm EXPLICITLY.
/// </summary>
public sealed class AddressSpaceApplierRawUnsTests
{
private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) =>
new(sink, NullLogger<AddressSpaceApplier>.Instance);
[Fact]
public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawContainers = new[]
{
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver),
new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device),
},
RawTags = new[]
{
new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed",
"Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
// Containers are folders in the Raw realm, parents-before-children.
sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[]
{
("Plant", AddressSpaceRealm.Raw),
("Plant/Modbus", AddressSpaceRealm.Raw),
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
});
// The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device.
var v = sink.Variables.ShouldHaveSingleItem();
v.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
v.Parent.ShouldBe("Plant/Modbus/dev1");
v.Realm.ShouldBe(AddressSpaceRealm.Raw);
v.Writable.ShouldBeTrue();
v.HistorianTagname.ShouldBeNull();
}
[Fact]
public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
// Historized, no override → historian tagname defaults to the RawPath.
new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true),
// Historized with override → the override wins.
new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true, HistorianTagname: "WW.Override"),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
var byId = sink.Variables.ToDictionary(v => v.NodeId);
byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath
byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override");
}
[Fact]
public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable()
{
var sink = new RealmRecordingSink();
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: Array.Empty<string>()),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable
var c = sink.Conditions.ShouldHaveSingleItem();
c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath
c.Parent.ShouldBe("Plant/A/dev");
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
c.IsNative.ShouldBeTrue();
// No referencing equipment ⇒ no multi-notifier wiring.
sink.NotifierWirings.ShouldBeEmpty();
}
[Fact]
public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
{
var sink = new RealmRecordingSink();
// UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
// EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
var composition = new AddressSpaceComposition(
new[] { new UnsAreaProjection("a1", "filling") },
new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
new[]
{
new EquipmentNode("EQ-1", "station1", "l1"),
new EquipmentNode("EQ-2", "station2", "l2"),
},
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),
// The composer emits Area/Line/Equipment NAME paths here.
ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
// The single condition is materialised ONCE at the RawPath (Raw realm)...
var c = sink.Conditions.ShouldHaveSingleItem();
c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
// ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
// logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
var w = sink.NotifierWirings.ShouldHaveSingleItem();
w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
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()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
// Backing raw tag: historized with an override → the UNS ref must register the SAME tagname.
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
IsHistorized: true, HistorianTagname: "WW.Speed"),
},
UnsReferenceVariables = new[]
{
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
"Plant/A/dev/speed", "Float", Writable: true),
},
};
NewApplier(sink).MaterialiseUnsReferences(composition);
// The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId).
var v = sink.Variables.ShouldHaveSingleItem();
v.NodeId.ShouldBe("filling/line1/station1/speed");
v.Parent.ShouldBe("EQ-1");
v.Realm.ShouldBe(AddressSpaceRealm.Uns);
v.Writable.ShouldBeTrue();
v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag
// An Organizes reference UNS→Raw links the two.
var r = sink.References.ShouldHaveSingleItem();
r.Source.ShouldBe("filling/line1/station1/speed");
r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns);
r.Target.ShouldBe("Plant/A/dev/speed");
r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw);
r.ReferenceType.ShouldBe("Organizes");
}
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
{
public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> Folders.Add((folderNodeId, parentNodeId, realm));
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
}
}