Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs
T
Joseph Doherty 875ec9fe47 fix(opcuaserver): UNS reference nodes repair on backing-tag historian/shape edits
Adversarial review of 9f68f272 (classifier: non-alarm raw-tag deltas became
surgical) found stale UNS inheritance after a surgical raw edit. Editing a raw
tag's IsHistorized / HistorianTagname / IsArray / ArrayLength left every
referencing UNS node STALE: none of those fields were on UnsReferenceVariable,
so no coincident UnsReferenceDelta fired; the applier updated the raw node in
place; and EnsureVariable early-returns on an existing node. Consequences:
HistoryRead through the UNS NodeId served a different series than through the
raw NodeId (breaking the documented dual-registration invariant), a
de-historized tag kept serving history via UNS, and a scalar->array flip left
the UNS mirror scalar AND writable (bypassing the array read-only write gate).

UnsReferenceVariable now carries Backing{IsHistorized,HistorianTagname,IsArray,
ArrayLength}, copied from the backing tag's TagConfigIntent at compose time, so
record equality makes the reference Changed in the SAME plan. Both deltas are
non-rebuild kinds, so the existing machinery repairs the mirror surgically:
remove-previous in Apply, recreate in MaterialiseUnsReferences. That pass now
reads the four values from the RECORD (the single truth the planner diffed)
instead of a rawByPath back-lookup, preserving historian resolution exactly
(blank override => BackingRawPath, which is the backing tag's NodeId).

The artifact-decode seam needs no change: DeploymentArtifact.BuildRawAndUnsSubtrees
drives AddressSpaceComposer.Compose verbatim, which is what guarantees byte-parity.
The classifier needs no change either (a UnsReferenceDelta never forces Rebuild);
its comment now records why.

Also from the same review: a three-place-contract comment on RawTagPlan's
hand-written Equals naming its two downstream consumers, plus classifier pins for
the .Any(!eligible) quantifier, an ArrayLength-only delta, and an
alarm-content-only delta.
2026-08-15 17:11:03 -04:00

273 lines
14 KiB
C#

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;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree
/// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed <c>s=&lt;RawPath&gt;</c>,
/// realm <see cref="AddressSpaceRealm.Raw"/>) and the UNS subtree (one Variable per
/// <see cref="UnsTagReference"/> keyed <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c>,
/// realm <see cref="AddressSpaceRealm.Uns"/>, carrying its backing RawPath for the Organizes ref +
/// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths.
/// </summary>
public sealed class AddressSpaceComposerDualNamespaceTests
{
// A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags.
// Speed sits under the group; Status directly under the device; Levels is an array tag.
private static AddressSpaceComposition ComposeFullFixture()
{
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 group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" };
var speed = new Tag
{
TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed",
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," +
"\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}",
};
var status = new Tag
{
TagId = "t-status", DeviceId = "dev-1", Name = "Status",
DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var levels = new Tag
{
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels",
DataType = "Int32", AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"isArray\":true,\"arrayLength\":8}",
};
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 refStatus = new UnsTagReference
{
UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status",
DisplayNameOverride = "MachineStatus",
};
return AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { refSpeed, refStatus },
tags: new[] { speed, status, levels },
rawFolders: new[] { folder },
devices: new[] { device },
tagGroups: new[] { group });
}
/// <summary>The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each
/// realm=Raw, with parents-before-children ordering + correct parent NodeIds.</summary>
[Fact]
public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents()
{
var c = ComposeFullFixture();
c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw);
var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder);
folder.NodeId.ShouldBe("Plant");
folder.ParentNodeId.ShouldBeNull(); // cluster root
folder.DisplayName.ShouldBe("Plant");
var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver);
driver.NodeId.ShouldBe("Plant/Modbus1");
driver.ParentNodeId.ShouldBe("Plant");
var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device);
device.NodeId.ShouldBe("Plant/Modbus1/PLC-A");
device.ParentNodeId.ShouldBe("Plant/Modbus1");
var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup);
group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
// Sorted by NodeId ordinal ⇒ a parent always precedes each of its children.
c.RawContainers.Select(n => n.NodeId)
.ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" });
}
/// <summary>Raw tags are Variables keyed <c>s=&lt;RawPath&gt;</c> (realm=Raw), carrying DataType /
/// writable / historize + historian tagname / array shape, and hang under their group (or device).</summary>
[Fact]
public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes()
{
var c = ComposeFullFixture();
c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw);
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed");
speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
speed.DriverInstanceId.ShouldBe("drv-modbus");
speed.DataType.ShouldBe("Float");
speed.Writable.ShouldBeTrue();
speed.IsHistorized.ShouldBeTrue();
speed.HistorianTagname.ShouldBe("speed_hist");
var status = c.RawTags.Single(t => t.TagId == "t-status");
status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device
status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
status.Writable.ShouldBeFalse();
status.IsHistorized.ShouldBeFalse();
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
levels.IsArray.ShouldBeTrue();
levels.ArrayLength.ShouldBe(8u);
}
/// <summary>A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list
/// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment.</summary>
[Fact]
public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths()
{
var c = ComposeFullFixture();
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
speed.Alarm.ShouldNotBeNull();
speed.Alarm!.AlarmType.ShouldBe("LimitAlarm");
speed.Alarm.Severity.ShouldBe(700);
speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
// A non-alarm tag carries no condition + its own referencing-equipment list.
var status = c.RawTags.Single(t => t.TagId == "t-status");
status.Alarm.ShouldBeNull();
status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
// An unreferenced tag has an empty referencing-equipment set.
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
levels.ReferencingEquipmentPaths.ShouldBeEmpty();
}
/// <summary>Each UnsTagReference emits a UNS Variable keyed
/// <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c> (realm=Uns), carrying its backing
/// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective
/// name = DisplayNameOverride else the backing raw tag's Name.</summary>
[Fact]
public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access()
{
var c = ComposeFullFixture();
c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns);
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name
speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed");
speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out
speedRef.DataType.ShouldBe("Float");
speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins
statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus");
statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status");
statusRef.DataType.ShouldBe("Boolean");
statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag
}
/// <summary>
/// 2026-08-15 surgical-deploys review — a UNS reference also carries its backing tag's HISTORIAN
/// intent + ARRAY shape (<c>Backing{IsHistorized,HistorianTagname,IsArray,ArrayLength}</c>). They are
/// on the record so a raw-side edit to any of them makes the reference record unequal, producing the
/// coincident <c>UnsReferenceDelta</c> that repairs the mirror node; without them the UNS node kept the
/// old historian tagname / scalar shape after a surgical raw edit.
/// </summary>
[Fact]
public void Uns_reference_variables_carry_backing_historian_intent_and_array_shape()
{
var c = ComposeFullFixture();
// Backing tag historized WITH an override → both fields ride the reference verbatim.
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
speedRef.BackingIsHistorized.ShouldBeTrue();
speedRef.BackingHistorianTagname.ShouldBe("speed_hist");
speedRef.BackingIsArray.ShouldBeFalse();
speedRef.BackingArrayLength.ShouldBeNull();
// Backing tag neither historized nor array → the defaults.
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
statusRef.BackingIsHistorized.ShouldBeFalse();
statusRef.BackingHistorianTagname.ShouldBeNull();
statusRef.BackingIsArray.ShouldBeFalse();
statusRef.BackingArrayLength.ShouldBeNull();
}
/// <summary>The array + historize-without-override branches of the same inheritance: an ARRAY backing tag
/// projects its shape onto the reference (so the UNS mirror is created as an array, and therefore
/// read-only), and a historized tag with a BLANK override leaves <c>BackingHistorianTagname</c> null — the
/// applier resolves that to the BackingRawPath, exactly as the raw side defaults to its NodeId.</summary>
[Fact]
public void Uns_reference_variables_carry_array_shape_and_blank_historian_override()
{
var driver = new DriverInstance
{
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = null,
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
};
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
var levels = new Tag
{
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"isArray\":true,\"arrayLength\":8,\"isHistorized\":true}",
};
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 reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
var c = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { levels },
rawFolders: Array.Empty<RawFolder>(), devices: new[] { device });
var levelsRef = c.UnsReferenceVariables.ShouldHaveSingleItem();
levelsRef.BackingIsArray.ShouldBeTrue();
levelsRef.BackingArrayLength.ShouldBe(8u);
levelsRef.BackingIsHistorized.ShouldBeTrue();
levelsRef.BackingHistorianTagname.ShouldBeNull(); // blank override ⇒ resolved to BackingRawPath later
}
/// <summary>A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy
/// convenience overloads + earlier callers keep working).</summary>
[Fact]
public void No_raw_or_uns_inputs_leaves_both_subtrees_empty()
{
var c = AddressSpaceComposer.Compose(
equipment: Array.Empty<Equipment>(),
driverInstances: Array.Empty<DriverInstance>(),
scriptedAlarms: Array.Empty<ScriptedAlarm>());
c.RawContainers.ShouldBeEmpty();
c.RawTags.ShouldBeEmpty();
c.UnsReferenceVariables.ShouldBeEmpty();
}
/// <summary>The Raw + UNS emit is deterministic — repeated calls produce element-identical output
/// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison).</summary>
[Fact]
public void Dual_subtree_emit_is_deterministic()
{
var a = ComposeFullFixture();
var b = ComposeFullFixture();
a.RawContainers.ShouldBe(b.RawContainers);
a.RawTags.ShouldBe(b.RawTags);
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
}
}