Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
T
Joseph Doherty 2d20aeb779 fix(opcua,drivers): carry driver status sub-codes to clients (#519) + health-write ordering (#517)
#519 — a driver's OPC UA status code now reaches a client with its sub-code intact.
DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum
and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication,
BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every
driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in
logs only. Resolved by option (b): the raw uint rides AttributeValuePublished ->
AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and
severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad).

The surface was wider than the issue costed (41 files, not "the sink and 3
implementations"): OpcUaQuality had non-driver producers with no uint to carry.
Each got its natural code — BadInternalError for a failed virtual-tag script,
BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of
mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the
engine already held. AddressSpaceApplier's removal writes deliberately keep the
generic Bad: not losing a code the driver had is this issue; inventing one for the
deploy path is a separate call.

Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change
rather than a severity-bucket change. While the annotation was 3-state those were the
same predicate; now a move between two Bad reasons changes what a subscriber reads.

New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and
StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so
it can only see a named constant, never an inline literal.

#517 — the symptom is real but the issue named the wrong path and its fix was unsafe.
ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the
mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth,
has no production call site.) InitializeAsync can: it runs on a fire-and-forget
Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a
catch which disposes the client before writing Faulted.

Moving the write earlier, as suggested, inverts the terminal state — teardown drains
in-flight loops that write health as they unwind, so a late loop could leave Healthy at
rest with every connection closed. The fix is a bracket (conservative before, terminal
after), applied to MTConnect's init-failure path only; shutdown keeps its single
trailing write, and the rule plus the reachability table are documented on
IDriver.ShutdownAsync.

CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing —
Healthy before initialize, after shutdown and after dispose. Now derived from _running.
Its test, named Health_is_always_Connected, asserted the defect and is replaced.

Every new guard was falsified by breaking the production code and watching it go red.
The two sub-code tests each assert two different Bad codes, since a single-code
assertion could pass against a hard-coded constant under the old projection.

Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in
Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in
Host.IntegrationTests.

Closes #517
Closes #519

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 05:25:27 -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, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
}
}