f4f3e17e3e
Un-darken the address-space composition for the v3 dual namespace. Composer (AddressSpaceComposer): - Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as Object/Folder nodes, keyed s=<RawPath>, parent-before-child) + RawTagPlan (raw tag Variables keyed (realm=Raw, s=<RawPath>) carrying DataType / writable / historize+historian tagname / array shape). Native-alarm intent attaches at the RAW tag (ConditionId = RawPath) with the list of referencing equipment UNS paths (one alarm at the raw tag, not one per equipment). - UNS subtree: UnsReferenceVariable per UnsTagReference, keyed (realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>), carrying its backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting DataType/AccessLevel from the backing raw tag. Effective name = DisplayNameOverride else backing raw Tag.Name. - All RawPaths flow through one shared RawTopology (RawPathResolver + memoised container paths), byte-parity with EquipmentReferenceMap. - Every new node carries an AddressSpaceRealm (so WP3's applier picks the namespace at the call site). Additive/defaulted model changes only — the un-migrated AddressSpaceApplier still compiles. Planner (AddressSpacePlanner.Compute) + AddressSpacePlan: - Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a rename = remove+add; UnsReferenceVariables keyed by the stable UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves, NodeId stable) and a display-name-override edit is UNS-only. - PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS. Classifier folds the new sets in (adds->PureAdd, removes->PureRemove, changed->Rebuild safe default). Tests: AddressSpaceComposerDualNamespaceTests (7), AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin), AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to drive the composer over the seeded config (the artifact-decode seam is WP3). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
209 lines
10 KiB
C#
209 lines
10 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=<RawPath></c>,
|
|
/// realm <see cref="AddressSpaceRealm.Raw"/>) and the UNS subtree (one Variable per
|
|
/// <see cref="UnsTagReference"/> keyed <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></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=<RawPath></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=<Area>/<Line>/<Equipment>/<EffectiveName></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>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);
|
|
}
|
|
}
|